fix(aw): harden collectors and grafana exports

This commit is contained in:
igor04091968
2026-05-28 10:54:46 +03:00
parent 524ab33f63
commit cc9e4a0669
29 changed files with 1181 additions and 131 deletions
+109 -2
View File
@@ -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 }}"
+6
View File
@@ -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 }}"
+14 -12
View File
@@ -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
+5 -2
View File
@@ -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
+6
View File
@@ -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
+2
View File
@@ -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"
+3 -2
View File
@@ -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"
+5
View File
@@ -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"
+42
View File
@@ -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
+24 -11
View File
@@ -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 =
'<h4>RDP summary</h4>' +
'<p>Этот блок строится из server-side worktime отчёта и не зависит от того, жива ли локальная интерактивная RDP-сессия.</p>' +
'<h4>RDP сводка</h4>' +
'<p>Этот блок строится из bucket <code>aw-worktime-sessions</code> через AW API и показывает сводку по RDP-сессиям выбранного хоста.</p>' +
'<div class="aw-ru-rdp-grid">' +
'<section class="aw-ru-rdp-card"><h5>Активное время</h5><div class="aw-ru-rdp-value">' + escapeHtml(formatDurationSeconds(totalActiveSeconds)) + '</div></section>' +
'<section class="aw-ru-rdp-card"><h5>Активных пользователей</h5><div class="aw-ru-rdp-value">' + escapeHtml(String(activeUsers.length)) + '</div></section>' +
@@ -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();
});
+89 -31
View File
@@ -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):
+1 -2
View File
@@ -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
+1 -2
View File
@@ -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();
+122 -21
View File
@@ -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}")
+1 -1
View File
@@ -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]
-5
View File
@@ -16,11 +16,6 @@
{ "type": "top_apps", "size": 3, "props": {} }
]
},
{
"id": "DLP",
"name": "DLP",
"elements": []
},
{
"id": "worktime",
"name": "Worktime",
+78 -1
View File
@@ -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",
+142
View File
@@ -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 = {
+71
View File
@@ -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
+3
View File
@@ -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
+11 -9
View File
@@ -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)
+130 -23
View File
@@ -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 {
+2
View File
@@ -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 `
+3
View File
@@ -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 `
+2
View File
@@ -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 `
+14 -5
View File
@@ -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
+3
View File
@@ -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 `
+15
View File
@@ -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 }
+277 -2
View File
@@ -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]@{