chore: merge main into PR #19
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
## Что входит
|
## Что входит
|
||||||
|
|
||||||
- `docs/preparation.md` — подготовка инфраструктуры и входных параметров.
|
- `docs/preparation.md` — подготовка инфраструктуры и входных параметров.
|
||||||
|
- `docs/codebase-onboarding.md` — обзор структуры репозитория и маршрут изучения для новичка.
|
||||||
- `docs/deployment.md` — пошаговый деплой LXC и ActivityWatch Server.
|
- `docs/deployment.md` — пошаговый деплой LXC и ActivityWatch Server.
|
||||||
- `docs/runbook.md` — быстрый runbook для оператора.
|
- `docs/runbook.md` — быстрый runbook для оператора.
|
||||||
- `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback.
|
- `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback.
|
||||||
|
|||||||
@@ -254,6 +254,7 @@
|
|||||||
loop:
|
loop:
|
||||||
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
|
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
|
||||||
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
|
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
|
||||||
|
- { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "{{ aw_server_webui_dir }}/js/aw-worktime-panel.js", mode: "0644" }
|
||||||
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
|
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
|
||||||
|
|
||||||
- name: Создать каталог /root/bootstrap для apply_webui_ru_patch.sh
|
- name: Создать каталог /root/bootstrap для apply_webui_ru_patch.sh
|
||||||
@@ -270,6 +271,7 @@
|
|||||||
loop:
|
loop:
|
||||||
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/root/bootstrap/aw-ru-patch.js", mode: "0644" }
|
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/root/bootstrap/aw-ru-patch.js", mode: "0644" }
|
||||||
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/root/bootstrap/aw-sw-cleanup.js", mode: "0644" }
|
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/root/bootstrap/aw-sw-cleanup.js", mode: "0644" }
|
||||||
|
- { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "/root/bootstrap/aw-worktime-panel.js", mode: "0644" }
|
||||||
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/root/bootstrap/aw-host-groups.json", mode: "0644" }
|
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/root/bootstrap/aw-host-groups.json", mode: "0644" }
|
||||||
|
|
||||||
- name: Скопировать apply_webui_ru_patch.sh скрипт
|
- name: Скопировать apply_webui_ru_patch.sh скрипт
|
||||||
@@ -293,9 +295,80 @@
|
|||||||
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
|
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
|
||||||
AW_SERVER_USER={{ aw_server_user }}
|
AW_SERVER_USER={{ aw_server_user }}
|
||||||
AW_SERVER_GROUP={{ aw_server_group }}
|
AW_SERVER_GROUP={{ aw_server_group }}
|
||||||
|
AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }}
|
||||||
|
AW_WORKTIME_TZ={{ aw_worktime_timezone }}
|
||||||
XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share
|
XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share
|
||||||
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
|
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
|
||||||
|
|
||||||
|
- name: Установить скрипт AW worktime API
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py"
|
||||||
|
dest: /usr/local/bin/aw-worktime-api.py
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Установить systemd unit AW worktime API
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.service"
|
||||||
|
dest: /etc/systemd/system/aw-worktime-api.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Установить скрипт AW worktime UI bridge
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.py"
|
||||||
|
dest: /usr/local/bin/aw-worktime-ui-bridge.py
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Установить systemd unit AW worktime UI bridge
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.service"
|
||||||
|
dest: /etc/systemd/system/aw-worktime-ui-bridge.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Установить systemd timer AW worktime UI bridge
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.timer"
|
||||||
|
dest: /etc/systemd/system/aw-worktime-ui-bridge.timer
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Перезагрузить systemd после установки AW worktime API
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
|
|
||||||
|
- name: Включить и перезапустить AW worktime API
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: aw-worktime-api.service
|
||||||
|
enabled: true
|
||||||
|
state: restarted
|
||||||
|
|
||||||
|
- name: Отключить legacy timer aw-worktime-afk-bridge (если есть)
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: aw-worktime-afk-bridge.timer
|
||||||
|
enabled: false
|
||||||
|
state: stopped
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Включить и перезапустить AW worktime UI bridge timer
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: aw-worktime-ui-bridge.timer
|
||||||
|
enabled: true
|
||||||
|
state: restarted
|
||||||
|
|
||||||
|
- name: Выполнить разовый прогон AW worktime UI bridge
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: aw-worktime-ui-bridge.service
|
||||||
|
state: started
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
- name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper)
|
- name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper)
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh"
|
cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh"
|
||||||
|
|||||||
@@ -201,7 +201,12 @@
|
|||||||
- aw_windows_afk_enabled | bool
|
- aw_windows_afk_enabled | bool
|
||||||
- aw_windows_hostname_result.stdout is defined
|
- aw_windows_hostname_result.stdout is defined
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
aw_windows_api_smoke_check_bucket_effective: "aw-watcher-afk_{{ aw_windows_hostname_result.stdout | trim }}"
|
aw_windows_api_smoke_check_bucket_effective: >-
|
||||||
|
{{
|
||||||
|
aw_windows_api_smoke_check_bucket
|
||||||
|
if (aw_windows_api_smoke_check_bucket | default('') | string | length) > 0
|
||||||
|
else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim)
|
||||||
|
}}
|
||||||
|
|
||||||
- name: Выполнить AW API smoke-check (проверка наличия свежих событий в AFK бакете)
|
- name: Выполнить AW API smoke-check (проверка наличия свежих событий в AFK бакете)
|
||||||
when:
|
when:
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ aw_server_db_path: "/var/lib/activitywatch/.local/share/activitywatch/aw-server-
|
|||||||
aw_server_log_dir: "/var/log/activitywatch"
|
aw_server_log_dir: "/var/log/activitywatch"
|
||||||
aw_server_user: "activitywatch"
|
aw_server_user: "activitywatch"
|
||||||
aw_server_group: "activitywatch"
|
aw_server_group: "activitywatch"
|
||||||
|
aw_worktime_report_base: "http://10.10.10.13:5610"
|
||||||
|
aw_worktime_timezone: "Europe/Moscow"
|
||||||
|
|
||||||
aw_repo_root: "{{ playbook_dir | dirname }}"
|
aw_repo_root: "{{ playbook_dir | dirname }}"
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ aw_server_log_dir: "/var/log/activitywatch"
|
|||||||
aw_server_db_path: "/var/lib/activitywatch/aw-server-rust/sqlite.db"
|
aw_server_db_path: "/var/lib/activitywatch/aw-server-rust/sqlite.db"
|
||||||
aw_server_user: "activitywatch"
|
aw_server_user: "activitywatch"
|
||||||
aw_server_group: "activitywatch"
|
aw_server_group: "activitywatch"
|
||||||
|
aw_worktime_report_base: "http://10.10.10.13:5610"
|
||||||
|
aw_worktime_timezone: "Europe/Moscow"
|
||||||
|
|
||||||
aw_repo_root: "/mnt/usb_hdd2/Projects/ActivityWatch-Russian"
|
aw_repo_root: "/mnt/usb_hdd2/Projects/ActivityWatch-Russian"
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ans
|
|||||||
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
|
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
|
||||||
aw_windows_fail_on_validation_error: true
|
aw_windows_fail_on_validation_error: true
|
||||||
|
|
||||||
aw_windows_migration_enabled: true
|
aw_windows_migration_enabled: false
|
||||||
aw_windows_legacy_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
|
aw_windows_legacy_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
|
||||||
aw_windows_legacy_state_root: "C:\\ProgramData\\ActivityWatch-Phase2"
|
aw_windows_legacy_state_root: "C:\\ProgramData\\ActivityWatch-Phase2"
|
||||||
aw_windows_migration_report_remote_path: "{{ aw_windows_state_root }}\\aw_migration_ansible.json"
|
aw_windows_migration_report_remote_path: "{{ aw_windows_state_root }}\\aw_migration_ansible.json"
|
||||||
@@ -50,4 +50,3 @@ aw_windows_migration_report_remote_path: "{{ aw_windows_state_root }}\\aw_migrat
|
|||||||
aw_windows_api_smoke_check_enabled: true
|
aw_windows_api_smoke_check_enabled: true
|
||||||
aw_windows_api_smoke_check_bucket: ""
|
aw_windows_api_smoke_check_bucket: ""
|
||||||
aw_windows_api_smoke_check_limit: 10
|
aw_windows_api_smoke_check_limit: 10
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
gather_facts: false
|
gather_facts: false
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
|
aw_windows_deploy_root: "C:\\Program Files\\AWatch-rus"
|
||||||
|
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
|
||||||
|
aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ansible.json"
|
||||||
|
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
|
||||||
|
aw_windows_server_scheme: "http"
|
||||||
|
aw_windows_server_host: "10.10.10.13"
|
||||||
|
aw_windows_server_port: 5600
|
||||||
|
aw_windows_fail_on_validation_error: true
|
||||||
aw_windows_launch_task_pattern: "ActivityWatch Launch *"
|
aw_windows_launch_task_pattern: "ActivityWatch Launch *"
|
||||||
aw_windows_recovery_task_name: "ActivityWatch Recovery"
|
aw_windows_recovery_task_name: "ActivityWatch Recovery"
|
||||||
aw_windows_force_task_restart: true
|
aw_windows_force_task_restart: true
|
||||||
@@ -44,6 +52,7 @@
|
|||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
|
url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
|
||||||
method: GET
|
method: GET
|
||||||
|
status_code: 200
|
||||||
return_content: true
|
return_content: true
|
||||||
register: aw_windows_api_smoke
|
register: aw_windows_api_smoke
|
||||||
until: >
|
until: >
|
||||||
@@ -87,4 +96,3 @@
|
|||||||
msg:
|
msg:
|
||||||
- "Validation OK on {{ inventory_hostname }}."
|
- "Validation OK on {{ inventory_hostname }}."
|
||||||
- "Report: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
|
- "Report: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
- install_aw_server.sh
|
- install_aw_server.sh
|
||||||
- apply_webui_ru_patch.sh
|
- apply_webui_ru_patch.sh
|
||||||
- activitywatch-server.service
|
- activitywatch-server.service
|
||||||
|
- aw-worktime-api.py
|
||||||
|
- aw-worktime-api.service
|
||||||
|
- aw-worktime-ui-bridge.py
|
||||||
|
- aw-worktime-ui-bridge.service
|
||||||
|
- aw-worktime-ui-bridge.timer
|
||||||
|
- aw-worktime-panel.js
|
||||||
- aw-server.env.example
|
- aw-server.env.example
|
||||||
- aw-ru-patch.js
|
- aw-ru-patch.js
|
||||||
- aw-sw-cleanup.js
|
- aw-sw-cleanup.js
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
- install_aw_server.sh
|
- install_aw_server.sh
|
||||||
- apply_webui_ru_patch.sh
|
- apply_webui_ru_patch.sh
|
||||||
- activitywatch-server.service
|
- activitywatch-server.service
|
||||||
|
- aw-worktime-api.py
|
||||||
|
- aw-worktime-api.service
|
||||||
|
- aw-worktime-ui-bridge.py
|
||||||
|
- aw-worktime-ui-bridge.service
|
||||||
|
- aw-worktime-ui-bridge.timer
|
||||||
|
- aw-worktime-panel.js
|
||||||
- aw-server.env.example
|
- aw-server.env.example
|
||||||
- aw-ru-patch.js
|
- aw-ru-patch.js
|
||||||
- aw-sw-cleanup.js
|
- aw-sw-cleanup.js
|
||||||
|
|||||||
@@ -159,6 +159,8 @@
|
|||||||
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
|
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
|
||||||
AW_SERVER_USER={{ aw_server_user }}
|
AW_SERVER_USER={{ aw_server_user }}
|
||||||
AW_SERVER_GROUP={{ aw_server_group }}
|
AW_SERVER_GROUP={{ aw_server_group }}
|
||||||
|
AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }}
|
||||||
|
AW_WORKTIME_TZ={{ aw_worktime_timezone }}
|
||||||
no_log: true
|
no_log: true
|
||||||
|
|
||||||
- name: Передать AW server env внутрь CT
|
- name: Передать AW server env внутрь CT
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ fi
|
|||||||
source "$ENV_FILE"
|
source "$ENV_FILE"
|
||||||
|
|
||||||
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}"
|
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}"
|
||||||
|
REPORT_BASE="${AW_WORKTIME_REPORT_BASE:-http://10.10.10.13:5610}"
|
||||||
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
|
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
|
||||||
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
|
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
|
||||||
|
WORKTIME_PANEL_SRC="/root/bootstrap/aw-worktime-panel.js"
|
||||||
HOST_GROUPS_SRC="/root/bootstrap/aw-host-groups.json"
|
HOST_GROUPS_SRC="/root/bootstrap/aw-host-groups.json"
|
||||||
INDEX_HTML="$WEBUI_DIR/index.html"
|
INDEX_HTML="$WEBUI_DIR/index.html"
|
||||||
SERVICE_WORKER="$WEBUI_DIR/service-worker.js"
|
SERVICE_WORKER="$WEBUI_DIR/service-worker.js"
|
||||||
TS=$(date +%Y%m%d%H%M%S)
|
TS=$(date +%Y%m%d%H%M%S)
|
||||||
PATCH_TARGET="$WEBUI_DIR/js/ru-patch-v5.js"
|
PATCH_TARGET="$WEBUI_DIR/js/ru-patch-v5.js"
|
||||||
SW_TARGET="$WEBUI_DIR/js/sw-cleanup.js"
|
SW_TARGET="$WEBUI_DIR/js/sw-cleanup.js"
|
||||||
|
WORKTIME_PANEL_TARGET="$WEBUI_DIR/js/aw-worktime-panel.js"
|
||||||
HOST_GROUPS_TARGET="$WEBUI_DIR/js/aw-host-groups.json"
|
HOST_GROUPS_TARGET="$WEBUI_DIR/js/aw-host-groups.json"
|
||||||
TRENDS_NEEDLE='this.activityStore.query_category_time_by_period(r)'
|
TRENDS_NEEDLE='this.activityStore.query_category_time_by_period(r)'
|
||||||
TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
|
TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
|
||||||
@@ -28,21 +31,76 @@ CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){retu
|
|||||||
|
|
||||||
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
|
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
|
||||||
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
|
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
|
||||||
|
[[ -f "$WORKTIME_PANEL_SRC" ]] || { echo "missing $WORKTIME_PANEL_SRC" >&2; exit 1; }
|
||||||
[[ -f "$HOST_GROUPS_SRC" ]] || { echo "missing $HOST_GROUPS_SRC" >&2; exit 1; }
|
[[ -f "$HOST_GROUPS_SRC" ]] || { echo "missing $HOST_GROUPS_SRC" >&2; exit 1; }
|
||||||
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
|
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
|
||||||
|
|
||||||
install -d "$WEBUI_DIR/js"
|
install -d "$WEBUI_DIR/js"
|
||||||
install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET"
|
install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET"
|
||||||
install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET"
|
install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET"
|
||||||
|
install -m 0644 "$WORKTIME_PANEL_SRC" "$WORKTIME_PANEL_TARGET"
|
||||||
install -m 0644 "$HOST_GROUPS_SRC" "$HOST_GROUPS_TARGET"
|
install -m 0644 "$HOST_GROUPS_SRC" "$HOST_GROUPS_TARGET"
|
||||||
cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS"
|
cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS"
|
||||||
|
|
||||||
patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')"
|
patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')"
|
||||||
sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')"
|
sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')"
|
||||||
|
worktime_panel_hash="$(sha1sum "$WORKTIME_PANEL_TARGET" | awk '{print substr($1,1,12)}')"
|
||||||
|
|
||||||
sed -i '/ru-patch-v5.js/d;/sw-cleanup.js/d;/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
|
python3 - "$WORKTIME_PANEL_TARGET" "$REPORT_BASE" <<'PY'
|
||||||
sed -i "s#</head>#<script src=\"/js/sw-cleanup.js?v=$sw_hash\"></script></head>#" "$INDEX_HTML"
|
from pathlib import Path
|
||||||
sed -i "s#</body>#<script defer=\"defer\" src=\"/js/ru-patch-v5.js?v=$patch_hash\"></script></body>#" "$INDEX_HTML"
|
import sys
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
report_base = sys.argv[2]
|
||||||
|
text = path.read_text()
|
||||||
|
text = text.replace("__AW_WORKTIME_REPORT_BASE__", report_base)
|
||||||
|
path.write_text(text)
|
||||||
|
PY
|
||||||
|
|
||||||
|
python3 - "$INDEX_HTML" "$sw_hash" "$patch_hash" "$worktime_panel_hash" "$REPORT_BASE" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
sw_hash = sys.argv[2]
|
||||||
|
patch_hash = sys.argv[3]
|
||||||
|
panel_hash = sys.argv[4]
|
||||||
|
report_base = sys.argv[5]
|
||||||
|
content = path.read_text()
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'<script[^>]+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js|aw-worktime-panel\.js)[^>]*></script>',
|
||||||
|
'',
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
content = re.sub(r"; frame-src 'self' [^\";>]*", "", content)
|
||||||
|
content = content.replace(
|
||||||
|
"script-src 'self' 'unsafe-eval'",
|
||||||
|
f"script-src 'self' 'unsafe-eval'; frame-src 'self' {report_base}",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
content = content.replace(
|
||||||
|
"</head>",
|
||||||
|
f'<script src="/js/sw-cleanup.js?v={sw_hash}"></script></head>',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
content = content.replace(
|
||||||
|
"</body>",
|
||||||
|
(
|
||||||
|
f'<script defer="defer" src="/js/ru-patch-v5.js?v={patch_hash}"></script>'
|
||||||
|
f'<script defer="defer" src="/js/aw-worktime-panel.js?v={panel_hash}"></script></body>'
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
if 'id="aw-report-links"' not in content:
|
||||||
|
content = content.replace(
|
||||||
|
"</body>",
|
||||||
|
'<div id="aw-report-links" style="position:fixed;right:12px;bottom:12px;z-index:99999;background:#111;color:#fff;padding:8px 10px;border-radius:8px;font:12px/1.4 sans-serif;opacity:.9">RDP report: loading...</div></body>',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
path.write_text(content)
|
||||||
|
PY
|
||||||
cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER"
|
cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER"
|
||||||
|
|
||||||
trends_chunk="$(grep -Rsl "$TRENDS_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
|
trends_chunk="$(grep -Rsl "$TRENDS_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ AW_SERVER_DATA_DIR=/var/lib/activitywatch
|
|||||||
AW_SERVER_LOG_DIR=/var/log/activitywatch
|
AW_SERVER_LOG_DIR=/var/log/activitywatch
|
||||||
AW_SERVER_USER=activitywatch
|
AW_SERVER_USER=activitywatch
|
||||||
AW_SERVER_GROUP=activitywatch
|
AW_SERVER_GROUP=activitywatch
|
||||||
|
AW_WORKTIME_REPORT_BASE=http://10.10.10.13:5610
|
||||||
|
AW_WORKTIME_TZ=Europe/Moscow
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
AW = "http://127.0.0.1:5600/api/0"
|
||||||
|
REPORT_TZ = ZoneInfo(os.environ.get("AW_WORKTIME_TZ", "Europe/Moscow"))
|
||||||
|
|
||||||
|
|
||||||
|
def get(u):
|
||||||
|
with urllib.request.urlopen(u, timeout=30) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def pts(s):
|
||||||
|
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def report_today():
|
||||||
|
now_local = datetime.now(REPORT_TZ)
|
||||||
|
start_local = datetime(now_local.year, now_local.month, now_local.day, tzinfo=REPORT_TZ)
|
||||||
|
end_local = start_local + timedelta(days=1) - timedelta(seconds=1)
|
||||||
|
start = start_local.astimezone(timezone.utc)
|
||||||
|
end = end_local.astimezone(timezone.utc)
|
||||||
|
b = get(AW + "/buckets")
|
||||||
|
sb = next((k for k in b if k.startswith("aw-worktime-sessions_")), None)
|
||||||
|
if not sb:
|
||||||
|
return []
|
||||||
|
ev = get(f"{AW}/buckets/{sb}/events?limit=50000")
|
||||||
|
by = {}
|
||||||
|
for e in ev:
|
||||||
|
ts = pts(e.get("timestamp"))
|
||||||
|
if ts < start or ts > end:
|
||||||
|
continue
|
||||||
|
d = e.get("data") or {}
|
||||||
|
user = (d.get("username") or "").strip()
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
state = (d.get("state") or "").lower()
|
||||||
|
active = ("актив" in state) or (state == "active")
|
||||||
|
row = by.setdefault(user, {"active": set(), "first": None, "last": None, "rows": 0})
|
||||||
|
row["rows"] += 1
|
||||||
|
if active:
|
||||||
|
second = ts.replace(microsecond=0)
|
||||||
|
row["active"].add(second)
|
||||||
|
row["first"] = second if row["first"] is None or second < row["first"] else row["first"]
|
||||||
|
row["last"] = second if row["last"] is None or second > row["last"] else row["last"]
|
||||||
|
rows = []
|
||||||
|
full = int((end_local - start_local).total_seconds())
|
||||||
|
for user in sorted(by):
|
||||||
|
row = by[user]
|
||||||
|
active_seconds = len(row["active"])
|
||||||
|
rows.append({
|
||||||
|
"user": user,
|
||||||
|
"active_seconds": active_seconds,
|
||||||
|
"active_hhmm": "%02d:%02d" % (active_seconds // 3600, (active_seconds % 3600) // 60),
|
||||||
|
"first_activity": row["first"].isoformat().replace("+00:00", "Z") if row["first"] else "",
|
||||||
|
"last_activity": row["last"].isoformat().replace("+00:00", "Z") if row["last"] else "",
|
||||||
|
"idle_seconds": max(0, full - active_seconds),
|
||||||
|
"sessions_count": row["rows"],
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(rows):
|
||||||
|
generated = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
date_local = datetime.now(REPORT_TZ).strftime("%Y-%m-%d")
|
||||||
|
trs = []
|
||||||
|
for row in rows:
|
||||||
|
trs.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['user']}</td>"
|
||||||
|
f"<td>{row['active_hhmm']}</td>"
|
||||||
|
f"<td>{row['active_seconds']}</td>"
|
||||||
|
f"<td>{row['first_activity']}</td>"
|
||||||
|
f"<td>{row['last_activity']}</td>"
|
||||||
|
f"<td>{row['idle_seconds']}</td>"
|
||||||
|
f"<td>{row['sessions_count']}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
if not trs:
|
||||||
|
trs.append('<tr><td colspan="7">No data for today yet.</td></tr>')
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>AW-rus Worktime</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f4f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--line: #dbe3ee;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #475569;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-2: #1d4ed8;
|
||||||
|
}}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.45 "Segoe UI", "Noto Sans", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(29,78,216,.08), transparent 28%),
|
||||||
|
radial-gradient(circle at top right, rgba(15,118,110,.10), transparent 24%),
|
||||||
|
var(--bg);
|
||||||
|
}}
|
||||||
|
.wrap {{ max-width: 1180px; margin: 0 auto; padding: 24px; }}
|
||||||
|
.hero {{
|
||||||
|
background: linear-gradient(135deg, #0f172a, #1e293b 58%, #0f766e);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
box-shadow: 0 22px 60px rgba(15,23,42,.22);
|
||||||
|
}}
|
||||||
|
.hero h1 {{ margin: 0 0 8px; font-size: 28px; }}
|
||||||
|
.meta {{ color: rgba(255,255,255,.84); }}
|
||||||
|
.actions {{ margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap; }}
|
||||||
|
.actions a {{
|
||||||
|
text-decoration: none;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255,255,255,.12);
|
||||||
|
border: 1px solid rgba(255,255,255,.18);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}}
|
||||||
|
.card {{
|
||||||
|
margin-top: 18px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 16px 40px rgba(15,23,42,.08);
|
||||||
|
}}
|
||||||
|
table {{ width: 100%; border-collapse: collapse; }}
|
||||||
|
th, td {{ padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; }}
|
||||||
|
th {{ background: #eef4fb; color: var(--muted); font-weight: 600; position: sticky; top: 0; }}
|
||||||
|
tr:nth-child(even) td {{ background: rgba(148,163,184,.06); }}
|
||||||
|
.num {{ font-variant-numeric: tabular-nums; }}
|
||||||
|
.good {{ color: var(--accent); font-weight: 700; }}
|
||||||
|
.muted {{ color: var(--muted); }}
|
||||||
|
@media (max-width: 900px) {{
|
||||||
|
.wrap {{ padding: 14px; }}
|
||||||
|
.hero h1 {{ font-size: 22px; }}
|
||||||
|
.card {{ overflow-x: auto; }}
|
||||||
|
table {{ min-width: 820px; }}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<section class="hero">
|
||||||
|
<h1>RDP Worktime Report</h1>
|
||||||
|
<div class="meta">Date: {date_local} · Timezone: {REPORT_TZ} · Generated UTC: {generated}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/reports/worktime/today?format=csv">Download CSV</a>
|
||||||
|
<a href="/reports/worktime/today">View JSON</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th>Active sec</th>
|
||||||
|
<th>First activity</th>
|
||||||
|
<th>Last activity</th>
|
||||||
|
<th>Idle sec</th>
|
||||||
|
<th>Samples</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{''.join(trs)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if not self.path.startswith("/reports/worktime/today"):
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
fmt = "json"
|
||||||
|
if "format=csv" in self.path:
|
||||||
|
fmt = "csv"
|
||||||
|
elif "format=html" in self.path:
|
||||||
|
fmt = "html"
|
||||||
|
rows = report_today()
|
||||||
|
if fmt == "csv":
|
||||||
|
out = io.StringIO()
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
out,
|
||||||
|
fieldnames=[
|
||||||
|
"user",
|
||||||
|
"active_seconds",
|
||||||
|
"active_hhmm",
|
||||||
|
"first_activity",
|
||||||
|
"last_activity",
|
||||||
|
"idle_seconds",
|
||||||
|
"sessions_count",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
data = out.getvalue().encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
return
|
||||||
|
if fmt == "html":
|
||||||
|
data = render_html(rows).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
return
|
||||||
|
obj = {
|
||||||
|
"generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
"report_timezone": str(REPORT_TZ),
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
HTTPServer(("0.0.0.0", 5610), H).serve_forever()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AW Worktime Report API
|
||||||
|
After=network.target activitywatch-server.service
|
||||||
|
Wants=activitywatch-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
EnvironmentFile=/etc/activitywatch/aw-server.env
|
||||||
|
ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-api.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
User=activitywatch
|
||||||
|
Group=activitywatch
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
(function () {
|
||||||
|
var reportBase = "__AW_WORKTIME_REPORT_BASE__";
|
||||||
|
var reportUrl = reportBase + "/reports/worktime/today?format=html";
|
||||||
|
var existing = document.getElementById("aw-report-links");
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
existing.innerHTML =
|
||||||
|
'RDP report: ' +
|
||||||
|
'<a href="' + reportUrl + '" style="color:#fcd34d" target="_blank">HTML</a> | ' +
|
||||||
|
'<a href="' + reportBase + '/reports/worktime/today?format=csv" style="color:#7dd3fc" target="_blank">CSV</a> | ' +
|
||||||
|
'<a href="' + reportBase + '/reports/worktime/today" style="color:#86efac" target="_blank">JSON</a> | ' +
|
||||||
|
'<a href="#" id="aw-report-toggle" style="color:#f9fafb">Panel</a>';
|
||||||
|
|
||||||
|
var panel = document.createElement("div");
|
||||||
|
panel.id = "aw-report-panel";
|
||||||
|
panel.style.cssText = [
|
||||||
|
"position:fixed",
|
||||||
|
"top:16px",
|
||||||
|
"right:16px",
|
||||||
|
"width:min(980px,calc(100vw - 32px))",
|
||||||
|
"height:min(760px,calc(100vh - 32px))",
|
||||||
|
"background:#fff",
|
||||||
|
"border:1px solid rgba(15,23,42,.15)",
|
||||||
|
"border-radius:12px",
|
||||||
|
"box-shadow:0 24px 80px rgba(15,23,42,.28)",
|
||||||
|
"overflow:hidden",
|
||||||
|
"z-index:100000",
|
||||||
|
"display:none"
|
||||||
|
].join(";");
|
||||||
|
|
||||||
|
panel.innerHTML =
|
||||||
|
'<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#0f172a;color:#fff;font:600 13px/1.2 sans-serif">' +
|
||||||
|
'<div>RDP Worktime Report</div>' +
|
||||||
|
'<div style="display:flex;gap:12px;align-items:center">' +
|
||||||
|
'<a href="' + reportUrl + '" target="_blank" style="color:#93c5fd;text-decoration:none">Open</a>' +
|
||||||
|
'<a href="#" id="aw-report-close" style="color:#fff;text-decoration:none">Close</a>' +
|
||||||
|
"</div></div>" +
|
||||||
|
'<iframe src="' + reportUrl + '" title="RDP Worktime Report" style="border:0;width:100%;height:calc(100% - 42px);background:#fff"></iframe>';
|
||||||
|
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
function openPanel(ev) {
|
||||||
|
if (ev) ev.preventDefault();
|
||||||
|
panel.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel(ev) {
|
||||||
|
if (ev) ev.preventDefault();
|
||||||
|
panel.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
var toggle = document.getElementById("aw-report-toggle");
|
||||||
|
if (toggle) toggle.addEventListener("click", openPanel);
|
||||||
|
var close = panel.querySelector("#aw-report-close");
|
||||||
|
if (close) close.addEventListener("click", closePanel);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
AW_URL = os.environ.get("AW_SERVER_URL", "http://127.0.0.1:5600")
|
||||||
|
HOST = os.environ.get("AW_WORKTIME_HOST", "SHARKON2025")
|
||||||
|
STATE_PATH = os.environ.get(
|
||||||
|
"AW_WORKTIME_UI_BRIDGE_STATE",
|
||||||
|
"/var/lib/activitywatch/aw-worktime-ui-bridge-state.json",
|
||||||
|
)
|
||||||
|
TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "20"))
|
||||||
|
|
||||||
|
|
||||||
|
SESSIONS_BUCKET = f"aw-worktime-sessions_{HOST}"
|
||||||
|
AFK_BUCKET = f"aw-watcher-afk_{HOST}"
|
||||||
|
WINDOW_BUCKET = f"aw-watcher-window_{HOST}"
|
||||||
|
|
||||||
|
|
||||||
|
def _req(method: str, path: str, payload=None):
|
||||||
|
data = None
|
||||||
|
headers = {}
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(AW_URL + path, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_bucket(bucket_id: str, event_type: str, client: str):
|
||||||
|
payload = {"client": client, "type": event_type, "hostname": HOST}
|
||||||
|
try:
|
||||||
|
_req("POST", f"/api/0/buckets/{bucket_id}", payload)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code != 304:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def load_state():
|
||||||
|
try:
|
||||||
|
with open(STATE_PATH, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict) and "last_ts" in data:
|
||||||
|
return data
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {"last_ts": "1970-01-01T00:00:00Z"}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state):
|
||||||
|
os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
|
||||||
|
tmp = STATE_PATH + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(state, f, ensure_ascii=False)
|
||||||
|
os.replace(tmp, STATE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def to_iso_utc(ts):
|
||||||
|
if ts.endswith("Z"):
|
||||||
|
return ts
|
||||||
|
return ts.replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def build_window_title(users, active_count):
|
||||||
|
if not users:
|
||||||
|
return "RDP idle"
|
||||||
|
return f"RDP active ({active_count}): " + ", ".join(users)
|
||||||
|
|
||||||
|
|
||||||
|
def transform(events):
|
||||||
|
out_afk = []
|
||||||
|
out_win = []
|
||||||
|
last_ts = None
|
||||||
|
|
||||||
|
for e in events:
|
||||||
|
ts = e.get("timestamp")
|
||||||
|
if not ts:
|
||||||
|
continue
|
||||||
|
duration = float(e.get("duration", 0.0))
|
||||||
|
data = e.get("data") or {}
|
||||||
|
active_users = data.get("activeUsers") or []
|
||||||
|
active_count = int(data.get("activeCount", len(active_users)))
|
||||||
|
is_active = active_count > 0
|
||||||
|
|
||||||
|
afk_data = {"status": "not-afk" if is_active else "afk", "source": "aw-worktime-ui-bridge"}
|
||||||
|
out_afk.append({"timestamp": ts, "duration": duration, "data": afk_data})
|
||||||
|
|
||||||
|
win_data = {
|
||||||
|
"app": "RDP",
|
||||||
|
"title": build_window_title(active_users, active_count),
|
||||||
|
"source": "aw-worktime-ui-bridge",
|
||||||
|
}
|
||||||
|
out_win.append({"timestamp": ts, "duration": duration, "data": win_data})
|
||||||
|
last_ts = ts
|
||||||
|
|
||||||
|
return out_afk, out_win, last_ts
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
state = load_state()
|
||||||
|
last_ts = state.get("last_ts", "1970-01-01T00:00:00Z")
|
||||||
|
|
||||||
|
ensure_bucket(AFK_BUCKET, "afkstatus", "aw-worktime-ui-bridge")
|
||||||
|
ensure_bucket(WINDOW_BUCKET, "currentwindow", "aw-worktime-ui-bridge")
|
||||||
|
|
||||||
|
query = {
|
||||||
|
"query": [
|
||||||
|
"events = query_bucket(find_bucket($bid));",
|
||||||
|
"RETURN = sort_by_timestamp(events);",
|
||||||
|
],
|
||||||
|
"timeperiods": [[last_ts, to_iso_utc(datetime.now(timezone.utc).isoformat())]],
|
||||||
|
}
|
||||||
|
rows = _req("POST", f"/api/0/query/?bid={SESSIONS_BUCKET}", query) or []
|
||||||
|
if not rows or not rows[0]:
|
||||||
|
return
|
||||||
|
|
||||||
|
events = rows[0]
|
||||||
|
afk_events, win_events, new_last_ts = transform(events)
|
||||||
|
if not afk_events or not win_events or not new_last_ts:
|
||||||
|
return
|
||||||
|
|
||||||
|
_req("POST", f"/api/0/buckets/{AFK_BUCKET}/events", afk_events)
|
||||||
|
_req("POST", f"/api/0/buckets/{WINDOW_BUCKET}/events", win_events)
|
||||||
|
save_state({"last_ts": new_last_ts})
|
||||||
|
print(f"posted_afk={len(afk_events)} posted_win={len(win_events)} last_ts={new_last_ts}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AW Worktime UI bridge (sessions -> afk/window)
|
||||||
|
After=network-online.target activitywatch-server.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
Environment=AW_SERVER_URL=http://127.0.0.1:5600
|
||||||
|
Environment=AW_WORKTIME_HOST=SHARKON2025
|
||||||
|
ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-ui-bridge.py
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run AW Worktime UI bridge every 30 seconds
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=20s
|
||||||
|
OnUnitActiveSec=30s
|
||||||
|
Unit=aw-worktime-ui-bridge.service
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -24,6 +24,11 @@ required_vars=(
|
|||||||
BOOTSTRAP_DIR="/root/bootstrap"
|
BOOTSTRAP_DIR="/root/bootstrap"
|
||||||
VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
|
VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
|
||||||
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
|
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
|
||||||
|
WORKTIME_API_SRC="$BOOTSTRAP_DIR/aw-worktime-api.py"
|
||||||
|
WORKTIME_API_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-api.service"
|
||||||
|
WORKTIME_UI_BRIDGE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.py"
|
||||||
|
WORKTIME_UI_BRIDGE_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.service"
|
||||||
|
WORKTIME_UI_BRIDGE_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.timer"
|
||||||
|
|
||||||
for var_name in "${required_vars[@]}"; do
|
for var_name in "${required_vars[@]}"; do
|
||||||
if [[ -z "${!var_name:-}" ]]; then
|
if [[ -z "${!var_name:-}" ]]; then
|
||||||
@@ -89,6 +94,36 @@ systemctl enable activitywatch-server.service
|
|||||||
systemctl restart activitywatch-server.service
|
systemctl restart activitywatch-server.service
|
||||||
systemctl --no-pager --full status activitywatch-server.service || true
|
systemctl --no-pager --full status activitywatch-server.service || true
|
||||||
|
|
||||||
|
if [[ -f "$WORKTIME_API_SRC" ]]; then
|
||||||
|
install -m 0755 "$WORKTIME_API_SRC" /usr/local/bin/aw-worktime-api.py
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then
|
||||||
|
install -m 0644 "$WORKTIME_API_SERVICE_SRC" /etc/systemd/system/aw-worktime-api.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable aw-worktime-api.service
|
||||||
|
systemctl restart aw-worktime-api.service
|
||||||
|
systemctl --no-pager --full status aw-worktime-api.service || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$WORKTIME_UI_BRIDGE_SRC" ]]; then
|
||||||
|
install -m 0755 "$WORKTIME_UI_BRIDGE_SRC" /usr/local/bin/aw-worktime-ui-bridge.py
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$WORKTIME_UI_BRIDGE_SERVICE_SRC" ]]; then
|
||||||
|
install -m 0644 "$WORKTIME_UI_BRIDGE_SERVICE_SRC" /etc/systemd/system/aw-worktime-ui-bridge.service
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then
|
||||||
|
install -m 0644 "$WORKTIME_UI_BRIDGE_TIMER_SRC" /etc/systemd/system/aw-worktime-ui-bridge.timer
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl disable --now aw-worktime-afk-bridge.timer >/dev/null 2>&1 || true
|
||||||
|
systemctl enable aw-worktime-ui-bridge.timer
|
||||||
|
systemctl restart aw-worktime-ui-bridge.timer
|
||||||
|
systemctl start aw-worktime-ui-bridge.service || true
|
||||||
|
systemctl --no-pager --full status aw-worktime-ui-bridge.timer || true
|
||||||
|
fi
|
||||||
|
|
||||||
for _ in $(seq 1 20); do
|
for _ in $(seq 1 20); do
|
||||||
if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then
|
if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Onboarding по кодовой базе AWatch-rus
|
||||||
|
|
||||||
|
Этот документ — быстрый вход для новичка: **что лежит где**, **как всё связано** и **что читать дальше**.
|
||||||
|
|
||||||
|
## 1) Что это за репозиторий
|
||||||
|
|
||||||
|
`AWatch-rus` — это не один сервис, а **инфраструктурный набор** для развёртывания и сопровождения ActivityWatch в прод-подобной среде:
|
||||||
|
|
||||||
|
- Proxmox/LXC-подготовка,
|
||||||
|
- установка и настройка AW Server,
|
||||||
|
- русификация Web UI,
|
||||||
|
- автоматизация через Ansible,
|
||||||
|
- клиентский rollout для Windows/Linux,
|
||||||
|
- дополнительная телеметрия (в т.ч. pfSense poller и DLP-сигналы),
|
||||||
|
- эксплуатационные runbook/operations документы.
|
||||||
|
|
||||||
|
Идея: чтобы развёртывание было **повторяемым**, а не «ручной магией в один вечер».
|
||||||
|
|
||||||
|
## 2) Карта проекта (по папкам)
|
||||||
|
|
||||||
|
### `docs/`
|
||||||
|
Главный источник истины по процессам.
|
||||||
|
|
||||||
|
- `preparation.md` — входные данные, prerequisites, что нужно до старта.
|
||||||
|
- `deployment.md` — базовый серверный деплой.
|
||||||
|
- `runbook.md` — быстрые операционные действия и проверки.
|
||||||
|
- `operations.md` — сопровождение, бэкапы, rollback.
|
||||||
|
- `windows/*` — отдельная ветка документации по Windows-оркестрации.
|
||||||
|
- `1C_GRAFANA_DEPLOYMENT_RU.md`, `pfsense.md`, `linux-client.md` — специализированные подсистемы.
|
||||||
|
|
||||||
|
### `proxmox/`
|
||||||
|
Скрипты ранней инфраструктурной фазы:
|
||||||
|
|
||||||
|
- `create-ct.sh` — создание/подготовка контейнера,
|
||||||
|
- `push-aw-artifacts.sh` — доставка артефактов и конфигов.
|
||||||
|
|
||||||
|
### `aw-server/`
|
||||||
|
Серверная «сердцевина»:
|
||||||
|
|
||||||
|
- `install_aw_server.sh` — установка AW Server,
|
||||||
|
- `apply_webui_ru_patch.sh` + `aw-ru-patch.js` — русификация UI,
|
||||||
|
- `activitywatch-server.service` — unit для systemd,
|
||||||
|
- `aw-server.env.example` — шаблон переменных окружения,
|
||||||
|
- `settings/*.json` — конфигурация представлений/классов.
|
||||||
|
|
||||||
|
### `ansible/`
|
||||||
|
Идемпотентная автоматизация (вместо ручных команд):
|
||||||
|
|
||||||
|
- playbook'и для серверного деплоя,
|
||||||
|
- сценарии provisioning + deploy,
|
||||||
|
- `group_vars/*.example.yml` и `inventory.example.ini` как шаблоны входных данных.
|
||||||
|
|
||||||
|
### `windows/`
|
||||||
|
PowerShell toolkit для клиентской стороны:
|
||||||
|
|
||||||
|
- `deploy-ensemble.ps1` — оркестратор,
|
||||||
|
- `deploy-single-user.ps1`, `deploy-domain-users.ps1` — сценарии установки,
|
||||||
|
- `validate-deployment.ps1` — post-check,
|
||||||
|
- `ActivityWatch.Windows.Common.psm1` — общая библиотека функций,
|
||||||
|
- скрипты DLP/browser telemetry.
|
||||||
|
|
||||||
|
### `pfsense/`
|
||||||
|
Отдельный poller для pfSense API + systemd unit.
|
||||||
|
|
||||||
|
### `grafana-1c/`
|
||||||
|
Набор для SQL exporter + Prometheus + Grafana дашбордов по 1C метрикам.
|
||||||
|
|
||||||
|
### `scripts/`
|
||||||
|
Утилиты и quality gates:
|
||||||
|
|
||||||
|
- `quality-gate.sh` — базовый preflight,
|
||||||
|
- инсталляторы Linux-клиента и console/ssh logger режимов.
|
||||||
|
|
||||||
|
### `secrets/`
|
||||||
|
Только шаблоны. Реальные секреты в репозиторий не кладутся.
|
||||||
|
|
||||||
|
## 3) Как компоненты связаны в потоке
|
||||||
|
|
||||||
|
Типовой pipeline:
|
||||||
|
|
||||||
|
1. Подготовка параметров (`docs/preparation.md`, `secrets/*.example`).
|
||||||
|
2. Provisioning контейнера в Proxmox (`proxmox/`).
|
||||||
|
3. Установка/настройка AW Server (`aw-server/`).
|
||||||
|
4. Включение автозапуска и проверка (`systemd` + `docs/runbook.md`).
|
||||||
|
5. Rollout клиентов (обычно `windows/`, иногда `scripts/install_aw_linux_client.sh`).
|
||||||
|
6. Эксплуатация и изменения через `docs/operations.md`.
|
||||||
|
7. При необходимости — расширение мониторинга (`grafana-1c/`, `pfsense/`).
|
||||||
|
|
||||||
|
## 4) Что важно понять в первую очередь
|
||||||
|
|
||||||
|
1. **Репозиторий документ-ориентированный**: сначала читаешь `docs/`, потом запускаешь скрипты.
|
||||||
|
2. **Шаблоны `.example` — обязательная точка входа**: не редактируй скрипты вместо заполнения переменных.
|
||||||
|
3. **Есть два режима работы**:
|
||||||
|
- ручной/полуручной (bash + runbook),
|
||||||
|
- автоматизированный (Ansible).
|
||||||
|
4. **Windows часть — полноценный под-проект** с собственными deploy/validate практиками.
|
||||||
|
5. **Безопасность**: никаких секретов в git; rollback и backup — не опция, а стандарт процесса.
|
||||||
|
|
||||||
|
## 5) Рекомендованный порядок изучения (первые 2–3 часа)
|
||||||
|
|
||||||
|
1. `README.md` — получить общую картину.
|
||||||
|
2. `docs/preparation.md` — понять входные параметры.
|
||||||
|
3. `docs/deployment.md` — увидеть «сквозной» серверный сценарий.
|
||||||
|
4. `docs/runbook.md` и `docs/operations.md` — как жить с системой после деплоя.
|
||||||
|
5. `aw-server/install_aw_server.sh` и `aw-server/activitywatch-server.service` — как реально стартует сервис.
|
||||||
|
6. `windows/deploy-ensemble.ps1` + `windows/validate-deployment.ps1` — клиентская фаза.
|
||||||
|
7. `ansible/README.md` и ключевые playbook'и — переход к промышленной автоматизации.
|
||||||
|
|
||||||
|
## 6) Практические подсказки для первого вклада
|
||||||
|
|
||||||
|
- Начни с правок документации или `.example`-шаблонов — это самый безопасный вход.
|
||||||
|
- Перед изменениями в скриптах сравни, нет ли уже Ansible-аналога (лучше поддерживать один «официальный» путь).
|
||||||
|
- Любая новая переменная должна быть отражена:
|
||||||
|
1) в `.example` файле,
|
||||||
|
2) в docs,
|
||||||
|
3) в проверках/валидации (если применимо).
|
||||||
|
- Для Windows-скриптов используй общие функции из `ActivityWatch.Windows.Common.psm1`, чтобы не дублировать логику.
|
||||||
|
|
||||||
|
## 7) Куда смотреть дальше (углубление)
|
||||||
|
|
||||||
|
- Если интересует эксплуатация и инциденты: `docs/runbook.md`, `docs/operations.md`, `docs/windows/troubleshooting.md`.
|
||||||
|
- Если интересует автодеплой: `ansible/provision_proxmox_ct_and_deploy_aw.yml` и `ansible/tasks/`.
|
||||||
|
- Если интересует наблюдаемость: `grafana-1c/` + `pfsense/` + `prometheus`/`alerts` конфиги.
|
||||||
|
- Если интересует hardening и DLP: `windows/hardening-recovery.ps1`, `docs/dlp-gap-analysis.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Если ты новичок в проекте, практичный старт: разверни тестовый стенд по `docs/deployment.md`, затем прогоняй валидации из `docs/runbook.md` и `windows/validate-deployment.ps1`.
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Стратегический анализ: AWatch-rus vs InfoWatch Traffic Monitor
|
||||||
|
|
||||||
|
## Текущие возможности AWatch-rus (что уже есть)
|
||||||
|
|
||||||
|
| Область | Реализовано |
|
||||||
|
|---------|------------|
|
||||||
|
| Activity tracking | `aw-watcher-afk`, `aw-watcher-window` — время активности, окна |
|
||||||
|
| Browser monitoring | `browser-domains-native-collector.ps1` — URL/домены через UIAutomation |
|
||||||
|
| DLP Phase 1 | Rule-based политики, incident bucket, cooldown/dedup |
|
||||||
|
| DLP Phase 2 | USB/print/clipboard мониторинг (`dlp-endpoint-signals-collector.ps1`), incident screenshot |
|
||||||
|
| File operations | Прототип `file-operations-collector.ps1` (create/delete/rename/archive) |
|
||||||
|
| Worktime | Session-level presence (`worktime-session-collector.ps1`) |
|
||||||
|
| Network | pfSense poller (firewall events → AW bucket) |
|
||||||
|
| Aggregation | `aggregate_dlp_events.py` — SQLite/PostgreSQL centralized store |
|
||||||
|
| UI | Русификация WebUI + DLP incident review panel в `aw-ru-patch.js` |
|
||||||
|
| Deployment | Ansible + PowerShell ensemble + InnoSetup + Proxmox LXC |
|
||||||
|
| Linux | Remote worker, console/SSH logger, web category logger |
|
||||||
|
|
||||||
|
## Ключевые разрывы до InfoWatch TM уровня
|
||||||
|
|
||||||
|
### 🔴 Критические (без них это не DLP, а мониторинг)
|
||||||
|
|
||||||
|
#### 1. Блокировка в реальном времени (Enforcement)
|
||||||
|
**InfoWatch**: Перехватывает и блокирует отправку до завершения — email не уйдёт, файл на USB не запишется, печать не пройдёт.
|
||||||
|
**AWatch-rus**: Только detect + log. Нет inline-перехвата трафика, нет file system filter driver.
|
||||||
|
|
||||||
|
**Что делать:**
|
||||||
|
- **USB write-block** — реализуемо через Group Policy + PowerShell enforcement (`Set-StoragePolicy`, device lockdown via WMI). Не требует kernel driver.
|
||||||
|
- **Print block** — перехват через Print Spooler event (307) + `Cancel-PrintJob`. Уже есть event monitoring — нужен только enforcement шаг.
|
||||||
|
- **Clipboard block** — `SetClipboardData` hook через .NET или `Clear-Clipboard` при policy violation. Рискованно по UX, но возможно.
|
||||||
|
- **Email/web block** — требует MITM proxy (аналог InfoWatch ICAP gateway). Реалистичнее начать с email-выгрузки через Exchange Journal/Transport Rule → анализ → карантин, чем строить inline proxy.
|
||||||
|
- **Приоритет**: USB write-block + print cancel — быстрый win, 2-3 недели.
|
||||||
|
|
||||||
|
#### 2. Email перехват
|
||||||
|
**InfoWatch**: SMTP/MAPI/IMAP — полный контроль корпоративной почты, включая вложения, тело, получатели.
|
||||||
|
**AWatch-rus**: Отсутствует.
|
||||||
|
|
||||||
|
**Что делать:**
|
||||||
|
- **Exchange/M365**: Transport Rule с Journal → Python парсер EML → событие в AW bucket `aw-email-monitor_<host>`.
|
||||||
|
- **On-prem SMTP**: Milter на Postfix/Sendmail (Python, 200-300 строк).
|
||||||
|
- **MVP**: Только metadata (from/to/subject/attachment names/sizes), без content inspection. Потом добавить content analysis.
|
||||||
|
- **Приоритет**: Высокий — email это #1 канал утечки в enterprise.
|
||||||
|
|
||||||
|
#### 3. Контент-анализ (Content Inspection)
|
||||||
|
**InfoWatch**: Лингвистический анализ, ML-классификация, OCR, document fingerprinting, digital watermarks, EDM (Exact Data Matching).
|
||||||
|
**AWatch-rus**: Только regex-matching в DLP policy rules.
|
||||||
|
|
||||||
|
**Что делать поэтапно:**
|
||||||
|
- **Phase A**: Словарные пакеты (ПДн по 152-ФЗ: ИНН, СНИЛС, паспорт, банковские реквизиты) — regex + Luhn/checksum validation. 1-2 недели.
|
||||||
|
- **Phase B**: Keyword/weighted scoring — JSON pack с весами, threshold для incident. 1 неделя.
|
||||||
|
- **Phase C**: OCR pipeline — Tesseract + screenshot analysis. Скриншоты уже снимаются! Нужен только OCR + content check step. 2-3 недели.
|
||||||
|
- **Phase D**: Document fingerprinting — SimHash/MinHash для корпоративных шаблонов. Server-side Python service. 3-4 недели.
|
||||||
|
- **Phase E**: ML-классификация — fine-tuned модель на корпоративных документах. Долгосрочно.
|
||||||
|
|
||||||
|
### 🟡 Важные (отличают серьёзный продукт от прототипа)
|
||||||
|
|
||||||
|
#### 4. Мессенджеры
|
||||||
|
**InfoWatch**: Skype, Telegram, WhatsApp, VK, Jabber, MS Teams.
|
||||||
|
**AWatch-rus**: Отсутствует.
|
||||||
|
|
||||||
|
**Что делать:**
|
||||||
|
- Перехват через `aw-watcher-window` + title parsing уже частично даёт metadata (видно, что пользователь в Telegram).
|
||||||
|
- Глубокий перехват текста мессенджеров требует accessibility API или memory scraping — сложно и ломко.
|
||||||
|
- Реалистичнее: интеграция с корпоративными мессенджерами через API (MS Teams webhook, Mattermost API).
|
||||||
|
- **MVP**: title-based detection "пользователь X общался в Telegram 2 часа" — уже почти есть.
|
||||||
|
|
||||||
|
#### 5. Облачные хранилища
|
||||||
|
**InfoWatch**: Контроль загрузки в DropBox, Google Drive, OneDrive, Яндекс.Диск.
|
||||||
|
**AWatch-rus**: Отсутствует.
|
||||||
|
|
||||||
|
**Что делать:**
|
||||||
|
- Browser-based detection: URL matching `drive.google.com/upload`, `disk.yandex.ru` — расширить `browser-domains-native-collector.ps1` правилами.
|
||||||
|
- Sync client detection: мониторинг процессов OneDrive/GoogleDrive + file watcher в sync-каталогах.
|
||||||
|
- **MVP**: Alert "файл был загружен в облако" через browser URL + file operation correlation. 1-2 недели.
|
||||||
|
|
||||||
|
#### 6. SIEM/SOAR интеграция
|
||||||
|
**InfoWatch**: CEF/syslog/API export, 75+ интеграций.
|
||||||
|
**AWatch-rus**: SQLite/PostgreSQL aggregator — foundation есть, но нет стандартных connectors.
|
||||||
|
|
||||||
|
**Что делать:**
|
||||||
|
- **CEF/Syslog exporter** — из `aggregate_dlp_events.py` в CEF format → rsyslog/Graylog/ELK. Python, 1-2 дня.
|
||||||
|
- **Webhook/HTTP callback** — при incident severity=high → POST в Teams/Telegram/PagerDuty. 1 день.
|
||||||
|
- **Grafana dashboards** — PostgreSQL уже поддерживается. Нужны готовые dashboard JSON. 2-3 дня.
|
||||||
|
- **Приоритет**: CEF exporter + Grafana — быстрые wins для демонстрации "enterprise-ready".
|
||||||
|
|
||||||
|
#### 7. RBAC и multi-tenant admin
|
||||||
|
**InfoWatch**: Ролевая модель, SoD, multi-tenant console, делегирование по отделам.
|
||||||
|
**AWatch-rus**: Single admin, нет ролей.
|
||||||
|
|
||||||
|
**Что делать:**
|
||||||
|
- ActivityWatch REST API не имеет auth из коробки. Нужен reverse proxy (nginx) с auth layer.
|
||||||
|
- **MVP**: Basic auth + nginx + IP whitelist. Достаточно для PoC.
|
||||||
|
- **Target**: Keycloak/LDAP auth proxy → role-based API access. Серьёзная работа, 4-6 недель.
|
||||||
|
|
||||||
|
### 🟢 Стратегические преимущества AWatch-rus (где вы уже лучше)
|
||||||
|
|
||||||
|
1. **Open-source база** — нет vendor lock-in, полный контроль над кодом.
|
||||||
|
2. **Лёгкий agent** — PowerShell collectors vs тяжёлый C++ agent InfoWatch (~200MB RAM).
|
||||||
|
3. **Гибкая DLP policy** — JSON rules, программируемые на лету, без перекомпиляции.
|
||||||
|
4. **Linux поддержка** — remote workers, SSH/console logging, web category — InfoWatch слабее в Linux.
|
||||||
|
5. **pfSense интеграция** — network visibility через firewall API, уникальная фича.
|
||||||
|
6. **Worktime tracking** — session-level presence, отработка с 1С (Grafana dashboards).
|
||||||
|
|
||||||
|
## Рекомендуемый стратегический roadmap
|
||||||
|
|
||||||
|
### Квартал 1 (ближайшие 3 месяца) — "Enforce & Detect"
|
||||||
|
| # | Задача | Усилие | Влияние |
|
||||||
|
|---|--------|--------|---------|
|
||||||
|
| 1 | USB write-block (GPO + enforcement step) | 2 нед | Критическое |
|
||||||
|
| 2 | Print job cancel при policy violation | 1 нед | Критическое |
|
||||||
|
| 3 | ПДн словарный пакет (ИНН/СНИЛС/паспорт regex) | 2 нед | Высокое |
|
||||||
|
| 4 | Email metadata collector (Exchange Journal) | 3 нед | Критическое |
|
||||||
|
| 5 | CEF/Syslog exporter для SIEM | 3 дня | Высокое |
|
||||||
|
| 6 | Grafana incident dashboards | 3 дня | Среднее |
|
||||||
|
|
||||||
|
### Квартал 2 — "Content & Cloud"
|
||||||
|
| # | Задача | Усилие | Влияние |
|
||||||
|
|---|--------|--------|---------|
|
||||||
|
| 7 | Cloud storage upload detection | 2 нед | Высокое |
|
||||||
|
| 8 | OCR pipeline (Tesseract + screenshot analysis) | 3 нед | Высокое |
|
||||||
|
| 9 | Weighted keyword scoring engine | 1 нед | Среднее |
|
||||||
|
| 10 | Webhook/callback при critical incidents | 2 дня | Среднее |
|
||||||
|
| 11 | Clipboard enforcement (clear on violation) | 1 нед | Среднее |
|
||||||
|
|
||||||
|
### Квартал 3 — "Intelligence & Scale"
|
||||||
|
| # | Задача | Усилие | Влияние |
|
||||||
|
|---|--------|--------|---------|
|
||||||
|
| 12 | Policy engine service (server-side, versioned) | 4 нед | Критическое |
|
||||||
|
| 13 | Document fingerprinting (SimHash) | 3 нед | Высокое |
|
||||||
|
| 14 | UEBA / risk scoring (anomaly detection) | 4 нед | Высокое |
|
||||||
|
| 15 | RBAC auth proxy (Keycloak/LDAP) | 4 нед | Среднее |
|
||||||
|
| 16 | Correlation engine (user+channel+object+time) | 3 нед | Высокое |
|
||||||
|
|
||||||
|
### Квартал 4 — "Enterprise"
|
||||||
|
| # | Задача | Усилие | Влияние |
|
||||||
|
|---|--------|--------|---------|
|
||||||
|
| 17 | Case management (investigations) | 4 нед | Среднее |
|
||||||
|
| 18 | Evidence chain / immutable audit log | 2 нед | Среднее |
|
||||||
|
| 19 | Compliance report generator (152-ФЗ) | 3 нед | Высокое |
|
||||||
|
| 20 | ML document classifier | 6+ нед | Высокое |
|
||||||
|
|
||||||
|
## Архитектурная рекомендация
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ AWatch-rus Server (Rust) │
|
||||||
|
│ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
|
||||||
|
│ │ AW API │ │ Policy │ │ Content │ │
|
||||||
|
│ │ (buckets │ │ Engine │ │ Analysis │ │
|
||||||
|
│ │ events) │ │ (Python │ │ Service │ │
|
||||||
|
│ │ │ │ sidecar) │ │ (OCR/ML/ │ │
|
||||||
|
│ │ │ │ │ │ fingerprint)│ │
|
||||||
|
│ └────┬─────┘ └─────┬─────┘ └──────┬───────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌────┴──────────────┴───────────────┴────────┐ │
|
||||||
|
│ │ PostgreSQL / SQLite │ │
|
||||||
|
│ └────┬──────────────┬───────────────┬────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌────┴─────┐ ┌─────┴─────┐ ┌─────┴────────┐ │
|
||||||
|
│ │ Grafana │ │ CEF/SIEM │ │ Webhook │ │
|
||||||
|
│ │ Dash │ │ Export │ │ Alerts │ │
|
||||||
|
│ └──────────┘ └───────────┘ └──────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
▲ ▲ ▲
|
||||||
|
┌──────┴──────┐ ┌─────┴─────┐ ┌─────┴──────┐
|
||||||
|
│ Windows │ │ Linux │ │ Network │
|
||||||
|
│ Endpoint │ │ Endpoint │ │ (pfSense, │
|
||||||
|
│ Collectors │ │ Watchers │ │ email │
|
||||||
|
│ + Enforce │ │ + SSH log │ │ gateway) │
|
||||||
|
└─────────────┘ └───────────┘ └────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Вывод
|
||||||
|
|
||||||
|
AWatch-rus уже покрывает ~25-30% функционала InfoWatch TM по каналам мониторинга. Критический разрыв — **отсутствие блокировки** (enforcement) и **контент-анализа**. Без этих двух компонентов продукт классифицируется как "мониторинг активности", а не "DLP".
|
||||||
|
|
||||||
|
Реалистичный путь к конкурентоспособности за 6-9 месяцев:
|
||||||
|
1. Добавить enforcement (USB/print/clipboard block) — переводит из "мониторинг" в "prevention"
|
||||||
|
2. Добавить content inspection (regex packs + OCR) — переводит из "метаданные" в "DLP"
|
||||||
|
3. Добавить email канал — закрывает #1 канал утечки
|
||||||
|
4. Добавить SIEM/Grafana export — делает продукт "enterprise-visible"
|
||||||
|
|
||||||
|
Сильная сторона проекта — **гибкость и лёгкость агента**. InfoWatch agent — тяжёлый C++ monolith. AWatch-rus collectors — лёгкие PowerShell/Python скрипты, которые можно быстро адаптировать. Это стратегическое преимущество для SMB/mid-market, где InfoWatch слишком дорог и тяжёл.
|
||||||
@@ -194,6 +194,7 @@
|
|||||||
loop:
|
loop:
|
||||||
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
|
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
|
||||||
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
|
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
|
||||||
|
- { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "{{ aw_server_webui_dir }}/js/aw-worktime-panel.js", mode: "0644" }
|
||||||
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
|
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
|
||||||
|
|
||||||
- name: Создать каталог /root/bootstrap для apply_webui_ru_patch.sh
|
- name: Создать каталог /root/bootstrap для apply_webui_ru_patch.sh
|
||||||
@@ -210,6 +211,7 @@
|
|||||||
loop:
|
loop:
|
||||||
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/root/bootstrap/aw-ru-patch.js", mode: "0644" }
|
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/root/bootstrap/aw-ru-patch.js", mode: "0644" }
|
||||||
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/root/bootstrap/aw-sw-cleanup.js", mode: "0644" }
|
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/root/bootstrap/aw-sw-cleanup.js", mode: "0644" }
|
||||||
|
- { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "/root/bootstrap/aw-worktime-panel.js", mode: "0644" }
|
||||||
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/root/bootstrap/aw-host-groups.json", mode: "0644" }
|
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/root/bootstrap/aw-host-groups.json", mode: "0644" }
|
||||||
|
|
||||||
- name: Скопировать apply_webui_ru_patch.sh скрипт
|
- name: Скопировать apply_webui_ru_patch.sh скрипт
|
||||||
@@ -233,9 +235,37 @@
|
|||||||
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
|
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
|
||||||
AW_SERVER_USER={{ aw_server_user }}
|
AW_SERVER_USER={{ aw_server_user }}
|
||||||
AW_SERVER_GROUP={{ aw_server_group }}
|
AW_SERVER_GROUP={{ aw_server_group }}
|
||||||
|
AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }}
|
||||||
|
AW_WORKTIME_TZ={{ aw_worktime_timezone }}
|
||||||
XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share
|
XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share
|
||||||
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
|
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
|
||||||
|
|
||||||
|
- name: Установить скрипт AW worktime API
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py"
|
||||||
|
dest: /usr/local/bin/aw-worktime-api.py
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Установить systemd unit AW worktime API
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.service"
|
||||||
|
dest: /etc/systemd/system/aw-worktime-api.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Перезагрузить systemd после установки AW worktime API
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
daemon_reload: true
|
||||||
|
|
||||||
|
- name: Включить и перезапустить AW worktime API
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: aw-worktime-api.service
|
||||||
|
enabled: true
|
||||||
|
state: restarted
|
||||||
|
|
||||||
- name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper)
|
- name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper)
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh"
|
cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ aw_server_data_dir: "/var/lib/activitywatch"
|
|||||||
aw_server_log_dir: "/var/log/activitywatch"
|
aw_server_log_dir: "/var/log/activitywatch"
|
||||||
aw_server_user: "activitywatch"
|
aw_server_user: "activitywatch"
|
||||||
aw_server_group: "activitywatch"
|
aw_server_group: "activitywatch"
|
||||||
|
aw_worktime_report_base: "http://10.10.10.13:5610"
|
||||||
|
aw_worktime_timezone: "Europe/Moscow"
|
||||||
|
|
||||||
aw_repo_root: "{{ playbook_dir | dirname }}"
|
aw_repo_root: "{{ playbook_dir | dirname }}"
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
- install_aw_server.sh
|
- install_aw_server.sh
|
||||||
- apply_webui_ru_patch.sh
|
- apply_webui_ru_patch.sh
|
||||||
- activitywatch-server.service
|
- activitywatch-server.service
|
||||||
|
- aw-worktime-api.py
|
||||||
|
- aw-worktime-api.service
|
||||||
|
- aw-worktime-panel.js
|
||||||
- aw-server.env.example
|
- aw-server.env.example
|
||||||
- aw-ru-patch.js
|
- aw-ru-patch.js
|
||||||
- aw-sw-cleanup.js
|
- aw-sw-cleanup.js
|
||||||
|
|||||||
+3
@@ -9,6 +9,9 @@
|
|||||||
- install_aw_server.sh
|
- install_aw_server.sh
|
||||||
- apply_webui_ru_patch.sh
|
- apply_webui_ru_patch.sh
|
||||||
- activitywatch-server.service
|
- activitywatch-server.service
|
||||||
|
- aw-worktime-api.py
|
||||||
|
- aw-worktime-api.service
|
||||||
|
- aw-worktime-panel.js
|
||||||
- aw-server.env.example
|
- aw-server.env.example
|
||||||
- aw-ru-patch.js
|
- aw-ru-patch.js
|
||||||
- aw-sw-cleanup.js
|
- aw-sw-cleanup.js
|
||||||
|
|||||||
@@ -159,6 +159,8 @@
|
|||||||
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
|
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
|
||||||
AW_SERVER_USER={{ aw_server_user }}
|
AW_SERVER_USER={{ aw_server_user }}
|
||||||
AW_SERVER_GROUP={{ aw_server_group }}
|
AW_SERVER_GROUP={{ aw_server_group }}
|
||||||
|
AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }}
|
||||||
|
AW_WORKTIME_TZ={{ aw_worktime_timezone }}
|
||||||
no_log: true
|
no_log: true
|
||||||
|
|
||||||
- name: Передать AW server env внутрь CT
|
- name: Передать AW server env внутрь CT
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ fi
|
|||||||
source "$ENV_FILE"
|
source "$ENV_FILE"
|
||||||
|
|
||||||
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}"
|
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}"
|
||||||
|
REPORT_BASE="${AW_WORKTIME_REPORT_BASE:-http://10.10.10.13:5610}"
|
||||||
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
|
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
|
||||||
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
|
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
|
||||||
|
WORKTIME_PANEL_SRC="/root/bootstrap/aw-worktime-panel.js"
|
||||||
HOST_GROUPS_SRC="/root/bootstrap/aw-host-groups.json"
|
HOST_GROUPS_SRC="/root/bootstrap/aw-host-groups.json"
|
||||||
INDEX_HTML="$WEBUI_DIR/index.html"
|
INDEX_HTML="$WEBUI_DIR/index.html"
|
||||||
SERVICE_WORKER="$WEBUI_DIR/service-worker.js"
|
SERVICE_WORKER="$WEBUI_DIR/service-worker.js"
|
||||||
TS=$(date +%Y%m%d%H%M%S)
|
TS=$(date +%Y%m%d%H%M%S)
|
||||||
PATCH_TARGET="$WEBUI_DIR/js/ru-patch-v5.js"
|
PATCH_TARGET="$WEBUI_DIR/js/ru-patch-v5.js"
|
||||||
SW_TARGET="$WEBUI_DIR/js/sw-cleanup.js"
|
SW_TARGET="$WEBUI_DIR/js/sw-cleanup.js"
|
||||||
|
WORKTIME_PANEL_TARGET="$WEBUI_DIR/js/aw-worktime-panel.js"
|
||||||
HOST_GROUPS_TARGET="$WEBUI_DIR/js/aw-host-groups.json"
|
HOST_GROUPS_TARGET="$WEBUI_DIR/js/aw-host-groups.json"
|
||||||
TRENDS_NEEDLE='this.activityStore.query_category_time_by_period(r)'
|
TRENDS_NEEDLE='this.activityStore.query_category_time_by_period(r)'
|
||||||
TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
|
TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
|
||||||
@@ -28,21 +31,76 @@ CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){retu
|
|||||||
|
|
||||||
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
|
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
|
||||||
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
|
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
|
||||||
|
[[ -f "$WORKTIME_PANEL_SRC" ]] || { echo "missing $WORKTIME_PANEL_SRC" >&2; exit 1; }
|
||||||
[[ -f "$HOST_GROUPS_SRC" ]] || { echo "missing $HOST_GROUPS_SRC" >&2; exit 1; }
|
[[ -f "$HOST_GROUPS_SRC" ]] || { echo "missing $HOST_GROUPS_SRC" >&2; exit 1; }
|
||||||
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
|
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
|
||||||
|
|
||||||
install -d "$WEBUI_DIR/js"
|
install -d "$WEBUI_DIR/js"
|
||||||
install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET"
|
install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET"
|
||||||
install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET"
|
install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET"
|
||||||
|
install -m 0644 "$WORKTIME_PANEL_SRC" "$WORKTIME_PANEL_TARGET"
|
||||||
install -m 0644 "$HOST_GROUPS_SRC" "$HOST_GROUPS_TARGET"
|
install -m 0644 "$HOST_GROUPS_SRC" "$HOST_GROUPS_TARGET"
|
||||||
cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS"
|
cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS"
|
||||||
|
|
||||||
patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')"
|
patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')"
|
||||||
sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')"
|
sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')"
|
||||||
|
worktime_panel_hash="$(sha1sum "$WORKTIME_PANEL_TARGET" | awk '{print substr($1,1,12)}')"
|
||||||
|
|
||||||
sed -i '/ru-patch-v5.js/d;/sw-cleanup.js/d;/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
|
python3 - "$WORKTIME_PANEL_TARGET" "$REPORT_BASE" <<'PY'
|
||||||
sed -i "s#</head>#<script src=\"/js/sw-cleanup.js?v=$sw_hash\"></script></head>#" "$INDEX_HTML"
|
from pathlib import Path
|
||||||
sed -i "s#</body>#<script defer=\"defer\" src=\"/js/ru-patch-v5.js?v=$patch_hash\"></script></body>#" "$INDEX_HTML"
|
import sys
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
report_base = sys.argv[2]
|
||||||
|
text = path.read_text()
|
||||||
|
text = text.replace("__AW_WORKTIME_REPORT_BASE__", report_base)
|
||||||
|
path.write_text(text)
|
||||||
|
PY
|
||||||
|
|
||||||
|
python3 - "$INDEX_HTML" "$sw_hash" "$patch_hash" "$worktime_panel_hash" "$REPORT_BASE" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
sw_hash = sys.argv[2]
|
||||||
|
patch_hash = sys.argv[3]
|
||||||
|
panel_hash = sys.argv[4]
|
||||||
|
report_base = sys.argv[5]
|
||||||
|
content = path.read_text()
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'<script[^>]+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js|aw-worktime-panel\.js)[^>]*></script>',
|
||||||
|
'',
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
content = re.sub(r"; frame-src 'self' [^\";>]*", "", content)
|
||||||
|
content = content.replace(
|
||||||
|
"script-src 'self' 'unsafe-eval'",
|
||||||
|
f"script-src 'self' 'unsafe-eval'; frame-src 'self' {report_base}",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
content = content.replace(
|
||||||
|
"</head>",
|
||||||
|
f'<script src="/js/sw-cleanup.js?v={sw_hash}"></script></head>',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
content = content.replace(
|
||||||
|
"</body>",
|
||||||
|
(
|
||||||
|
f'<script defer="defer" src="/js/ru-patch-v5.js?v={patch_hash}"></script>'
|
||||||
|
f'<script defer="defer" src="/js/aw-worktime-panel.js?v={panel_hash}"></script></body>'
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
if 'id="aw-report-links"' not in content:
|
||||||
|
content = content.replace(
|
||||||
|
"</body>",
|
||||||
|
'<div id="aw-report-links" style="position:fixed;right:12px;bottom:12px;z-index:99999;background:#111;color:#fff;padding:8px 10px;border-radius:8px;font:12px/1.4 sans-serif;opacity:.9">RDP report: loading...</div></body>',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
path.write_text(content)
|
||||||
|
PY
|
||||||
cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER"
|
cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER"
|
||||||
|
|
||||||
trends_chunk="$(grep -Rsl "$TRENDS_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
|
trends_chunk="$(grep -Rsl "$TRENDS_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ AW_SERVER_DATA_DIR=/var/lib/activitywatch
|
|||||||
AW_SERVER_LOG_DIR=/var/log/activitywatch
|
AW_SERVER_LOG_DIR=/var/log/activitywatch
|
||||||
AW_SERVER_USER=activitywatch
|
AW_SERVER_USER=activitywatch
|
||||||
AW_SERVER_GROUP=activitywatch
|
AW_SERVER_GROUP=activitywatch
|
||||||
|
AW_WORKTIME_REPORT_BASE=http://10.10.10.13:5610
|
||||||
|
AW_WORKTIME_TZ=Europe/Moscow
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
AW = "http://127.0.0.1:5600/api/0"
|
||||||
|
REPORT_TZ = ZoneInfo(os.environ.get("AW_WORKTIME_TZ", "Europe/Moscow"))
|
||||||
|
|
||||||
|
|
||||||
|
def get(u):
|
||||||
|
with urllib.request.urlopen(u, timeout=30) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def pts(s):
|
||||||
|
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def report_today():
|
||||||
|
now_local = datetime.now(REPORT_TZ)
|
||||||
|
start_local = datetime(now_local.year, now_local.month, now_local.day, tzinfo=REPORT_TZ)
|
||||||
|
end_local = start_local + timedelta(days=1) - timedelta(seconds=1)
|
||||||
|
start = start_local.astimezone(timezone.utc)
|
||||||
|
end = end_local.astimezone(timezone.utc)
|
||||||
|
b = get(AW + "/buckets")
|
||||||
|
sb = next((k for k in b if k.startswith("aw-worktime-sessions_")), None)
|
||||||
|
if not sb:
|
||||||
|
return []
|
||||||
|
ev = get(f"{AW}/buckets/{sb}/events?limit=50000")
|
||||||
|
by = {}
|
||||||
|
for e in ev:
|
||||||
|
ts = pts(e.get("timestamp"))
|
||||||
|
if ts < start or ts > end:
|
||||||
|
continue
|
||||||
|
d = e.get("data") or {}
|
||||||
|
user = (d.get("username") or "").strip()
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
state = (d.get("state") or "").lower()
|
||||||
|
active = ("актив" in state) or (state == "active")
|
||||||
|
row = by.setdefault(user, {"active": set(), "first": None, "last": None, "rows": 0})
|
||||||
|
row["rows"] += 1
|
||||||
|
if active:
|
||||||
|
second = ts.replace(microsecond=0)
|
||||||
|
row["active"].add(second)
|
||||||
|
row["first"] = second if row["first"] is None or second < row["first"] else row["first"]
|
||||||
|
row["last"] = second if row["last"] is None or second > row["last"] else row["last"]
|
||||||
|
rows = []
|
||||||
|
full = int((end_local - start_local).total_seconds())
|
||||||
|
for user in sorted(by):
|
||||||
|
row = by[user]
|
||||||
|
active_seconds = len(row["active"])
|
||||||
|
rows.append({
|
||||||
|
"user": user,
|
||||||
|
"active_seconds": active_seconds,
|
||||||
|
"active_hhmm": "%02d:%02d" % (active_seconds // 3600, (active_seconds % 3600) // 60),
|
||||||
|
"first_activity": row["first"].isoformat().replace("+00:00", "Z") if row["first"] else "",
|
||||||
|
"last_activity": row["last"].isoformat().replace("+00:00", "Z") if row["last"] else "",
|
||||||
|
"idle_seconds": max(0, full - active_seconds),
|
||||||
|
"sessions_count": row["rows"],
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(rows):
|
||||||
|
generated = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
date_local = datetime.now(REPORT_TZ).strftime("%Y-%m-%d")
|
||||||
|
trs = []
|
||||||
|
for row in rows:
|
||||||
|
trs.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['user']}</td>"
|
||||||
|
f"<td>{row['active_hhmm']}</td>"
|
||||||
|
f"<td>{row['active_seconds']}</td>"
|
||||||
|
f"<td>{row['first_activity']}</td>"
|
||||||
|
f"<td>{row['last_activity']}</td>"
|
||||||
|
f"<td>{row['idle_seconds']}</td>"
|
||||||
|
f"<td>{row['sessions_count']}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
if not trs:
|
||||||
|
trs.append('<tr><td colspan="7">No data for today yet.</td></tr>')
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>AW-rus Worktime</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f4f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--line: #dbe3ee;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #475569;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-2: #1d4ed8;
|
||||||
|
}}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.45 "Segoe UI", "Noto Sans", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(29,78,216,.08), transparent 28%),
|
||||||
|
radial-gradient(circle at top right, rgba(15,118,110,.10), transparent 24%),
|
||||||
|
var(--bg);
|
||||||
|
}}
|
||||||
|
.wrap {{ max-width: 1180px; margin: 0 auto; padding: 24px; }}
|
||||||
|
.hero {{
|
||||||
|
background: linear-gradient(135deg, #0f172a, #1e293b 58%, #0f766e);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
box-shadow: 0 22px 60px rgba(15,23,42,.22);
|
||||||
|
}}
|
||||||
|
.hero h1 {{ margin: 0 0 8px; font-size: 28px; }}
|
||||||
|
.meta {{ color: rgba(255,255,255,.84); }}
|
||||||
|
.actions {{ margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap; }}
|
||||||
|
.actions a {{
|
||||||
|
text-decoration: none;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255,255,255,.12);
|
||||||
|
border: 1px solid rgba(255,255,255,.18);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}}
|
||||||
|
.card {{
|
||||||
|
margin-top: 18px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 16px 40px rgba(15,23,42,.08);
|
||||||
|
}}
|
||||||
|
table {{ width: 100%; border-collapse: collapse; }}
|
||||||
|
th, td {{ padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; }}
|
||||||
|
th {{ background: #eef4fb; color: var(--muted); font-weight: 600; position: sticky; top: 0; }}
|
||||||
|
tr:nth-child(even) td {{ background: rgba(148,163,184,.06); }}
|
||||||
|
.num {{ font-variant-numeric: tabular-nums; }}
|
||||||
|
.good {{ color: var(--accent); font-weight: 700; }}
|
||||||
|
.muted {{ color: var(--muted); }}
|
||||||
|
@media (max-width: 900px) {{
|
||||||
|
.wrap {{ padding: 14px; }}
|
||||||
|
.hero h1 {{ font-size: 22px; }}
|
||||||
|
.card {{ overflow-x: auto; }}
|
||||||
|
table {{ min-width: 820px; }}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<section class="hero">
|
||||||
|
<h1>RDP Worktime Report</h1>
|
||||||
|
<div class="meta">Date: {date_local} · Timezone: {REPORT_TZ} · Generated UTC: {generated}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/reports/worktime/today?format=csv">Download CSV</a>
|
||||||
|
<a href="/reports/worktime/today">View JSON</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th>Active sec</th>
|
||||||
|
<th>First activity</th>
|
||||||
|
<th>Last activity</th>
|
||||||
|
<th>Idle sec</th>
|
||||||
|
<th>Samples</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{''.join(trs)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if not self.path.startswith("/reports/worktime/today"):
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
fmt = "json"
|
||||||
|
if "format=csv" in self.path:
|
||||||
|
fmt = "csv"
|
||||||
|
elif "format=html" in self.path:
|
||||||
|
fmt = "html"
|
||||||
|
rows = report_today()
|
||||||
|
if fmt == "csv":
|
||||||
|
out = io.StringIO()
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
out,
|
||||||
|
fieldnames=[
|
||||||
|
"user",
|
||||||
|
"active_seconds",
|
||||||
|
"active_hhmm",
|
||||||
|
"first_activity",
|
||||||
|
"last_activity",
|
||||||
|
"idle_seconds",
|
||||||
|
"sessions_count",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
data = out.getvalue().encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
return
|
||||||
|
if fmt == "html":
|
||||||
|
data = render_html(rows).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
return
|
||||||
|
obj = {
|
||||||
|
"generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
"report_timezone": str(REPORT_TZ),
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
HTTPServer(("0.0.0.0", 5610), H).serve_forever()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AW Worktime Report API
|
||||||
|
After=network.target activitywatch-server.service
|
||||||
|
Wants=activitywatch-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
EnvironmentFile=/etc/activitywatch/aw-server.env
|
||||||
|
ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-api.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
User=activitywatch
|
||||||
|
Group=activitywatch
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
(function () {
|
||||||
|
var reportBase = "__AW_WORKTIME_REPORT_BASE__";
|
||||||
|
var reportUrl = reportBase + "/reports/worktime/today?format=html";
|
||||||
|
var existing = document.getElementById("aw-report-links");
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
existing.innerHTML =
|
||||||
|
'RDP report: ' +
|
||||||
|
'<a href="' + reportUrl + '" style="color:#fcd34d" target="_blank">HTML</a> | ' +
|
||||||
|
'<a href="' + reportBase + '/reports/worktime/today?format=csv" style="color:#7dd3fc" target="_blank">CSV</a> | ' +
|
||||||
|
'<a href="' + reportBase + '/reports/worktime/today" style="color:#86efac" target="_blank">JSON</a> | ' +
|
||||||
|
'<a href="#" id="aw-report-toggle" style="color:#f9fafb">Panel</a>';
|
||||||
|
|
||||||
|
var panel = document.createElement("div");
|
||||||
|
panel.id = "aw-report-panel";
|
||||||
|
panel.style.cssText = [
|
||||||
|
"position:fixed",
|
||||||
|
"top:16px",
|
||||||
|
"right:16px",
|
||||||
|
"width:min(980px,calc(100vw - 32px))",
|
||||||
|
"height:min(760px,calc(100vh - 32px))",
|
||||||
|
"background:#fff",
|
||||||
|
"border:1px solid rgba(15,23,42,.15)",
|
||||||
|
"border-radius:12px",
|
||||||
|
"box-shadow:0 24px 80px rgba(15,23,42,.28)",
|
||||||
|
"overflow:hidden",
|
||||||
|
"z-index:100000",
|
||||||
|
"display:none"
|
||||||
|
].join(";");
|
||||||
|
|
||||||
|
panel.innerHTML =
|
||||||
|
'<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#0f172a;color:#fff;font:600 13px/1.2 sans-serif">' +
|
||||||
|
'<div>RDP Worktime Report</div>' +
|
||||||
|
'<div style="display:flex;gap:12px;align-items:center">' +
|
||||||
|
'<a href="' + reportUrl + '" target="_blank" style="color:#93c5fd;text-decoration:none">Open</a>' +
|
||||||
|
'<a href="#" id="aw-report-close" style="color:#fff;text-decoration:none">Close</a>' +
|
||||||
|
"</div></div>" +
|
||||||
|
'<iframe src="' + reportUrl + '" title="RDP Worktime Report" style="border:0;width:100%;height:calc(100% - 42px);background:#fff"></iframe>';
|
||||||
|
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
function openPanel(ev) {
|
||||||
|
if (ev) ev.preventDefault();
|
||||||
|
panel.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel(ev) {
|
||||||
|
if (ev) ev.preventDefault();
|
||||||
|
panel.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
var toggle = document.getElementById("aw-report-toggle");
|
||||||
|
if (toggle) toggle.addEventListener("click", openPanel);
|
||||||
|
var close = panel.querySelector("#aw-report-close");
|
||||||
|
if (close) close.addEventListener("click", closePanel);
|
||||||
|
})();
|
||||||
@@ -24,6 +24,8 @@ required_vars=(
|
|||||||
BOOTSTRAP_DIR="/root/bootstrap"
|
BOOTSTRAP_DIR="/root/bootstrap"
|
||||||
VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
|
VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
|
||||||
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
|
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
|
||||||
|
WORKTIME_API_SRC="$BOOTSTRAP_DIR/aw-worktime-api.py"
|
||||||
|
WORKTIME_API_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-api.service"
|
||||||
|
|
||||||
for var_name in "${required_vars[@]}"; do
|
for var_name in "${required_vars[@]}"; do
|
||||||
if [[ -z "${!var_name:-}" ]]; then
|
if [[ -z "${!var_name:-}" ]]; then
|
||||||
@@ -89,6 +91,18 @@ systemctl enable activitywatch-server.service
|
|||||||
systemctl restart activitywatch-server.service
|
systemctl restart activitywatch-server.service
|
||||||
systemctl --no-pager --full status activitywatch-server.service || true
|
systemctl --no-pager --full status activitywatch-server.service || true
|
||||||
|
|
||||||
|
if [[ -f "$WORKTIME_API_SRC" ]]; then
|
||||||
|
install -m 0755 "$WORKTIME_API_SRC" /usr/local/bin/aw-worktime-api.py
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then
|
||||||
|
install -m 0644 "$WORKTIME_API_SERVICE_SRC" /etc/systemd/system/aw-worktime-api.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable aw-worktime-api.service
|
||||||
|
systemctl restart aw-worktime-api.service
|
||||||
|
systemctl --no-pager --full status aw-worktime-api.service || true
|
||||||
|
fi
|
||||||
|
|
||||||
for _ in $(seq 1 20); do
|
for _ in $(seq 1 20); do
|
||||||
if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then
|
if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -516,6 +516,17 @@ function Test-LooksLikeMojibakeQuestionMarks {
|
|||||||
return $Value -match '\?{2,}'
|
return $Value -match '\?{2,}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-LooksLikeRussianTitleMaskedAsQuestionMarks {
|
||||||
|
param([AllowNull()][string]$Value)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
|
||||||
|
|
||||||
|
$trimmed = $Value.Trim()
|
||||||
|
if ($trimmed -match '[A-Za-zА-Яа-я0-9]') { return $false }
|
||||||
|
|
||||||
|
# Typical broken Cyrillic print title shape: multiple words of question marks.
|
||||||
|
return $trimmed -match '^\?{3,}(\s+\?{3,})+$'
|
||||||
|
}
|
||||||
|
|
||||||
function Normalize-OwnerForMatch {
|
function Normalize-OwnerForMatch {
|
||||||
param([AllowNull()][string]$Value)
|
param([AllowNull()][string]$Value)
|
||||||
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
|
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
|
||||||
@@ -823,12 +834,12 @@ while ($true) {
|
|||||||
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
|
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
|
||||||
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
|
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
|
||||||
|
|
||||||
$printerName = [string]$job.Name
|
$printerName = Normalize-PrinterForMatch -Value ([string]$job.Name)
|
||||||
$documentName = [string]$job.Document
|
$documentName = [string]$job.Document
|
||||||
$owner = [string]$job.Owner
|
$owner = [string]$job.Owner
|
||||||
$documentNameOriginal = $documentName
|
$documentNameOriginal = $documentName
|
||||||
|
|
||||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
|
if (Test-LooksLikeRussianTitleMaskedAsQuestionMarks -Value $documentName) {
|
||||||
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
|
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
|
||||||
if ($eventDocumentName) {
|
if ($eventDocumentName) {
|
||||||
$documentName = $eventDocumentName
|
$documentName = $eventDocumentName
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Анализ DLP-скриптов PowerShell (работоспособность)
|
||||||
|
|
||||||
|
Дата анализа: **2026-05-04 (UTC)**
|
||||||
|
|
||||||
|
## Проверенный scope
|
||||||
|
|
||||||
|
- `windows/dlp-endpoint-signals-collector.ps1`
|
||||||
|
- `windows/file-operations-collector.ps1`
|
||||||
|
- `windows/dlp-policy.example.json`
|
||||||
|
- `windows/web-category-rules.example.json`
|
||||||
|
|
||||||
|
## Ключевой итог
|
||||||
|
|
||||||
|
DLP-скрипты в целом рабочие по архитектуре (heartbeat в ActivityWatch, policy-driven правила, cooldown, enforcement), но есть **критичный риск misconfiguration** и несколько эксплуатационных рисков.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что точно хорошо
|
||||||
|
|
||||||
|
1. В обоих коллекторах включены `Set-StrictMode -Version Latest` и `$ErrorActionPreference = 'Stop'`.
|
||||||
|
2. Есть отправка событий в отдельные bucket’ы (`aw-dlp-endpoint-signals_*`, `aw-dlp-incidents_*`, `aw-file-operations_*`).
|
||||||
|
3. В endpoint-коллекторе реализованы:
|
||||||
|
- правила по буферу обмена / USB / печати,
|
||||||
|
- suppression через cooldown (`Should-EmitByCooldown`),
|
||||||
|
- опциональный screenshot capture при инциденте.
|
||||||
|
4. В file collector есть наблюдение за `Desktop/Documents/Downloads` через `FileSystemWatcher`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Найденные проблемы и риски
|
||||||
|
|
||||||
|
### 1) Критично: дефолтный путь конфига в endpoint-скрипте не совпадает с проектом
|
||||||
|
|
||||||
|
- `dlp-endpoint-signals-collector.ps1` использует по умолчанию:
|
||||||
|
- `C:\ProgramData\ActivityWatch\deployment-config.json`
|
||||||
|
- Остальной проект использует namespace `AWatch-rus` (`C:\ProgramData\AWatch-rus\...`).
|
||||||
|
|
||||||
|
**Риск:** endpoint-коллектор может стартовать без нужного deployment-конфига и работать с неверными/пустыми параметрами.
|
||||||
|
|
||||||
|
### 2) Нет строгой проверки HTTP-результата в file collector
|
||||||
|
|
||||||
|
В `file-operations-collector.ps1` POST выполняется через `HttpClient`, но код ответа не валидируется (`IsSuccessStatusCode` не проверяется), ошибки частично только логируются.
|
||||||
|
|
||||||
|
**Риск:** «тихая» потеря telemetry при 4xx/5xx.
|
||||||
|
|
||||||
|
### 3) Watcher не снимает event subscriptions явно
|
||||||
|
|
||||||
|
Есть `Register-ObjectEvent`, но в `finally` disposal только watcher-объектов; отписка событий (`Unregister-Event`) явно не делается.
|
||||||
|
|
||||||
|
**Риск:** при рестартах/долгой работе возможно накопление подписок в сессии.
|
||||||
|
|
||||||
|
### 4) Screenshot/GUI-зависимость для enforcement
|
||||||
|
|
||||||
|
`Capture-IncidentScreenshot` и balloon notification завязаны на `System.Windows.Forms/System.Drawing`.
|
||||||
|
|
||||||
|
**Риск:** в non-interactive / service context часть enforcement UX может не работать (событие уйдёт, но скриншот/уведомление может не сформироваться).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендации (приоритет)
|
||||||
|
|
||||||
|
1. **P1:** выровнять дефолтный `ConfigPath` в `dlp-endpoint-signals-collector.ps1` на `C:\ProgramData\AWatch-rus\deployment-config.json`.
|
||||||
|
2. **P1:** добавить проверку `response.IsSuccessStatusCode` в `file-operations-collector.ps1` и логировать body/status при ошибках.
|
||||||
|
3. **P2:** сохранить subscription-объекты `Register-ObjectEvent` и делать `Unregister-Event` в `finally`.
|
||||||
|
4. **P2:** для enforcement/UI добавить fallback режим «headless» (только лог + heartbeat).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что не удалось проверить в текущей среде
|
||||||
|
|
||||||
|
В этом контейнере отсутствует `pwsh`, поэтому не выполнены:
|
||||||
|
|
||||||
|
- синтаксический parse всех `*.ps1/*.psm1` через PowerShell parser;
|
||||||
|
- `Test-ModuleManifest`;
|
||||||
|
- smoke-run на Windows API (`Get-WinEvent`, `Get-Partition`, `Get-Disk`, `Set-Clipboard`, `Win32_PrintJob`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Команды для целевой Windows-проверки
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1) Синтаксис
|
||||||
|
Get-ChildItem .\windows -Recurse -Include *.ps1,*.psm1 | ForEach-Object {
|
||||||
|
[void][System.Management.Automation.Language.Parser]::ParseFile($_.FullName,[ref]$null,[ref]$errs)
|
||||||
|
if($errs){ "FAIL $($_.FullName)" } else { "OK $($_.FullName)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2) Быстрый запуск file collector (с логом)
|
||||||
|
.\windows\file-operations-collector.ps1 -ConfigPath 'C:\ProgramData\AWatch-rus\deployment-config.json' -LogPath 'C:\ProgramData\AWatch-rus\collector-fileops.log'
|
||||||
|
|
||||||
|
# 3) Быстрый запуск endpoint collector (с логом)
|
||||||
|
.\windows\dlp-endpoint-signals-collector.ps1 -ConfigPath 'C:\ProgramData\AWatch-rus\deployment-config.json' -PolicyPath 'C:\ProgramData\AWatch-rus\dlp-policy.json' -LogPath 'C:\ProgramData\AWatch-rus\collector-endpoint.log'
|
||||||
|
```
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
KIT_DIR="install-kit-awindows-20260427-211240"
|
||||||
|
|
||||||
|
python - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
root=Path('.')
|
||||||
|
kit=Path('install-kit-awindows-20260427-211240')
|
||||||
|
if not kit.exists():
|
||||||
|
raise SystemExit('Install kit directory not found')
|
||||||
|
|
||||||
|
def sha(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
all_compared=[]
|
||||||
|
mismatches=[]
|
||||||
|
missing_in_repo=[]
|
||||||
|
|
||||||
|
for kp in sorted(p for p in kit.rglob('*') if p.is_file() and p.name!='MANIFEST.txt'):
|
||||||
|
rel=kp.relative_to(kit)
|
||||||
|
rp=root/rel
|
||||||
|
if not rp.exists():
|
||||||
|
missing_in_repo.append(str(rel))
|
||||||
|
continue
|
||||||
|
all_compared.append(str(rel))
|
||||||
|
if sha(kp)!=sha(rp):
|
||||||
|
mismatches.append(str(rel))
|
||||||
|
|
||||||
|
ps_mismatches=[p for p in mismatches if p.startswith('windows/') and p.endswith('.ps1') or p.endswith('.psm1') or p.endswith('.psd1')]
|
||||||
|
|
||||||
|
print(f'Compared files: {len(all_compared)}')
|
||||||
|
print(f'Missing in repo: {len(missing_in_repo)}')
|
||||||
|
print(f'Mismatched content: {len(mismatches)}')
|
||||||
|
if missing_in_repo:
|
||||||
|
print('--- Missing in repo ---')
|
||||||
|
for p in missing_in_repo:
|
||||||
|
print(p)
|
||||||
|
if mismatches:
|
||||||
|
print('--- Mismatches ---')
|
||||||
|
for p in mismatches:
|
||||||
|
print(p)
|
||||||
|
print(f'PowerShell mismatches: {len(ps_mismatches)}')
|
||||||
|
if ps_mismatches:
|
||||||
|
print('--- PowerShell mismatches ---')
|
||||||
|
for p in ps_mismatches:
|
||||||
|
print(p)
|
||||||
|
PY
|
||||||
@@ -77,4 +77,7 @@ ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_windows.yml --check
|
|||||||
log "Deploy aw_windows..."
|
log "Deploy aw_windows..."
|
||||||
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_windows.yml | tee -a "${LOG_DIR}/deploy_aw_windows.log"
|
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_windows.yml | tee -a "${LOG_DIR}/deploy_aw_windows.log"
|
||||||
|
|
||||||
|
log "Post-validate aw_windows..."
|
||||||
|
ansible-playbook -i ansible/inventory.ini ansible/post_validate_aw_windows.yml | tee -a "${LOG_DIR}/post_validate_aw_windows.log"
|
||||||
|
|
||||||
log "DONE. Logs: ${LOG_DIR}"
|
log "DONE. Logs: ${LOG_DIR}"
|
||||||
|
|||||||
+14
-3
@@ -4,17 +4,17 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
echo "[1/3] Bash syntax check"
|
echo "[1/4] Bash syntax check"
|
||||||
find aw-server proxmox -type f -name "*.sh" -print0 | xargs -0 -r -n1 bash -n
|
find aw-server proxmox -type f -name "*.sh" -print0 | xargs -0 -r -n1 bash -n
|
||||||
|
|
||||||
echo "[2/3] Shellcheck (if available)"
|
echo "[2/4] Shellcheck (if available)"
|
||||||
if command -v shellcheck >/dev/null 2>&1; then
|
if command -v shellcheck >/dev/null 2>&1; then
|
||||||
find aw-server proxmox -type f -name "*.sh" -print0 | xargs -0 -r shellcheck -e SC1007,SC1090,SC2016
|
find aw-server proxmox -type f -name "*.sh" -print0 | xargs -0 -r shellcheck -e SC1007,SC1090,SC2016
|
||||||
else
|
else
|
||||||
echo "shellcheck not found, skipping."
|
echo "shellcheck not found, skipping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[3/3] PowerShell parse check (if pwsh available)"
|
echo "[3/4] PowerShell parse check (if pwsh available)"
|
||||||
if command -v pwsh >/dev/null 2>&1; then
|
if command -v pwsh >/dev/null 2>&1; then
|
||||||
pwsh -NoLogo -NoProfile -Command '
|
pwsh -NoLogo -NoProfile -Command '
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -28,4 +28,15 @@ else
|
|||||||
echo "pwsh not found, skipping."
|
echo "pwsh not found, skipping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
echo "[4/4] Ansible syntax check (if ansible-playbook available)"
|
||||||
|
if command -v ansible-playbook >/dev/null 2>&1; then
|
||||||
|
for playbook in ansible/*.yml; do
|
||||||
|
ansible-playbook --syntax-check "$playbook" -i ansible/inventory.example.ini >/dev/null
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "ansible-playbook not found, skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "quality-gate: OK"
|
echo "quality-gate: OK"
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DAY=""
|
||||||
|
FROM=""
|
||||||
|
TO=""
|
||||||
|
AW_BASE_URL="${AW_BASE_URL:-http://10.10.10.13:5600/api/0}"
|
||||||
|
OUT_DIR="${OUT_DIR:-reports}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage:
|
||||||
|
$0 --day today|yesterday
|
||||||
|
$0 --from YYYY-MM-DD --to YYYY-MM-DD
|
||||||
|
Env:
|
||||||
|
AW_BASE_URL (default: ${AW_BASE_URL})
|
||||||
|
OUT_DIR (default: ${OUT_DIR})
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--day) DAY="${2:-}"; shift 2 ;;
|
||||||
|
--from) FROM="${2:-}"; shift 2 ;;
|
||||||
|
--to) TO="${2:-}"; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$DAY" ]]; then
|
||||||
|
if [[ "$DAY" == "today" ]]; then
|
||||||
|
FROM="$(date +%F)"
|
||||||
|
TO="$FROM"
|
||||||
|
elif [[ "$DAY" == "yesterday" ]]; then
|
||||||
|
FROM="$(date -d 'yesterday' +%F)"
|
||||||
|
TO="$FROM"
|
||||||
|
else
|
||||||
|
echo "Invalid --day: $DAY" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$FROM" || -z "$TO" ]]; then
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
CSV_OUT="${OUT_DIR}/rdp-worktime-${FROM}_${TO}.csv"
|
||||||
|
JSON_OUT="${OUT_DIR}/rdp-worktime-${FROM}_${TO}.json"
|
||||||
|
|
||||||
|
python3 - "$AW_BASE_URL" "$FROM" "$TO" "$CSV_OUT" "$JSON_OUT" <<'PY'
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
base, from_d, to_d, csv_out, json_out = sys.argv[1:6]
|
||||||
|
|
||||||
|
def get_json(url: str):
|
||||||
|
with urllib.request.urlopen(url, timeout=30) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
def parse_ts(s):
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||||
|
|
||||||
|
buckets = get_json(f"{base}/buckets")
|
||||||
|
sessions_bucket = None
|
||||||
|
for k in buckets.keys():
|
||||||
|
if k.startswith("aw-worktime-sessions_"):
|
||||||
|
sessions_bucket = k
|
||||||
|
break
|
||||||
|
|
||||||
|
if not sessions_bucket:
|
||||||
|
raise SystemExit("No aw-worktime-sessions_* bucket found")
|
||||||
|
|
||||||
|
start = datetime.fromisoformat(from_d + "T00:00:00+00:00")
|
||||||
|
end = datetime.fromisoformat(to_d + "T23:59:59+00:00")
|
||||||
|
|
||||||
|
ev = get_json(f"{base}/buckets/{sessions_bucket}/events?limit=20000")
|
||||||
|
by_user = {}
|
||||||
|
for e in ev:
|
||||||
|
ts = parse_ts(e.get("timestamp"))
|
||||||
|
if ts is None or ts < start or ts > end:
|
||||||
|
continue
|
||||||
|
d = e.get("data") or {}
|
||||||
|
user = (d.get("username") or "").strip()
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
state = (d.get("state") or "").strip().lower()
|
||||||
|
is_active = ("актив" in state) or (state == "active")
|
||||||
|
rec = by_user.setdefault(user, {"active_ts": set(), "first": None, "last": None, "rows": 0})
|
||||||
|
rec["rows"] += 1
|
||||||
|
if is_active:
|
||||||
|
rec["active_ts"].add(ts.replace(microsecond=0))
|
||||||
|
rec["first"] = ts if rec["first"] is None or ts < rec["first"] else rec["first"]
|
||||||
|
rec["last"] = ts if rec["last"] is None or ts > rec["last"] else rec["last"]
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
full_range = int((end - start).total_seconds())
|
||||||
|
for user in sorted(by_user.keys()):
|
||||||
|
rec = by_user[user]
|
||||||
|
active = len(rec["active_ts"])
|
||||||
|
idle = max(0, full_range - active)
|
||||||
|
rows.append({
|
||||||
|
"user": user,
|
||||||
|
"active_seconds": int(active),
|
||||||
|
"active_hhmm": f"{int(active)//3600:02d}:{(int(active)%3600)//60:02d}",
|
||||||
|
"first_activity": rec["first"].isoformat().replace("+00:00","Z") if rec["first"] else "",
|
||||||
|
"last_activity": rec["last"].isoformat().replace("+00:00","Z") if rec["last"] else "",
|
||||||
|
"idle_seconds": int(idle),
|
||||||
|
"sessions_count": rec["rows"],
|
||||||
|
})
|
||||||
|
|
||||||
|
with open(csv_out, "w", newline="", encoding="utf-8") as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=[
|
||||||
|
"user","active_seconds","active_hhmm","first_activity","last_activity","idle_seconds","sessions_count"
|
||||||
|
])
|
||||||
|
w.writeheader()
|
||||||
|
w.writerows(rows)
|
||||||
|
|
||||||
|
with open(json_out, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({
|
||||||
|
"from": from_d,
|
||||||
|
"to": to_d,
|
||||||
|
"generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00","Z"),
|
||||||
|
"rows": rows
|
||||||
|
}, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(csv_out)
|
||||||
|
print(json_out)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "CSV: ${CSV_OUT}"
|
||||||
|
echo "JSON: ${JSON_OUT}"
|
||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
KIT_DIR="install-kit-awindows-20260427-211240"
|
||||||
|
MANIFEST="$KIT_DIR/MANIFEST.txt"
|
||||||
|
ZIP_ARCHIVE="install-kit-awindows-20260427-211240.zip"
|
||||||
|
TAR_ARCHIVE="install-kit-awindows-20260427-211240.tar.gz"
|
||||||
|
|
||||||
|
required_files=(
|
||||||
|
"$MANIFEST"
|
||||||
|
"$KIT_DIR/README-INSTALL-KIT.txt"
|
||||||
|
"$KIT_DIR/windows/deploy-ensemble.ps1"
|
||||||
|
"$KIT_DIR/windows/validate-deployment.ps1"
|
||||||
|
"$KIT_DIR/ansible/deploy_aw_windows_phase2.yml"
|
||||||
|
"$KIT_DIR/aw-server/install_aw_server.sh"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "[1/4] Required files presence"
|
||||||
|
for file in "${required_files[@]}"; do
|
||||||
|
[[ -f "$file" ]] || { echo "Missing required file: $file"; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[2/4] Manifest checksum verification"
|
||||||
|
sha256sum -c "$MANIFEST" >/dev/null
|
||||||
|
|
||||||
|
echo "[3/4] Manifest completeness"
|
||||||
|
python - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
kit=Path('install-kit-awindows-20260427-211240')
|
||||||
|
manifest=kit/'MANIFEST.txt'
|
||||||
|
listed=[]
|
||||||
|
for line in manifest.read_text().splitlines():
|
||||||
|
line=line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts=line.split(' ',1)
|
||||||
|
if len(parts)!=2:
|
||||||
|
print(f'Invalid MANIFEST line: {line}')
|
||||||
|
sys.exit(1)
|
||||||
|
listed.append(parts[1])
|
||||||
|
listed_set=set(listed)
|
||||||
|
actual_set={str(p) for p in kit.rglob('*') if p.is_file() and p.name!='MANIFEST.txt'}
|
||||||
|
missing=sorted(listed_set-actual_set)
|
||||||
|
extra=sorted(actual_set-listed_set)
|
||||||
|
if missing or extra:
|
||||||
|
print('Missing files listed in MANIFEST:', missing)
|
||||||
|
print('Files not listed in MANIFEST:', extra)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f'MANIFEST complete: {len(actual_set)} files tracked')
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "[4/4] Archive composition check"
|
||||||
|
python - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import tarfile, zipfile, sys
|
||||||
|
kit_prefix='install-kit-awindows-20260427-211240/'
|
||||||
|
zip_path=Path('install-kit-awindows-20260427-211240.zip')
|
||||||
|
tar_path=Path('install-kit-awindows-20260427-211240.tar.gz')
|
||||||
|
if not zip_path.exists() or not tar_path.exists():
|
||||||
|
print('Archives not found')
|
||||||
|
sys.exit(1)
|
||||||
|
with zipfile.ZipFile(zip_path) as z:
|
||||||
|
zip_files=sorted(i for i in z.namelist() if not i.endswith('/'))
|
||||||
|
with tarfile.open(tar_path, 'r:gz') as t:
|
||||||
|
tar_files=sorted(m.name for m in t.getmembers() if m.isfile())
|
||||||
|
if zip_files != tar_files:
|
||||||
|
print('ZIP and TAR contents differ')
|
||||||
|
sys.exit(1)
|
||||||
|
if not all(f.startswith(kit_prefix) for f in zip_files):
|
||||||
|
print('Unexpected archive prefix layout')
|
||||||
|
sys.exit(1)
|
||||||
|
print(f'Archives match: {len(zip_files)} files')
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "validate_install_kit: OK"
|
||||||
@@ -541,7 +541,7 @@ function Send-DlpIncidentHeartbeat {
|
|||||||
} + $captureData
|
} + $captureData
|
||||||
} | ConvertTo-Json -Depth 5 -Compress
|
} | ConvertTo-Json -Depth 5 -Compress
|
||||||
|
|
||||||
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null
|
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-FileSha256Hex {
|
function Get-FileSha256Hex {
|
||||||
@@ -746,7 +746,7 @@ function Send-Heartbeat {
|
|||||||
}
|
}
|
||||||
} | ConvertTo-Json -Depth 4 -Compress
|
} | ConvertTo-Json -Depth 4 -Compress
|
||||||
|
|
||||||
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null
|
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
function Send-CategoryHeartbeat {
|
function Send-CategoryHeartbeat {
|
||||||
@@ -783,7 +783,7 @@ function Send-CategoryHeartbeat {
|
|||||||
}
|
}
|
||||||
} | ConvertTo-Json -Depth 4 -Compress
|
} | ConvertTo-Json -Depth 4 -Compress
|
||||||
|
|
||||||
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null
|
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
Load-CustomCategoryRules -Path $resolvedRulesPath
|
Load-CustomCategoryRules -Path $resolvedRulesPath
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function Invoke-AwJsonPost {
|
|||||||
)
|
)
|
||||||
|
|
||||||
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
|
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
|
||||||
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
|
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
function Ensure-Bucket {
|
function Ensure-Bucket {
|
||||||
@@ -243,6 +243,10 @@ function Show-EnforcementNotification {
|
|||||||
[Parameter(Mandatory = $true)][string]$Title,
|
[Parameter(Mandatory = $true)][string]$Title,
|
||||||
[Parameter(Mandatory = $true)][string]$Body
|
[Parameter(Mandatory = $true)][string]$Body
|
||||||
)
|
)
|
||||||
|
if ($script:HeadlessMode) {
|
||||||
|
Write-EndpointLog ("headless mode: skip notification title={0}" -f $Title)
|
||||||
|
return $false
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
|
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
|
||||||
$icon = New-Object System.Windows.Forms.NotifyIcon
|
$icon = New-Object System.Windows.Forms.NotifyIcon
|
||||||
@@ -254,9 +258,11 @@ function Show-EnforcementNotification {
|
|||||||
$icon.ShowBalloonTip(5000)
|
$icon.ShowBalloonTip(5000)
|
||||||
Start-Sleep -Milliseconds 200
|
Start-Sleep -Milliseconds 200
|
||||||
$icon.Dispose()
|
$icon.Dispose()
|
||||||
|
return $true
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message)
|
Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message)
|
||||||
|
return $false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +346,44 @@ function Get-StringHash {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-ClipboardTextSafe {
|
||||||
|
[OutputType([string])]
|
||||||
|
param()
|
||||||
|
|
||||||
|
try {
|
||||||
|
$v = Get-Clipboard -Raw -ErrorAction Stop
|
||||||
|
if ($null -ne $v) { return [string]$v }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases.
|
||||||
|
try {
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
$result = [string]::Empty
|
||||||
|
$thread = [System.Threading.Thread]{
|
||||||
|
try {
|
||||||
|
$script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$script:__aw_clip = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$thread.SetApartmentState([System.Threading.ApartmentState]::STA)
|
||||||
|
$thread.Start()
|
||||||
|
$thread.Join(3000) | Out-Null
|
||||||
|
if ($thread.IsAlive) { $thread.Abort() }
|
||||||
|
$result = [string]$script:__aw_clip
|
||||||
|
Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue
|
||||||
|
return $result
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message)
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Load-DlpPolicy {
|
function Load-DlpPolicy {
|
||||||
param([string]$Path)
|
param([string]$Path)
|
||||||
|
|
||||||
@@ -405,6 +449,9 @@ function Evaluate-ClipboardRules {
|
|||||||
[string]$ClipboardText,
|
[string]$ClipboardText,
|
||||||
[string]$ClipboardHash
|
[string]$ClipboardHash
|
||||||
)
|
)
|
||||||
|
if ([string]::IsNullOrEmpty($ClipboardText) -or [string]::IsNullOrEmpty($ClipboardHash)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($rule in @($script:Policy.endpoint.clipboard)) {
|
foreach ($rule in @($script:Policy.endpoint.clipboard)) {
|
||||||
if (-not $rule) { continue }
|
if (-not $rule) { continue }
|
||||||
@@ -435,8 +482,13 @@ function Evaluate-ClipboardRules {
|
|||||||
|
|
||||||
$enforced = $false
|
$enforced = $false
|
||||||
if ($action -eq 'block') {
|
if ($action -eq 'block') {
|
||||||
$enforced = Invoke-ClipboardEnforcement
|
if ($script:HeadlessMode) {
|
||||||
Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message
|
Write-EndpointLog ("headless fallback: clipboard rule={0} requires block, skipped interactive enforcement" -f $ruleId)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$enforced = Invoke-ClipboardEnforcement
|
||||||
|
[void](Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{
|
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{
|
||||||
@@ -470,8 +522,13 @@ function Evaluate-UsbRules {
|
|||||||
|
|
||||||
$enforced = $false
|
$enforced = $false
|
||||||
if ($action -eq 'block') {
|
if ($action -eq 'block') {
|
||||||
$enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter
|
if ($script:HeadlessMode) {
|
||||||
Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message
|
Write-EndpointLog ("headless fallback: usb rule={0} requires block, skipped interactive enforcement drive={1}" -f $ruleId, $DriveLetter)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter
|
||||||
|
[void](Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{
|
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{
|
||||||
@@ -515,8 +572,13 @@ function Evaluate-PrintRules {
|
|||||||
|
|
||||||
$enforced = $false
|
$enforced = $false
|
||||||
if ($action -eq 'block') {
|
if ($action -eq 'block') {
|
||||||
$enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner
|
if ($script:HeadlessMode) {
|
||||||
Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message
|
Write-EndpointLog ("headless fallback: print rule={0} requires block, skipped interactive enforcement printer={1}" -f $ruleId, $PrinterName)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner
|
||||||
|
[void](Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{
|
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{
|
||||||
@@ -535,6 +597,17 @@ function Test-LooksLikeMojibakeQuestionMarks {
|
|||||||
return $Value -match '\?{2,}'
|
return $Value -match '\?{2,}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-LooksLikeRussianTitleMaskedAsQuestionMarks {
|
||||||
|
param([AllowNull()][string]$Value)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
|
||||||
|
|
||||||
|
$trimmed = $Value.Trim()
|
||||||
|
if ($trimmed -match '[A-Za-zА-Яа-я0-9]') { return $false }
|
||||||
|
|
||||||
|
# Typical broken Cyrillic print title shape: multiple words of question marks.
|
||||||
|
return $trimmed -match '^\?{3,}(\s+\?{3,})+$'
|
||||||
|
}
|
||||||
|
|
||||||
function Normalize-OwnerForMatch {
|
function Normalize-OwnerForMatch {
|
||||||
param([AllowNull()][string]$Value)
|
param([AllowNull()][string]$Value)
|
||||||
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
|
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
|
||||||
@@ -743,6 +816,7 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
Write-EndpointLog ("printservice fallback failed: {0}" -f $_.Exception.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return $null
|
return $null
|
||||||
@@ -779,9 +853,13 @@ $script:LogPath = $resolvedLogPath
|
|||||||
$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot
|
$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot
|
||||||
$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
|
$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
|
||||||
$script:ScreenshotTypesLoaded = $false
|
$script:ScreenshotTypesLoaded = $false
|
||||||
|
$script:HeadlessMode = ($env:SESSIONNAME -eq 'Service') -or (-not [Environment]::UserInteractive)
|
||||||
|
|
||||||
Load-DlpPolicy -Path $resolvedPolicyPath
|
Load-DlpPolicy -Path $resolvedPolicyPath
|
||||||
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
|
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
|
||||||
|
if ($script:HeadlessMode) {
|
||||||
|
Write-EndpointLog "headless mode enabled: enforcement UI is disabled, incident heartbeat and logs only"
|
||||||
|
}
|
||||||
|
|
||||||
while ($true) {
|
while ($true) {
|
||||||
try {
|
try {
|
||||||
@@ -791,7 +869,7 @@ while ($true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$clipboardText = Get-Clipboard -Raw -ErrorAction SilentlyContinue
|
$clipboardText = Get-ClipboardTextSafe
|
||||||
if ($clipboardText) {
|
if ($clipboardText) {
|
||||||
$clipboardHash = Get-StringHash -Value $clipboardText
|
$clipboardHash = Get-StringHash -Value $clipboardText
|
||||||
if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) {
|
if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) {
|
||||||
@@ -805,6 +883,7 @@ while ($true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
Write-EndpointLog ("clipboard poll failed: {0}" -f $_.Exception.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -832,6 +911,7 @@ while ($true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
Write-EndpointLog ("usb poll failed: {0}" -f $_.Exception.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -842,23 +922,33 @@ while ($true) {
|
|||||||
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
|
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
|
||||||
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
|
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
|
||||||
|
|
||||||
$printerName = [string]$job.Name
|
$printerName = Normalize-PrinterForMatch -Value ([string]$job.Name)
|
||||||
$documentName = [string]$job.Document
|
$documentName = [string]$job.Document
|
||||||
$owner = [string]$job.Owner
|
$owner = [string]$job.Owner
|
||||||
$documentNameOriginal = $documentName
|
$documentNameOriginal = $documentName
|
||||||
|
|
||||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
|
if (Test-LooksLikeRussianTitleMaskedAsQuestionMarks -Value $documentName) {
|
||||||
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
|
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
|
||||||
if ($eventDocumentName) {
|
if ($eventDocumentName) {
|
||||||
$documentName = $eventDocumentName
|
$documentName = $eventDocumentName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$printDocumentNorm = if ($documentName) { [string]$documentName } else { '' }
|
||||||
|
$printSignalKey = ('{0}|{1}|{2}|{3}' -f
|
||||||
|
(Normalize-PrinterForMatch -Value $printerName),
|
||||||
|
(Normalize-OwnerForMatch -Value $owner),
|
||||||
|
$printDocumentNorm.ToLowerInvariant(),
|
||||||
|
'print_job')
|
||||||
|
if (-not (Should-EmitByCooldown -Fingerprint $printSignalKey -CooldownSeconds 90)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
|
Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
|
||||||
printerName = $printerName
|
printerName = $printerName
|
||||||
documentName = $documentName
|
documentName = $documentName
|
||||||
documentNameOriginal = $documentNameOriginal
|
documentNameOriginal = $documentNameOriginal
|
||||||
owner = $owner
|
owner = $owner
|
||||||
|
eventSource = 'win32_printjob'
|
||||||
}
|
}
|
||||||
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
|
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
|
||||||
}
|
}
|
||||||
@@ -872,6 +962,7 @@ while ($true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
Write-EndpointLog ("printjob poll failed: {0}" -f $_.Exception.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -899,6 +990,17 @@ while ($true) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effectiveDocument = if ($resolvedDocument) { [string]$resolvedDocument } else { [string]$documentName }
|
||||||
|
$printSignalKey = ('{0}|{1}|{2}|{3}' -f
|
||||||
|
(Normalize-PrinterForMatch -Value $printerName),
|
||||||
|
(Normalize-OwnerForMatch -Value $owner),
|
||||||
|
$effectiveDocument.ToLowerInvariant(),
|
||||||
|
'print_job')
|
||||||
|
if (-not (Should-EmitByCooldown -Fingerprint $printSignalKey -CooldownSeconds 90)) {
|
||||||
|
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'skip' -MatchReason 'dedupe-recent-printjob' -ResolvedDocument $resolvedDocument
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
|
Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
|
||||||
printerName = $printerName
|
printerName = $printerName
|
||||||
documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName }
|
documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName }
|
||||||
@@ -919,6 +1021,7 @@ while ($true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
Write-EndpointLog ("printservice poll failed: {0}" -f $_.Exception.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
|||||||
@@ -48,13 +48,23 @@ function Invoke-AwJsonPost {
|
|||||||
[Parameter(Mandatory = $true)][string]$Uri,
|
[Parameter(Mandatory = $true)][string]$Uri,
|
||||||
[Parameter(Mandatory = $true)][string]$Json
|
[Parameter(Mandatory = $true)][string]$Json
|
||||||
)
|
)
|
||||||
|
$httpClient = $null
|
||||||
try {
|
try {
|
||||||
$httpClient = New-Object System.Net.Http.HttpClient
|
$httpClient = New-Object System.Net.Http.HttpClient
|
||||||
$content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json")
|
$content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json")
|
||||||
$response = $httpClient.PostAsync($Uri, $content).Result
|
$response = $httpClient.PostAsync($Uri, $content).Result
|
||||||
$httpClient.Dispose()
|
if (-not $response.IsSuccessStatusCode) {
|
||||||
|
$status = [int]$response.StatusCode
|
||||||
|
$reason = [string]$response.ReasonPhrase
|
||||||
|
$body = $response.Content.ReadAsStringAsync().Result
|
||||||
|
Write-FileCollectorLog ("POST failed: uri={0} status={1} reason={2} body={3}" -f $Uri, $status, $reason, $body)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-FileCollectorLog "POST Error: $($_.Exception.Message)"
|
Write-FileCollectorLog "POST Error: $($_.Exception.Message)"
|
||||||
|
} finally {
|
||||||
|
if ($null -ne $httpClient) {
|
||||||
|
$httpClient.Dispose()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +178,7 @@ if ($resolvedPaths.Count -eq 0) {
|
|||||||
Write-FileCollectorLog "Starting watch on paths: $($resolvedPaths -join ', ')"
|
Write-FileCollectorLog "Starting watch on paths: $($resolvedPaths -join ', ')"
|
||||||
|
|
||||||
$watchers = @()
|
$watchers = @()
|
||||||
|
$subscriptions = @()
|
||||||
foreach ($path in $resolvedPaths) {
|
foreach ($path in $resolvedPaths) {
|
||||||
$watcher = New-Object System.IO.FileSystemWatcher
|
$watcher = New-Object System.IO.FileSystemWatcher
|
||||||
$watcher.Path = $path
|
$watcher.Path = $path
|
||||||
@@ -188,6 +199,7 @@ foreach ($path in $resolvedPaths) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$watchers += $watcher
|
$watchers += $watcher
|
||||||
|
$subscriptions += @($onChanged, $onDeleted, $onRenamed)
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-FileCollectorLog "Collector started. Waiting for events..."
|
Write-FileCollectorLog "Collector started. Waiting for events..."
|
||||||
@@ -199,6 +211,14 @@ try {
|
|||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
Write-FileCollectorLog "Stopping collector..."
|
Write-FileCollectorLog "Stopping collector..."
|
||||||
|
foreach ($sub in @($subscriptions)) {
|
||||||
|
try {
|
||||||
|
if ($sub -and $sub.Id) {
|
||||||
|
Unregister-Event -SubscriptionId $sub.Id -ErrorAction SilentlyContinue
|
||||||
|
Remove-Job -Id $sub.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
foreach ($w in $watchers) {
|
foreach ($w in $watchers) {
|
||||||
$w.EnableRaisingEvents = $false
|
$w.EnableRaisingEvents = $false
|
||||||
$w.Dispose()
|
$w.Dispose()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#define AwDefaultServerHost "10.10.10.13"
|
#define AwDefaultServerHost "10.10.10.13"
|
||||||
#define AwDefaultServerPort "5600"
|
#define AwDefaultServerPort "5600"
|
||||||
|
#define AwDefaultWorktimeReportBase "http://10.10.10.13:5610"
|
||||||
#define AwDefaultUsers "user1,user2,user3,user4,user5"
|
#define AwDefaultUsers "user1,user2,user3,user4,user5"
|
||||||
#define AwDefaultInstallRoot "C:\\Program Files\\AWatch-rus\\bin"
|
#define AwDefaultInstallRoot "C:\\Program Files\\AWatch-rus\\bin"
|
||||||
#define AwDefaultStateRoot "C:\\ProgramData\\AWatch-rus"
|
#define AwDefaultStateRoot "C:\\ProgramData\\AWatch-rus"
|
||||||
@@ -42,6 +43,7 @@ Source: "..\..\migrate-awatch-rus-paths.ps1"; DestDir: "{app}\windows"; Flags: i
|
|||||||
Source: "..\..\worktime-session-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
|
Source: "..\..\worktime-session-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
|
||||||
Source: "..\..\browser-domains-native-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
|
Source: "..\..\browser-domains-native-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
|
||||||
Source: "..\..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
|
Source: "..\..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
|
||||||
|
Source: "..\..\file-operations-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
|
||||||
Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
|
Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
|
||||||
Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
|
Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
|
||||||
; Offline payload (optional): place ZIP into windows/installkit/innosetup/payload/ before compiling.
|
; Offline payload (optional): place ZIP into windows/installkit/innosetup/payload/ before compiling.
|
||||||
@@ -147,6 +149,8 @@ begin
|
|||||||
'Укажите сервер ActivityWatch (куда агенты будут отправлять данные).',
|
'Укажите сервер ActivityWatch (куда агенты будут отправлять данные).',
|
||||||
'Если нужно, измените host/port. По умолчанию — наша конфигурация.'
|
'Если нужно, измените host/port. По умолчанию — наша конфигурация.'
|
||||||
);
|
);
|
||||||
|
{ Worktime CSV/JSON reports are served by aw-worktime-api on :5610 (AwDefaultWorktimeReportBase).
|
||||||
|
Standard AW "Сегодня" is backed by server-side aw-worktime-ui-bridge timer on AW host. }
|
||||||
ServerHostPage.Add('ServerHost', False);
|
ServerHostPage.Add('ServerHost', False);
|
||||||
ServerHostPage.Add('ServerPort', False);
|
ServerHostPage.Add('ServerPort', False);
|
||||||
ServerHostPage.Values[0] := '{#AwDefaultServerHost}';
|
ServerHostPage.Values[0] := '{#AwDefaultServerHost}';
|
||||||
|
|||||||
Reference in New Issue
Block a user