diff --git a/README.md b/README.md
index b3e1846..b2d419a 100755
--- a/README.md
+++ b/README.md
@@ -10,16 +10,19 @@
- `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback.
- `docs/windows/ensemble.md` — orchestration-пакет для Windows-деплоя и проверки.
- `docs/linux-client.md` — user-space rollout Linux-клиента ActivityWatch на удалённый `AW server`.
+- `docs/linux-remote-worker.md` — полный Linux remote-worker stack: GUI, SSH/console и browser admin UI вроде Proxmox `:8006`.
- `docs/console-ssh-logger.md` — логирование только консольных команд и SSH-сессий в AW.
- `docs/dlp-gap-analysis.md` — разрыв до enterprise DLP и roadmap.
- `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox.
- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI.
- `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT).
- `pfsense/` — внешний poller для pfSense API и systemd unit под Debian/Ubuntu utility VM.
-- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, phase-2 DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`).
+- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, phase-2 DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`) и session-level presence для удалённых Windows/RDP пользователей (`aw-worktime-sessions_*`).
- `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок.
- `scripts/install_aw_linux_client.sh` — установка Linux bundle + autostart для remote AW server.
- `scripts/install_aw_console_ssh_logger.sh` — user-space установка console/ssh logger.
+- `scripts/install_aw_linux_web_category_logger.sh` — user-space классификация browser admin UI по title/class.
+- `scripts/install_aw_linux_remote_worker.sh` — полный Linux remote-worker installer.
## Базовый сценарий
@@ -51,6 +54,11 @@
- `docs/linux-client.md`
- `scripts/install_aw_linux_client.sh`
+Для полного Linux remote-worker сценария:
+
+- `docs/linux-remote-worker.md`
+- `scripts/install_aw_linux_remote_worker.sh`
+
Для режима “только консоль/ssh” без GUI watcher'ов:
- `docs/console-ssh-logger.md`
diff --git a/Sync_from_git.sh b/Sync_from_git.sh
new file mode 100755
index 0000000..91f58e5
--- /dev/null
+++ b/Sync_from_git.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+set -eu
+
+REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+
+cd "$REPO_DIR"
+
+echo "==> Fetch origin"
+git fetch origin
+
+CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
+
+echo "==> Pull origin/$CURRENT_BRANCH"
+git pull --ff-only origin "$CURRENT_BRANCH"
+
+echo "==> Done"
+git status --short --branch
diff --git a/Sync_to_git.sh b/Sync_to_git.sh
new file mode 100755
index 0000000..15e2e86
--- /dev/null
+++ b/Sync_to_git.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+set -eu
+
+REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+
+cd "$REPO_DIR"
+
+BRANCH=$(git rev-parse --abbrev-ref HEAD)
+
+if [ "${1:-}" = "" ]; then
+ echo "Usage: $0 \"commit message\"" >&2
+ exit 1
+fi
+
+COMMIT_MESSAGE=$1
+
+echo "==> Git status"
+git status --short --branch
+
+echo "==> Stage changes"
+git add -A
+
+if git diff --cached --quiet; then
+ echo "No staged changes to commit."
+ exit 0
+fi
+
+echo "==> Commit"
+git commit -m "$COMMIT_MESSAGE"
+
+echo "==> Push origin/$BRANCH"
+git push origin "$BRANCH"
+
+echo "==> Done"
diff --git a/aw-server/aw-host-groups.json b/aw-server/aw-host-groups.json
index f3d0118..7432f60 100644
--- a/aw-server/aw-host-groups.json
+++ b/aw-server/aw-host-groups.json
@@ -29,6 +29,21 @@
{ "label": "DLP", "type": "bucket", "bucket_prefix": "aw-dlp-endpoint-signals_" }
]
},
+ {
+ "id": "linux-remote",
+ "name": "Linux remote workers",
+ "description": "Linux-хосты удалённых сотрудников: GUI активность, SSH/console и browser admin UI.",
+ "patterns": [
+ "^(LINUX-WS|LINUX-DESKTOP|LX-|DESKTOP-|ADMIN-|WORKSTATION-|DEVBOX-)"
+ ],
+ "links": [
+ { "label": "Активность", "type": "activity" },
+ { "label": "SSH сессии", "type": "bucket", "bucket_prefix": "aw-ssh-sessions_" },
+ { "label": "Команды shell", "type": "bucket", "bucket_prefix": "aw-console-commands_" },
+ { "label": "Web категории", "type": "bucket", "bucket_prefix": "aw-detmir-web-category_" },
+ { "label": "Все бакеты", "type": "buckets" }
+ ]
+ },
{
"id": "virtual-infra",
"name": "Virtual servers + Proxmox",
diff --git a/aw-server/aw-ru-patch.js b/aw-server/aw-ru-patch.js
index 2491d05..6af4d61 100755
--- a/aw-server/aw-ru-patch.js
+++ b/aw-server/aw-ru-patch.js
@@ -680,6 +680,19 @@
{ label: "DLP", type: "bucket", bucket_prefix: "aw-dlp-endpoint-signals_" }
]
},
+ {
+ id: "linux-remote",
+ name: "Linux remote workers",
+ description: "Linux-хосты удалённых сотрудников: GUI активность, SSH/console и browser admin UI.",
+ patterns: ["^(LINUX-WS|LINUX-DESKTOP|LX-|DESKTOP-|ADMIN-|WORKSTATION-|DEVBOX-)"],
+ links: [
+ { label: "Активность", type: "activity" },
+ { label: "SSH сессии", type: "bucket", bucket_prefix: "aw-ssh-sessions_" },
+ { label: "Команды shell", type: "bucket", bucket_prefix: "aw-console-commands_" },
+ { label: "Web категории", type: "bucket", bucket_prefix: "aw-detmir-web-category_" },
+ { label: "Все бакеты", type: "buckets" }
+ ]
+ },
{
id: "virtual-infra",
name: "Virtual servers + Proxmox",
@@ -740,7 +753,15 @@
const prefixes = [
"aw-watcher-window_",
"aw-watcher-afk_",
+ "aw-console-commands_",
+ "aw-ssh-sessions_",
+ "aw-linux-web-context_",
+ "aw-detmir-web-category_",
"aw-dlp-endpoint-signals_",
+ "aw-session-events_",
+ "aw-worktime-sessions_",
+ "aw-pve-webadmin-events_",
+ "aw-pve-task-events_",
"aw-dlp-incidents_",
"aw-pfsense-health_",
"aw-pfsense-gateways_",
@@ -770,7 +791,27 @@
return result;
}
- function matchHostGroup(host, groups) {
+ function hostHasBucketPrefix(hostBuckets, prefix) {
+ return (hostBuckets || []).some(function (bucketId) {
+ return String(bucketId || "").indexOf(prefix) === 0;
+ });
+ }
+
+ function matchHostGroup(host, groups, hostBuckets) {
+ const bucketList = hostBuckets || [];
+ if (hostHasBucketPrefix(bucketList, "aw-dlp-endpoint-signals_") || hostHasBucketPrefix(bucketList, "aw-session-events_")) {
+ return "windows-rdp";
+ }
+ if (
+ hostHasBucketPrefix(bucketList, "aw-console-commands_") ||
+ hostHasBucketPrefix(bucketList, "aw-ssh-sessions_") ||
+ hostHasBucketPrefix(bucketList, "aw-linux-web-context_") ||
+ hostHasBucketPrefix(bucketList, "aw-detmir-web-category_")
+ ) {
+ if (!hostHasBucketPrefix(bucketList, "aw-pve-webadmin-events_") && !hostHasBucketPrefix(bucketList, "aw-pve-task-events_")) {
+ return "linux-remote";
+ }
+ }
for (const group of groups) {
const patterns = Array.isArray(group.patterns) ? group.patterns : [];
for (const pattern of patterns) {
@@ -813,7 +854,7 @@
grouped.set("__ungrouped__", []);
Array.from(hostBuckets.keys()).sort().forEach(function (host) {
- const groupId = matchHostGroup(host, groups) || "__ungrouped__";
+ const groupId = matchHostGroup(host, groups, hostBuckets.get(host) || []) || "__ungrouped__";
grouped.get(groupId).push(host);
});
@@ -869,7 +910,7 @@
center.setAttribute("data-aw-ru-host-groups", "1");
center.innerHTML =
'
Разделы хостов
' +
- 'Здесь хосты разделены на пользовательские Windows RDP и инфраструктурные виртуальные серверы/Proxmox.
' +
+ 'Здесь хосты разделены на Windows RDP, Linux remote workers и инфраструктурные узлы.
' +
'';
heading.parentElement.insertBefore(center, heading.nextSibling);
}
diff --git a/aw-server/settings/classes-worktime.json b/aw-server/settings/classes-worktime.json
index b6361f4..11ee810 100644
--- a/aw-server/settings/classes-worktime.json
+++ b/aw-server/settings/classes-worktime.json
@@ -20,7 +20,7 @@
"name": ["Работа", "Документы"],
"rule": {
"type": "regex",
- "regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64)\\.exe\\b|Adobe Reader|Acrobat",
+ "regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64|libreoffice|writer|calc)\\.exe\\b|LibreOffice|OnlyOffice|Adobe Reader|Acrobat",
"ignore_case": true
},
"data": { "color": "#2E7D32" }
@@ -40,7 +40,7 @@
"name": ["Работа", "Администрирование"],
"rule": {
"type": "regex",
- "regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd)\\.exe\\b",
+ "regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd|gnome-terminal|gnome-terminal-server|xfce4-terminal|konsole|tilix|alacritty|xterm|remmina|virt-manager)\\.exe\\b|\\b(gnome-terminal|gnome-terminal-server|xfce4-terminal|konsole|tilix|alacritty|xterm|remmina|virt-manager)\\b|Proxmox Virtual Environment|\\bpfSense\\b|\\bGrafana\\b|\\bKibana\\b|\\bPortainer\\b",
"ignore_case": true
},
"data": { "color": "#6D4C41" }
@@ -56,7 +56,7 @@
"name": ["Интернет", "Браузер"],
"rule": {
"type": "regex",
- "regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser)\\.exe\\b",
+ "regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser|chromium)\\.exe\\b|\\b(chrome|chromium|firefox|opera|brave|vivaldi)\\b",
"ignore_case": true
},
"data": { "color": "#00897B" }
@@ -82,7 +82,7 @@
"name": ["ActivityWatch"],
"rule": {
"type": "regex",
- "regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b",
+ "regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b|\\baw-(watcher|qt)\\b",
"ignore_case": true
},
"data": {}
diff --git a/docs/linux-remote-worker.md b/docs/linux-remote-worker.md
new file mode 100644
index 0000000..29930da
--- /dev/null
+++ b/docs/linux-remote-worker.md
@@ -0,0 +1,85 @@
+# Linux remote worker deployment
+
+## Назначение
+
+Этот сценарий закрывает полный набор данных по Linux-удалёнщику:
+
+- GUI active window и `afk` через `ActivityWatch`;
+- SSH и shell-команды через console/ssh logger;
+- браузерные админки по title-based правилам, включая Proxmox `https://...:8006`.
+
+Итоговый целевой набор bucket'ов:
+
+- `aw-watcher-window_`
+- `aw-watcher-afk_`
+- `aw-console-commands_`
+- `aw-ssh-sessions_`
+- `aw-linux-web-context_`
+- `aw-detmir-web-category_`
+
+## Установка
+
+```bash
+cd /path/to/AWatch-rus
+sh ./scripts/install_aw_linux_remote_worker.sh \
+ --server-host 10.10.10.13 \
+ --server-port 5600
+```
+
+## Что ставится
+
+1. `scripts/install_aw_linux_client.sh`
+2. `scripts/install_aw_console_ssh_logger.sh`
+3. `scripts/install_aw_linux_web_category_logger.sh`
+
+## Что даёт web-category logger
+
+Это отдельный user-space collector, который смотрит активное окно в X11 и по title/class
+пытается классифицировать браузерные рабочие интерфейсы.
+
+Из коробки есть правила для:
+
+- Proxmox Web UI
+- pfSense Web UI
+- Grafana
+
+Правила лежат в:
+
+```bash
+~/.config/aw-linux-web-category/rules.json
+```
+
+Для Proxmox `:8006` collector пишет события в `aw-detmir-web-category_` с полями вроде:
+
+- `categoryGroup=work`
+- `category=Администрирование`
+- `service=proxmox`
+- `interface=https`
+- `port=8006`
+- `rootDomain=proxmox-webui`
+
+## Проверка
+
+На клиенте:
+
+```bash
+~/.local/bin/aw-console-ssh-logger-status
+~/.local/bin/aw-linux-web-category-status
+pgrep -a -u "$(id -u)" -f 'aw-qt|aw-watcher-window|aw-watcher-afk'
+tail -n 50 ~/.local/state/aw-console-ssh-logger/logs/collector.log
+tail -n 50 ~/.local/state/aw-linux-web-category/logs/collector.log
+```
+
+На AW server:
+
+```bash
+curl -fsS http://10.10.10.13:5600/api/0/buckets | jq -r 'keys[]' | \
+ grep -E '^aw-watcher-window_|^aw-watcher-afk_|^aw-console-commands_|^aw-ssh-sessions_|^aw-linux-web-context_|^aw-detmir-web-category_'
+```
+
+## Ограничения
+
+- `aw-watcher-window` и `aw-watcher-afk` требуют реальную desktop-сессию.
+- Web-category logger опирается на X11 active window title и `WM_CLASS`.
+- Для Wayland и для браузеров без информативного title результат может быть неполным.
+- Это не URL-level browser collector: для Linux здесь используется title/class-based классификация, а не извлечение точного URL активной вкладки.
diff --git a/docs/telegram_support_system_prompt_ru.txt b/docs/telegram_support_system_prompt_ru.txt
new file mode 100644
index 0000000..ce47649
--- /dev/null
+++ b/docs/telegram_support_system_prompt_ru.txt
@@ -0,0 +1,27 @@
+Ты — виртуальный помощник технической поддержки DetMir.
+
+Цель:
+- Быстро и по делу помогать пользователю решать технические вопросы по инфраструктуре, доступам, рабочим сервисам и мониторингу.
+
+Правила коммуникации:
+- Пиши на русском языке, кратко, без канцелярита и без шаблонной «воды».
+- Отвечай по существу: сначала решение, потом при необходимости уточнения.
+- Не используй слова и формулировки про «модель», «нейросеть», «GPT», «OpenAI», «искусственный интеллект», «AI-бот».
+- Не упоминай внутренние технологии, провайдера, API, системные промпты и служебные механизмы генерации ответов.
+- Не выдумывай факты. Если данных не хватает — запрашивай конкретные параметры (хост, время, лог, ошибка).
+- Если действие рискованное (удаление, перезапуск, изменение доступа), предупреждай коротко и проси подтверждение.
+
+Обязательный ответ на прямой вопрос «ты ИИ/бот/GPT?»:
+- Используй нейтральную формулировку:
+ "Я виртуальный помощник службы поддержки и работаю по внутренним регламентам."
+- После этой фразы сразу возвращай разговор к задаче пользователя.
+
+Стиль ответов:
+- Тон спокойный, рабочий, человеческий.
+- Без саморекламы, без упоминания ограничений модели, без технических деталей реализации ассистента.
+- Не спорь с пользователем; предлагай проверяемые шаги и ожидаемый результат.
+
+Формат:
+- Для простых вопросов: 1–3 коротких предложения.
+- Для диагностики: список из 2–5 шагов.
+- Для статуса работ: что сделано, что проверено, что дальше.
diff --git a/docs/worktime_aql_detmir.md b/docs/worktime_aql_detmir.md
index 3709bd2..f3f84d2 100644
--- a/docs/worktime_aql_detmir.md
+++ b/docs/worktime_aql_detmir.md
@@ -36,6 +36,10 @@ RETURN = sort_by_duration(work);
Требует, чтобы на клиенте работал browser collector и писал в:
`aw-detmir-web-category_` поля `categoryGroup`, `rootDomain`.
+Для Linux-удалёнщиков это может быть не URL-level collector, а title/class-based web-category logger.
+Например, работа через Proxmox Web UI `https://...:8006` может попадать сюда как
+`rootDomain=proxmox-webui`, `categoryGroup=work`, `category=Администрирование`.
+
```javascript
web = flood(query_bucket("aw-detmir-web-category_SHARKON2025"));
not_afk = flood(query_bucket("aw-watcher-afk_SHARKON2025"));
@@ -68,3 +72,24 @@ RETURN = sort_by_duration(events);
- Если web-поток пустой, рабочее время в браузере корректно посчитать по доменам не получится. Тогда либо:
- чинить/запускать browser collector;
- либо временно считать браузер в `window` как «Интернет/Браузер» без разделения на work/personal.
+
+## Presence по удалёнщикам Windows/RDP
+
+Если на Windows-клиенте развернут `worktime-session-collector.ps1`, то появляется bucket
+`aw-worktime-sessions_` с heartbeat по `quser`/RDP session state.
+
+Это не замена `afk/window`, а отдельный канал для ответа на вопрос:
+«кто и когда вообще был в активной удалённой сессии».
+
+```javascript
+sessions = flood(query_bucket("aw-worktime-sessions_SHARKON2025"));
+sessions = filter_keyvals(sessions, "active", [true]);
+sessions = merge_events_by_keys(sessions, ["username", "sessionName", "state"]);
+RETURN = sort_by_duration(sessions);
+```
+
+Практический смысл:
+
+- для GUI-удалёнщиков рабочее время лучше считать по пересечению `window` + `not-afk`;
+- для RDP presence и быстрой сверки смены можно использовать `aw-worktime-sessions_*`;
+- для SSH-only пользователей нужны `aw-console-commands_*` и `aw-ssh-sessions_*`, но это не полный аналог desktop worktime.
diff --git a/scripts/install_aw_linux_remote_worker.sh b/scripts/install_aw_linux_remote_worker.sh
new file mode 100644
index 0000000..3cebd20
--- /dev/null
+++ b/scripts/install_aw_linux_remote_worker.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env sh
+set -eu
+
+SERVER_HOST="10.10.10.13"
+SERVER_PORT="5600"
+POLL_INTERVAL="5"
+AW_VERSION="0.13.2"
+
+usage() {
+ cat <<'EOF'
+Usage: install_aw_linux_remote_worker.sh [options]
+
+Options:
+ --server-host HOST Remote AW server host (default: 10.10.10.13)
+ --server-port PORT Remote AW server port (default: 5600)
+ --poll-interval SEC Poll interval for Linux loggers (default: 5)
+ --version VERSION ActivityWatch version for GUI watcher bundle (default: 0.13.2)
+ -h, --help Show this help
+EOF
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --server-host)
+ SERVER_HOST="$2"
+ shift 2
+ ;;
+ --server-port)
+ SERVER_PORT="$2"
+ shift 2
+ ;;
+ --poll-interval)
+ POLL_INTERVAL="$2"
+ shift 2
+ ;;
+ --version)
+ AW_VERSION="$2"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+
+sh "${SCRIPT_DIR}/install_aw_linux_client.sh" \
+ --server-host "${SERVER_HOST}" \
+ --server-port "${SERVER_PORT}" \
+ --version "${AW_VERSION}"
+
+sh "${SCRIPT_DIR}/install_aw_console_ssh_logger.sh" \
+ --server-host "${SERVER_HOST}" \
+ --server-port "${SERVER_PORT}" \
+ --poll-interval "${POLL_INTERVAL}"
+
+sh "${SCRIPT_DIR}/install_aw_linux_web_category_logger.sh" \
+ --server-host "${SERVER_HOST}" \
+ --server-port "${SERVER_PORT}" \
+ --poll-interval "${POLL_INTERVAL}"
+
+echo "Linux remote worker full-stack install completed."
+echo "Expected buckets on AW server:"
+echo " - aw-watcher-window_$(hostname -s)"
+echo " - aw-watcher-afk_$(hostname -s)"
+echo " - aw-console-commands_$(hostname -s)"
+echo " - aw-ssh-sessions_$(hostname -s)"
+echo " - aw-linux-web-context_$(hostname -s)"
+echo " - aw-detmir-web-category_$(hostname -s)"
diff --git a/scripts/install_aw_linux_web_category_logger.sh b/scripts/install_aw_linux_web_category_logger.sh
new file mode 100644
index 0000000..4666df7
--- /dev/null
+++ b/scripts/install_aw_linux_web_category_logger.sh
@@ -0,0 +1,367 @@
+#!/usr/bin/env sh
+set -eu
+
+SERVER_HOST="10.10.10.13"
+SERVER_PORT="5600"
+POLL_INTERVAL="5"
+INSTALL_ROOT="${HOME}/.local/opt/aw-linux-web-category"
+BIN_DIR="${HOME}/.local/bin"
+STATE_DIR="${HOME}/.local/state/aw-linux-web-category"
+LOG_DIR="${HOME}/.local/state/aw-linux-web-category/logs"
+CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/aw-linux-web-category"
+SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
+
+usage() {
+ cat <<'EOF'
+Usage: install_aw_linux_web_category_logger.sh [options]
+
+Options:
+ --server-host HOST AW server host (default: 10.10.10.13)
+ --server-port PORT AW server port (default: 5600)
+ --poll-interval SEC Poll interval in seconds (default: 5)
+ -h, --help Show this help
+EOF
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --server-host)
+ SERVER_HOST="$2"
+ shift 2
+ ;;
+ --server-port)
+ SERVER_PORT="$2"
+ shift 2
+ ;;
+ --poll-interval)
+ POLL_INTERVAL="$2"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+mkdir -p "${INSTALL_ROOT}" "${BIN_DIR}" "${STATE_DIR}" "${LOG_DIR}" "${CONFIG_DIR}" "${SYSTEMD_USER_DIR}"
+
+write_file() {
+ target="$1"
+ mkdir -p "$(dirname "${target}")"
+ cat > "${target}"
+}
+
+write_file "${CONFIG_DIR}/rules.json" <<'EOF'
+{
+ "rules": [
+ {
+ "id": "proxmox-webui",
+ "categoryGroup": "work",
+ "category": "Администрирование",
+ "service": "proxmox",
+ "interface": "https",
+ "port": 8006,
+ "rootDomain": "proxmox-webui",
+ "windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)",
+ "titleRegex": "(?i)proxmox\\s+virtual\\s+environment|\\bproxmox\\b|\\bnode\\b.*\\bsummary\\b|\\bvirtual machine\\b"
+ },
+ {
+ "id": "pfsense-webui",
+ "categoryGroup": "work",
+ "category": "Администрирование",
+ "service": "pfsense",
+ "interface": "https",
+ "port": 443,
+ "rootDomain": "pfsense-webui",
+ "windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)",
+ "titleRegex": "(?i)\\bpfsense\\b|\\bfirewall\\b"
+ },
+ {
+ "id": "grafana-webui",
+ "categoryGroup": "work",
+ "category": "Администрирование",
+ "service": "grafana",
+ "interface": "https",
+ "port": 3000,
+ "rootDomain": "grafana-webui",
+ "windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)",
+ "titleRegex": "(?i)\\bgrafana\\b|dashboard"
+ }
+ ]
+}
+EOF
+
+write_file "${INSTALL_ROOT}/config.json" <.*)"\s*$', title)
+ if title_match:
+ title = title_match.group("value")
+ else:
+ title = title.split("=", 1)[-1].strip().strip('"')
+
+ class_values = re.findall(r'"([^"]+)"', klass)
+ window_class = " ".join(class_values) if class_values else klass.split("=", 1)[-1].strip()
+
+ if not title and not window_class:
+ return None
+
+ return {
+ "windowId": win_id,
+ "title": title,
+ "windowClass": window_class
+ }
+
+ def ensure_bucket(self, bucket_id, bucket_type):
+ if bucket_id in self.ensured:
+ return True
+ payload = {"client": "aw-linux-web-category", "type": bucket_type, "hostname": self.host}
+ req = urllib.request.Request(
+ f"{self.server}/buckets/{bucket_id}",
+ data=json.dumps(payload).encode("utf-8"),
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=10):
+ self.ensured.add(bucket_id)
+ return True
+ except urllib.error.HTTPError as err:
+ if err.code in (304, 409):
+ self.ensured.add(bucket_id)
+ return True
+ return False
+ except urllib.error.URLError:
+ return False
+
+ def heartbeat(self, bucket_id, payload, bucket_type, pulse=60):
+ if not self.ensure_bucket(bucket_id, bucket_type):
+ return False
+ req = urllib.request.Request(
+ f"{self.server}/buckets/{bucket_id}/heartbeat?pulsetime={pulse}",
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=10):
+ return True
+ except urllib.error.URLError:
+ return False
+
+ def match_rule(self, window):
+ title = window.get("title", "")
+ window_class = window.get("windowClass", "")
+ for rule in self.rules:
+ if rule["windowClassRegex"].search(window_class) and rule["titleRegex"].search(title):
+ return rule
+ return None
+
+ def run(self):
+ while True:
+ window = self.get_active_window()
+ if window:
+ raw_key = f"{window.get('windowClass','')}|{window.get('title','')}"
+ if raw_key != self.last_raw_key:
+ raw_event = {
+ "timestamp": iso_now(),
+ "duration": 0,
+ "data": {
+ "source": "x11_active_window",
+ "username": self.user,
+ "host": self.host,
+ "windowId": window.get("windowId", ""),
+ "windowClass": window.get("windowClass", ""),
+ "title": window.get("title", "")
+ }
+ }
+ self.heartbeat(self.raw_bucket, raw_event, "aw.linux.web.context", pulse=20)
+ self.last_raw_key = raw_key
+
+ rule = self.match_rule(window)
+ if rule:
+ category_key = rule["id"] + "|" + window.get("title", "")
+ if category_key != self.last_category_key:
+ event = {
+ "timestamp": iso_now(),
+ "duration": 0,
+ "data": {
+ "source": "linux_window_title_rule",
+ "username": self.user,
+ "host": self.host,
+ "windowClass": window.get("windowClass", ""),
+ "title": window.get("title", ""),
+ "categoryGroup": rule["categoryGroup"],
+ "category": rule["category"],
+ "categoryRule": rule["id"],
+ "service": rule["service"],
+ "interface": rule["interface"],
+ "port": rule["port"],
+ "rootDomain": rule["rootDomain"]
+ }
+ }
+ self.heartbeat(self.category_bucket, event, "aw.web.category", pulse=30)
+ self.last_category_key = category_key
+
+ time.sleep(self.poll_interval)
+
+
+def main():
+ config_path = pathlib.Path(os.environ.get("AW_LINUX_WEB_CATEGORY_CONFIG", "~/.local/opt/aw-linux-web-category/config.json")).expanduser()
+ cfg = json.loads(config_path.read_text(encoding="utf-8"))
+ Collector(cfg).run()
+
+
+if __name__ == "__main__":
+ main()
+EOF
+chmod 0755 "${INSTALL_ROOT}/collector.py"
+
+write_file "${BIN_DIR}/aw-linux-web-category-start" </dev/null 2>&1; then
+ exit 0
+fi
+nohup "${INSTALL_ROOT}/collector.py" >> "${LOG_DIR}/collector.log" 2>&1 &
+EOF
+chmod 0755 "${BIN_DIR}/aw-linux-web-category-start"
+
+write_file "${BIN_DIR}/aw-linux-web-category-stop" <<'EOF'
+#!/usr/bin/env sh
+set -eu
+pkill -u "$(id -u)" -f "aw-linux-web-category/collector.py" || true
+EOF
+chmod 0755 "${BIN_DIR}/aw-linux-web-category-stop"
+
+write_file "${BIN_DIR}/aw-linux-web-category-status" <<'EOF'
+#!/usr/bin/env sh
+set -eu
+pgrep -a -u "$(id -u)" -f "aw-linux-web-category/collector.py" || true
+EOF
+chmod 0755 "${BIN_DIR}/aw-linux-web-category-status"
+
+write_file "${SYSTEMD_USER_DIR}/aw-linux-web-category.service" </dev/null 2>&1; then
+ systemctl --user daemon-reload >/dev/null 2>&1 || true
+ systemctl --user enable --now aw-linux-web-category.service >/dev/null 2>&1 || true
+ if systemctl --user is-active --quiet aw-linux-web-category.service >/dev/null 2>&1; then
+ started_with_systemd="1"
+ fi
+fi
+
+if [ "${started_with_systemd}" != "1" ]; then
+ "${BIN_DIR}/aw-linux-web-category-start"
+fi
+
+echo "Installed AW Linux web-category logger"
+echo "Server target: ${SERVER_HOST}:${SERVER_PORT}"
+echo "Raw bucket: aw-linux-web-context_$(hostname -s)"
+echo "Category bucket: aw-detmir-web-category_$(hostname -s)"
+echo "Rules: ${CONFIG_DIR}/rules.json"
+echo "Log file: ${LOG_DIR}/collector.log"
diff --git a/scripts/install_aw_pve_webadmin_logger.sh b/scripts/install_aw_pve_webadmin_logger.sh
index 07de63c..98b664d 100755
--- a/scripts/install_aw_pve_webadmin_logger.sh
+++ b/scripts/install_aw_pve_webadmin_logger.sh
@@ -67,7 +67,8 @@ cat > "$CONFIG_PATH" <$null
+ if (-not $lines) {
+ return @()
+ }
+
+ foreach ($line in ($lines | Select-Object -Skip 1)) {
+ $clean = ($line -replace '^\s*>?', '').Trim()
+ if (-not $clean) {
+ continue
+ }
+
+ $parts = $clean -split '\s+'
+ if ($parts.Count -lt 4) {
+ continue
+ }
+
+ $sessionId = 0
+ if ($parts[2] -match '^\d+$') {
+ $sessionId = [int]$parts[2]
+ }
+
+ $records += [pscustomobject]@{
+ username = $parts[0]
+ sessionName = $parts[1]
+ sessionId = $sessionId
+ state = $parts[3]
+ }
+ }
+ }
+ catch {
+ }
+
+ return $records
+}
+
+$cfg = Get-Config -Path $ConfigPath
+$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME }
+$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port
+$bucketId = 'aw-worktime-sessions_' + $hostValue
+$pulse = 120
+$sleepSec = if ($PollSeconds -gt 0) {
+ $PollSeconds
+}
+elseif ($cfg.collector -and $cfg.collector.pollSeconds) {
+ [int]$cfg.collector.pollSeconds
+}
+else {
+ 30
+}
+
+Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue
+
+while ($true) {
+ $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
+ $records = Get-SessionRecords
+ if (-not $records -or $records.Count -eq 0) {
+ $records = @([pscustomobject]@{
+ username = $env:USERNAME
+ sessionName = ''
+ sessionId = (Get-Process -Id $PID).SessionId
+ state = 'Unknown'
+ })
+ }
+
+ foreach ($rec in $records) {
+ $payload = @{
+ timestamp = $now
+ duration = 0
+ data = @{
+ username = [string]$rec.username
+ userId = "$($env:USERDOMAIN)\$($rec.username)"
+ sessionId = [int]$rec.sessionId
+ sessionName = [string]$rec.sessionName
+ state = [string]$rec.state
+ active = ($rec.state -match 'Active')
+ hostname = $hostValue
+ source = 'worktime-session-collector'
+ }
+ } | ConvertTo-Json -Depth 6 -Compress
+
+ try {
+ Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
+ }
+ catch {
+ }
+ }
+
+ Start-Sleep -Seconds $sleepSec
+}