diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml
index 5aae10d..371dac5 100644
--- a/ansible/deploy_aw_server.yml
+++ b/ansible/deploy_aw_server.yml
@@ -94,6 +94,89 @@
- "{{ aw_rus_health_validation_dir }}"
- "{{ aw_server_log_dir }}"
+ - name: Установить prune script для локального state
+ ansible.builtin.copy:
+ src: "{{ aw_repo_root }}/aw-server/aw-prune-local-state.sh"
+ dest: /usr/local/bin/aw-prune-local-state.sh
+ owner: root
+ group: root
+ mode: "0755"
+
+ - name: Ограничить рост journald на aw-server
+ ansible.builtin.copy:
+ dest: /etc/systemd/journald.conf.d/aw-rus-retention.conf
+ owner: root
+ group: root
+ mode: "0644"
+ content: |
+ [Journal]
+ SystemMaxUse={{ aw_server_journal_system_max_use }}
+ RuntimeMaxUse={{ aw_server_journal_runtime_max_use }}
+ SystemKeepFree={{ aw_server_journal_system_keep_free }}
+ register: aw_journald_dropin
+
+ - name: Установить systemd service prune локального state
+ ansible.builtin.copy:
+ dest: /etc/systemd/system/aw-prune-local-state.service
+ owner: root
+ group: root
+ mode: "0644"
+ content: |
+ [Unit]
+ Description=Prune ActivityWatch local backups and temp state
+
+ [Service]
+ Type=oneshot
+ Environment=AW_DATA_DIR={{ aw_server_data_dir }}
+ Environment=AW_BACKUP_RETENTION_DAYS={{ aw_server_backup_retention_days }}
+ Environment=AW_BACKUP_KEEP_LAST_DB={{ aw_server_backup_keep_last_db }}
+ Environment=AW_BACKUP_KEEP_LAST_JSON={{ aw_server_backup_keep_last_json }}
+ ExecStart=/usr/local/bin/aw-prune-local-state.sh
+
+ - name: Установить systemd timer prune локального state
+ ansible.builtin.copy:
+ dest: /etc/systemd/system/aw-prune-local-state.timer
+ owner: root
+ group: root
+ mode: "0644"
+ content: |
+ [Unit]
+ Description=Daily prune of ActivityWatch local backups and temp state
+
+ [Timer]
+ OnCalendar=*-*-* 04:40:00
+ Persistent=true
+
+ [Install]
+ WantedBy=timers.target
+
+ - name: Перечитать systemd после retention unit/drop-in
+ ansible.builtin.systemd:
+ daemon_reload: true
+
+ - name: Включить и запустить timer prune локального state
+ ansible.builtin.systemd:
+ name: aw-prune-local-state.timer
+ enabled: true
+ state: started
+
+ - name: Применить journald retention без простоя
+ ansible.builtin.command:
+ argv:
+ - systemctl
+ - restart
+ - systemd-journald
+ when: aw_journald_dropin.changed
+ failed_when: false
+
+ - name: Сжать существующий journald до нового лимита
+ ansible.builtin.command:
+ argv:
+ - journalctl
+ - --vacuum-size={{ aw_server_journal_system_max_use }}
+ changed_when: true
+ failed_when: false
+
- name: (Check mode) Пропустить установку релиза ActivityWatch
ansible.builtin.debug:
msg: "ansible_check_mode=true: download/unarchive/install of ActivityWatch release is skipped."
@@ -115,6 +198,11 @@
remote_src: true
extra_opts: ["-o"]
+ - name: Удалить временный архив ActivityWatch после распаковки
+ ansible.builtin.file:
+ path: "{{ aw_archive_path }}"
+ state: absent
+
- name: Найти распакованный каталог ActivityWatch
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
@@ -288,6 +376,22 @@
dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh
mode: "0755"
+ - name: Проверить Influx token для AW worktime exporter
+ ansible.builtin.assert:
+ that:
+ - aw_worktime_influx_token is defined
+ - aw_worktime_influx_token | length > 0
+ fail_msg: "aw_worktime_influx_enabled=true, но aw_worktime_influx_token пуст. Exporter будет падать и Grafana не получит worktime-ряды."
+ when: aw_worktime_influx_enabled | default(false) | bool
+
+ - name: Проверить Influx token для AW DLP exporter
+ ansible.builtin.assert:
+ that:
+ - aw_dlp_influx_token is defined
+ - aw_dlp_influx_token | length > 0
+ fail_msg: "aw_dlp_influx_enabled=true, но aw_dlp_influx_token пуст. Exporter будет падать и Grafana не получит DLP-ряды."
+ when: aw_dlp_influx_enabled | default(false) | bool
+
- name: Записать /etc/activitywatch/aw-server.env перед хотфиксами
ansible.builtin.copy:
dest: /etc/activitywatch/aw-server.env
@@ -922,7 +1026,6 @@
ansible.builtin.systemd:
name: aw-worktime-influx-exporter.service
state: started
- failed_when: false
when: aw_worktime_influx_enabled | default(false) | bool
- name: Включить и перезапустить AW DLP Influx exporter timer
@@ -936,7 +1039,6 @@
ansible.builtin.systemd:
name: aw-dlp-influx-exporter.service
state: started
- failed_when: false
when: aw_dlp_influx_enabled | default(false) | bool
- name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper)
@@ -1514,6 +1616,11 @@
remote_src: true
creates: "{{ aw_hayabusa_release_dir }}/{{ aw_hayabusa_binary_name }}"
+ - name: Удалить временный архив Hayabusa после распаковки
+ ansible.builtin.file:
+ path: "{{ aw_hayabusa_archive_path }}"
+ state: absent
+
- name: Нормализовать права release Hayabusa
ansible.builtin.file:
path: "{{ aw_hayabusa_release_dir }}"
diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml
index 7e0dcf9..eec5eeb 100644
--- a/ansible/deploy_aw_windows.yml
+++ b/ansible/deploy_aw_windows.yml
@@ -14,6 +14,7 @@
aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip"
aw_windows_package_zip_path: ""
aw_windows_domain: "SHARKON2025"
+ aw_windows_builtin_administrator_name: "Администратор"
aw_windows_users:
- Администратор
- user1
@@ -49,6 +50,7 @@
aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts"
aw_windows_forensics_root: "{{ aw_windows_state_root }}\\forensics\\evtx-exports"
aw_windows_logon_marker_enabled: true
+ aw_windows_process_events_enabled: true
aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
@@ -137,6 +139,8 @@
- aw_windows_server_port is defined
- aw_windows_server_scheme is defined
- aw_windows_domain is defined
+ - aw_windows_builtin_administrator_name is defined
+ - aw_windows_builtin_administrator_name | length > 0
- aw_windows_users_effective | length > 0
- aw_windows_install_root is defined
- aw_windows_state_root is defined
@@ -228,6 +232,7 @@
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
+ $env:AWATCH_RUS_BUILTIN_ADMINISTRATOR_NAME = "{{ aw_windows_builtin_administrator_name }}"
$params = @{
ServerScheme = "{{ aw_windows_server_scheme }}"
ServerHost = "{{ aw_windows_server_host_effective }}"
@@ -247,6 +252,7 @@
EvtxExportRoot = "{{ aw_windows_forensics_root }}"
EvtxRetentionDays = {{ aw_windows_evtx_retention_days | int }}
LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }}
+ ProcessEventsEnabled = {{ '$true' if (aw_windows_process_events_enabled | bool) else '$false' }}
PolicyMode = "{{ aw_windows_policy_mode }}"
PolicyEngineEnabled = {{ '$true' if (aw_windows_policy_engine_enabled | bool) else '$false' }}
PolicyEngineHost = "{{ aw_windows_policy_engine_host_effective }}"
diff --git a/ansible/deploy_tsj_guardian_bot_proxmox.yml b/ansible/deploy_tsj_guardian_bot_proxmox.yml
index 106fec4..aaaedca 100644
--- a/ansible/deploy_tsj_guardian_bot_proxmox.yml
+++ b/ansible/deploy_tsj_guardian_bot_proxmox.yml
@@ -5,9 +5,10 @@
gather_facts: true
vars:
- tsj_bot_user: "codex"
+ tsj_bot_runtime_root: "/opt/infra-admin"
+ tsj_bot_user: "root"
tsj_bot_group: "admin"
- tsj_bot_root: "/home/codex/infra-admin/tsj-bot"
+ tsj_bot_root: "{{ tsj_bot_runtime_root }}/tsj-bot"
tsj_bot_script_name: "tsj_guardian_bot.py"
tsj_bot_script_dest: "{{ tsj_bot_root }}/{{ tsj_bot_script_name }}"
tsj_bot_source_local_path: "{{ aw_repo_root }}/proxmox/tsj_guardian_bot.py"
@@ -16,8 +17,8 @@
tsj_bot_openvpn_helper_source_local_path: "{{ aw_repo_root }}/proxmox/{{ tsj_bot_openvpn_helper_name }}"
tsj_bot_service_name: "tsj-guardian-bot.service"
tsj_bot_env_path: "{{ tsj_bot_root }}/.env"
- tsj_bot_state_dir: "/home/codex/infra-admin/.state"
- tsj_bot_logs_dir: "/home/codex/infra-admin/logs"
+ tsj_bot_state_dir: "{{ tsj_bot_runtime_root }}/.state"
+ tsj_bot_logs_dir: "{{ tsj_bot_runtime_root }}/logs"
tsj_bot_default_chat_id: "{{ telegram_default_chat_id | default(telegram_allowed_chat_ids.split(',')[0]) }}"
pre_tasks:
@@ -130,15 +131,16 @@
NODE_13_URL={{ tsj_bot_node_13_url | default('http://10.10.10.13:5600/api/0/info') }}
NODE_16_URL={{ tsj_bot_node_16_url | default('http://10.10.10.16/') }}
NODE_16_ENABLED={{ tsj_bot_node_16_enabled | default('false') }}
- CHECK_SCRIPT={{ tsj_bot_check_script | default('/home/codex/infra-admin/scripts/system_self_support.sh --check') }}
- HEAL_SCRIPT={{ tsj_bot_heal_script | default('/home/codex/infra-admin/scripts/system_self_support.sh --heal') }}
+ INFRA_ADMIN_ROOT={{ tsj_bot_runtime_root }}
+ CHECK_SCRIPT={{ tsj_bot_check_script | default(tsj_bot_runtime_root + '/scripts/system_self_support.sh --check') }}
+ HEAL_SCRIPT={{ tsj_bot_heal_script | default(tsj_bot_runtime_root + '/scripts/system_self_support.sh --heal') }}
FS_WARN_PCT={{ tsj_bot_fs_warn_pct | default(85) }}
FS_CRIT_PCT={{ tsj_bot_fs_crit_pct | default(92) }}
FS_TARGETS={{ tsj_bot_fs_targets | default('host,200,201,202,203') }}
FS_EXCLUDE_TYPES={{ tsj_bot_fs_exclude_types | default('tmpfs,devtmpfs,proc,sysfs,cgroup,cgroup2,overlay,squashfs,nsfs,tracefs,debugfs,securityfs,configfs,fusectl,mqueue,hugetlbfs,ramfs') }}
- STATE_FILE={{ tsj_bot_state_file | default('/home/codex/infra-admin/.state/tsj_guardian_state.json') }}
- LOG_FILE={{ tsj_bot_log_file | default('/home/codex/infra-admin/logs/tsj_guardian_bot.log') }}
- HEARTBEAT_FILE={{ tsj_bot_heartbeat_file | default('/home/codex/infra-admin/.state/tsj_guardian_heartbeat') }}
+ STATE_FILE={{ tsj_bot_state_file | default(tsj_bot_runtime_root + '/.state/tsj_guardian_state.json') }}
+ LOG_FILE={{ tsj_bot_log_file | default(tsj_bot_runtime_root + '/logs/tsj_guardian_bot.log') }}
+ HEARTBEAT_FILE={{ tsj_bot_heartbeat_file | default(tsj_bot_runtime_root + '/.state/tsj_guardian_heartbeat') }}
CHECK_INTERVAL_SEC={{ tsj_bot_check_interval_sec | default(60) }}
OPERATOR_TIMEOUT_SEC={{ tsj_bot_operator_timeout_sec | default(900) }}
RETRY_AUTORECOVERY_EVERY_SEC={{ tsj_bot_retry_autorecovery_every_sec | default(300) }}
@@ -170,8 +172,8 @@
OPENVPN_EXPIRY_WARN_TIMEOUT_SEC={{ tsj_bot_openvpn_expiry_warn_timeout_sec | default(120) }}
OPENVPN_EXPIRY_WARN_INTERVAL_SEC={{ tsj_bot_openvpn_expiry_warn_interval_sec | default(21600) }}
PFSENSE_MCP_BEARER={{ tsj_bot_pfsense_mcp_bearer | default(pfsense_mcp_bearer | default('')) }}
- SERVER_FALLBACK_COMMANDS={{ tsj_bot_server_fallback_commands | default('/home/codex/infra-admin/scripts/system_self_support.sh --heal') }}
- UPDATES_SCRIPT={{ tsj_bot_updates_script | default('/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py') }}
+ SERVER_FALLBACK_COMMANDS={{ tsj_bot_server_fallback_commands | default(tsj_bot_runtime_root + '/scripts/system_self_support.sh --heal') }}
+ UPDATES_SCRIPT={{ tsj_bot_updates_script | default('/usr/bin/python3 ' + tsj_bot_runtime_root + '/scripts/proxmox_lxc_critical_updates.py') }}
UPDATE_TARGETS={{ tsj_bot_update_targets | default('auto') }}
AW_RUS_API_BASE={{ tsj_bot_aw_rus_api_base | default('http://10.10.10.13:5600/api/0') }}
AW_RUS_WORKTIME_BASE={{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }}
@@ -257,7 +259,7 @@
[Service]
Type=simple
User=root
- WorkingDirectory=/home/codex/infra-admin
+ WorkingDirectory={{ tsj_bot_runtime_root }}
EnvironmentFile={{ tsj_bot_env_path }}
ExecStart=/usr/bin/python3 {{ tsj_bot_script_dest }}
Restart=always
diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml
index 7aae288..af29ce2 100644
--- a/ansible/group_vars/all.yml
+++ b/ansible/group_vars/all.yml
@@ -12,21 +12,24 @@ aw_server_inventory_host: "{{ (groups['aw_server'] | default([]) | first) | defa
aw_server_public_host: "{{ (hostvars[aw_server_inventory_host].ansible_host | default(aw_server_inventory_host, true)) if (aw_server_inventory_host | length) > 0 else 'aw-server' }}"
aw_worktime_report_base: "http://{{ aw_server_public_host }}:5610"
aw_worktime_timezone: "Europe/Moscow"
-aw_worktime_influx_enabled: false
+aw_worktime_influx_enabled: true
aw_worktime_influx_url: "http://10.10.10.10:8086"
aw_worktime_influx_org: "proxmox"
aw_worktime_influx_bucket: "aw_metrics"
aw_worktime_influx_hosts: "SHARKON2025"
aw_worktime_influx_days: "today,yesterday"
-aw_dlp_influx_enabled: false
+aw_worktime_influx_token: "{{ lookup('env', 'AW_WORKTIME_INFLUX_TOKEN') }}"
+aw_dlp_influx_enabled: true
aw_dlp_influx_url: "http://10.10.10.10:8086"
aw_dlp_influx_org: "proxmox"
aw_dlp_influx_bucket: "aw_metrics"
aw_dlp_influx_hosts: "SHARKON2025"
aw_dlp_influx_lookback_days: 30
aw_dlp_influx_event_limit: 2000
+aw_dlp_influx_token: "{{ lookup('env', 'AW_DLP_INFLUX_TOKEN') }}"
aw_monitored_windows_host: "192.168.100.18"
aw_monitored_windows_hostname: "SHARKON2025"
+aw_rus_health_worktime_api_base: "http://127.0.0.1:5610"
aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health"
aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation"
aw_hayabusa_auto_case_enabled: true
diff --git a/ansible/group_vars/aw_server.yml b/ansible/group_vars/aw_server.yml
index 6241ea6..be493f5 100644
--- a/ansible/group_vars/aw_server.yml
+++ b/ansible/group_vars/aw_server.yml
@@ -30,6 +30,12 @@ aw_hayabusa_staging_dir: "{{ aw_hayabusa_inbox_dir }}/staging"
aw_hayabusa_archive_packages_dir: "{{ aw_hayabusa_archive_dir }}/packages"
aw_hayabusa_archive_extracted_dir: "{{ aw_hayabusa_archive_dir }}/extracted"
aw_hayabusa_logs_dir: "{{ aw_hayabusa_state_dir }}/logs"
+aw_server_backup_retention_days: 7
+aw_server_backup_keep_last_db: 2
+aw_server_backup_keep_last_json: 2
+aw_server_journal_system_max_use: "100M"
+aw_server_journal_runtime_max_use: "50M"
+aw_server_journal_system_keep_free: "500M"
aw_dlp_policy_engine_enabled: true
aw_dlp_policy_engine_bind_host: "0.0.0.0"
aw_dlp_policy_engine_port: 5601
diff --git a/ansible/group_vars/aw_windows.yml b/ansible/group_vars/aw_windows.yml
index 225790e..8ca319d 100644
--- a/ansible/group_vars/aw_windows.yml
+++ b/ansible/group_vars/aw_windows.yml
@@ -15,6 +15,7 @@ aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases
aw_windows_package_zip_path: ""
aw_windows_domain: "SHARKON2025"
+aw_windows_builtin_administrator_name: "Администратор"
aw_windows_users:
- Администратор
- user1
@@ -58,6 +59,7 @@ aw_windows_evtx_channels:
- Microsoft-Windows-TerminalServices-LocalSessionManager/Operational
- Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational
aw_windows_logon_marker_enabled: true
+aw_windows_process_events_enabled: true
aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
diff --git a/ansible/group_vars/proxmox-bot.example.yml b/ansible/group_vars/proxmox-bot.example.yml
index 0daec36..5e6f490 100644
--- a/ansible/group_vars/proxmox-bot.example.yml
+++ b/ansible/group_vars/proxmox-bot.example.yml
@@ -4,6 +4,7 @@ telegram_default_chat_id: 123456789
# Path on controller (this machine) to source bot script for deployment.
tsj_bot_source_local_path: "/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/tsj_guardian_bot.py"
+tsj_bot_runtime_root: "/opt/infra-admin"
# Optional bot tuning
tsj_bot_check_interval_sec: 60
@@ -37,8 +38,8 @@ tsj_bot_openvpn_expiry_warn_enabled: "false"
tsj_bot_openvpn_expiry_warn_days: 30
tsj_bot_openvpn_expiry_warn_timeout_sec: 120
tsj_bot_openvpn_expiry_warn_interval_sec: 21600
-tsj_bot_server_fallback_commands: "/home/codex/infra-admin/scripts/system_self_support.sh --heal"
-tsj_bot_updates_script: "/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py"
+tsj_bot_server_fallback_commands: "/opt/infra-admin/scripts/system_self_support.sh --heal"
+tsj_bot_updates_script: "/usr/bin/python3 /opt/infra-admin/scripts/proxmox_lxc_critical_updates.py"
tsj_bot_update_targets: "auto"
tsj_bot_pfsense_mcp_bearer: "CHANGE_ME"
diff --git a/ansible/group_vars/windows.example.yml b/ansible/group_vars/windows.example.yml
index ea80fe6..ae3eee2 100644
--- a/ansible/group_vars/windows.example.yml
+++ b/ansible/group_vars/windows.example.yml
@@ -13,6 +13,10 @@ aw_windows_package_version: "v0.13.2"
aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip"
aw_windows_package_zip_path: ""
aw_windows_domain: "SHARKON2025"
+# Localized name of the built-in local Administrator account (SID ending in -500).
+# On the current Russian Windows host this must stay "Администратор";
+# do not replace it with "Administrator" unless the target OS account is actually named that way.
+aw_windows_builtin_administrator_name: "Администратор"
aw_windows_users:
- Администратор
- user1
@@ -46,6 +50,7 @@ aw_windows_evtx_channels:
- Microsoft-Windows-TerminalServices-LocalSessionManager/Operational
- Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational
aw_windows_logon_marker_enabled: true
+aw_windows_process_events_enabled: true
aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
diff --git a/aw-server/aw-prune-local-state.sh b/aw-server/aw-prune-local-state.sh
new file mode 100644
index 0000000..c2c6afd
--- /dev/null
+++ b/aw-server/aw-prune-local-state.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+AW_DATA_DIR="${AW_DATA_DIR:-/var/lib/activitywatch}"
+BACKUP_DIR="${AW_BACKUP_DIR:-${AW_DATA_DIR}/backups}"
+KEEP_DAYS="${AW_BACKUP_RETENTION_DAYS:-7}"
+KEEP_LAST_DB="${AW_BACKUP_KEEP_LAST_DB:-2}"
+KEEP_LAST_JSON="${AW_BACKUP_KEEP_LAST_JSON:-2}"
+
+prune_group() {
+ local keep_last="$1"
+ local keep_days="$2"
+ shift 2
+ local files=()
+ local idx=0
+ local cutoff
+ cutoff="$(date -d "-${keep_days} days" +%s)"
+ mapfile -t files < <(find "$@" -maxdepth 1 -type f -printf '%T@ %p\n' 2>/dev/null | sort -nr | awk '{ $1=""; sub(/^ /,""); print }')
+ for path in "${files[@]}"; do
+ idx=$((idx + 1))
+ if [ "$idx" -le "$keep_last" ]; then
+ continue
+ fi
+ [ -f "$path" ] || continue
+ if [ "$(stat -c %Y "$path")" -lt "$cutoff" ]; then
+ rm -f -- "$path"
+ fi
+ done
+}
+
+mkdir -p "$BACKUP_DIR"
+
+prune_group "$KEEP_LAST_DB" "$KEEP_DAYS" "${BACKUP_DIR}/db"
+prune_group "$KEEP_LAST_JSON" "$KEEP_DAYS" "$BACKUP_DIR"
+
+find /tmp -maxdepth 1 -type f \
+ \( -name 'activitywatch-*.zip' -o -name 'hayabusa-*.zip' -o -name 'aw-hayabusa-profiles.txt' \) \
+ -mtime +0 -delete 2>/dev/null || true
+
+find /tmp -maxdepth 1 -type f \
+ \( -name 'aw-worktime-ui-bridge.py' -o -name 'views-default.json' -o -name 'apply_webui_ru_patch.out' \) \
+ -mtime +1 -delete 2>/dev/null || true
diff --git a/aw-server/aw-ru-patch.js b/aw-server/aw-ru-patch.js
index 67c21f6..6b546fd 100755
--- a/aw-server/aw-ru-patch.js
+++ b/aw-server/aw-ru-patch.js
@@ -24,6 +24,7 @@
["Tools", "Инструменты"],
["Raw Data", "Сырые данные"],
["Summary", "Сводка"],
+ ["Worktime", "Рабочее время"],
["All", "Все"],
["None", "Нет"],
["Date", "Дата"],
@@ -236,6 +237,7 @@
['Common words in "Uncategorized" events', 'Частые слова в событиях "Без категории"'],
["No words with significant duration. You're good to go!", "Нет слов со значимой длительностью. Здесь всё в порядке."],
["Top apps", "Топ приложений"],
+ ["Top Applications", "Топ приложений"],
["Top titles", "Топ заголовков"],
["Top URLs", "Топ URL"],
["Top domains", "Топ доменов"],
@@ -596,8 +598,8 @@
}, "");
center.innerHTML =
- '
Этот блок строится из server-side worktime отчёта и не зависит от того, жива ли локальная интерактивная RDP-сессия.
' +
+ '' +
'
Активное время
' + escapeHtml(formatDurationSeconds(totalActiveSeconds)) + '
' +
'
Активных пользователей
' + escapeHtml(String(activeUsers.length)) + '
' +
@@ -844,9 +846,15 @@
}
function removeBadDlpLinks(root) {
- const badLinks = root.querySelectorAll("a[href*='/view/DLP']");
- badLinks.forEach(function (link) {
+ const links = Array.from(root.querySelectorAll("a[href], [role='link']"));
+ links.forEach(function (link) {
+ const href = String(link.getAttribute("href") || "");
+ const label = normalizeText(link.textContent || "");
+ const isBrokenActivityDlpLink = /\/view\/dlp(?:[/?#]|$)/i.test(href);
+ const isActivityTabDlpLabel = label === "DLP" && !!link.closest("li");
+ if (!isBrokenActivityDlpLink && !isActivityTabDlpLabel) return;
const item = link.closest("li") || link;
+ if (item && item.getAttribute && item.getAttribute("data-aw-ru-dlp-item") === "1") return;
item.remove();
});
}
@@ -2113,6 +2121,16 @@
});
}
+ function applyTextAndNavigationPatches(root) {
+ if (!root) return;
+ walk(root);
+ translateAttributes(root);
+ hideNoiseNavigation(root);
+ hidePveAuditTabForRegularHost(root);
+ patchActivityHeading(root);
+ patchCategoryBuilderHostLabel(root);
+ }
+
function detachObserver() {
if (!observerAttached) return;
observer.disconnect();
@@ -2147,6 +2165,7 @@
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
}
}
+ applyTextAndNavigationPatches(document.body);
staticPatchRouteKey = routeKey;
return;
}
@@ -2154,13 +2173,8 @@
ensureSettingsHost();
ensureHostGroupsData().catch(function () {});
normalizeCategoryBuilderUnknownHostRefs();
+ applyTextAndNavigationPatches(document.body);
if (routeChanged) {
- walk(document.body);
- translateAttributes(document.body);
- hideNoiseNavigation(document.body);
- hidePveAuditTabForRegularHost(document.body);
- patchActivityHeading(document.body);
- patchCategoryBuilderHostLabel(document.body);
staticPatchRouteKey = routeKey;
}
injectPveAuditCenter(document.body);
@@ -2187,7 +2201,6 @@
const observer = new MutationObserver(function () {
if (applyPatchInFlight) return;
- if (isDlpSignalBucketRoute() && document.body && document.body.querySelector("[data-aw-ru-dlp-center='1']")) return;
scheduleApplyPatch();
});
diff --git a/aw-server/aw-worktime-api.py b/aw-server/aw-worktime-api.py
index 380137b..19247e4 100644
--- a/aw-server/aw-worktime-api.py
+++ b/aw-server/aw-worktime-api.py
@@ -7,6 +7,7 @@ import json
import os
import sys
import tempfile
+import threading
import urllib.request
from datetime import datetime, timezone, timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -46,8 +47,14 @@ MANAGER_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_CACHE
MANAGER_CACHE_DIR = Path(os.environ.get("AW_WORKTIME_MANAGER_CACHE_DIR", "/var/lib/activitywatch/worktime-cache"))
MANAGER_ALIASES_JSON = Path(os.environ.get("AW_WORKTIME_MANAGER_ALIASES_JSON", "/etc/activitywatch/worktime-manager-aliases.json"))
MANAGER_EXCLUDE_USERS = {item.strip().lower() for item in os.environ.get("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "").split(",") if item.strip()}
+EVENTS_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", "30")))
+WORKTIME_EVENTS_LIMIT = max(1000, int(os.environ.get("AW_WORKTIME_EVENTS_LIMIT", "50000")))
MODULE_PATH = Path(__file__).resolve()
_ALIASES_CACHE = {"mtime": None, "users": {}, "owners": {}, "raw": {}}
+_EVENTS_CACHE_LOCK = threading.Lock()
+_EVENTS_CACHE = {}
+_MANAGEMENT_BUILD_LOCKS_LOCK = threading.Lock()
+_MANAGEMENT_BUILD_LOCKS = {}
def get(u):
@@ -274,6 +281,16 @@ def get_sessions_bucket_id(host):
return f"aw-worktime-sessions_{resolve_host(host)}"
+def get_management_build_lock(host, report_date):
+ key = (resolve_host(host), report_date.isoformat())
+ with _MANAGEMENT_BUILD_LOCKS_LOCK:
+ lock = _MANAGEMENT_BUILD_LOCKS.get(key)
+ if lock is None:
+ lock = threading.Lock()
+ _MANAGEMENT_BUILD_LOCKS[key] = lock
+ return lock
+
+
def resolve_report_date(day=None, date_text=None):
now_local = datetime.now(REPORT_TZ)
if date_text:
@@ -512,17 +529,31 @@ def aggregate_hourly_rows(events, start, end, host):
def fetch_events_for_date(host, report_date):
bounds = get_report_bounds(report_date)
bucket_id = get_sessions_bucket_id(host)
+ events = fetch_bucket_events(bucket_id, host)
+ return bounds, events
+
+
+def fetch_bucket_events(bucket_id, host):
+ now = now_utc()
+ if EVENTS_CACHE_TTL_SECONDS > 0:
+ with _EVENTS_CACHE_LOCK:
+ cached = _EVENTS_CACHE.get(bucket_id)
+ if cached is not None and age_seconds(cached["stored_at"], now=now) <= EVENTS_CACHE_TTL_SECONDS:
+ return cached["events"]
try:
get(f"{AW}/buckets/{bucket_id}")
except Exception:
log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}")
- return bounds, []
+ return []
try:
- events = get(f"{AW}/buckets/{bucket_id}/events?limit=50000")
+ events = get(f"{AW}/buckets/{bucket_id}/events?limit={WORKTIME_EVENTS_LIMIT}")
except Exception:
log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}")
- return bounds, []
- return bounds, events
+ return []
+ if EVENTS_CACHE_TTL_SECONDS > 0:
+ with _EVENTS_CACHE_LOCK:
+ _EVENTS_CACHE[bucket_id] = {"stored_at": now, "events": events}
+ return events
def build_report_summary(rows):
@@ -971,9 +1002,10 @@ def load_management_cache(host, report_date):
path = management_cache_path(host, report_date)
if not path.exists():
return None
- age = age_seconds(datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc))
- if age is None or age > MANAGER_CACHE_TTL_SECONDS:
- return None
+ if report_date >= datetime.now(REPORT_TZ).date():
+ age = age_seconds(datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc))
+ if age is None or age > MANAGER_CACHE_TTL_SECONDS:
+ return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
@@ -1360,27 +1392,42 @@ def _build_management_core(rows, host, report_date, owner_filter="", department_
}
-def build_management_trend(host, anchor_date, owner_filter="", department_filter=""):
+def _management_trend_item(payload, report_date):
+ summary = payload["summary"]
+ return {
+ "report_date": report_date.isoformat(),
+ "users_count": summary["users_count"],
+ "active_users": summary["active_users"],
+ "inactive_users": summary["inactive_users"],
+ "workday_total_active_seconds": summary["workday_total_active_seconds"],
+ "workday_total_active_hhmm": summary["workday_total_active_hhmm"],
+ "portfolio_coverage_pct": summary["portfolio_coverage_pct"],
+ "actions_count": summary["actions_count"],
+ "critical_actions_count": summary["critical_actions_count"],
+ }
+
+
+def build_management_trend(host, anchor_date, owner_filter="", department_filter="", precomputed_payloads=None):
trend = []
+ precomputed_payloads = precomputed_payloads or {}
for offset in range(MANAGER_TREND_DAYS - 1, -1, -1):
current_date = anchor_date - timedelta(days=offset)
- bounds, events = fetch_events_for_date(host, current_date)
- rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host)
- payload = _build_management_core(rows, host, current_date, owner_filter=owner_filter, department_filter=department_filter)
- summary = payload["summary"]
- trend.append(
- {
- "report_date": current_date.isoformat(),
- "users_count": summary["users_count"],
- "active_users": summary["active_users"],
- "inactive_users": summary["inactive_users"],
- "workday_total_active_seconds": summary["workday_total_active_seconds"],
- "workday_total_active_hhmm": summary["workday_total_active_hhmm"],
- "portfolio_coverage_pct": summary["portfolio_coverage_pct"],
- "actions_count": summary["actions_count"],
- "critical_actions_count": summary["critical_actions_count"],
- }
- )
+ payload = precomputed_payloads.get(current_date)
+ if payload is None:
+ payload = load_management_cache(host, current_date)
+ if payload is None:
+ bounds, events = fetch_events_for_date(host, current_date)
+ rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host)
+ payload = _build_management_core(rows, host, current_date, owner_filter=owner_filter, department_filter=department_filter)
+ elif owner_filter or department_filter:
+ payload = apply_management_filters_to_payload(
+ payload,
+ owner_filter=owner_filter,
+ department_filter=department_filter,
+ include_sources=False,
+ include_source_actions=False,
+ )
+ trend.append(_management_trend_item(payload, current_date))
return trend
@@ -1423,7 +1470,13 @@ def build_management_payload(rows, host, report_date, owner_filter="", departmen
payload = _build_management_core(rows, host, report_date, owner_filter=owner_filter, department_filter=department_filter)
source_freshness, source_actions = build_source_freshness(resolve_host(host))
payload["sources"] = source_freshness
- payload["trend"] = build_management_trend(resolve_host(host), report_date, owner_filter=owner_filter, department_filter=department_filter)
+ payload["trend"] = build_management_trend(
+ resolve_host(host),
+ report_date,
+ owner_filter=owner_filter,
+ department_filter=department_filter,
+ precomputed_payloads={report_date: payload},
+ )
payload["trend_scope"] = "portfolio"
if source_actions:
payload["actions"].extend(filter_management_actions(source_actions, payload["rows"], owner_filter=owner_filter, department_filter=department_filter))
@@ -1461,11 +1514,16 @@ def management_report_for_date(host, report_date, owner_filter="", department_fi
cached = load_management_cache(host, report_date)
if cached is not None:
return cached
- bounds, events = fetch_events_for_date(host, report_date)
- rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host)
- payload = build_management_payload(rows, host, report_date)
- save_management_cache(host, report_date, payload)
- return payload
+ lock = get_management_build_lock(host, report_date)
+ with lock:
+ cached = load_management_cache(host, report_date)
+ if cached is not None:
+ return cached
+ bounds, events = fetch_events_for_date(host, report_date)
+ rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host)
+ payload = build_management_payload(rows, host, report_date)
+ save_management_cache(host, report_date, payload)
+ return payload
def report_today(host):
diff --git a/aw-server/aw-worktime-autoheal.sh b/aw-server/aw-worktime-autoheal.sh
index 12270b3..d027072 100644
--- a/aw-server/aw-worktime-autoheal.sh
+++ b/aw-server/aw-worktime-autoheal.sh
@@ -8,7 +8,7 @@ WORKTIME_HEALTH_URL="${WORKTIME_HEALTH_URL:-http://127.0.0.1:5610/health}"
WORKTIME_REPORT_TIMEOUT_SECONDS="${WORKTIME_REPORT_TIMEOUT_SECONDS:-20}"
WORKTIME_MANAGEMENT_WARM_ENABLED="${WORKTIME_MANAGEMENT_WARM_ENABLED:-1}"
WORKTIME_MANAGEMENT_WARM_URL="${WORKTIME_MANAGEMENT_WARM_URL:-http://127.0.0.1:5610/reports/worktime/management?day=today&format=json}"
-WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS="${WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS:-25}"
+WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS="${WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS:-60}"
WORKTIME_TODAY_PROBE_ENABLED="${WORKTIME_TODAY_PROBE_ENABLED:-1}"
WORKTIME_TODAY_PROBE_URL="${WORKTIME_TODAY_PROBE_URL:-http://127.0.0.1:5610/reports/worktime/today?day=today&format=json}"
WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS="${WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS:-20}"
@@ -28,7 +28,6 @@ probe_url() {
probe_reports() {
probe_url "$WORKTIME_HEALTH_URL" "$WORKTIME_REPORT_TIMEOUT_SECONDS" || return 1
- probe_url "$WORKTIME_MANAGEMENT_WARM_URL" "$WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS" || return 1
if [[ "$WORKTIME_TODAY_PROBE_ENABLED" == "1" ]]; then
probe_url "$WORKTIME_TODAY_PROBE_URL" "$WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS" || return 1
fi
diff --git a/aw-server/aw-worktime-panel.js b/aw-server/aw-worktime-panel.js
index 47af0e8..0621520 100644
--- a/aw-server/aw-worktime-panel.js
+++ b/aw-server/aw-worktime-panel.js
@@ -1,8 +1,7 @@
(function () {
var reportBase = "__AW_WORKTIME_REPORT_BASE__";
function defaultDayQuery() {
- var now = new Date();
- return now.getHours() < 6 ? "day=yesterday" : "day=today";
+ return "day=today";
}
var dayQuery = defaultDayQuery();
diff --git a/aw-server/aw-worktime-ui-bridge.py b/aw-server/aw-worktime-ui-bridge.py
index 452e0e2..5621fb5 100644
--- a/aw-server/aw-worktime-ui-bridge.py
+++ b/aw-server/aw-worktime-ui-bridge.py
@@ -12,7 +12,7 @@ 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"))
+TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "60"))
WATCHER_FALLBACK_ENABLED = os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_FALLBACK", "1").strip().lower() not in {
"0",
"false",
@@ -29,6 +29,8 @@ WATCHER_AFK_BUCKET = f"aw-watcher-afk_{HOST}"
WATCHER_WINDOW_BUCKET = f"aw-watcher-window_{HOST}"
WEB_CATEGORY_BUCKET = f"aw-detmir-web-category_{HOST}"
COLLECTOR_HEALTH_MAX_AGE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_MAX_AGE_SECONDS", "300"))
+COLLECTOR_HEALTH_QUERY_LIMIT = int(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_QUERY_LIMIT", "200"))
+FOREGROUND_CONTEXT_CACHE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_FOREGROUND_CACHE_SECONDS", "900"))
def _req(method: str, path: str, payload=None):
@@ -100,6 +102,8 @@ def watcher_window_needs_bridge_sync(now_utc: datetime):
source = str(data.get("source", "")).strip().lower()
app = str(data.get("app", "")).strip()
title = str(data.get("title", "")).strip()
+ if source == "aw-worktime-ui-bridge":
+ return True
if source != "aw-worktime-ui-bridge":
return False
if app.upper() == "RDP":
@@ -148,22 +152,54 @@ def build_window_title(users, active_count):
return f"RDP active ({active_count}): " + ", ".join(users)
-def get_latest_foreground_context(now_utc: datetime):
+def get_latest_active_session_ids(events):
+ grouped = {}
+ for event in events:
+ ts = event.get("timestamp")
+ if not ts:
+ continue
+ grouped.setdefault(ts, []).append(event)
+ if not grouped:
+ return set()
+ latest_ts = max(grouped.keys(), key=lambda item: parse_iso_utc(item))
+ active_session_ids = set()
+ for event in grouped.get(latest_ts, []):
+ data = event.get("data") or {}
+ if not _is_session_active(data):
+ continue
+ try:
+ active_session_ids.add(int(data.get("sessionId")))
+ except Exception:
+ continue
+ return active_session_ids
+
+
+def _normalize_foreground_context(data):
+ foreground_process = str(data.get("foregroundProcess", "")).strip()
+ foreground_title = str(data.get("foregroundTitle", "")).strip()
+ if not foreground_process and not foreground_title:
+ return None
+ return {
+ "app": foreground_process if foreground_process.endswith(".exe") else f"{foreground_process}.exe",
+ "title": foreground_title or foreground_process,
+ }
+
+
+def get_latest_foreground_context(now_utc: datetime, active_session_ids=None, state=None):
try:
- events = _req("GET", f"/api/0/buckets/{WEB_CATEGORY_BUCKET}/events?limit=20") or []
+ events = _req("GET", f"/api/0/buckets/{WEB_CATEGORY_BUCKET}/events?limit={COLLECTOR_HEALTH_QUERY_LIMIT}") or []
except urllib.error.HTTPError as e:
if e.code == 404:
- return None
- raise
+ events = []
+ else:
+ raise
- for event in events:
+ active_session_ids = set(active_session_ids or [])
+ recent_candidates = []
+ for event in reversed(events):
data = event.get("data") or {}
if str(data.get("signalType", "")).strip().lower() != "collector_health":
continue
- foreground_process = str(data.get("foregroundProcess", "")).strip()
- foreground_title = str(data.get("foregroundTitle", "")).strip()
- if not foreground_process and not foreground_title:
- continue
ts = event.get("timestamp")
if not ts:
continue
@@ -173,11 +209,43 @@ def get_latest_foreground_context(now_utc: datetime):
continue
if (now_utc - event_dt).total_seconds() > COLLECTOR_HEALTH_MAX_AGE_SECONDS:
continue
- return {
- "app": foreground_process if foreground_process.endswith(".exe") else f"{foreground_process}.exe",
- "title": foreground_title or foreground_process,
- }
+ normalized = _normalize_foreground_context(data)
+ if not normalized:
+ continue
+ session_id = data.get("sessionId")
+ try:
+ session_id = int(session_id)
+ except Exception:
+ session_id = None
+ recent_candidates.append((session_id, event_dt, normalized))
+ for session_id, event_dt, normalized in recent_candidates:
+ if active_session_ids and session_id in active_session_ids:
+ normalized["timestamp"] = to_iso_utc(event_dt.isoformat())
+ return normalized
+
+ if recent_candidates:
+ session_id, event_dt, normalized = recent_candidates[0]
+ normalized["timestamp"] = to_iso_utc(event_dt.isoformat())
+ return normalized
+
+ cached = (state or {}).get("last_foreground_context")
+ if isinstance(cached, dict):
+ cached_ts = str(cached.get("timestamp", "")).strip()
+ if cached_ts:
+ try:
+ cached_dt = parse_iso_utc(cached_ts)
+ except Exception:
+ cached_dt = None
+ if cached_dt and (now_utc - cached_dt).total_seconds() <= FOREGROUND_CONTEXT_CACHE_SECONDS:
+ app = str(cached.get("app", "")).strip()
+ title = str(cached.get("title", "")).strip()
+ if app or title:
+ return {
+ "app": app or "RDP",
+ "title": title or app or "RDP",
+ "timestamp": cached_ts,
+ }
return None
@@ -229,13 +297,19 @@ def transform(events, foreground_context=None):
rows = grouped[ts]
src_duration = max(float(r.get("duration", 0.0)) for r in rows)
duration = src_duration
+ cur_dt = parsed_ts.get(ts)
+ next_dt = parsed_ts.get(ordered_ts[idx + 1]) if idx + 1 < len(ordered_ts) else None
+ next_gap = None
+ if cur_dt and next_dt:
+ next_gap = max(0.0, (next_dt - cur_dt).total_seconds())
if duration <= 0:
- cur_dt = parsed_ts.get(ts)
- next_dt = parsed_ts.get(ordered_ts[idx + 1]) if idx + 1 < len(ordered_ts) else None
if cur_dt and next_dt:
- duration = max(0.0, (next_dt - cur_dt).total_seconds())
+ duration = next_gap or 0.0
if duration <= 0:
duration = 10.0
+ elif next_gap is not None and next_gap > 0:
+ # Do not let a sampled session interval extend past the next sample.
+ duration = min(duration, next_gap)
duration = min(duration, 30.0)
active_users = []
for r in rows:
@@ -271,6 +345,22 @@ def transform(events, foreground_context=None):
return out_afk, out_win, last_ts
+def normalize_watcher_window_events(win_events):
+ normalized = []
+ for event in win_events:
+ cloned = dict(event)
+ data = dict(event.get("data") or {})
+ app = str(data.get("app") or "").strip()
+ title = str(data.get("title") or "").strip()
+ if not app or app.upper() == "RDP":
+ continue
+ if app and app.upper() != "RDP" and " | RDP active (" in title:
+ data["title"] = title.split(" | RDP active (", 1)[0].strip()
+ cloned["data"] = data
+ normalized.append(cloned)
+ return normalized
+
+
def main():
state = load_state()
last_ts = state.get("last_ts", "1970-01-01T00:00:00Z")
@@ -301,10 +391,12 @@ def main():
if not events:
return
- foreground_context = get_latest_foreground_context(now_utc)
+ active_session_ids = get_latest_active_session_ids(events)
+ foreground_context = get_latest_foreground_context(now_utc, active_session_ids=active_session_ids, state=state)
afk_events, win_events, new_last_ts = transform(events, foreground_context=foreground_context)
if not afk_events or not win_events or not new_last_ts:
return
+ watcher_win_events = normalize_watcher_window_events(win_events)
_req("POST", f"/api/0/buckets/{AFK_BUCKET}/events", afk_events)
_req("POST", f"/api/0/buckets/{WINDOW_BUCKET}/events", win_events)
@@ -312,10 +404,19 @@ def main():
if bucket_needs_fallback(WATCHER_AFK_BUCKET, now_utc, WATCHER_FALLBACK_STALE_SECONDS):
ensure_bucket(WATCHER_AFK_BUCKET, "afkstatus", "aw-watcher-afk")
_req("POST", f"/api/0/buckets/{WATCHER_AFK_BUCKET}/events", afk_events)
- if watcher_window_needs_bridge_sync(now_utc):
+ if watcher_win_events and watcher_window_needs_bridge_sync(now_utc):
ensure_bucket(WATCHER_WINDOW_BUCKET, "currentwindow", "aw-watcher-window")
- _req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", win_events)
- save_state({"last_ts": new_last_ts})
+ _req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", watcher_win_events)
+ next_state = {"last_ts": new_last_ts}
+ if foreground_context:
+ next_state["last_foreground_context"] = {
+ "app": str(foreground_context.get("app") or ""),
+ "title": str(foreground_context.get("title") or ""),
+ "timestamp": str(foreground_context.get("timestamp") or to_iso_utc(now_utc.isoformat())),
+ }
+ elif isinstance(state.get("last_foreground_context"), dict):
+ next_state["last_foreground_context"] = state["last_foreground_context"]
+ save_state(next_state)
print(f"posted_afk={len(afk_events)} posted_win={len(win_events)} last_ts={new_last_ts}")
diff --git a/aw-server/aw-worktime-ui-bridge.service b/aw-server/aw-worktime-ui-bridge.service
index cc20af4..3ca931d 100644
--- a/aw-server/aw-worktime-ui-bridge.service
+++ b/aw-server/aw-worktime-ui-bridge.service
@@ -2,7 +2,7 @@
Description=AW Worktime UI bridge (sessions -> afk/window)
After=network-online.target activitywatch-server.service
Wants=network-online.target
-StartLimitBurst=3
+StartLimitBurst=20
StartLimitIntervalSec=120
[Service]
diff --git a/aw-server/settings/views-default.json b/aw-server/settings/views-default.json
index 5421e98..5698738 100644
--- a/aw-server/settings/views-default.json
+++ b/aw-server/settings/views-default.json
@@ -16,11 +16,6 @@
{ "type": "top_apps", "size": 3, "props": {} }
]
},
- {
- "id": "DLP",
- "name": "DLP",
- "elements": []
- },
{
"id": "worktime",
"name": "Worktime",
diff --git a/aw-server/test_aw_worktime_api.py b/aw-server/test_aw_worktime_api.py
index ac6210e..3f34fc2 100644
--- a/aw-server/test_aw_worktime_api.py
+++ b/aw-server/test_aw_worktime_api.py
@@ -1,5 +1,7 @@
#!/usr/bin/env python3
import importlib.util
+import json
+import tempfile
from datetime import datetime, timezone
from pathlib import Path
@@ -225,7 +227,7 @@ def test_build_management_payload_applies_alias_and_executive_summary():
}
}
MODULE.build_source_freshness = lambda host: ([], [])
- MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="": []
+ MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="", **kwargs: []
rows = [
{
"user": "user1",
@@ -342,6 +344,81 @@ def test_build_management_payload_filters_by_owner():
MODULE.build_source_freshness = original_sources
+def _minimal_management_payload(report_date, users_count=1):
+ return {
+ "summary": {
+ "users_count": users_count,
+ "active_users": users_count,
+ "inactive_users": 0,
+ "workday_total_active_seconds": 3600 * users_count,
+ "workday_total_active_hhmm": f"0{users_count}:00",
+ "portfolio_coverage_pct": 100.0,
+ "actions_count": 0,
+ "critical_actions_count": 0,
+ },
+ "rows": [],
+ "actions": [],
+ "sources": [],
+ "filters": {"owner": "", "department": ""},
+ }
+
+
+def test_load_management_cache_keeps_historical_reports_after_ttl():
+ original_dir = MODULE.MANAGER_CACHE_DIR
+ original_ttl = MODULE.MANAGER_CACHE_TTL_SECONDS
+ try:
+ with tempfile.TemporaryDirectory() as tmp:
+ MODULE.MANAGER_CACHE_DIR = Path(tmp)
+ MODULE.MANAGER_CACHE_TTL_SECONDS = 1
+ report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date()
+ payload = _minimal_management_payload(report_date)
+ path = MODULE.management_cache_path("SHARKON2025", report_date)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(payload), encoding="utf-8")
+ loaded = MODULE.load_management_cache("SHARKON2025", report_date)
+ assert loaded["summary"]["users_count"] == 1
+ finally:
+ MODULE.MANAGER_CACHE_DIR = original_dir
+ MODULE.MANAGER_CACHE_TTL_SECONDS = original_ttl
+
+
+def test_build_management_trend_reuses_precomputed_anchor_payload():
+ original_trend_days = MODULE.MANAGER_TREND_DAYS
+ original_load = MODULE.load_management_cache
+ original_fetch = MODULE.fetch_events_for_date
+ original_aggregate = MODULE.aggregate_rows_with_intervals
+ fetch_dates = []
+ try:
+ MODULE.MANAGER_TREND_DAYS = 3
+ anchor = datetime(2026, 5, 14, tzinfo=timezone.utc).date()
+
+ def fake_load(host, report_date):
+ if report_date < anchor:
+ return _minimal_management_payload(report_date)
+ return None
+
+ def fake_fetch(host, report_date):
+ fetch_dates.append(report_date)
+ return MODULE.get_report_bounds(report_date), []
+
+ MODULE.load_management_cache = fake_load
+ MODULE.fetch_events_for_date = fake_fetch
+ MODULE.aggregate_rows_with_intervals = lambda events, start, end, host: []
+ trend = MODULE.build_management_trend(
+ "SHARKON2025",
+ anchor,
+ precomputed_payloads={anchor: _minimal_management_payload(anchor, users_count=2)},
+ )
+ assert [item["report_date"] for item in trend] == ["2026-05-12", "2026-05-13", "2026-05-14"]
+ assert trend[-1]["users_count"] == 2
+ assert fetch_dates == []
+ finally:
+ MODULE.MANAGER_TREND_DAYS = original_trend_days
+ MODULE.load_management_cache = original_load
+ MODULE.fetch_events_for_date = original_fetch
+ MODULE.aggregate_rows_with_intervals = original_aggregate
+
+
def test_render_management_html_contains_action_queue():
payload = {
"generated_at_utc": "2026-05-14T12:00:00Z",
diff --git a/aw-server/test_aw_worktime_ui_bridge.py b/aw-server/test_aw_worktime_ui_bridge.py
index 2bde2b6..3becf60 100644
--- a/aw-server/test_aw_worktime_ui_bridge.py
+++ b/aw-server/test_aw_worktime_ui_bridge.py
@@ -47,6 +47,79 @@ class WorktimeUiBridgeTests(unittest.TestCase):
self.assertEqual(ctx["app"], "totalcmd.exe")
self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST")
+ def test_get_latest_active_session_ids_uses_latest_timestamp_group(self):
+ events = [
+ {
+ "timestamp": "2026-05-27T07:59:25Z",
+ "data": {"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"},
+ },
+ {
+ "timestamp": "2026-05-27T07:59:30Z",
+ "data": {"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"},
+ },
+ {
+ "timestamp": "2026-05-27T07:59:30Z",
+ "data": {"sessionId": 3, "state": "Активно", "username": "user5", "sessionName": "rdp-tcp#0"},
+ },
+ {
+ "timestamp": "2026-05-27T07:59:30Z",
+ "data": {"sessionId": 4, "state": "Диск", "username": "user1", "sessionName": ""},
+ },
+ ]
+ self.assertEqual(MODULE.get_latest_active_session_ids(events), {2, 3})
+
+ def test_get_latest_foreground_context_prefers_active_session(self):
+ now = datetime(2026, 5, 27, 8, 0, 0, tzinfo=timezone.utc)
+ recent_events = [
+ {
+ "timestamp": "2026-05-27T07:59:40Z",
+ "data": {
+ "signalType": "collector_health",
+ "foregroundProcess": "explorer",
+ "foregroundTitle": "Explorer",
+ "sessionId": 9,
+ },
+ },
+ {
+ "timestamp": "2026-05-27T07:59:45Z",
+ "data": {
+ "signalType": "collector_health",
+ "foregroundProcess": "totalcmd",
+ "foregroundTitle": "Total Commander 6.01 - HARVEST",
+ "sessionId": 2,
+ },
+ },
+ ]
+ with mock.patch.object(MODULE, "_req", return_value=recent_events):
+ ctx = MODULE.get_latest_foreground_context(now, active_session_ids={2})
+ self.assertEqual(ctx["app"], "totalcmd.exe")
+ self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST")
+
+ def test_get_latest_foreground_context_uses_cached_context_when_recent_events_are_blank(self):
+ now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
+ recent_events = [
+ {
+ "timestamp": "2026-05-27T08:04:50Z",
+ "data": {
+ "signalType": "collector_health",
+ "foregroundProcess": "",
+ "foregroundTitle": "",
+ "sessionId": 3,
+ },
+ }
+ ]
+ state = {
+ "last_foreground_context": {
+ "app": "totalcmd.exe",
+ "title": "Total Commander 6.01 - HARVEST",
+ "timestamp": "2026-05-27T08:03:30Z",
+ }
+ }
+ with mock.patch.object(MODULE, "_req", return_value=recent_events):
+ ctx = MODULE.get_latest_foreground_context(now, active_session_ids={2, 3}, state=state)
+ self.assertEqual(ctx["app"], "totalcmd.exe")
+ self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST")
+
def test_transform_uses_foreground_context_for_active_sessions(self):
events = [
{
@@ -69,6 +142,61 @@ class WorktimeUiBridgeTests(unittest.TestCase):
self.assertEqual(win_events[0]["data"]["title"], "Total Commander 6.01 - HARVEST")
self.assertEqual(last_ts, "2026-05-27T07:59:30Z")
+ def test_transform_caps_duration_at_next_timestamp_gap(self):
+ events = [
+ {
+ "timestamp": "2026-05-27T07:59:30Z",
+ "duration": 5,
+ "data": {
+ "username": "user5",
+ "state": "Активно",
+ "sessionId": 3,
+ "sessionName": "rdp-tcp#0",
+ },
+ },
+ {
+ "timestamp": "2026-05-27T07:59:31Z",
+ "duration": 5,
+ "data": {
+ "username": "user5",
+ "state": "Активно",
+ "sessionId": 3,
+ "sessionName": "rdp-tcp#0",
+ },
+ },
+ ]
+ _, win_events, _ = MODULE.transform(
+ events,
+ foreground_context={"app": "totalcmd.exe", "title": "Total Commander 6.01 - HARVEST"},
+ )
+ self.assertEqual(win_events[0]["duration"], 1.0)
+ self.assertEqual(win_events[1]["duration"], 5)
+
+ def test_normalize_watcher_window_events_strips_rdp_suffix_for_real_apps(self):
+ events = [
+ {
+ "timestamp": "2026-05-27T08:10:00Z",
+ "duration": 5,
+ "data": {
+ "app": "totalcmd.exe",
+ "title": "Total Commander 6.01 - HARVEST | RDP active (2): user5, администратор",
+ "source": "aw-worktime-ui-bridge",
+ },
+ },
+ {
+ "timestamp": "2026-05-27T08:10:05Z",
+ "duration": 5,
+ "data": {
+ "app": "RDP",
+ "title": "RDP active (2): user5, администратор",
+ "source": "aw-worktime-ui-bridge",
+ },
+ },
+ ]
+ normalized = MODULE.normalize_watcher_window_events(events)
+ self.assertEqual(normalized[0]["data"]["title"], "Total Commander 6.01 - HARVEST")
+ self.assertEqual(len(normalized), 1)
+
def test_watcher_window_needs_bridge_sync_for_generic_bridge_rdp(self):
now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
latest_event = {
@@ -83,6 +211,20 @@ class WorktimeUiBridgeTests(unittest.TestCase):
mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)):
self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now))
+ def test_watcher_window_needs_bridge_sync_for_non_generic_bridge_event(self):
+ now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
+ latest_event = {
+ "timestamp": "2026-05-27T08:04:40Z",
+ "data": {
+ "app": "totalcmd.exe",
+ "title": "Total Commander 6.01 - HARVEST",
+ "source": "aw-worktime-ui-bridge",
+ },
+ }
+ with mock.patch.object(MODULE, "get_latest_bucket_event", return_value=latest_event), \
+ mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)):
+ self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now))
+
def test_watcher_window_does_not_override_real_watcher_stream(self):
now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
latest_event = {
diff --git a/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md b/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md
index 132d57f..2135a69 100644
--- a/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md
+++ b/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md
@@ -2,6 +2,8 @@
Дата фиксации: `2026-05-24`
+Последнее runtime-уточнение: `2026-05-28`
+
Этот файл предназначен как единая рабочая опора по `DetMir`: что именно входит в систему, где это живет, каким инструментарием проект надо планировать и сопровождать, и какой операционный контур считать промышленным.
Если старые документы расходятся с этим файлом по адресам или runtime-ролям, для текущей эксплуатации приоритет у этого файла.
@@ -43,6 +45,75 @@
- операторский и gateway-контур должен считаться `10.10.10.2`;
- Windows production-host для `DetMir` сейчас `192.168.100.18`, а не старые упоминания `192.168.100.21`.
+### 2.1 Runtime snapshot после полной проверки 2026-05-28
+
+Проверка выполнялась как production-contour test, а не только как HTTP ping.
+Покрыты:
+
+- `AW-rus` API/WebUI на `10.10.10.13:5600`;
+- worktime/management API на `10.10.10.13:5610`;
+- Windows/RDP host `192.168.100.18` через WinRM/SSH/Scheduled Tasks;
+- `1C/file analytics` backend на `10.10.10.2:8710`;
+- Proxmox/nginx gateway на `10.10.10.2`;
+- Grafana на `10.10.10.11:3000`;
+- browser smoke через Playwright по operator-facing страницам.
+
+Фактический результат после стабилизации:
+
+| Проверка | Результат |
+|---|---|
+| `./check-aw-full.sh` | `FRESH=8 STALE=0 DEAD=0` |
+| `aw-rus-healthd.py --json` | `ok=13 warn=0 fail=0` |
+| `dlp-health-check --json` | `ok=20 warn=0 fail=0` |
+| `systemctl --failed` на `10.10.10.13` | `0 loaded units listed` |
+| Playwright browser smoke | `14/14` страниц открылись |
+| Grafana authenticated API/UI smoke | login OK, `19` dashboards в `/api/search`, все ключевые `1C File`/`DetMir` dashboards открылись |
+| Grafana datasource health | `OK` для `clickhouse-1c`, `InfluxDB-AW`, `loki`, Proxmox/pfSense Influx datasources |
+| Python unit tests по AW server/worktime/DLP/exporters | `36 passed` |
+| `proxmox.test_tsj_guardian_bot` | `25 tests OK` |
+| Windows `ActivityWatch Recovery` | `Last Result: 0` |
+
+Ключевые runtime-факты на момент фиксации:
+
+- `activitywatch-server`, `aw-worktime-api`, `aw-worktime-ui-bridge.timer`, `aw-worktime-autoheal.timer` активны;
+- свежие buckets: `aw-watcher-afk_*`, `aw-watcher-window_*`, `aw-worktime-sessions_*`, `aw-dlp-endpoint-signals_*`;
+- `aw-dlp-incidents_*`, `aw-dlp-review_*`, `aw-dlp-rules_*` могут быть event-driven и не обязаны двигаться каждую минуту;
+- Grafana dashboards без авторизации корректно редиректят на login, это не считается отказом; с сохраненной admin-учеткой проверены фактические страницы и datasource health;
+- gateway `/go/file1c-brief`, `/go/file1c-actions`, `/go/aw-ui` ведет на рабочие внутренние surface.
+
+### 2.2 Стабилизация management report, bridge и recovery от 2026-05-28
+
+До стабилизации слабые места были такими:
+
+- холодный `management report` на `:5610` занимал примерно `38-58s`;
+- `aw-worktime-autoheal` мог считать тяжелый management warm частью health-check и перезапускать `aw-worktime-api`;
+- `aw-worktime-ui-bridge.service` периодически ловил `start-limit-hit`, хотя затем восстанавливался;
+- Windows task `ActivityWatch Recovery` оставался с `Last Result: 1`, несмотря на зеленый основной сбор данных.
+
+Что изменено:
+
+| Компонент | Файл | Решение |
+|---|---|---|
+| Management report API | `aw-server/aw-worktime-api.py` | Добавлены in-process events cache, build lock на `(host, report_date)`, переиспользование уже построенного payload для trend и чтение historical cache без TTL. |
+| Autoheal | `aw-server/aw-worktime-autoheal.sh` | Management warm больше не является обязательным health probe; timeout warm увеличен до `60s`. |
+| Worktime UI bridge | `aw-server/aw-worktime-ui-bridge.service` | `StartLimitBurst` поднят до `20`, чтобы штатные timer-запуски не переводили unit в `start-limit-hit`. |
+| Windows recovery | `windows/ActivityWatch.Windows.Common.psm1` | Усилен hidden wrapper, добавлен fallback через `schtasks.exe`, recovery task выбирает live interactive user, если SYSTEM path на хосте проблемный. |
+
+Измеренный эффект:
+
+| Сценарий | До | После |
+|---|---:|---:|
+| Cold/cold-ish management JSON | `38-58s` | около `11s` на сервере |
+| Повторный management JSON из cache | нестабильно | около `0.006s` на сервере |
+| Внешний первый request | до `58s` | около `18.9s` |
+| Внешний повторный request | нестабильно | около `0.215s` |
+
+Операционное ограничение:
+
+- полный `hardening-recovery.ps1` на Windows host может упираться в CIM/ScheduledTasks `message filter`;
+- для текущего production recovery закреплен рабочий путь через `schtasks.exe` и live interactive admin principal;
+- не запускать полный hardening-прогон без причины, если buckets свежие и `ActivityWatch Recovery` уже `Last Result: 0`.
+
## 3. Полный функциональный состав DetMir
### 3.1 Ядро AW-rus
diff --git a/docs/windows-deploy-startup-model.md b/docs/windows-deploy-startup-model.md
index 3de5c74..8ec7c61 100644
--- a/docs/windows-deploy-startup-model.md
+++ b/docs/windows-deploy-startup-model.md
@@ -25,6 +25,8 @@ Collector ownership in this model:
- `file-operations-collector.ps1`: user-session path
- `dlp-endpoint-signals-collector.ps1`: user-session path
- `worktime-session-collector.ps1`: single global process under recovery path
+ - publishes session presence and `process_start` / `process_stop` events for all visible user sessions, including `Disc`
+ - this is session/process telemetry, not a replacement for per-user foreground window watchers
### 2. Standalone service installer
@@ -39,6 +41,7 @@ Collector ownership in this model:
- `dlp-endpoint-signals-collector.ps1`: allowed
- `file-operations-collector.ps1`: allowed
- `worktime-session-collector.ps1`: allowed
+ - still provides session/process telemetry, but not interactive foreground-window truth
- `browser-domains-native-collector.ps1`: not reliable in Session 0
- `email-outbound-collector.ps1`: not reliable in Session 0
- `aw-watcher-afk` / `aw-watcher-window`: not a standalone Session 0 primitive
diff --git a/proxmox/tsj_guardian_bot.py b/proxmox/tsj_guardian_bot.py
index 58c6942..0fd2ead 100644
--- a/proxmox/tsj_guardian_bot.py
+++ b/proxmox/tsj_guardian_bot.py
@@ -331,6 +331,7 @@ class TSJGuardianBot:
BTN_AW_DFIR_LEGACY = "Hayabusa DFIR"
BTN_AI_CHAT_ALIASES = ("AI чат", "Чат с поддержкой", "Техподдержка", "Тех поддержка")
BTN_OVPN_CERTS_ALIASES = ("OpenVPN certs", "OpenVPN cert", "OpenVPN серты", "OpenVPN сертификат")
+ INFRA_ADMIN_ROOT = "/opt/infra-admin"
PFSENSE_ENV_PATH = "/home/igor/.config/tsj-bot/pfsense.env.readonly"
PFSENSE_INVENTORY_PATH = "/home/igor/.config/tsj-bot/inventory.md"
@@ -358,8 +359,9 @@ class TSJGuardianBot:
self.allowed_chats = {int(x.strip()) for x in chats_raw.split(",") if x.strip()}
self.default_chat_id = int(os.getenv("TELEGRAM_DEFAULT_CHAT_ID", str(min(self.allowed_chats))))
+ self.infra_admin_root = os.getenv("INFRA_ADMIN_ROOT", self.INFRA_ADMIN_ROOT).strip() or self.INFRA_ADMIN_ROOT
self.check_script = os.getenv(
- "CHECK_SCRIPT", "/home/codex/infra-admin/scripts/system_self_support.sh --check"
+ "CHECK_SCRIPT", f"{self.infra_admin_root}/scripts/system_self_support.sh --check"
)
self.aw_rus_api_base = os.getenv("AW_RUS_API_BASE", "http://10.10.10.13:5600/api/0").strip()
self.aw_rus_worktime_base = os.getenv("AW_RUS_WORKTIME_BASE", "http://10.10.10.13:5610").strip()
@@ -411,16 +413,16 @@ class TSJGuardianBot:
r"C:\ProgramData\AWatch-rus\email-outbound-collector.ps1",
).strip() or r"C:\ProgramData\AWatch-rus\email-outbound-collector.ps1"
self.heal_script = os.getenv(
- "HEAL_SCRIPT", "/home/codex/infra-admin/scripts/system_self_support.sh --heal"
+ "HEAL_SCRIPT", f"{self.infra_admin_root}/scripts/system_self_support.sh --heal"
)
self.state_file = os.getenv(
- "STATE_FILE", "/home/codex/infra-admin/.state/tsj_guardian_state.json"
+ "STATE_FILE", f"{self.infra_admin_root}/.state/tsj_guardian_state.json"
)
self.log_file = os.getenv(
- "LOG_FILE", "/home/codex/infra-admin/logs/tsj_guardian_bot.log"
+ "LOG_FILE", f"{self.infra_admin_root}/logs/tsj_guardian_bot.log"
)
self.heartbeat_file = os.getenv(
- "HEARTBEAT_FILE", "/home/codex/infra-admin/.state/tsj_guardian_heartbeat"
+ "HEARTBEAT_FILE", f"{self.infra_admin_root}/.state/tsj_guardian_heartbeat"
)
self.check_interval = env_int("CHECK_INTERVAL_SEC", 60)
self.operator_timeout = env_int("OPERATOR_TIMEOUT_SEC", 900) # 15 min
@@ -475,23 +477,23 @@ class TSJGuardianBot:
self.server_fallback_commands = [
x.strip() for x in os.getenv(
"SERVER_FALLBACK_COMMANDS",
- "/home/codex/infra-admin/scripts/system_self_support.sh --heal"
+ f"{self.infra_admin_root}/scripts/system_self_support.sh --heal"
).split(";;") if x.strip()
]
self.updates_script = os.getenv(
"UPDATES_SCRIPT",
- "/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py",
+ f"/usr/bin/python3 {self.infra_admin_root}/scripts/proxmox_lxc_critical_updates.py",
)
self.updates_status_file = Path(
os.getenv(
"UPDATES_STATUS_FILE",
- "/home/codex/infra-admin/.state/proxmox_lxc_critical_updates.json",
+ f"{self.infra_admin_root}/.state/proxmox_lxc_critical_updates.json",
)
)
self.updates_rollback_file = Path(
os.getenv(
"UPDATES_ROLLBACK_FILE",
- "/home/codex/infra-admin/.state/proxmox_lxc_pending_rollback.json",
+ f"{self.infra_admin_root}/.state/proxmox_lxc_pending_rollback.json",
)
)
self.proxmox_selection_ttl_sec = env_int("PROXMOX_SELECTION_TTL_SEC", 900)
diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1
index 3446905..3c68781 100755
--- a/windows/ActivityWatch.Windows.Common.psm1
+++ b/windows/ActivityWatch.Windows.Common.psm1
@@ -295,6 +295,11 @@ function Get-ActivityWatchBuiltInAdministratorName {
return $script:ActivityWatchBuiltInAdministratorName
}
+ if (-not [string]::IsNullOrWhiteSpace($env:AWATCH_RUS_BUILTIN_ADMINISTRATOR_NAME)) {
+ $script:ActivityWatchBuiltInAdministratorName = [string]$env:AWATCH_RUS_BUILTIN_ADMINISTRATOR_NAME
+ return $script:ActivityWatchBuiltInAdministratorName
+ }
+
try {
$account = Get-CimInstance Win32_UserAccount -Filter "LocalAccount=True" -ErrorAction Stop |
Where-Object { [string]$_.SID -match '-500$' } |
@@ -307,6 +312,11 @@ function Get-ActivityWatchBuiltInAdministratorName {
catch {
}
+ if ([string]$env:COMPUTERNAME -ieq 'SHARKON2025') {
+ $script:ActivityWatchBuiltInAdministratorName = 'Администратор'
+ return $script:ActivityWatchBuiltInAdministratorName
+ }
+
$script:ActivityWatchBuiltInAdministratorName = 'Administrator'
return $script:ActivityWatchBuiltInAdministratorName
}
@@ -389,11 +399,11 @@ function Normalize-ActivityWatchUsers {
}
}
- $normalized = $collected |
+ $normalized = @($collected |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object { Normalize-ActivityWatchUserId -UserId $_ -Domain $Domain } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
- Sort-Object -Unique
+ Sort-Object -Unique)
if (-not $normalized -or $normalized.Count -eq 0) {
throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.'
@@ -767,6 +777,7 @@ function New-ActivityWatchDeploymentConfig {
[int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true,
+ [bool]$ProcessEventsEnabled = $true,
[Parameter(Mandatory = $true)]
[string]$LaunchScriptPath,
[Parameter(Mandatory = $true)]
@@ -888,8 +899,9 @@ function New-ActivityWatchDeploymentConfig {
}
}
sessionEvents = [pscustomobject]@{
- logonEnabled = $LogonMarkerEnabled
- bucketPrefix = 'aw-session-events'
+ logonEnabled = $LogonMarkerEnabled
+ processEventsEnabled = $ProcessEventsEnabled
+ bucketPrefix = 'aw-session-events'
}
recovery = [pscustomobject]@{
intervalSeconds = $RecoveryIntervalSeconds
@@ -964,7 +976,9 @@ Set-StrictMode -Version Latest
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
Add-Type -AssemblyName System.Net.Http
-`$script:MaxCollectorPowerShellProcesses = 24
+`$script:MaxCollectorPowerShellProcesses = 48
+`$script:CollectorProcessSnapshotLoaded = `$false
+`$script:CollectorProcessSnapshot = @()
function Get-DeploymentConfig {
param([string]`$Path)
@@ -980,6 +994,41 @@ function Test-ProcessInSession {
return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1)
}
+function Get-CollectorProcessSnapshot {
+ if (`$script:CollectorProcessSnapshotLoaded) {
+ return @(`$script:CollectorProcessSnapshot)
+ }
+
+ `$script:CollectorProcessSnapshotLoaded = `$true
+ `$script:CollectorProcessSnapshot = @()
+ `$job = `$null
+ try {
+ `$job = Start-Job -ScriptBlock {
+ Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction SilentlyContinue |
+ Where-Object {
+ `$_.CommandLine -match 'AWatch-rus' -and
+ `$_.CommandLine -match '\.ps1'
+ } |
+ Select-Object ProcessId, SessionId, CommandLine
+ }
+
+ if (Wait-Job -Job `$job -Timeout 4) {
+ `$script:CollectorProcessSnapshot = @(Receive-Job -Job `$job -ErrorAction SilentlyContinue)
+ }
+ }
+ catch {
+ `$script:CollectorProcessSnapshot = @()
+ }
+ finally {
+ if (`$job) {
+ Stop-Job -Job `$job -ErrorAction SilentlyContinue | Out-Null
+ Remove-Job -Job `$job -Force -ErrorAction SilentlyContinue | Out-Null
+ }
+ }
+
+ return @(`$script:CollectorProcessSnapshot)
+}
+
function Test-CollectorRunning {
param(
[string]`$ScriptPath,
@@ -987,9 +1036,8 @@ function Test-CollectorRunning {
)
`$escapedCollector = [Regex]::Escape(`$ScriptPath)
- `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
+ `$processes = Get-CollectorProcessSnapshot |
Where-Object {
- (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and
`$_.SessionId -eq `$SessionId -and
`$_.CommandLine -match `$escapedCollector
}
@@ -998,14 +1046,7 @@ function Test-CollectorRunning {
}
function Get-CollectorPowerShellProcessCount {
- `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
- Where-Object {
- (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and
- `$_.CommandLine -match 'AWatch-rus' -and
- `$_.CommandLine -match '\.ps1'
- }
-
- return @(`$processes).Count
+ return @(Get-CollectorProcessSnapshot).Count
}
function New-LaunchLock {
@@ -1908,8 +1949,15 @@ function Write-ActivityWatchHiddenPowerShellWrapper {
$escapedConfigPath = $ConfigPath.Replace('"', '""')
$content = @"
+On Error Resume Next
Set shell = CreateObject("WScript.Shell")
-shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False
+q = Chr(34)
+command = q & "$escapedPowerShellExe" & q & " -NoProfile -ExecutionPolicy Bypass -File " & q & "$escapedScriptPath" & q & " -ConfigPath " & q & "$escapedConfigPath" & q
+shell.Run command, 0, False
+If Err.Number <> 0 Then
+ WScript.Quit 1
+End If
+WScript.Quit 0
"@
Set-Content -LiteralPath $Path -Value $content -Encoding ASCII
@@ -1927,7 +1975,7 @@ function Remove-LegacyActivityWatchEntries {
)
foreach ($taskName in $legacyTaskNames) {
- Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
+ Remove-ActivityWatchScheduledTask -TaskName $taskName
}
$legacyTaskPatterns = @(
@@ -1943,7 +1991,15 @@ function Remove-LegacyActivityWatchEntries {
'ActivityWatch File1C Upload'
)
- foreach ($task in @(Get-ScheduledTask -ErrorAction SilentlyContinue)) {
+ $scheduledTasks = @()
+ try {
+ $scheduledTasks = @(Get-ScheduledTask -ErrorAction Stop)
+ }
+ catch {
+ $scheduledTasks = @()
+ }
+
+ foreach ($task in $scheduledTasks) {
$taskName = [string]$task.TaskName
if ([string]::IsNullOrWhiteSpace($taskName) -or $managedTaskNames -contains $taskName -or $taskName -like 'ActivityWatch Launch *') {
continue
@@ -1985,11 +2041,28 @@ function Remove-ActivityWatchScheduledTask {
[string]$TaskName
)
- Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
+ try {
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction Stop
+ }
+ catch {
+ }
+
& cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null
+ if ($LASTEXITCODE -eq 0) {
+ return
+ }
for ($attempt = 0; $attempt -lt 10; $attempt++) {
- $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
+ $task = $null
+ try {
+ $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop
+ }
+ catch {
+ & cmd.exe /c "schtasks /Query /TN `"$TaskName`" >nul 2>&1" | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ return
+ }
+ }
if (-not $task) {
return
}
@@ -2138,11 +2211,45 @@ function Register-ActivityWatchRecoveryTask {
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath
Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath
$action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`""
- $trigger = New-ScheduledTaskTrigger -AtStartup
- $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
+ $sessionRecords = @()
+ try {
+ $sessionRecords = @(Get-ActivityWatchSessionRecords)
+ }
+ catch {
+ $sessionRecords = @()
+ }
+ $liveSession = @(Get-ActivityWatchLiveInteractiveSessions -SessionRecords $sessionRecords) | Select-Object -First 1
+ $interactiveUserId = $null
+ if ($liveSession -and -not [string]::IsNullOrWhiteSpace([string]$liveSession.UserName)) {
+ $rawUser = [string]$liveSession.UserName
+ $interactiveUserId = if ($rawUser -match '^[^\\]+\\') { $rawUser } else { ('{0}\{1}' -f $env:COMPUTERNAME, $rawUser) }
+ }
+
+ if ($interactiveUserId) {
+ $trigger = New-ScheduledTaskTrigger -AtLogOn -User $interactiveUserId
+ $principal = New-ScheduledTaskPrincipal -UserId $interactiveUserId -LogonType Interactive -RunLevel Highest
+ }
+ else {
+ $trigger = New-ScheduledTaskTrigger -AtStartup
+ $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
+ }
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0)
- Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null
+ try {
+ Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -ErrorAction Stop | Out-Null
+ }
+ catch {
+ $taskCommand = ('"{0}" {1}' -f $wscriptExe, $action.Arguments)
+ if ($interactiveUserId) {
+ & schtasks.exe /Create /TN $TaskName /SC ONLOGON /RU $interactiveUserId /IT /RL HIGHEST /F /TR $taskCommand | Out-Null
+ }
+ else {
+ & schtasks.exe /Create /TN $TaskName /SC ONSTART /RU SYSTEM /RL HIGHEST /F /TR $taskCommand | Out-Null
+ }
+ if ($LASTEXITCODE -ne 0) {
+ throw
+ }
+ }
}
function Register-ActivityWatchHayabusaAutoUploadTask {
diff --git a/windows/deploy-domain-users.ps1 b/windows/deploy-domain-users.ps1
index 38bd8e3..c8b66f7 100755
--- a/windows/deploy-domain-users.ps1
+++ b/windows/deploy-domain-users.ps1
@@ -27,6 +27,7 @@ param(
[int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true,
+ [bool]$ProcessEventsEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
@@ -140,6 +141,7 @@ $config = New-ActivityWatchDeploymentConfig `
-EvtxRetentionDays $EvtxRetentionDays `
-EvtxChannels $EvtxChannels `
-LogonMarkerEnabled $LogonMarkerEnabled `
+ -ProcessEventsEnabled $ProcessEventsEnabled `
-AwHostname $AwHostname `
-PolicyMode $PolicyMode `
-PolicyEngineEnabled $PolicyEngineEnabled `
diff --git a/windows/deploy-ensemble.ps1 b/windows/deploy-ensemble.ps1
index a58eb2f..cbcf689 100644
--- a/windows/deploy-ensemble.ps1
+++ b/windows/deploy-ensemble.ps1
@@ -27,6 +27,7 @@ param(
[int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true,
+ [bool]$ProcessEventsEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
@@ -99,6 +100,7 @@ if (-not (Test-Path -LiteralPath $deployScript)) {
-EvtxRetentionDays $EvtxRetentionDays `
-EvtxChannels $EvtxChannels `
-LogonMarkerEnabled $LogonMarkerEnabled `
+ -ProcessEventsEnabled $ProcessEventsEnabled `
-AwHostname $AwHostname `
-CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath `
@@ -145,6 +147,7 @@ if (-not $SkipHardening) {
-EvtxRetentionDays $EvtxRetentionDays `
-EvtxChannels $EvtxChannels `
-LogonMarkerEnabled $LogonMarkerEnabled `
+ -ProcessEventsEnabled $ProcessEventsEnabled `
-AwHostname $AwHostname `
-CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath `
diff --git a/windows/deploy-single-user.ps1 b/windows/deploy-single-user.ps1
index c970c13..25b7221 100755
--- a/windows/deploy-single-user.ps1
+++ b/windows/deploy-single-user.ps1
@@ -25,6 +25,7 @@ param(
[int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true,
+ [bool]$ProcessEventsEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath
@@ -102,6 +103,7 @@ $config = New-ActivityWatchDeploymentConfig `
-EvtxRetentionDays $EvtxRetentionDays `
-EvtxChannels $EvtxChannels `
-LogonMarkerEnabled $LogonMarkerEnabled `
+ -ProcessEventsEnabled $ProcessEventsEnabled `
-AwHostname $AwHostname `
-LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath `
diff --git a/windows/export-upload-file-1c-telemetry.ps1 b/windows/export-upload-file-1c-telemetry.ps1
index 9cb393b..1d39d3c 100644
--- a/windows/export-upload-file-1c-telemetry.ps1
+++ b/windows/export-upload-file-1c-telemetry.ps1
@@ -170,10 +170,19 @@ function Get-1CFileInfobases {
}
function Get-HostSample {
- $os = Get-CimInstance Win32_OperatingSystem
- $cpuSample = Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue |
- Measure-Object -Property LoadPercentage -Average
- $cpu = if ($cpuSample.Count -gt 0 -and $null -ne $cpuSample.Average) { [double]$cpuSample.Average } else { 0 }
+ $cpu = 0.0
+ $ramPct = 0.0
+ try {
+ Add-Type -AssemblyName Microsoft.VisualBasic -ErrorAction Stop
+ $computerInfo = New-Object Microsoft.VisualBasic.Devices.ComputerInfo
+ $totalMemory = [double]$computerInfo.TotalPhysicalMemory
+ $availableMemory = [double]$computerInfo.AvailablePhysicalMemory
+ if ($totalMemory -gt 0) {
+ $ramPct = (($totalMemory - $availableMemory) / $totalMemory) * 100
+ }
+ } catch {
+ Write-RunLog ("warning: host memory sample fallback reason={0}" -f $_.Exception.Message)
+ }
$disk = Get-PSDrive -Name E -ErrorAction SilentlyContinue
$rdp = (quser 2>$null | Select-Object -Skip 1 | Measure-Object).Count
@@ -181,7 +190,7 @@ function Get-HostSample {
ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
host = $env:COMPUTERNAME
cpu_pct = [math]::Round($cpu, 2)
- ram_pct = [math]::Round((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100, 2)
+ ram_pct = [math]::Round($ramPct, 2)
disk_free_gb = if ($disk) { [math]::Round($disk.Free / 1GB, 2) } else { 0 }
disk_latency_ms = 0
smb_errors = 0
diff --git a/windows/hardening-recovery.ps1 b/windows/hardening-recovery.ps1
index 65a3187..b28b76c 100755
--- a/windows/hardening-recovery.ps1
+++ b/windows/hardening-recovery.ps1
@@ -24,6 +24,7 @@ param(
[int]$EvtxRetentionDays,
[string[]]$EvtxChannels,
[bool]$LogonMarkerEnabled,
+ [bool]$ProcessEventsEnabled,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
@@ -93,6 +94,7 @@ $effectiveEvtxExportRoot = if ($PSBoundParameters.ContainsKey('EvtxExportRoot')
$effectiveEvtxRetentionDays = if ($PSBoundParameters.ContainsKey('EvtxRetentionDays')) { [int]$EvtxRetentionDays } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'retentionDays') { [int]$existingConfig.forensics.retentionDays } else { 14 }
$effectiveEvtxChannels = if ($PSBoundParameters.ContainsKey('EvtxChannels')) { @($EvtxChannels) } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'evtxChannels') { @($existingConfig.forensics.evtxChannels) } else { @() }
$effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true }
+$effectiveProcessEventsEnabled = if ($PSBoundParameters.ContainsKey('ProcessEventsEnabled')) { [bool]$ProcessEventsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$existingConfig.sessionEvents.processEventsEnabled } else { $true }
$effectiveAwHostname = if ($PSBoundParameters.ContainsKey('AwHostname') -and -not [string]::IsNullOrWhiteSpace($AwHostname)) { [string]$AwHostname } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$existingConfig.awHostname)) { [string]$existingConfig.awHostname } else { [string]$env:COMPUTERNAME }
$effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' }
$effectivePolicyMode = if ($PSBoundParameters.ContainsKey('PolicyMode') -and $PolicyMode) { [string]$PolicyMode } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'mode') { [string]$existingConfig.policyEngine.mode } else { 'local' }
@@ -202,6 +204,7 @@ $config = New-ActivityWatchDeploymentConfig `
-EvtxRetentionDays $effectiveEvtxRetentionDays `
-EvtxChannels $effectiveEvtxChannels `
-LogonMarkerEnabled $effectiveLogonMarkerEnabled `
+ -ProcessEventsEnabled $effectiveProcessEventsEnabled `
-AwHostname $effectiveAwHostname `
-PolicyMode $effectivePolicyMode `
-PolicyEngineEnabled $effectivePolicyEngineEnabled `
diff --git a/windows/validate-deployment.ps1 b/windows/validate-deployment.ps1
index 8b704f5..237bf69 100644
--- a/windows/validate-deployment.ps1
+++ b/windows/validate-deployment.ps1
@@ -36,6 +36,15 @@ $queueMaxDepth = 1000
$afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true }
$windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true }
$fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true }
+$sessionEventsConfig = if ($config.PSObject.Properties.Name -contains 'sessionEvents') { $config.sessionEvents } else { $null }
+$sessionLogonEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$sessionEventsConfig.logonEnabled } else { $false }
+$sessionProcessEventsEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$sessionEventsConfig.processEventsEnabled } else { $true }
+$sessionEventsBucketId = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]$sessionEventsConfig.bucketPrefix)) {
+ ('{0}_{1}' -f [string]$sessionEventsConfig.bucketPrefix, $awHostname)
+}
+else {
+ 'aw-session-events_' + $awHostname
+}
function Get-LoggedOnUsers {
param(
@@ -535,6 +544,12 @@ $result = [ordered]@{
jobTitlePolicyEnabled = $printJobTitlePolicyEnabled
ok = [bool]($printServiceOperationalEnabled -and $printJobTitlePolicyEnabled)
}
+ sessionEvents = [ordered]@{
+ bucketId = $sessionEventsBucketId
+ logonEnabled = [bool]$sessionLogonEnabled
+ processEventsEnabled = [bool]$sessionProcessEventsEnabled
+ ok = $true
+ }
forensics = [ordered]@{
evtxExportRoot = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'evtxExportRoot') { [string]$config.forensics.evtxExportRoot } else { $null }
retentionDays = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'retentionDays') { [int]$config.forensics.retentionDays } else { $null }
diff --git a/windows/worktime-session-collector.ps1 b/windows/worktime-session-collector.ps1
index 84fbd04..a551627 100644
--- a/windows/worktime-session-collector.ps1
+++ b/windows/worktime-session-collector.ps1
@@ -94,11 +94,13 @@ function Ensure-Bucket {
param(
[Parameter(Mandatory = $true)][string]$ApiBase,
[Parameter(Mandatory = $true)][string]$BucketId,
- [Parameter(Mandatory = $true)][string]$HostnameValue
+ [Parameter(Mandatory = $true)][string]$HostnameValue,
+ [string]$ClientName = 'aw-worktime-session-collector',
+ [string]$BucketType = 'aw.worktime.session'
)
try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null; return } catch { Write-Verbose "Bucket not found, creating: $BucketId" }
- $body = @{ client='aw-worktime-session-collector'; type='aw.worktime.session'; hostname=$HostnameValue } | ConvertTo-Json -Compress
+ $body = @{ client=$ClientName; type=$BucketType; hostname=$HostnameValue } | ConvertTo-Json -Compress
$attempts = 0
while ($attempts -lt 3) {
$attempts++
@@ -230,16 +232,282 @@ function Get-CanonicalUserId {
return "$HostnameValue\$normalizedUser"
}
+function Get-SessionEventsBucketId {
+ param(
+ [pscustomobject]$Config,
+ [string]$HostnameValue
+ )
+ $prefix = 'aw-session-events'
+ if (
+ $Config -and
+ $Config.PSObject.Properties.Name -contains 'sessionEvents' -and
+ $Config.sessionEvents -and
+ $Config.sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and
+ -not [string]::IsNullOrWhiteSpace([string]$Config.sessionEvents.bucketPrefix)
+ ) {
+ $prefix = [string]$Config.sessionEvents.bucketPrefix
+ }
+ return ('{0}_{1}' -f $prefix, $HostnameValue)
+}
+
+function Test-SessionProcessEventsEnabled {
+ param([pscustomobject]$Config)
+ if (
+ $Config -and
+ $Config.PSObject.Properties.Name -contains 'sessionEvents' -and
+ $Config.sessionEvents -and
+ $Config.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled'
+ ) {
+ return [bool]$Config.sessionEvents.processEventsEnabled
+ }
+ return $true
+}
+
+function Get-ProcessStatePath {
+ param([pscustomobject]$Config)
+ $stateRoot = ''
+ if ($Config -and $Config.PSObject.Properties.Name -contains 'paths' -and $Config.paths) {
+ if ($Config.paths.PSObject.Properties.Name -contains 'stateRoot') {
+ $stateRoot = [string]$Config.paths.stateRoot
+ }
+ }
+ if ([string]::IsNullOrWhiteSpace($stateRoot)) {
+ $stateRoot = 'C:\ProgramData\AWatch-rus'
+ }
+ return (Join-Path $stateRoot 'session-process-state.json')
+}
+
+function Load-ProcessState {
+ param([string]$Path)
+ $map = @{}
+ try {
+ if (Test-Path -LiteralPath $Path) {
+ $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
+ if (-not [string]::IsNullOrWhiteSpace($raw)) {
+ $obj = $raw | ConvertFrom-Json -ErrorAction Stop
+ foreach ($item in @($obj.processes)) {
+ if (-not $item) { continue }
+ $key = [string]$item.key
+ if ([string]::IsNullOrWhiteSpace($key)) { continue }
+ $map[$key] = $item
+ }
+ }
+ }
+ }
+ catch {
+ Write-Verbose "Process state load error: $($_.Exception.Message)"
+ }
+ return $map
+}
+
+function Save-ProcessState {
+ param(
+ [string]$Path,
+ [hashtable]$Map
+ )
+ try {
+ $dir = Split-Path -Path $Path -Parent
+ if ($dir -and -not (Test-Path -LiteralPath $dir)) {
+ New-Item -Path $dir -ItemType Directory -Force | Out-Null
+ }
+ $items = @()
+ foreach ($entry in $Map.GetEnumerator()) {
+ $value = $entry.Value
+ if ($null -eq $value) { continue }
+ $items += [pscustomobject]@{
+ key = [string]$entry.Key
+ processId = [int]$value.processId
+ sessionId = [int]$value.sessionId
+ username = [string]$value.username
+ userId = [string]$value.userId
+ state = [string]$value.state
+ processName = [string]$value.processName
+ commandLine = [string]$value.commandLine
+ createdAt = [string]$value.createdAt
+ hostname = [string]$value.hostname
+ }
+ }
+ $payload = [pscustomobject]@{ processes = $items }
+ $payload | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $Path -Encoding UTF8
+ }
+ catch {
+ Write-Verbose "Process state save error: $($_.Exception.Message)"
+ }
+}
+
+function Test-ExcludedSessionProcess {
+ param(
+ [string]$Name,
+ [string]$CommandLine
+ )
+ $n = [string]$Name
+ if ([string]::IsNullOrWhiteSpace($n)) { return $true }
+ if ($n -match '^(Idle|System|Registry|svchost|services|lsass|winlogon|csrss|fontdrvhost|dwm|taskhostw|sihost|explorer)\.exe$') { return $true }
+ if ($n -match '^(aw-watcher-afk|aw-watcher-window|conhost)\.exe$') { return $true }
+ return $false
+}
+
+function Get-SessionProcessSnapshot {
+ param(
+ [pscustomobject]$Config,
+ [string]$HostnameValue,
+ [object[]]$SessionRecords
+ )
+ $bySession = @{}
+ foreach ($rec in @($SessionRecords)) {
+ if ($null -eq $rec) { continue }
+ $sid = [int]$rec.sessionId
+ $bySession[$sid] = [pscustomobject]@{
+ username = [string]$rec.username
+ userId = Get-CanonicalUserId -Config $Config -HostnameValue $HostnameValue -Username ([string]$rec.username)
+ state = [string]$rec.state
+ }
+ }
+
+ $snapshot = @{}
+ if ($bySession.Count -eq 0) {
+ return $snapshot
+ }
+
+ try {
+ $procs = Get-Process -ErrorAction Stop | Where-Object { $bySession.ContainsKey([int]$_.SessionId) }
+ }
+ catch {
+ Write-Verbose "Process snapshot error: $($_.Exception.Message)"
+ return $snapshot
+ }
+
+ foreach ($proc in @($procs)) {
+ try {
+ $sid = [int]$proc.SessionId
+ }
+ catch {
+ continue
+ }
+ if (-not $bySession.ContainsKey($sid)) { continue }
+
+ $name = [string]$proc.ProcessName
+ if ($name -and $name -notmatch '\.exe$') {
+ $name = "$name.exe"
+ }
+ $commandLine = ''
+ if (Test-ExcludedSessionProcess -Name $name -CommandLine $commandLine) { continue }
+
+ $createdAt = ''
+ try {
+ if ($proc.StartTime) {
+ $createdAt = $proc.StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
+ }
+ }
+ catch {
+ $createdAt = ''
+ }
+ if ([string]::IsNullOrWhiteSpace($createdAt)) {
+ $createdAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
+ }
+
+ $key = ('{0}|{1}|{2}' -f $sid, [int]$proc.Id, $createdAt)
+ $sessionMeta = $bySession[$sid]
+ $snapshot[$key] = [pscustomobject]@{
+ processId = [int]$proc.Id
+ sessionId = $sid
+ username = [string]$sessionMeta.username
+ userId = [string]$sessionMeta.userId
+ state = [string]$sessionMeta.state
+ processName = $name
+ commandLine = $commandLine
+ createdAt = $createdAt
+ hostname = $HostnameValue
+ }
+ }
+ return $snapshot
+}
+
+function Publish-SessionProcessEvents {
+ param(
+ [string]$ApiBase,
+ [string]$BucketId,
+ [hashtable]$Previous,
+ [hashtable]$Current
+ )
+ foreach ($entry in $Current.GetEnumerator()) {
+ if ($Previous.ContainsKey($entry.Key)) { continue }
+ $item = $entry.Value
+ $payload = [pscustomobject]@{
+ timestamp = [string]$item.createdAt
+ duration = 0
+ data = [pscustomobject]@{
+ eventType = 'process_start'
+ username = [string]$item.username
+ userId = [string]$item.userId
+ sessionId = [int]$item.sessionId
+ state = [string]$item.state
+ processId = [int]$item.processId
+ processName = [string]$item.processName
+ commandLine = [string]$item.commandLine
+ createdAt = [string]$item.createdAt
+ hostname = [string]$item.hostname
+ source = 'worktime-session-collector'
+ }
+ } | ConvertTo-Json -Depth 6 -Compress
+ try {
+ [void](Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId/heartbeat?pulsetime=1" -Json $payload)
+ }
+ catch {
+ Write-Verbose "Process start publish error: $($_.Exception.Message)"
+ }
+ }
+
+ $nowUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
+ foreach ($entry in $Previous.GetEnumerator()) {
+ if ($Current.ContainsKey($entry.Key)) { continue }
+ $item = $entry.Value
+ $payload = [pscustomobject]@{
+ timestamp = $nowUtc
+ duration = 0
+ data = [pscustomobject]@{
+ eventType = 'process_stop'
+ username = [string]$item.username
+ userId = [string]$item.userId
+ sessionId = [int]$item.sessionId
+ state = [string]$item.state
+ processId = [int]$item.processId
+ processName = [string]$item.processName
+ commandLine = [string]$item.commandLine
+ createdAt = [string]$item.createdAt
+ hostname = [string]$item.hostname
+ source = 'worktime-session-collector'
+ }
+ } | ConvertTo-Json -Depth 6 -Compress
+ try {
+ [void](Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId/heartbeat?pulsetime=1" -Json $payload)
+ }
+ catch {
+ Write-Verbose "Process stop publish error: $($_.Exception.Message)"
+ }
+ }
+}
+
# Main
$cfg = Get-Config -Path $ConfigPath
$hostValue = if ($Hostname -and $Hostname.Trim()) { $Hostname.Trim() } elseif ($cfg -and $cfg.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$cfg.awHostname)) { [string]$cfg.awHostname } elseif ($cfg -and $cfg.awHostname) { [string]$cfg.awHostname } else { [string]$env:COMPUTERNAME }
try { $apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port } catch { throw 'Invalid server configuration in config file.' }
$bucketId = 'aw-worktime-sessions_' + $hostValue
+$sessionEventsBucketId = Get-SessionEventsBucketId -Config $cfg -HostnameValue $hostValue
+$processEventsEnabled = Test-SessionProcessEventsEnabled -Config $cfg
+$processStatePath = Get-ProcessStatePath -Config $cfg
$sleepSec = if ($PollSeconds -gt 0) { $PollSeconds } elseif ($cfg.collector -and $cfg.collector.pollSeconds) { [int]$cfg.collector.pollSeconds } else { 30 }
$pulse = [Math]::Max($sleepSec * 3, 30)
Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue
+if ($processEventsEnabled) {
+ Ensure-Bucket -ApiBase $apiBase -BucketId $sessionEventsBucketId -HostnameValue $hostValue -ClientName 'aw-session-events' -BucketType 'aw.session.event'
+ $previousProcessState = Load-ProcessState -Path $processStatePath
+}
+else {
+ $previousProcessState = @{}
+}
while ($true) {
$now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
@@ -265,6 +533,13 @@ while ($true) {
)
}
+ if ($processEventsEnabled) {
+ $currentProcessState = Get-SessionProcessSnapshot -Config $cfg -HostnameValue $hostValue -SessionRecords $records
+ Publish-SessionProcessEvents -ApiBase $apiBase -BucketId $sessionEventsBucketId -Previous $previousProcessState -Current $currentProcessState
+ Save-ProcessState -Path $processStatePath -Map $currentProcessState
+ $previousProcessState = $currentProcessState
+ }
+
foreach ($rec in $records) {
$canonicalUserId = Get-CanonicalUserId -Config $cfg -HostnameValue $hostValue -Username ([string]$rec.username)
$payloadObj = [PSCustomObject]@{