Compare commits

...
77 changed files with 24436 additions and 19672 deletions
-89
View File
@@ -1,89 +0,0 @@
# Copilot instructions for ActivityWatch-Russian
Purpose: help future Copilot sessions quickly understand how to build, validate, and modify this repo.
---
## Build / test / lint (how-to)
- Shell script checks (CI & local):
- Full: ./scripts/quality-gate.sh
- Single file (syntax): bash -n <script>. Example: bash -n scripts/install_aw_linux_client.sh
- Run shellcheck locally (same checks as CI): install shellcheck then run:
find . -type f -name "*.sh" -print0 | xargs -0 -r shellcheck -e SC1007,SC1090,SC2016
- PowerShell checks (Windows / CI):
- Single-file analysis (locally in PowerShell):
Invoke-ScriptAnalyzer -Path windows/deploy-ensemble.ps1
- CI installs PSScriptAnalyzer and runs against windows/*.ps1, *.psm1, *.psd1
- Python scripts / utilities:
- Run a single utility: python3 scripts/aggregate_dlp_events.py
- Many scripts are helpers for operations; no test harness in repo.
- Monitoring stack (Docker Compose):
- Start: cd grafana-1c && docker-compose up -d
- Start a single service: docker-compose up -d grafana
- Server install / deploy helpers:
- AW server install: aw-server/install_aw_server.sh
- Apply RU WebUI patch: aw-server/apply_webui_ru_patch.sh
- Windows deploy/validation: windows/deploy-ensemble.ps1 and windows/validate-deployment.ps1
Notes: there is no unified unit-test suite. Use the script checks and CI pipeline (.github/workflows/ci.yml) as the canonical validation steps.
---
## High-level architecture (short)
- Windows collectors (PowerShell) run on endpoints and POST events to the ActivityWatch Server HTTP API.
- ActivityWatch Server (deployed on Linux CT/LXC via Proxmox or Debian VM) stores events in PostgreSQL and serves WebUI.
- Integration layer: pollers and aggregators (Python) for pfSense, DLP aggregation, Prometheus exporter.
- Monitoring: Prometheus + Grafana (docker-compose in grafana-1c) and a SQL exporter for direct DB dashboards.
Key ports: AW API 5600/5666, PostgreSQL 5432, Prometheus 9090, Grafana 3000, exporter 9398.
---
## Key repository conventions
- Branching / commits:
- Use feature branches. Commit style follows Conventional Commits (feat/fix/docs/chore).
- Secrets and envs:
- Secrets live in secrets/*.env templates and must NOT be committed. Use secrets/deploy.secrets.env locally; CI and scripts expect templates (.example).
- Preflight / PR checks:
- Run bash -n for shell scripts and Invoke-ScriptAnalyzer for PowerShell before opening PRs.
- Update docs/runbook.md and related runbooks when behavior changes.
- RU patching:
- WebUI localization is applied via aw-server/aw-ru-patch.js and aw-server/apply_webui_ru_patch.sh — treat these as idempotent patch steps during deploy.
- Systemd / deploy units:
- activitywatch-server.service / aw-worktime-api.service / aw-worktime-ui-bridge.service are included in aw-server/ for production use.
- CI expectations:
- .github/workflows/ci.yml runs shellcheck and PSScriptAnalyzer. Use scripts/quality-gate.sh locally to replicate preflight.
---
## Important files & quick references
- docs/ (onboarding, deployment, runbook) — start here for operational context.
- aw-server/ — server install script, env template, RU patch, systemd units.
- ansible/ — automated provisioning playbooks for CT/Proxmox and Windows deploys.
- windows/ — PowerShell collectors and orchestration; validation scripts are here.
- scripts/ — helpers (aggregate_dlp_events.py, installers, quality-gate.sh).
- grafana-1c/ — docker-compose monitoring stack and dashboards.
---
## AI assistant & other tool configs to check
- No Copilot-specific instruction file existed before this addition.
- No CLAUDE.md, .cursorrules, AGENTS.md, .windsurfrules, CONVENTIONS.md, or AIDER_CONVENTIONS.md detected at repo root. If adding automated assistant rules, place them in repo root or .github and document cross-references here.
---
If you need the Copilot instructions extended (e.g., adding run examples for specific scripts, more detailed CI breakdown, or mapping tests to files), say which area to expand.
-23
View File
@@ -8,29 +8,6 @@ secrets/runtime.env
*.bak
windows/*.report.json
.rollout-logs/
reports/
tmp/
graphify-out/cache/
graphify-out/powershell-parse-results*.json
graphify-out/powershell-pssa-warn-results.json
graphify-out/shellcheck-*.txt
graphify-out/validate_dryrun_out*.txt
graphify-out/pssa_diffs.txt
.graphify_analysis.json
.graphify_ast.json
.graphify_cached.json
.graphify_chunk_list_*.txt
.graphify_detect.json
.graphify_extract.json
.graphify_labels.json
.graphify_python
.graphify_semantic.json
.graphify_uncached.txt
graphify-out/GRAPH_REPORT.md
graphify-out/graph.html
graphify-out/graph.json
.pssa_run.ps1
data/
# IDE
.idea/
+11236
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
-14
View File
@@ -1,14 +0,0 @@
# ActivityWatch-Russian
## Current Milestone: v1.0 Data Pipeline Stability
**Goal:** Ensure stable collection and rendering of endpoint activity data in AW Web UI.
**Target features:**
- Reliable ingestion from Windows collectors (`endpoint-signals`, `browser-domains`).
- Stable AW server query/render path for worktime/activity pages.
- Deployment/runbook consistency (Ansible + install kit + rollback notes).
## Evolution
This document evolves at phase transitions and milestone boundaries.
-14
View File
@@ -1,14 +0,0 @@
# ROADMAP
## 🚧 v1.0 Data Pipeline Stability
- [ ] Phase 1: Collectors and API flow hardening
- [ ] Phase 2: UI reports consistency and release proof
### Phase 1: Collectors and API flow hardening
**Goal:** Stabilize collectors-to-server pipeline and remove known transport/runtime failure modes.
**Depends on:** none
### Phase 2: UI reports consistency and release proof
**Goal:** Validate report links/output, enforce rollout checks, and freeze reproducible release state.
**Depends on:** Phase 1
-26
View File
@@ -1,26 +0,0 @@
---
milestone: v1.0
milestone_name: Data Pipeline Stability
phase: "0"
phase_name: Not started
current_plan: 0
total_plans_in_phase: 0
status: planning
progress_percent: 0
last_activity: 2026-05-07
---
## Current Position
Phase: Not started (defining requirements)
Plan: —
Status: Defining requirements
Last activity: 2026-05-07 — Milestone v1.0 started
## Decisions
- Prioritize reliable data presence in activity/worktime views over UI extensions.
## Blockers
- None.
@@ -1,17 +0,0 @@
# Phase 1 Context
## Phase
Phase 1: Collectors and API flow hardening
## Focus
- Endpoint collectors must continuously send data without silent hangs.
- AW server must accept/query data for UI pages consistently.
- Failure points around transport/CORS/runtime must be explicitly checked.
## Initial Acceptance Targets
- Endpoint collector heartbeats arrive regularly.
- Browser domains and endpoint signals appear in corresponding buckets.
- Activity page for target host shows non-zero timeline/events for active period.
@@ -1,42 +0,0 @@
# PLAN — Phase 01: collectors-and-api-flow-hardening
## Goal
Deliver stable collector-to-server data flow so activity/worktime pages have consistent data.
## Work Items
1. Validate collector runtime and log rotation behavior.
2. Validate server ingest endpoints and bucket write/read checks.
3. Validate CORS/origin and report link consistency.
4. Add/adjust scripts or runbook checks to detect zero-data regressions early.
## Verification
- Manual and scripted checks show fresh events in target buckets.
- Host activity page reflects real activity (not `0s`) for active sessions.
- No repeating transport errors in collector logs during test window.
## Status
Planned.
## 2. Варианты доработки DLP
### Вариант A: “Hardening” — Стабилизация текущего
Цель: довести текущие коллекторы до production-grade уровня надёжности.
| # | Задача | Усилие | Влияние |
|---|---|---|---|
| A1 | HTTP retry + exponential backoff во всех коллекторах | 3-5 дней | Высокое — перестанут теряться события |
| A2 | Локальный WAL (Write-Ahead Log) — буферизация событий при недоступности сервера | 1-2 нед | Критическое — гарантия доставки |
| A3 | Healthcheck endpoint и self-diagnostics в каждом коллекторе | 3-5 дней | Среднее — видимость состояния агентов |
| A4 | Расширить aggregator: добавить `aw-email-monitor_` и `aw-dlp-endpoint-signals_` в сбор | 1 день | Среднее |
| A5 | Systemd timer / Windows Task для aggregator (автоматический запуск) | 1 день | Среднее |
| A6 | Убрать пароль из `inventory.ini` → использовать Ansible Vault или env var | 1 час | Критическое (безопасность) |
| A7 | Graceful shutdown и cleanup event subscriptions во всех коллекторах | 2-3 дня | Среднее |
Общее усилие: ~3-4 недели.
Рекомендация: обязательно сделать перед любым масштабированием. Без этого DLP — “best effort” мониторинг, а не надёжная система.
@@ -0,0 +1,20 @@
---
created: 2026-05-07T21:46:00Z
title: Deploy standalone agent on SHARKON2025 and validate live flow
area: tooling
files:
- windows/installkit/innosetup/AWatch-rus-InnoSetup.iss
- windows/install-standalone-service.ps1
- windows/aw-standalone-service.ps1
- windows/installkit/innosetup/BUILD.md
- docs/windows/deployment.md
- docs/windows/troubleshooting.md
---
## Problem
Standalone InnoSetup mode and service wrapper are implemented and pushed, and AW API write path was verified by manual heartbeat posts. But production value still depends on real endpoint rollout: installer must be deployed on SHARKON2025 (192.168.100.21), service must be running persistently, and UI/API must show continuously fresh events without manual seeding.
## Solution
Deploy the newly built installer `AWatch-rus-InstallKit.exe` to SHARKON2025, run installation with target `10.10.10.13:5600`, verify `AWatchRusStandaloneAgent` state, inspect `standalone-agent-service.log`, and confirm fresh `metadata.end` progression for `aw-dlp-endpoint-signals_SHARKON2025`, `aw-file-operations_SHARKON2025`, and `aw-worktime-sessions_SHARKON2025` over time.
-1
View File
@@ -9,7 +9,6 @@
- `docs/deployment.md` — пошаговый деплой LXC и ActivityWatch Server.
- `docs/runbook.md` — быстрый runbook для оператора.
- `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback.
- `docs/artifacts-policy.md` — политика generated-артефактов и rollout-gates.
- `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`.
-1
View File
@@ -1,5 +1,4 @@
#!/bin/sh
# shellcheck disable=SC1007
set -eu
REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-1
View File
@@ -1,5 +1,4 @@
#!/bin/sh
# shellcheck disable=SC1007
set -eu
REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+1 -1
View File
@@ -6,7 +6,7 @@ aw-ct ansible_host=10.20.30.13 ansible_user=root ansible_port=22
[aw_windows]
# Примечание: в русифицированных Windows часто нужен "Администратор", а не "Administrator".
win-node1 ansible_host=192.168.100.21 ansible_user=Администратор ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
win-node1 ansible_host=192.168.100.21 ansible_user=Администратор ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
[aw_pfsense_pollers]
# pfsense-poller1 ansible_host=192.168.100.30 ansible_user=root ansible_port=22
+1 -1
View File
@@ -2,4 +2,4 @@
localhost ansible_connection=local ansible_user=root
[aw_windows]
rdp-prod ansible_host=192.168.100.21 ansible_user=Администратор ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
rdp-prod ansible_host=192.168.100.21 ansible_user=Администратор ansible_password=Sergei2009@ ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
+1 -1
View File
@@ -18,7 +18,7 @@ echo "=== ActivityWatch Data Check: $HOSTNAME_FILTER ==="
echo ""
echo -n "Server connectivity... "
RESP=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/info" 2>&1)
if echo "$RESP" | jq -e '.version' > /dev/null 2>&1; then
if [ $? -eq 0 ] && echo "$RESP" | jq -e '.version' > /dev/null 2>&1; then
VERSION=$(echo "$RESP" | jq -r '.version')
echo -e "${GREEN}OK${NC} (aw-server v$VERSION)"
else
+1 -1
View File
@@ -22,7 +22,7 @@ echo ""
echo -e "${CYAN}--- 1. AW Server ($SERVER) ---${NC}"
echo -n " Connectivity... "
RESP=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/info" 2>&1)
if echo "$RESP" | jq -e '.version' > /dev/null 2>&1; then
if [ $? -eq 0 ] && echo "$RESP" | jq -e '.version' > /dev/null 2>&1; then
VERSION=$(echo "$RESP" | jq -r '.version')
echo -e " ${GREEN}OK${NC} (aw-server $VERSION)"
else
-50
View File
@@ -1,50 +0,0 @@
# Artifacts Policy
## Purpose
Define which files are source-of-truth and which are generated runtime/research artifacts that must not block or pollute production rollouts.
## Source of Truth
Tracked and reviewable:
- `ansible/`
- `aw-server/`
- `windows/`
- `scripts/`
- `docs/`
- install-kit templates and manifests under `windows/installkit/innosetup/`
## Generated / Volatile Artifacts
Not for production commits:
- `.graphify_*` cache/analysis outputs
- `graphify-out/cache/*`
- `graphify-out/shellcheck-*.txt`
- `graphify-out/validate_dryrun_out*.txt`
- `graphify-out/powershell-parse-results*.json`
- `graphify-out/powershell-pssa-warn-results.json`
- `graphify-out/pssa_diffs.txt`
- `reports/*`
- `tmp/*`
These paths are ignored by `.gitignore` and additionally guarded by `scripts/quality-gate.sh`.
## Rollout Gate
`scripts/prod_rollout.sh` must run only when:
1. `AW_MAINTENANCE_ACK=YES` is set.
2. `scripts/quality-gate.sh` passes.
3. Preflight checks pass:
- `ansible ping`/`win_ping`
- `./check-aw-data.sh`
- `./check-aw-full.sh`
If any gate fails, rollout stops.
## Notes
- Secrets policy remains temporary by operator choice; credentials may still exist in local `inventory.ini` during this phase.
- Dedicated secrets hardening (vault/env-only enforcement) is a separate follow-up track.
-2
View File
@@ -8,8 +8,6 @@ The prototype reads:
- `aw-file-operations_*` (`aw.file.operation`) — file create/delete/rename telemetry, including `archiveHint`.
- `aw-dlp-incidents_*` (`aw.dlp.incident`) — browser/endpoint DLP incidents and screenshot metadata when available.
- `aw-dlp-endpoint-signals_*` (`aw.dlp.endpoint.signal`) — endpoint signal heartbeats/events.
- `aw-email-monitor_*` (`aw.email.signal`) — outbound email signal stream.
## SQLite smoke test
-2
View File
@@ -17,7 +17,6 @@
- секреты не хранить в git;
- каждое изменение фиксировать в ticket/run log;
- публичную публикацию делать через отдельный proxy/security layer.
- generated-артефакты и исследовательские кэши вести по [artifacts-policy.md](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/docs/artifacts-policy.md).
## Регулярные проверки
@@ -125,4 +124,3 @@ systemctl restart activitywatch-server.service
- не обновлять поверх рабочего бинарника без backup;
- не открывать `5600/tcp` наружу без отдельной защиты;
- не править `index.html` вручную без backup.
- не запускать `scripts/prod_rollout.sh` без `AW_MAINTENANCE_ACK=YES`.
-9
View File
@@ -196,15 +196,6 @@ systemctl restart activitywatch-server.service
## Перед любыми изменениями
0. Подтвердить maintenance window и gate:
```sh
export AW_MAINTENANCE_ACK=YES
./scripts/quality-gate.sh
```
Если `quality-gate` падает (например, drift install-kit vs repo), rollout не запускать.
1. Сделать snapshot или `vzdump`.
2. Сохранить текущий `/etc/activitywatch/aw-server.env`.
3. Сохранить текущий `index.html`.
+12
View File
@@ -9,6 +9,10 @@
- `windows/validate-deployment.ps1` — машинная проверка состояния и JSON-отчёт.
- `windows/browser-domains-native-collector.ps1` — native collector доменов браузера с категоризацией.
- `windows/dlp-endpoint-signals-collector.ps1` — Windows/RDP collector (clipboard/USB/print signals).
- `windows/file-operations-collector.ps1` — collector файловых операций (create/delete/rename/archive hints).
- `windows/worktime-session-collector.ps1` — collector RDP-сессий и активности.
- `windows/install-standalone-service.ps1` — standalone установка агента как Windows Service (без Task Scheduler).
- `windows/aw-standalone-service.ps1` — service wrapper для поддержания collector-процессов.
- `windows/web-category-rules.example.json` — пример кастомных правил категоризации.
- `windows/dlp-policy.example.json` — пример DLP-политики (phase-1: alerting incidents).
@@ -27,6 +31,14 @@
- Корректно регистрирует задачи через `-LogonType Interactive` (совместимо с Windows Server, где `InteractiveToken` не поддерживается).
- Поддерживает отключение шумных watcher'ов через `-AfkEnabled:$false` и `-WindowEnabled:$false`.
### Standalone InnoSetup (без Ansible, без Task Scheduler)
- InnoSetup запускает `install-standalone-service.ps1`.
- Мастер спрашивает только `ServerHost` и `ServerPort`.
- Создаётся сервис `AWatchRusStandaloneAgent` (auto-start, restart-on-failure).
- Сервис управляет collector-скриптами и держит по одной рабочей копии каждого коллектора.
- `deployment-config.json` формируется в `C:\ProgramData\AWatch-rus\deployment-config.json`.
Важно:
- Скриншот делается только при DLP-инциденте (`Send-DlpIncidentHeartbeat`), не по таймеру и не на обычной активности.
+29
View File
@@ -88,6 +88,35 @@ Start-ScheduledTask -TaskName 'ActivityWatch Launch [CONTOSO_user01]'
## Диагностика
### Standalone service не работает
Проверить сервис:
```powershell
Get-Service AWatchRusStandaloneAgent
sc.exe query AWatchRusStandaloneAgent
```
Перезапуск:
```powershell
Restart-Service AWatchRusStandaloneAgent
```
Лог service wrapper:
```powershell
Get-Content C:\ProgramData\AWatch-rus\logs\standalone-agent-service.log -Tail 200
```
Проверить дочерние collector-процессы:
```powershell
Get-CimInstance Win32_Process |
Where-Object { $_.Name -eq 'powershell.exe' -and $_.CommandLine -like '*AWatch-rus*collector*.ps1*' } |
Select-Object ProcessId, SessionId, CommandLine
```
Проверить задачи:
```powershell
+5
View File
@@ -4,4 +4,9 @@ GRAFANA_ADMIN_PASSWORD=change_me_now
GRAFANA_PORT=3000
PROMETHEUS_PORT=9090
SQL_EXPORTER_PORT=9399
AW_EXPORTER_PORT=9398
AW_SERVER_HOST=10.10.10.13
AW_SERVER_PORT=5600
AW_SERVER_SCHEME=http
AW_SCRAPE_INTERVAL_SECONDS=30
ONEC_DSN=postgres://onec_reader:change_me@10.10.10.20:5432/onec_db?sslmode=disable
+29 -21
View File
@@ -3,44 +3,46 @@
Готовый каркас для непрерывного сбора KPI из 1С и анализа в Grafana:
- `sql-exporter` читает SQL-представления KPI из БД 1С;
- `aw-exporter` собирает метрики ActivityWatch и отдает их Prometheus;
- `prometheus` собирает метрики и применяет alert-rules;
- `grafana` поднимает datasource и дашборд автоматически.
## Полные пути
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/.env.example`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/docker-compose.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql-exporter/sql_exporter.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql-exporter/collectors/onec_accounting_kpi.collector.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/prometheus.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/alerts.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/recording_rules.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/grafana/dashboards/1c-accounting-overview.json`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/grafana/dashboards/1c-accounting-sre.json`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/postgres_views_template.sql`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/mssql_views_template.sql`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/discover_postgres_1c.sh`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/validate_kpi_views.sh`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/check_pipeline.sh`
- `./.env.example`
- `./docker-compose.yml`
- `./sql-exporter/sql_exporter.yml`
- `./sql-exporter/collectors/onec_accounting_kpi.collector.yml`
- `./prometheus/prometheus.yml`
- `./prometheus/alerts.yml`
- `./prometheus/recording_rules.yml`
- `./grafana/dashboards/1c-accounting-overview.json`
- `./grafana/dashboards/1c-accounting-sre.json`
- `./sql/postgres_views_template.sql`
- `./sql/mssql_views_template.sql`
- `./tools/discover_postgres_1c.sh`
- `./tools/validate_kpi_views.sh`
- `./tools/check_pipeline.sh`
## Быстрый запуск
1. Подготовьте env:
```bash
cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c
cd grafana-1c
cp .env.example .env
```
2. В `.env` задайте:
- `GRAFANA_ADMIN_USER`, `GRAFANA_ADMIN_PASSWORD`;
- `ONEC_DSN` (DSN read-only пользователя в БД 1С).
- `ONEC_DSN` (DSN read-only пользователя в БД 1С);
- при необходимости `AW_SERVER_HOST`, `AW_SERVER_PORT`, `AW_SERVER_SCHEME`, `AW_EXPORTER_PORT` и `AW_SCRAPE_INTERVAL_SECONDS` для ActivityWatch exporter.
3. В БД 1С создайте KPI-представления:
- для PostgreSQL возьмите `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/postgres_views_template.sql`;
- для MS SQL возьмите `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/mssql_views_template.sql`.
- для PostgreSQL возьмите `./sql/postgres_views_template.sql`;
- для MS SQL возьмите `./sql/mssql_views_template.sql`.
4. Поднимите стек:
@@ -52,7 +54,9 @@ docker compose up -d
```bash
curl -fsS http://127.0.0.1:9399/metrics | head
curl -fsS http://127.0.0.1:9398/metrics | head
curl -fsS http://127.0.0.1:9090/-/healthy
curl -fsS http://127.0.0.1:3000/api/health
```
Откройте Grafana: `http://<host>:3000`.
@@ -62,23 +66,25 @@ curl -fsS http://127.0.0.1:9090/-/healthy
Профилирование структуры 1С (PostgreSQL):
```bash
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/discover_postgres_1c.sh \
sh ./tools/discover_postgres_1c.sh \
"postgres://user:pass@db-host:5432/db?sslmode=disable"
```
Проверка KPI views:
```bash
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/validate_kpi_views.sh \
sh ./tools/validate_kpi_views.sh \
"postgres://user:pass@db-host:5432/db?sslmode=disable"
```
Проверка end-to-end пайплайна:
```bash
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/check_pipeline.sh
sh ./tools/check_pipeline.sh
```
Скрипт проверяет полный путь сбора данных: `sql-exporter` и `aw-exporter` отдают обязательные метрики, Prometheus успешно выполняет запросы по scrape-targets, а Grafana отвечает на health/API, видит datasource `prometheus` и provisioned dashboards. Если стек запущен не из каталога репозитория, передайте путь к каталогу `grafana-1c` первым аргументом.
## Что контролируется
- Непроведенные документы (`onec_unposted_documents_total`)
@@ -86,6 +92,8 @@ sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/check_pipeline.
- Просроченная дебиторка (`onec_overdue_receivables_total`)
- Ошибки проведения за 24ч (`onec_posting_errors_total`)
- Свежесть данных из 1С (`onec_data_freshness_seconds`)
- Доступность ActivityWatch API (`aw_up`)
- Количество bucket/events ActivityWatch (`aw_buckets_total`, `aw_bucket_events_count`)
## Принципы безопасности
+5 -4
View File
@@ -6,14 +6,15 @@ services:
container_name: awrus-aw-exporter
restart: unless-stopped
environment:
- AW_SERVER_HOST=10.10.10.13
- AW_SERVER_PORT=5600
- AW_SERVER_SCHEME=http
- AW_SERVER_HOST=${AW_SERVER_HOST:-10.10.10.13}
- AW_SERVER_PORT=${AW_SERVER_PORT:-5600}
- AW_SERVER_SCHEME=${AW_SERVER_SCHEME:-http}
- EXPORTER_PORT=9398
- SCRAPE_INTERVAL_SECONDS=${AW_SCRAPE_INTERVAL_SECONDS:-30}
volumes:
- ./sql-exporter/collectors/aw_activitywatch.py:/app/aw_activitywatch.py:ro
ports:
- "9398:9398"
- "${AW_EXPORTER_PORT:-9398}:9398"
command:
- "python3"
- "/app/aw_activitywatch.py"
+199 -111
View File
@@ -1,121 +1,209 @@
{
"dashboard": {
"title": "ActivityWatch Overview",
"tags": ["activitywatch", "monitoring"],
"timezone": "browser",
"panels": [
{
"id": 1,
"title": "Total Buckets",
"type": "stat",
"targets": [
{
"expr": "aw_buckets_total",
"refId": "A",
"legendFormat": "Total Buckets"
}
],
"options": {
"colorMode": "value",
"graphMode": "area"
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
"title": "ActivityWatch Overview",
"tags": [
"activitywatch",
"monitoring"
],
"timezone": "browser",
"panels": [
{
"id": 1,
"title": "Total Buckets",
"type": "stat",
"targets": [
{
"expr": "aw_buckets_total",
"refId": "A",
"legendFormat": "Total Buckets",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
}
],
"options": {
"colorMode": "value",
"graphMode": "area"
},
{
"id": 2,
"title": "Events per Bucket",
"type": "table",
"targets": [
{
"expr": "aw_bucket_events_count",
"format": "table",
"instant": true,
"refId": "B"
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": true
}
}
}
]
},
{
"id": 3,
"title": "Collector Status",
"type": "stat",
"targets": [
{
"expr": "aw_collector_status",
"format": "table",
"instant": true,
"refId": "C"
}
],
"options": {
"colorMode": "value"
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
}
},
{
"id": 4,
"title": "Events Timeline",
"type": "graph",
"targets": [
{
"expr": "rate(aw_events_total[5m])",
"legendFormat": "{{bucket}} - {{event_type}}",
"refId": "D"
}
],
"fieldConfig": {
"defaults": {
"custom": {
"lineWidth": 2,
"fillOpacity": 10
}
}
}
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
{
"id": 5,
"title": "Last Event Timestamp",
"type": "gauge",
"targets": [
{
"expr": "aw_events_last_timestamp",
"legendFormat": "{{bucket}}",
"refId": "E"
}
],
"options": {
"orientation": "horizontal"
},
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": 0},
{"color": "yellow", "value": 3600},
{"color": "green", "value": 86400}
]
}
}
}
}
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 0
}
]
},
{
"id": 2,
"title": "Events per Bucket",
"type": "table",
"targets": [
{
"expr": "aw_bucket_events_count",
"format": "table",
"instant": true,
"refId": "B",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": true
}
}
}
],
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 6,
"y": 0
}
},
{
"id": 3,
"title": "Collector Status",
"type": "stat",
"targets": [
{
"expr": "aw_collector_status",
"format": "table",
"instant": true,
"refId": "C",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
}
],
"options": {
"colorMode": "value"
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 6,
"x": 18,
"y": 0
}
},
{
"id": 4,
"title": "Events Timeline",
"type": "graph",
"targets": [
{
"expr": "rate(aw_events_total[5m])",
"legendFormat": "{{bucket}} - {{event_type}}",
"refId": "D",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
}
],
"fieldConfig": {
"defaults": {
"custom": {
"lineWidth": 2,
"fillOpacity": 10
}
}
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 8
}
},
{
"id": 5,
"title": "Last Event Timestamp",
"type": "gauge",
"targets": [
{
"expr": "aw_events_last_timestamp",
"legendFormat": "{{bucket}}",
"refId": "E",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
}
],
"options": {
"orientation": "horizontal"
},
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "yellow",
"value": 3600
},
{
"color": "green",
"value": 86400
}
]
}
}
}
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 8
}
}
],
"uid": "activitywatch-overview",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": {
"from": "now-6h",
"to": "now"
}
}
@@ -1,20 +1,11 @@
apiVersion: 1
providers:
- name: 1C-Buhgalteria
- name: awatch-rus
orgId: 1
folder: "1C"
folder: "AWatch-rus"
type: file
disableDeletion: true
editable: false
options:
path: /var/lib/grafana/dashboards
- name: ActivityWatch
orgId: 2
folder: "ActivityWatch"
type: file
disableDeletion: false
editable: true
options:
path: /var/lib/grafana/dashboards
@@ -1,9 +0,0 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
@@ -1,9 +0,0 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
+102 -103
View File
@@ -4,139 +4,138 @@ ActivityWatch Prometheus Exporter
Собирает метрики из ActivityWatch API и экспонирует их в формате Prometheus.
"""
import time
import logging
import os
import time
from datetime import datetime
import requests
from prometheus_client import start_http_server, Gauge, Counter, Histogram, Info
from datetime import datetime, timedelta
from prometheus_client import Counter, Gauge, Info, start_http_server
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Configuration
AW_SERVER_HOST = "10.10.10.13"
AW_SERVER_PORT = 5600
AW_SERVER_SCHEME = "http"
AW_API_BASE = f"{AW_SERVER_SCHEME}://{AW_SERVER_HOST}:{AW_SERVER_PORT}/api/0"
EXPORTER_PORT = 9398
AW_SERVER_HOST = os.getenv("AW_SERVER_HOST", "10.10.10.13")
AW_SERVER_PORT = int(os.getenv("AW_SERVER_PORT", "5600"))
AW_SERVER_SCHEME = os.getenv("AW_SERVER_SCHEME", "http")
AW_API_BASE = os.getenv(
"AW_API_BASE",
f"{AW_SERVER_SCHEME}://{AW_SERVER_HOST}:{AW_SERVER_PORT}/api/0",
)
EXPORTER_PORT = int(os.getenv("EXPORTER_PORT", "9398"))
SCRAPE_INTERVAL_SECONDS = int(os.getenv("SCRAPE_INTERVAL_SECONDS", "30"))
# Metrics
aw_buckets_total = Gauge('aw_buckets_total', 'Total number of ActivityWatch buckets')
aw_events_total = Counter('aw_events_total', 'Total number of ActivityWatch events', ['bucket', 'event_type'])
aw_events_last_timestamp = Gauge('aw_events_last_timestamp', 'Timestamp of last event in bucket', ['bucket'])
aw_bucket_events_count = Gauge('aw_bucket_events_count', 'Number of events in bucket', ['bucket'])
aw_collector_status = Info('aw_collector_status', 'Status of ActivityWatch collectors')
aw_server_info = Info('aw_server_info', 'ActivityWatch server information')
aw_up = Gauge("aw_up", "ActivityWatch API availability: 1 if the last scrape succeeded, 0 otherwise")
aw_buckets_total = Gauge("aw_buckets_total", "Total number of ActivityWatch buckets")
aw_events_total = Counter("aw_events_total", "Total number of ActivityWatch events observed", ["bucket", "event_type"])
aw_events_last_timestamp = Gauge("aw_events_last_timestamp", "Timestamp of last event in bucket", ["bucket"])
aw_bucket_events_count = Gauge("aw_bucket_events_count", "Number of events sampled from bucket", ["bucket"])
aw_collector_status = Gauge(
"aw_collector_status",
"ActivityWatch bucket collector status: 1 if bucket was observed during the last scrape",
["bucket", "client", "hostname", "type"],
)
aw_server_info = Info("aw_server", "ActivityWatch server information")
class ActivityWatchExporter:
def __init__(self, api_base):
self.api_base = api_base
self.api_base = api_base.rstrip("/")
self.session = requests.Session()
self.session.headers.update({'Accept': 'application/json'})
self.bucket_cache = {}
self.session.headers.update({"Accept": "application/json"})
self.bucket_event_counts = {}
def get_buckets(self):
"""Get all buckets from ActivityWatch API."""
try:
response = self.session.get(f"{self.api_base}/buckets", timeout=10)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get buckets: {e}")
return {}
def get_bucket_events(self, bucket_id, limit=1):
response = self.session.get(f"{self.api_base}/buckets", timeout=10)
response.raise_for_status()
return response.json()
def get_bucket_events(self, bucket_id, limit=1000):
"""Get events from a specific bucket."""
try:
response = self.session.get(
f"{self.api_base}/buckets/{bucket_id}/events",
params={'limit': limit},
timeout=10
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get events for {bucket_id}: {e}")
return []
def get_bucket_info(self, bucket_id):
"""Get detailed info about a bucket."""
try:
response = self.session.get(f"{self.api_base}/buckets/{bucket_id}", timeout=10)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get info for {bucket_id}: {e}")
return {}
response = self.session.get(
f"{self.api_base}/buckets/{bucket_id}/events",
params={"limit": limit},
timeout=10,
)
response.raise_for_status()
return response.json()
@staticmethod
def event_type(event):
data = event.get("data") or {}
return str(data.get("app") or data.get("title") or event.get("$schema") or "unknown")
@staticmethod
def event_timestamp(event):
timestamp = event.get("timestamp", 0)
if isinstance(timestamp, str):
return datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()
return float(timestamp or 0)
def collect_metrics(self):
"""Collect metrics from ActivityWatch."""
buckets = self.get_buckets()
# Update bucket count
try:
buckets = self.get_buckets()
aw_up.set(1)
except Exception as exc:
logger.error("Failed to get buckets: %s", exc)
aw_up.set(0)
return
aw_buckets_total.set(len(buckets))
# Server info
aw_server_info.info({
'host': AW_SERVER_HOST,
'port': AW_SERVER_PORT,
'scheme': AW_SERVER_SCHEME,
'api_base': self.api_base
})
# Collector status
collectors = {}
aw_server_info.info(
{
"host": AW_SERVER_HOST,
"port": str(AW_SERVER_PORT),
"scheme": AW_SERVER_SCHEME,
"api_base": self.api_base,
}
)
aw_collector_status.clear()
for bucket_id, bucket_data in buckets.items():
client = bucket_data.get('client', 'unknown')
hostname = bucket_data.get('hostname', 'unknown')
bucket_type = bucket_data.get('type', 'unknown')
# Count events
events = self.get_bucket_events(bucket_id, limit=1000)
client = str(bucket_data.get("client", "unknown"))
hostname = str(bucket_data.get("hostname", "unknown"))
bucket_type = str(bucket_data.get("type", "unknown"))
try:
events = self.get_bucket_events(bucket_id)
except Exception as exc:
logger.error("Failed to get events for %s: %s", bucket_id, exc)
events = []
event_count = len(events)
aw_bucket_events_count.labels(bucket=bucket_id).set(event_count)
# Last event timestamp
aw_collector_status.labels(bucket=bucket_id, client=client, hostname=hostname, type=bucket_type).set(1)
previous_count = self.bucket_event_counts.get(bucket_id)
if previous_count is not None and event_count > previous_count:
for event in events[: event_count - previous_count]:
aw_events_total.labels(bucket=bucket_id, event_type=self.event_type(event)).inc()
self.bucket_event_counts[bucket_id] = event_count
if events:
last_event = events[0]
timestamp = last_event.get('timestamp', 0)
try:
# Convert to Unix timestamp if needed
if isinstance(timestamp, str):
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
unix_ts = dt.timestamp()
else:
unix_ts = timestamp
aw_events_last_timestamp.labels(bucket=bucket_id).set(unix_ts)
except:
pass
# Collector status
collector_key = f"{hostname}_{client}"
collectors[collector_key] = {
'status': 'active',
'bucket': bucket_id,
'type': bucket_type,
'events': event_count
}
aw_collector_status.info(collectors)
aw_events_last_timestamp.labels(bucket=bucket_id).set(self.event_timestamp(events[0]))
except Exception as exc:
logger.warning("Failed to parse last event timestamp for %s: %s", bucket_id, exc)
def main():
exporter = ActivityWatchExporter(AW_API_BASE)
# Initial collection
exporter.collect_metrics()
# Start HTTP server
start_http_server(EXPORTER_PORT)
logger.info(f"ActivityWatch exporter started on port {EXPORTER_PORT}")
logger.info(f"Scraping ActivityWatch API at {AW_API_BASE}")
# Collect metrics every 30 seconds
logger.info("ActivityWatch exporter started on port %s", EXPORTER_PORT)
logger.info("Scraping ActivityWatch API at %s", AW_API_BASE)
while True:
time.sleep(30)
time.sleep(SCRAPE_INTERVAL_SECONDS)
exporter.collect_metrics()
if __name__ == '__main__':
if __name__ == "__main__":
main()
+88 -13
View File
@@ -1,20 +1,95 @@
#!/bin/sh
set -eu
STACK_DIR="${1:-/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c}"
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
STACK_DIR="${1:-$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)}"
ENV_FILE="$STACK_DIR/.env"
echo "[*] Checking endpoints"
curl -fsS http://127.0.0.1:9399/metrics >/tmp/awrus-onec-metrics.out
curl -fsS http://127.0.0.1:9090/-/healthy >/tmp/awrus-prom-healthy.out
curl -fsS "http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22onec_sql_exporter%22%7D" >/tmp/awrus-prom-up.json
curl -fsS "http://127.0.0.1:9090/api/v1/query?query=onec_data_freshness_seconds" >/tmp/awrus-prom-freshness.json
env_value() {
key="$1"
default="$2"
current=$(eval "printf '%s' \"\${$key:-}\"")
if [ -n "$current" ]; then
printf '%s' "$current"
return
fi
if [ -f "$ENV_FILE" ]; then
value=$(sed -n "s/^$key=//p" "$ENV_FILE" | tail -n 1)
if [ -n "$value" ]; then
printf '%s' "$value"
return
fi
fi
printf '%s' "$default"
}
echo "[*] Checking container status"
cd "$STACK_DIR"
docker compose ps
GRAFANA_PORT=$(env_value GRAFANA_PORT 3000)
PROMETHEUS_PORT=$(env_value PROMETHEUS_PORT 9090)
SQL_EXPORTER_PORT=$(env_value SQL_EXPORTER_PORT 9399)
AW_EXPORTER_PORT=$(env_value AW_EXPORTER_PORT 9398)
GRAFANA_ADMIN_USER=$(env_value GRAFANA_ADMIN_USER admin)
GRAFANA_ADMIN_PASSWORD=$(env_value GRAFANA_ADMIN_PASSWORD change_me_now)
TMP_DIR="${TMPDIR:-/tmp}"
METRICS_OUT="$TMP_DIR/awrus-onec-metrics.out"
AW_METRICS_OUT="$TMP_DIR/awrus-aw-metrics.out"
PROM_HEALTH_OUT="$TMP_DIR/awrus-prom-healthy.out"
PROM_UP_OUT="$TMP_DIR/awrus-prom-up.json"
PROM_FRESHNESS_OUT="$TMP_DIR/awrus-prom-freshness.json"
GRAFANA_HEALTH_OUT="$TMP_DIR/awrus-grafana-health.json"
GRAFANA_DS_OUT="$TMP_DIR/awrus-grafana-datasources.json"
GRAFANA_DASH_OUT="$TMP_DIR/awrus-grafana-dashboards.json"
require_metric() {
metric_name="$1"
metrics_file="$2"
if ! grep -q "^$metric_name" "$metrics_file"; then
echo "[!] Required metric '$metric_name' was not found in $metrics_file" >&2
exit 1
fi
}
require_prometheus_success() {
file="$1"
if ! grep -q '"status":"success"' "$file"; then
echo "[!] Prometheus query did not return status=success: $file" >&2
cat "$file" >&2
exit 1
fi
}
echo "[*] Checking exporter endpoints"
curl -fsS "http://127.0.0.1:$SQL_EXPORTER_PORT/metrics" >"$METRICS_OUT"
curl -fsS "http://127.0.0.1:$AW_EXPORTER_PORT/metrics" >"$AW_METRICS_OUT"
require_metric "onec_data_freshness_seconds" "$METRICS_OUT"
require_metric "aw_up" "$AW_METRICS_OUT"
echo "[*] Checking Prometheus health and scrape targets"
curl -fsS "http://127.0.0.1:$PROMETHEUS_PORT/-/healthy" >"$PROM_HEALTH_OUT"
curl -fsS "http://127.0.0.1:$PROMETHEUS_PORT/api/v1/query?query=up%7Bjob%3D~%22onec_sql_exporter%7Caw_activitywatch_exporter%22%7D" >"$PROM_UP_OUT"
curl -fsS "http://127.0.0.1:$PROMETHEUS_PORT/api/v1/query?query=onec_data_freshness_seconds" >"$PROM_FRESHNESS_OUT"
require_prometheus_success "$PROM_UP_OUT"
require_prometheus_success "$PROM_FRESHNESS_OUT"
echo "[*] Checking Grafana health, datasource and dashboards"
curl -fsS "http://127.0.0.1:$GRAFANA_PORT/api/health" >"$GRAFANA_HEALTH_OUT"
curl -fsS -u "$GRAFANA_ADMIN_USER:$GRAFANA_ADMIN_PASSWORD" "http://127.0.0.1:$GRAFANA_PORT/api/datasources/uid/prometheus" >"$GRAFANA_DS_OUT"
curl -fsS -u "$GRAFANA_ADMIN_USER:$GRAFANA_ADMIN_PASSWORD" "http://127.0.0.1:$GRAFANA_PORT/api/search?type=dash-db&query=" >"$GRAFANA_DASH_OUT"
if command -v docker >/dev/null 2>&1; then
echo "[*] Checking container status"
cd "$STACK_DIR"
docker compose ps
else
echo "[*] docker command not found; skipping container status"
fi
echo "[+] Pipeline health artifacts:"
echo " /tmp/awrus-onec-metrics.out"
echo " /tmp/awrus-prom-healthy.out"
echo " /tmp/awrus-prom-up.json"
echo " /tmp/awrus-prom-freshness.json"
echo " $METRICS_OUT"
echo " $AW_METRICS_OUT"
echo " $PROM_HEALTH_OUT"
echo " $PROM_UP_OUT"
echo " $PROM_FRESHNESS_OUT"
echo " $GRAFANA_HEALTH_OUT"
echo " $GRAFANA_DS_OUT"
echo " $GRAFANA_DASH_OUT"
+101
View File
@@ -0,0 +1,101 @@
# ActivityWatch-Russian Knowledge Graph Report
## Overview
- **Total Nodes**: 404
- **Total Edges**: 933
- **Communities**: 27
- **Source**: AST extraction (code-only corpus)
## Communities by Size
### Community 1 (62 nodes)
- install_kit_awindows_20260427_211240_windows_dlp_endpoint_signals_collector_ps1
- dlp_endpoint_signals_collector_get_deploymentconfig
- dlp_endpoint_signals_collector_write_endpointlog
- dlp_endpoint_signals_collector_invoke_awjsonpost
- dlp_endpoint_signals_collector_ensure_bucket
- ... and 57 more
### Community 2 (56 nodes)
- aw_server_aw_ru_patch_js
- aw_ru_patch_injectstyles
- aw_ru_patch_hidenoisenavigation
- aw_ru_patch_getcurrenthostfromhash
- aw_ru_patch_ispvelikehost
- ... and 51 more
### Community 3 (54 nodes)
- install_kit_awindows_20260427_211240_windows_browser_domains_native_collector_ps1
- browser_domains_native_collector_get_deploymentconfig
- browser_domains_native_collector_write_collectorlog
- browser_domains_native_collector_write_dlpincidentlog
- browser_domains_native_collector_test_domainmatch
- ... and 49 more
### Community 0 (38 nodes)
- scripts_aggregate_dlp_events_py
- aggregate_dlp_events_bucket
- aggregate_dlp_events_awevent
- aggregate_dlp_events_psycopgconnection
- protocol
- ... and 33 more
### Community 5 (34 nodes)
- aw_ru_patch_isdlpsignalbucketroute
- aw_ru_patch_getdlphostfrombucketid
- aw_ru_patch_builddlpkey
- aw_ru_patch_loadbucketevents
- aw_ru_patch_serializerulematch
- ... and 29 more
### Community 4 (34 nodes)
- install_kit_awindows_20260427_211240_windows_email_outbound_collector_ps1
- email_outbound_collector_get_deploymentconfig
- email_outbound_collector_write_collectorlog
- email_outbound_collector_invoke_awjsonpost
- email_outbound_collector_ensure_bucket
- ... and 29 more
### Community 8 (28 nodes)
- aw_ru_patch_replacetext
- aw_ru_patch_walk
- aw_ru_patch_translateattributes
- aw_ru_patch_ishomeroute
- aw_ru_patch_getdefaulthostgroupsconfig
- ... and 23 more
### Community 9 (18 nodes)
- aw_ru_patch_getdlpbucketidfromhash
- aw_ru_patch_generatedlpid
- aw_ru_patch_awapijson
- aw_ru_patch_ensureawbucket
- aw_ru_patch_saveawheartbeat
- ... and 13 more
### Community 10 (14 nodes)
- install_kit_awindows_20260427_211240_windows_migrate_awatch_rus_paths_ps1
- migrate_awatch_rus_paths_copy_directorycontents
- migrate_awatch_rus_paths_copy_ifexists
- migrate_awatch_rus_paths_convert_pathvalue
- migrate_awatch_rus_paths_stop_awatchtaskset
- ... and 9 more
### Community 6 (12 nodes)
- grafana_1c_sql_exporter_collectors_aw_activitywatch_py
- aw_activitywatch_activitywatchexporter
- aw_activitywatch_activitywatchexporter_init
- aw_activitywatch_activitywatchexporter_get_buckets
- aw_activitywatch_activitywatchexporter_get_bucket_events
- ... and 7 more
## File Types
The graph was built from code files including:
- PowerShell scripts (.ps1)
- Python scripts (.py)
- JavaScript patches (.js)
- Configuration files
## Notes
- This is a structural (AST-based) graph showing code relationships
- No semantic extraction was performed (no docs/papers in corpus)
- Communities represent clusters of related functions and modules
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -31,15 +31,6 @@ cd ansible
ansible-playbook -i inventory.ini deploy_aw_server.yml
```
## Секреты (пароли) безопасно
Рекомендуемый способ не хранить пароли в репозитории — перед запуском экспортировать их в переменные окружения:
- Linux `aw_server` (SSH пароль root): `AW_SSH_PASSWORD`
- Windows `aw_windows` (WinRM пароль): `AW_WINRM_PASSWORD`
В `group_vars/aw_server.yml` и `group_vars/windows.yml` они читаются через `lookup('env', ...)`.
## Полный установочный playbook (всё за один запуск)
Если нужно прогнать полный цикл одной командой:
@@ -158,13 +149,3 @@ Playbook:
- Для полного сценария CT создаётся автоматически через `pct create`.
- На Windows/RDP host развёрнуты AFK/window watchers, browser domain collector, DLP endpoint collector и worktime session collector.
- Проверочный JSON-отчёт Windows playbook должен иметь `overallOk=true`.
## Prod rollout одной командой
Для ручного запуска с dry-run и логированием используйте:
```bash
bash scripts/prod_rollout.sh
```
Скрипт попросит `AW_SSH_PASSWORD` и `AW_WINRM_PASSWORD` интерактивно (ввод скрыт) и сложит логи в `.rollout-logs/`.
@@ -54,11 +54,6 @@
- "{{ aw_server_webui_dir }}"
- "{{ aw_server_webui_dir }}/js"
- "{{ aw_server_data_dir }}"
- "{{ aw_server_db_path | dirname }}"
- "{{ aw_server_data_dir }}/.config"
- "{{ aw_server_data_dir }}/.config/activitywatch"
- "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust"
- "{{ aw_server_data_dir }}/backups"
- "{{ aw_server_log_dir }}"
- /etc/activitywatch
- "{{ aw_bootstrap_dir }}"
@@ -79,147 +74,100 @@
- "{{ aw_server_webui_dir }}"
- "{{ aw_server_webui_dir }}/js"
- "{{ aw_server_data_dir }}"
- "{{ aw_server_db_path | dirname }}"
- "{{ aw_server_data_dir }}/.config"
- "{{ aw_server_data_dir }}/.config/activitywatch"
- "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust"
- "{{ aw_server_data_dir }}/backups"
- "{{ aw_server_log_dir }}"
- name: (Check mode) Пропустить установку релиза ActivityWatch
ansible.builtin.debug:
msg: "ansible_check_mode=true: download/unarchive/install of ActivityWatch release is skipped."
when: ansible_check_mode
- name: Скачать архив релиза ActivityWatch
ansible.builtin.get_url:
url: "{{ aw_server_download_url }}"
dest: "{{ aw_archive_path }}"
mode: "0644"
- name: Установить релиз ActivityWatch (download/unarchive/install)
when: not ansible_check_mode
block:
- name: Скачать архив релиза ActivityWatch
ansible.builtin.get_url:
url: "{{ aw_server_download_url }}"
dest: "{{ aw_archive_path }}"
mode: "0644"
- name: Распаковать релиз ActivityWatch
ansible.builtin.unarchive:
src: "{{ aw_archive_path }}"
dest: "{{ aw_release_dir }}"
remote_src: true
extra_opts: ["-o"]
- name: Распаковать релиз ActivityWatch
ansible.builtin.unarchive:
src: "{{ aw_archive_path }}"
dest: "{{ aw_release_dir }}"
remote_src: true
extra_opts: ["-o"]
- name: Найти распакованный каталог ActivityWatch
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
file_type: directory
patterns: "activitywatch*"
register: aw_release_find
- name: Найти распакованный каталог ActivityWatch
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
recurse: true
file_type: directory
patterns: "activitywatch*"
register: aw_release_find
- name: Найти бинарный файл AW server
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
file_type: file
patterns:
- aw-server-rust
- aw-server
register: aw_server_binary_find
- name: Найти бинарный файл AW server
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
recurse: true
file_type: file
patterns:
- aw-server-rust
- aw-server
register: aw_server_binary_find
- name: Найти каталог WebUI
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
file_type: directory
patterns:
- aw-webui
- webui
register: aw_webui_dir_find
- name: Найти index.html WebUI
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
recurse: true
file_type: file
patterns:
- index.html
register: aw_webui_index_find
- name: Сохранить пути распакованного релиза
ansible.builtin.set_fact:
aw_release_extracted: "{{ (aw_release_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}"
aw_server_binary_path: "{{ (aw_server_binary_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}"
aw_webui_source_path: "{{ (aw_webui_dir_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}"
- name: Сохранить пути распакованного релиза (binary + webui index)
ansible.builtin.set_fact:
aw_release_extracted: "{{ (aw_release_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}"
aw_server_binary_path: >-
{{
(
(
(aw_server_binary_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list)
| select('match', '.*/aw-server-rust$') | list | first
)
| default(
(
(aw_server_binary_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first)
),
true
)
) | default('')
}}
aw_webui_index_path: >-
{{
(
(
(aw_webui_index_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list)
| select('search', '/static/index\\.html$') | list | first
)
| default(
(
(aw_webui_index_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first)
),
true
)
) | default('')
}}
- name: Проверить, что компоненты релиза найдены
ansible.builtin.assert:
that:
- aw_release_extracted is defined
- aw_release_extracted | length > 0
- aw_server_binary_path is defined
- aw_server_binary_path | length > 0
- aw_webui_source_path is defined
- aw_webui_source_path | length > 0
fail_msg: "Не удалось найти бинарный файл или WebUI в распакованном релизе ActivityWatch."
- name: Сохранить каталог WebUI (dirname index.html)
ansible.builtin.set_fact:
aw_webui_source_path: "{{ aw_webui_index_path | dirname }}"
- name: Создать каталог установленного релиза
ansible.builtin.file:
path: "{{ aw_release_install_dir }}"
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
- name: Проверить, что компоненты релиза найдены
ansible.builtin.assert:
that:
- aw_release_extracted is defined
- aw_release_extracted | length > 0
- aw_server_binary_path is defined
- aw_server_binary_path | length > 0
- aw_webui_source_path is defined
- aw_webui_source_path | length > 0
fail_msg: "Не удалось найти бинарный файл или WebUI в распакованном релизе ActivityWatch."
- name: Установить бинарный файл AW server
ansible.builtin.copy:
remote_src: true
src: "{{ aw_server_binary_path }}"
dest: "{{ aw_release_install_dir }}/aw-server-rust"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
- name: Создать каталог установленного релиза
ansible.builtin.file:
path: "{{ aw_release_install_dir }}"
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
- name: Создать ссылку на активный бинарный файл AW server
ansible.builtin.file:
src: "{{ aw_release_install_dir }}/aw-server-rust"
dest: /opt/activitywatch/bin/aw-server-rust
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
state: link
force: true
- name: Установить бинарный файл AW server
ansible.builtin.copy:
remote_src: true
src: "{{ aw_server_binary_path }}"
dest: "{{ aw_release_install_dir }}/aw-server-rust"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
- name: Синхронизировать WebUI в RU каталог
ansible.builtin.command:
cmd: "rsync -a {{ aw_webui_source_path }}/ {{ aw_server_webui_dir }}/"
- name: Создать ссылку на активный бинарный файл AW server
ansible.builtin.file:
src: "{{ aw_release_install_dir }}/aw-server-rust"
dest: /opt/activitywatch/bin/aw-server-rust
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
state: link
force: true
- name: Синхронизировать WebUI в RU каталог
ansible.builtin.command:
cmd: "rsync -a {{ aw_webui_source_path }}/ {{ aw_server_webui_dir }}/"
- name: Настроить владельца файлов /opt/activitywatch
ansible.builtin.file:
path: /opt/activitywatch
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
recurse: true
- name: Настроить владельца файлов /opt/activitywatch
ansible.builtin.file:
path: /opt/activitywatch
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
recurse: true
- name: Установить systemd service из шаблона репозитория
ansible.builtin.copy:
@@ -236,401 +184,143 @@
- Перезагрузить systemd
- Перезапустить activitywatch
- name: (Check mode) Пропустить WebUI patch и запуск сервиса
ansible.builtin.debug:
msg: "ansible_check_mode=true: WebUI patch + service start + API checks are skipped."
when: ansible_check_mode
- name: Применить WebUI RU patch и запустить сервис
when: not ansible_check_mode
block:
- name: Скопировать RU patch файлы WebUI из репозитория
ansible.builtin.copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
loop:
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "{{ aw_server_webui_dir }}/js/aw-worktime-panel.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
- name: Создать каталог /root/bootstrap для apply_webui_ru_patch.sh
ansible.builtin.file:
path: /root/bootstrap
state: directory
mode: "0755"
- name: Скопировать RU patch файлы для apply_webui_ru_patch.sh (хотфиксы compiled JS чанков)
ansible.builtin.copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
loop:
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/root/bootstrap/aw-ru-patch.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/root/bootstrap/aw-sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "/root/bootstrap/aw-worktime-panel.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/root/bootstrap/aw-host-groups.json", mode: "0644" }
- name: Скопировать apply_webui_ru_patch.sh скрипт
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/apply_webui_ru_patch.sh"
dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh
mode: "0755"
- name: Записать /etc/activitywatch/aw-server.env перед хотфиксами
ansible.builtin.copy:
dest: /etc/activitywatch/aw-server.env
mode: "0640"
owner: root
group: root
content: |
AW_SERVER_BIND_HOST={{ aw_server_bind_host }}
AW_SERVER_PORT={{ aw_server_port }}
AW_SERVER_DATA_DIR={{ aw_server_data_dir }}
AW_SERVER_DB_PATH={{ aw_server_db_path }}
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
AW_SERVER_USER={{ aw_server_user }}
AW_SERVER_GROUP={{ aw_server_group }}
AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }}
AW_WORKTIME_TZ={{ aw_worktime_timezone }}
XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
- name: Установить скрипт AW worktime API
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py"
dest: /usr/local/bin/aw-worktime-api.py
owner: root
group: root
mode: "0755"
- name: Установить systemd unit AW worktime API
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.service"
dest: /etc/systemd/system/aw-worktime-api.service
owner: root
group: root
mode: "0644"
- name: Установить скрипт AW worktime UI bridge
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.py"
dest: /usr/local/bin/aw-worktime-ui-bridge.py
owner: root
group: root
mode: "0755"
- name: Установить systemd unit AW worktime UI bridge
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.service"
dest: /etc/systemd/system/aw-worktime-ui-bridge.service
owner: root
group: root
mode: "0644"
- name: Установить systemd timer AW worktime UI bridge
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.timer"
dest: /etc/systemd/system/aw-worktime-ui-bridge.timer
owner: root
group: root
mode: "0644"
- name: Перезагрузить systemd после установки AW worktime API
ansible.builtin.systemd:
daemon_reload: true
- name: Включить и перезапустить AW worktime API
ansible.builtin.systemd:
name: aw-worktime-api.service
enabled: true
state: restarted
- name: Отключить legacy timer aw-worktime-afk-bridge (если есть)
ansible.builtin.systemd:
name: aw-worktime-afk-bridge.timer
enabled: false
state: stopped
failed_when: false
- name: Включить и перезапустить AW worktime UI bridge timer
ansible.builtin.systemd:
name: aw-worktime-ui-bridge.timer
enabled: true
state: restarted
- name: Выполнить разовый прогон AW worktime UI bridge
ansible.builtin.systemd:
name: aw-worktime-ui-bridge.service
state: started
failed_when: false
- name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper)
ansible.builtin.command:
cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh"
register: apply_ru_patch_result
failed_when: false
- name: Вывести результат применения хотфиксов
ansible.builtin.debug:
msg: "apply_webui_ru_patch.sh: {{ apply_ru_patch_result.stdout }}"
- name: Проверить наличие index.html после копирования
ansible.builtin.stat:
path: "{{ aw_server_webui_dir }}/index.html"
register: aw_webui_ru_index
- name: Проверить, что index.html доступен для RU patch
ansible.builtin.assert:
that:
- aw_webui_ru_index.stat.exists
fail_msg: "Не найден index.html WebUI для применения RU patch."
- name: Удалить старые теги RU patch из index.html
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '<script[^>]+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js)[^>]*></script>'
replace: ''
- name: Добавить cleanup script RU patch в index.html
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</head>'
replace: '<script src="/js/sw-cleanup.js?v={{ aw_sw_cleanup_cache_bust }}"></script></head>'
- name: Добавить загрузчик RU patch перед закрытием body
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</body>'
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
- name: Скопировать merge script AW DB на сервер
ansible.builtin.copy:
src: "{{ aw_repo_root }}/scripts/merge_aw_server_dbs.py"
dest: /usr/local/bin/merge_aw_server_dbs.py
owner: root
group: root
mode: "0755"
- name: Проверить наличие legacy root DB
ansible.builtin.stat:
path: /root/.local/share/activitywatch/aw-server-rust/sqlite.db
register: aw_legacy_root_db
- name: Проверить наличие target DB
ansible.builtin.stat:
path: "{{ aw_server_db_path }}"
register: aw_target_db
- name: Остановить сервис перед merge server DB
ansible.builtin.systemd:
name: activitywatch-server.service
state: stopped
when: aw_legacy_root_db.stat.exists | default(false)
- name: Создать backup каталоги server DB
ansible.builtin.file:
path: "{{ aw_server_data_dir }}/backups/db"
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
when: aw_legacy_root_db.stat.exists | default(false)
- name: Backup target DB перед merge
ansible.builtin.copy:
remote_src: true
src: "{{ aw_server_db_path }}"
dest: "{{ aw_server_data_dir }}/backups/db/target-before-merge-{{ ansible_date_time.iso8601_basic_short }}.sqlite.db"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0644"
when:
- aw_legacy_root_db.stat.exists | default(false)
- aw_target_db.stat.exists | default(false)
- name: Backup legacy root DB перед merge
ansible.builtin.copy:
remote_src: true
src: /root/.local/share/activitywatch/aw-server-rust/sqlite.db
dest: "{{ aw_server_data_dir }}/backups/db/legacy-root-{{ ansible_date_time.iso8601_basic_short }}.sqlite.db"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0644"
when: aw_legacy_root_db.stat.exists | default(false)
- name: Merge legacy root DB в target DB
ansible.builtin.command:
argv:
- python3
- /usr/local/bin/merge_aw_server_dbs.py
- --base
- /root/.local/share/activitywatch/aw-server-rust/sqlite.db
- --overlay
- "{{ aw_server_db_path }}"
- --output
- "{{ aw_server_db_path }}.merged"
when:
- aw_legacy_root_db.stat.exists | default(false)
- aw_target_db.stat.exists | default(false)
- name: Install merged DB as active target DB
ansible.builtin.copy:
remote_src: true
src: "{{ aw_server_db_path }}.merged"
dest: "{{ aw_server_db_path }}"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0644"
when:
- aw_legacy_root_db.stat.exists | default(false)
- aw_target_db.stat.exists | default(false)
- name: Скопировать legacy root DB в target DB если target ещё не существует
ansible.builtin.copy:
remote_src: true
src: /root/.local/share/activitywatch/aw-server-rust/sqlite.db
dest: "{{ aw_server_db_path }}"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0644"
when:
- aw_legacy_root_db.stat.exists | default(false)
- not (aw_target_db.stat.exists | default(false))
- name: Записать aw-server-rust config.toml с разрешёнными CORS origin
ansible.builtin.copy:
dest: "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust/config.toml"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0644"
content: |
cors = [
{% for origin in aw_server_cors_origins | default([]) %}
"{{ origin }}"{% if not loop.last %},{% endif %}
{% endfor %}
]
- name: Включить и запустить сервис
ansible.builtin.systemd:
name: activitywatch-server.service
enabled: true
state: restarted
daemon_reload: true
- name: Дождаться ответа API
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/info"
method: GET
status_code: 200
register: aw_api
retries: 10
delay: 3
until: aw_api.status == 200
- name: Считать текущие server-side settings
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/"
method: GET
status_code: 200
register: aw_settings_current
when: aw_apply_worktime_settings | default(false) | bool
- name: Считать текущие server-side views
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/views"
method: GET
status_code: 200
register: aw_views_current
when: aw_apply_worktime_settings | default(false) | bool
- name: Считать текущие server-side classes
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/classes"
method: GET
status_code: 200
register: aw_classes_current
when: aw_apply_worktime_settings | default(false) | bool
- name: Создать backup текущих server-side settings/views/classes
- name: Скопировать RU patch файлы WebUI из репозитория
ansible.builtin.copy:
dest: "{{ aw_server_data_dir }}/backups/{{ item.name }}-{{ ansible_date_time.iso8601_basic_short }}.json"
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0644"
content: "{{ item.payload | to_nice_json }}"
loop:
- name: settings
payload: "{{ aw_settings_current.json | default({}) }}"
- name: views
payload: "{{ aw_views_current.json | default(none) }}"
- name: classes
payload: "{{ aw_classes_current.json | default(none) }}"
when: aw_apply_worktime_settings | default(false) | bool
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "{{ aw_server_webui_dir }}/js/aw-worktime-panel.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
- name: Настроить DLP Aggregator (Phase 2)
block:
- name: Создать каталог для скриптов
ansible.builtin.file:
path: "/opt/activitywatch/scripts"
state: directory
owner: root
group: root
mode: "0755"
- name: Создать каталог /root/bootstrap для apply_webui_ru_patch.sh
ansible.builtin.file:
path: /root/bootstrap
state: directory
mode: "0755"
- name: Скопировать агрегатор событий DLP
ansible.builtin.copy:
src: "{{ aw_repo_root }}/scripts/aggregate_dlp_events.py"
dest: "/opt/activitywatch/scripts/aggregate_dlp_events.py"
owner: root
group: root
mode: "0755"
- name: Скопировать RU patch файлы для apply_webui_ru_patch.sh (хотфиксы compiled JS чанков)
ansible.builtin.copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
loop:
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/root/bootstrap/aw-ru-patch.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/root/bootstrap/aw-sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "/root/bootstrap/aw-worktime-panel.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/root/bootstrap/aw-host-groups.json", mode: "0644" }
- name: Установить systemd unit для агрегатора
ansible.builtin.copy:
dest: /etc/systemd/system/activitywatch-dlp-aggregator.service
content: |
[Unit]
Description=ActivityWatch DLP Event Aggregator
After=activitywatch-server.service
- name: Скопировать apply_webui_ru_patch.sh скрипт
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/apply_webui_ru_patch.sh"
dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh
mode: "0755"
[Service]
Type=oneshot
User={{ aw_server_user }}
WorkingDirectory={{ aw_server_data_dir }}
ExecStart=/usr/bin/python3 /opt/activitywatch/scripts/aggregate_dlp_events.py \
--aw-url http://127.0.0.1:{{ aw_server_port }}/api/0 \
--sqlite-path {{ aw_server_data_dir }}/dlp_warehouse.sqlite \
--state-path {{ aw_server_data_dir }}/dlp-aggregator-state.json
- name: Записать /etc/activitywatch/aw-server.env перед хотфиксами
ansible.builtin.copy:
dest: /etc/activitywatch/aw-server.env
mode: "0640"
owner: root
group: root
content: |
AW_SERVER_BIND_HOST={{ aw_server_bind_host }}
AW_SERVER_PORT={{ aw_server_port }}
AW_SERVER_DATA_DIR={{ aw_server_data_dir }}
AW_SERVER_DB_PATH={{ aw_server_db_path }}
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
AW_SERVER_USER={{ aw_server_user }}
AW_SERVER_GROUP={{ aw_server_group }}
AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }}
AW_WORKTIME_TZ={{ aw_worktime_timezone }}
XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
[Install]
WantedBy=multi-user.target
- name: Установить скрипт AW worktime API
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py"
dest: /usr/local/bin/aw-worktime-api.py
owner: root
group: root
mode: "0755"
- name: Установить systemd timer для агрегатора
ansible.builtin.copy:
dest: /etc/systemd/system/activitywatch-dlp-aggregator.timer
content: |
[Unit]
Description=Run ActivityWatch DLP Aggregator every 5 minutes
- name: Установить systemd unit AW worktime API
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.service"
dest: /etc/systemd/system/aw-worktime-api.service
owner: root
group: root
mode: "0644"
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
AccuracySec=1s
- name: Перезагрузить systemd после установки AW worktime API
ansible.builtin.systemd:
daemon_reload: true
[Install]
WantedBy=timers.target
- name: Включить и перезапустить AW worktime API
ansible.builtin.systemd:
name: aw-worktime-api.service
enabled: true
state: restarted
- name: Включить и запустить таймер агрегатора
ansible.builtin.systemd:
name: activitywatch-dlp-aggregator.timer
enabled: true
state: started
daemon_reload: true
- name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper)
ansible.builtin.command:
cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh"
register: apply_ru_patch_result
failed_when: false
- name: Вывести результат применения хотфиксов
ansible.builtin.debug:
msg: "apply_webui_ru_patch.sh: {{ apply_ru_patch_result.stdout }}"
- name: Проверить наличие index.html после копирования
ansible.builtin.stat:
path: "{{ aw_server_webui_dir }}/index.html"
register: aw_webui_ru_index
- name: Проверить, что index.html доступен для RU patch
ansible.builtin.assert:
that:
- aw_webui_ru_index.stat.exists
fail_msg: "Не найден index.html WebUI для применения RU patch."
- name: Удалить старые теги RU patch из index.html
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '<script[^>]+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js)[^>]*></script>'
replace: ''
- name: Добавить cleanup script RU patch в index.html
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</head>'
replace: '<script src="/js/sw-cleanup.js?v={{ aw_sw_cleanup_cache_bust }}"></script></head>'
- name: Добавить загрузчик RU patch перед закрытием body
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</body>'
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
- name: Включить и запустить сервис
ansible.builtin.systemd:
name: activitywatch-server.service
enabled: true
state: restarted
daemon_reload: true
- name: Дождаться ответа API
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/info"
method: GET
status_code: 200
register: aw_api
retries: 10
delay: 3
until: aw_api.status == 200
- name: Применить базовые worktime settings (classes)
ansible.builtin.uri:
@@ -652,12 +342,16 @@
- name: Вычислить worktime durationDefault из aw_worktime_from/to
ansible.builtin.set_fact:
aw_worktime_from_h: "{{ (aw_worktime_from | default('08:00')).split(':')[0] | int }}"
aw_worktime_from_m: "{{ (aw_worktime_from | default('08:00')).split(':')[1] | int }}"
aw_worktime_to_h: "{{ (aw_worktime_to | default('17:00')).split(':')[0] | int }}"
aw_worktime_to_m: "{{ (aw_worktime_to | default('17:00')).split(':')[1] | int }}"
aw_worktime_duration_default_derived: >-
{{
(
(
(((aw_worktime_to | default('17:00')).split(':')[0] | int) * 60 + ((aw_worktime_to | default('17:00')).split(':')[1] | int)) -
(((aw_worktime_from | default('08:00')).split(':')[0] | int) * 60 + ((aw_worktime_from | default('08:00')).split(':')[1] | int))
((aw_worktime_to_h | int) * 60 + (aw_worktime_to_m | int)) -
((aw_worktime_from_h | int) * 60 + (aw_worktime_from_m | int))
) * 60
)
}}
@@ -685,46 +379,20 @@
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/startOfDay"
method: POST
body: "\"{{ aw_worktime_start_of_day }}\""
headers:
Content-Type: application/json
status_code: [200, 201]
body: "{{ aw_worktime_start_of_day }}"
body_format: json
status_code: 200
when: aw_apply_worktime_settings | default(false) | bool
- name: Применить базовый период worktime (durationDefault seconds)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/durationDefault"
method: POST
body: "{{ aw_worktime_duration_default_effective | string }}"
headers:
Content-Type: application/json
status_code: [200, 201]
body: "{{ aw_worktime_duration_default_effective }}"
body_format: json
status_code: 200
when: aw_apply_worktime_settings | default(false) | bool
- name: Применить always_active_pattern для fallback без AFK
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/always_active_pattern"
method: POST
body: "\"{{ aw_server_always_active_pattern }}\""
headers:
Content-Type: application/json
status_code: [200, 201]
when:
- aw_apply_worktime_settings | default(false) | bool
- (aw_server_always_active_pattern | default('') | string | length) > 0
- name: Применить landingpage профиля
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/landingpage"
method: POST
body: "\"{{ aw_server_landingpage }}\""
headers:
Content-Type: application/json
status_code: [200, 201]
when:
- aw_apply_worktime_settings | default(false) | bool
- (aw_server_landingpage | default('') | string | length) > 0
handlers:
- name: Перезагрузить systemd
ansible.builtin.systemd:
@@ -25,7 +25,6 @@
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
aw_windows_file_ops_enabled: true
aw_windows_local_agent_logs_enabled: false
aw_windows_incident_capture_enabled: true
aw_windows_incident_screenshot_enabled: true
@@ -79,7 +78,6 @@
- browser-domains-native-collector.ps1
- dlp-endpoint-signals-collector.ps1
- email-outbound-collector.ps1
- file-operations-collector.ps1
- worktime-session-collector.ps1
- migrate-awatch-rus-paths.ps1
- deploy-domain-users.ps1
@@ -89,18 +87,6 @@
- web-category-rules.example.json
- dlp-policy.example.json
- name: Нормализовать кодировку PowerShell файлов (UTF-8 BOM для Windows PowerShell)
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$toolkitDir = "{{ aw_windows_deploy_root }}\windows"
$encIn = New-Object System.Text.UTF8Encoding($false)
$encOut = New-Object System.Text.UTF8Encoding($true)
Get-ChildItem -LiteralPath $toolkitDir -File -Include *.ps1,*.psm1,*.psd1 | ForEach-Object {
$text = [System.IO.File]::ReadAllText($_.FullName, $encIn)
[System.IO.File]::WriteAllText($_.FullName, $text, $encOut)
}
- name: Загрузить список пользователей для доменного развёртывания
ansible.windows.win_copy:
dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt"
@@ -145,7 +131,6 @@
StateRoot = "{{ aw_windows_state_root }}"
AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }}
WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }}
FileOpsEnabled = {{ '$true' if (aw_windows_file_ops_enabled | bool) else '$false' }}
LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }}
IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_enabled | bool) else '$false' }}
IncidentScreenshotEnabled = {{ '$true' if (aw_windows_incident_screenshot_enabled | bool) else '$false' }}
@@ -165,19 +150,6 @@
{% endif %}
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
- name: Удалить лишние ActivityWatch Launch tasks вне текущего deployment-config
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json
$desired = @($config.userTasks | ForEach-Object { [string]$_.LaunchTaskName })
foreach ($task in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch *' })) {
if ($desired -notcontains [string]$task.TaskName) {
Unregister-ScheduledTask -TaskName $task.TaskName -Confirm:$false -ErrorAction SilentlyContinue
& cmd.exe /c "schtasks /Delete /TN `"$($task.TaskName)`" /F >nul 2>&1" | Out-Null
}
}
- name: Принудительно запустить ActivityWatch recovery и launch tasks
when: aw_windows_force_task_restart | bool
ansible.windows.win_powershell:
@@ -200,7 +172,6 @@
when:
- aw_windows_api_smoke_check_enabled | bool
- aw_windows_afk_enabled | bool
- aw_windows_hostname_result.stdout is defined
ansible.builtin.set_fact:
aw_windows_api_smoke_check_bucket_effective: >-
{{
@@ -209,51 +180,54 @@
else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim)
}}
- name: Выполнить AW API smoke-check (проверка наличия свежих событий в AFK бакете)
- name: Дождаться свежих AFK событий на AW server
when:
- aw_windows_api_smoke_check_enabled | bool
- aw_windows_afk_enabled | bool
delegate_to: localhost
ansible.builtin.uri:
url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
method: GET
status_code: 200
register: aw_windows_api_smoke_result
until: aw_windows_api_smoke_result.json | length > 0
retries: 5
delay: 5
ignore_errors: true
return_content: true
register: aw_windows_api_smoke
until: >
aw_windows_api_smoke.status == 200 and
(aw_windows_api_smoke.json | length) > 0 and
(
aw_windows_api_smoke.json
| selectattr('data.status', 'equalto', 'not-afk')
| list
| length
) > 0
retries: 10
delay: 6
- name: Валидировать развёртывание на эндпоинте
- name: Выполнить валидацию и сохранить отчёт на целевом Windows host
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$result = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
$report = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
-ConfigPath "{{ aw_windows_state_root }}\deployment-config.json"
$result | ConvertTo-Json -Depth 8 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
return $result
$report | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
if ({{ '$true' if (aw_windows_fail_on_validation_error | bool) else '$false' }} -and -not [bool]$report.overallOk) {
throw "Проверка развёртывания ActivityWatch завершилась ошибкой. Отчёт: {{ aw_windows_validation_remote_path }}"
}
- name: Создать локальную директорию для отчётов валидации
- name: Создать локальный каталог для validation reports
ansible.builtin.file:
path: "{{ aw_windows_validation_local_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
- name: Стянуть отчёт валидации с эндпоинта
- name: Забрать validation report
ansible.builtin.fetch:
src: "{{ aw_windows_validation_remote_path }}"
dest: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
flat: true
- name: Проверить статус валидации
ansible.builtin.shell: |
python3 - <<'PY'
import json, sys
with open('{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json', 'r') as f:
data = json.load(f)
if not data.get('overallOk', False):
print(f"Validation failed for {{ inventory_hostname }}: {data.get('summary', 'Unknown error')}")
sys.exit(1)
PY
delegate_to: localhost
when: aw_windows_fail_on_validation_error | bool
- name: Показать путь к отчёту
ansible.builtin.debug:
msg:
- "Windows/RDP развёртывание завершено на {{ inventory_hostname }}."
- "Отчёт проверки: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
@@ -4,7 +4,6 @@ aw_server_bind_host: "0.0.0.0"
aw_server_port: 5600
aw_server_webui_dir: "/opt/activitywatch/webui-ru"
aw_server_data_dir: "/var/lib/activitywatch"
aw_server_db_path: "/var/lib/activitywatch/.local/share/activitywatch/aw-server-rust/sqlite.db"
aw_server_log_dir: "/var/log/activitywatch"
aw_server_user: "activitywatch"
aw_server_group: "activitywatch"
@@ -13,17 +12,9 @@ aw_worktime_timezone: "Europe/Moscow"
aw_repo_root: "{{ playbook_dir | dirname }}"
# Применить базовые категории и views для рабочего времени через AW settings API.
# При прод-обновлениях это нужно оставлять включённым, иначе UI остаётся без views/classes.
aw_apply_worktime_settings: true
# Дополнительные origin для aw-server-rust CORS.
# Обязательно включите тот origin, с которого реально открывается Web UI.
aw_server_cors_origins:
- "http://127.0.0.1:5600"
- "http://localhost:5600"
- "http://10.10.10.13:5600"
- "http://aw-server:5600"
# Опционально: применить базовые категории и views для рабочего времени через AW settings API.
# Внимание: это перезаписывает существующие server-side settings/classes/views.
aw_apply_worktime_settings: false
# Опциональные значения периода рабочего времени в Web UI.
# startOfDay задаёт границу дня и стартовое время окна отчёта.
@@ -33,5 +24,3 @@ aw_server_cors_origins:
aw_worktime_from: "08:00"
aw_worktime_to: "17:00"
aw_worktime_start_of_day: "{{ aw_worktime_from }}"
aw_server_always_active_pattern: "aw-watcher-window"
aw_server_landingpage: "/activity/SHARKON2025/view/"
@@ -23,7 +23,6 @@ aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
aw_windows_file_ops_enabled: true
aw_windows_local_agent_logs_enabled: false
aw_windows_incident_capture_enabled: true
aw_windows_incident_screenshot_enabled: true
@@ -11,9 +11,6 @@
- activitywatch-server.service
- aw-worktime-api.py
- aw-worktime-api.service
- aw-worktime-ui-bridge.py
- aw-worktime-ui-bridge.service
- aw-worktime-ui-bridge.timer
- aw-worktime-panel.js
- aw-server.env.example
- aw-ru-patch.js
@@ -11,9 +11,6 @@
- activitywatch-server.service
- aw-worktime-api.py
- aw-worktime-api.service
- aw-worktime-ui-bridge.py
- aw-worktime-ui-bridge.service
- aw-worktime-ui-bridge.timer
- aw-worktime-panel.js
- aw-server.env.example
- aw-ru-patch.js
@@ -9,7 +9,7 @@ EnvironmentFile=/etc/activitywatch/aw-server.env
User=__AW_SERVER_USER__
Group=__AW_SERVER_GROUP__
WorkingDirectory=__AW_SERVER_DATA_DIR__
ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT" --dbpath "$AW_SERVER_DB_PATH" --webpath "$AW_SERVER_WEBUI_DIR"'
ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT"'
Restart=on-failure
RestartSec=5s
StateDirectory=activitywatch
@@ -17,7 +17,7 @@ LogsDirectory=activitywatch
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=read-only
ProtectHome=true
LimitNOFILE=65535
[Install]
@@ -29,21 +29,6 @@
{ "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",
@@ -370,16 +370,6 @@
return /^pve[-_]/i.test(String(host || ""));
}
function isLikelyClientHost(host) {
const value = String(host || "").trim();
if (!value) return false;
if (/^(?:unknown|undefined|null)$/i.test(value)) return false;
if (/^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1)$/i.test(value)) return false;
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) return false;
if (value.indexOf(":") !== -1 && /^[0-9a-f:\[\]]+$/i.test(value)) return false;
return true;
}
function enforceSafeActivityViewForPveHost() {
const hash = window.location.hash || "";
const match = hash.match(/^#\/activity\/([^/]+)\/day\/([^/]+)\/view\/([^/?#]+)/i);
@@ -396,9 +386,9 @@
function getDlpHostFromSettings(settings) {
const routeHost = getCurrentHostFromHash();
if (isLikelyClientHost(routeHost)) return routeHost;
if (routeHost) return routeHost;
const bucketHost = getDlpHostFromBucketId(getDlpBucketIdFromHash());
if (isLikelyClientHost(bucketHost)) return bucketHost;
if (bucketHost) return bucketHost;
return getTrendsHostFromSettings(settings);
}
@@ -690,19 +680,6 @@
{ 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",
@@ -763,15 +740,7 @@
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_",
@@ -801,27 +770,7 @@
return result;
}
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";
}
}
function matchHostGroup(host, groups) {
for (const group of groups) {
const patterns = Array.isArray(group.patterns) ? group.patterns : [];
for (const pattern of patterns) {
@@ -864,7 +813,7 @@
grouped.set("__ungrouped__", []);
Array.from(hostBuckets.keys()).sort().forEach(function (host) {
const groupId = matchHostGroup(host, groups, hostBuckets.get(host) || []) || "__ungrouped__";
const groupId = matchHostGroup(host, groups) || "__ungrouped__";
grouped.get(groupId).push(host);
});
@@ -920,7 +869,7 @@
center.setAttribute("data-aw-ru-host-groups", "1");
center.innerHTML =
'<h4>Разделы хостов</h4>' +
'<p>Здесь хосты разделены на Windows RDP, Linux remote workers и инфраструктурные узлы.</p>' +
'<p>Здесь хосты разделены на пользовательские Windows RDP и инфраструктурные виртуальные серверы/Proxmox.</p>' +
'<div class="aw-ru-host-groups-grid" data-aw-ru-host-groups-grid><section class="aw-ru-host-group-card"><p>Загрузка...</p></section></div>';
heading.parentElement.insertBefore(center, heading.nextSibling);
}
@@ -1476,8 +1425,7 @@
if (!settings || typeof settings !== "object") return "";
const landingpage = typeof settings.landingpage === "string" ? settings.landingpage : "";
const match = landingpage.match(/\/activity\/([^/]+)/);
const host = match && match[1] ? decodeURIComponent(match[1]) : "";
return isLikelyClientHost(host) ? host : "";
return match && match[1] ? match[1] : "";
}
function getTrendsPath(hash) {
@@ -1544,7 +1492,8 @@
.map(function (bucketId) { return bucketId.replace(/^aw-watcher-window_/i, ""); })
.filter(Boolean)
.filter(function (host) { return !/^unknown$/i.test(host); });
if (isLikelyClientHost(settingsHost) && hosts.indexOf(settingsHost) >= 0) return settingsHost;
if (settingsHost && hosts.indexOf(settingsHost) >= 0) return settingsHost;
if (settingsHost) return settingsHost;
hosts.sort();
return hosts[0] || "";
}
@@ -1584,8 +1533,7 @@
window.fetch = function (input, init) {
try {
const url = typeof input === "string" ? input : String(input && input.url || "");
const isCategoryBuilderRoute = /^#\/settings\/category-builder(?:[/?#]|$)/i.test(window.location.hash || "");
if (isCategoryBuilderRoute && /\/api\/0\/query\/?$/i.test(url) && init && typeof init.body === "string") {
if (/\/api\/0\/query\/?$/i.test(url) && init && typeof init.body === "string") {
init = Object.assign({}, init, {
body: rewriteUnknownCategoryBuilderQueryBody(init.body)
});
@@ -1609,8 +1557,7 @@
proto.send = function (body) {
try {
const url = String(this.__awRuUrl || "");
const isCategoryBuilderRoute = /^#\/settings\/category-builder(?:[/?#]|$)/i.test(window.location.hash || "");
if (isCategoryBuilderRoute && /\/api\/0\/query\/?$/i.test(url) && typeof body === "string") {
if (/\/api\/0\/query\/?$/i.test(url) && typeof body === "string") {
body = rewriteUnknownCategoryBuilderQueryBody(body);
}
} catch (error) {
@@ -26,9 +26,6 @@ VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
WORKTIME_API_SRC="$BOOTSTRAP_DIR/aw-worktime-api.py"
WORKTIME_API_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-api.service"
WORKTIME_UI_BRIDGE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.py"
WORKTIME_UI_BRIDGE_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.service"
WORKTIME_UI_BRIDGE_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.timer"
for var_name in "${required_vars[@]}"; do
if [[ -z "${!var_name:-}" ]]; then
@@ -106,24 +103,6 @@ if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then
systemctl --no-pager --full status aw-worktime-api.service || true
fi
if [[ -f "$WORKTIME_UI_BRIDGE_SRC" ]]; then
install -m 0755 "$WORKTIME_UI_BRIDGE_SRC" /usr/local/bin/aw-worktime-ui-bridge.py
fi
if [[ -f "$WORKTIME_UI_BRIDGE_SERVICE_SRC" ]]; then
install -m 0644 "$WORKTIME_UI_BRIDGE_SERVICE_SRC" /etc/systemd/system/aw-worktime-ui-bridge.service
fi
if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then
install -m 0644 "$WORKTIME_UI_BRIDGE_TIMER_SRC" /etc/systemd/system/aw-worktime-ui-bridge.timer
systemctl daemon-reload
systemctl disable --now aw-worktime-afk-bridge.timer >/dev/null 2>&1 || true
systemctl enable aw-worktime-ui-bridge.timer
systemctl restart aw-worktime-ui-bridge.timer
systemctl start aw-worktime-ui-bridge.service || true
systemctl --no-pager --full status aw-worktime-ui-bridge.timer || true
fi
for _ in $(seq 1 20); do
if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then
break
@@ -20,7 +20,7 @@
"name": ["Работа", "Документы"],
"rule": {
"type": "regex",
"regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64|libreoffice|writer|calc)\\.exe\\b|LibreOffice|OnlyOffice|Adobe Reader|Acrobat",
"regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64)\\.exe\\b|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|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",
"regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd)\\.exe\\b",
"ignore_case": true
},
"data": { "color": "#6D4C41" }
@@ -56,7 +56,7 @@
"name": ["Интернет", "Браузер"],
"rule": {
"type": "regex",
"regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser|chromium)\\.exe\\b|\\b(chrome|chromium|firefox|opera|brave|vivaldi)\\b",
"regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser)\\.exe\\b",
"ignore_case": true
},
"data": { "color": "#00897B" }
@@ -82,7 +82,7 @@
"name": ["ActivityWatch"],
"rule": {
"type": "regex",
"regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b|\\baw-(watcher|qt)\\b",
"regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b",
"ignore_case": true
},
"data": {}
@@ -306,9 +306,6 @@ function Copy-ActivityWatchCollectorAssets {
$resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop
Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force
}
else {
Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force
}
if ($CustomPolicySource) {
$resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop
@@ -537,7 +534,11 @@ function Get-CollectorPowerShellProcessCount {
function New-LaunchLock {
param([string]`$StateRoot, [int]`$SessionId)
`$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId)
if (-not (Test-Path -LiteralPath `$StateRoot)) {
New-Item -Path `$StateRoot -ItemType Directory -Force | Out-Null
}
`$lockPath = Join-Path `$StateRoot ("launch-watchers-session-{0}.lock" -f `$SessionId)
if (Test-Path -LiteralPath `$lockPath) {
try {
`$lockData = Get-Content -LiteralPath `$lockPath -Raw | ConvertFrom-Json
@@ -730,11 +731,13 @@ function Start-CollectorScriptIfNeeded {
return
}
`$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null }
`$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass')
if (`$staParam) { `$argumentList += `$staParam }
`$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath)
Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden
Start-Process -FilePath `$PowerShellExe -ArgumentList @(
'-NoProfile',
'-WindowStyle', 'Hidden',
'-ExecutionPolicy', 'Bypass',
'-File', `$ScriptPath,
'-ConfigPath', `$ConfigPath
) -WindowStyle Hidden
}
`$config = Get-DeploymentConfig -Path `$ConfigPath
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
@@ -112,8 +112,8 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Output 'ActivityWatch развёрнут для пользователей:'
$targetUsers | ForEach-Object { Write-Output " - $_" }
Write-Output "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Output "Каталог данных: $StateRoot"
Write-Output "Файл DLP-политики: $($assetResult.ActivePolicy)"
Write-Host 'ActivityWatch развёрнут для пользователей:'
$targetUsers | ForEach-Object { Write-Host " - $_" }
Write-Host "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "Каталог данных: $StateRoot"
Write-Host "Файл DLP-политики: $($assetResult.ActivePolicy)"
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
@@ -136,6 +136,6 @@ if ($reportDirectory) {
$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $effectiveReportPath -Encoding UTF8
Write-Output 'Комплексное развёртывание ActivityWatch завершено.'
Write-Output "Пользователи: $($resolvedUsers -join ', ')"
Write-Output "Отчёт: $effectiveReportPath"
Write-Host 'Комплексное развёртывание ActivityWatch завершено.'
Write-Host "Пользователи: $($resolvedUsers -join ', ')"
Write-Host "Отчёт: $effectiveReportPath"
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
@@ -104,9 +104,9 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Output "ActivityWatch развёрнут для пользователя: $TargetUser"
Write-Output "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Output "Каталог установки: $InstallRoot"
Write-Output "Каталог данных: $StateRoot"
Write-Output "Файл правил: $($assetResult.ActiveRules)"
Write-Output "Файл DLP-политики: $($assetResult.ActivePolicy)"
Write-Host "ActivityWatch развёрнут для пользователя: $TargetUser"
Write-Host "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "Каталог установки: $InstallRoot"
Write-Host "Каталог данных: $StateRoot"
Write-Host "Файл правил: $($assetResult.ActiveRules)"
Write-Host "Файл DLP-политики: $($assetResult.ActivePolicy)"
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost,
@@ -151,6 +151,6 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $effectiveRecoveryScript -ConfigPath $effectiveConfigPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Output 'Укрепление и восстановление ActivityWatch завершены.'
Write-Output "Конфигурация: $effectiveConfigPath"
Write-Output "Пользователи восстановлены: $($effectiveUsers -join ', ')"
Write-Host 'Укрепление и восстановление ActivityWatch завершены.'
Write-Host "Конфигурация: $effectiveConfigPath"
Write-Host "Пользователи восстановлены: $($effectiveUsers -join ', ')"
@@ -1,4 +1,4 @@
[CmdletBinding(SupportsShouldProcess = $true)]
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string]$OldInstallRoot = 'C:\Program Files\ActivityWatch-Phase2',
[string]$OldStateRoot = 'C:\ProgramData\ActivityWatch-Phase2',
@@ -154,36 +154,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Миграция ActivityWatch W
@{ Source = $NewStateRoot; Name = 'new-state' }
)) {
if (Test-Path -LiteralPath $item.Source) {
$backupDest = Join-Path $backupRoot $item.Name
New-ActivityWatchDirectory -Path $backupDest
$excludeDirs = @()
if ($item.Source -eq $NewStateRoot) {
# Avoid infinite recursion: backupRoot is inside NewStateRoot by default.
$excludeDirs += $backupRoot
}
$robocopyArgs = @(
$item.Source,
$backupDest,
'/E',
'/R:1',
'/W:1',
'/NFL',
'/NDL',
'/NJH',
'/NJS',
'/NP'
)
if ($excludeDirs.Count -gt 0) {
$robocopyArgs += '/XD'
$robocopyArgs += $excludeDirs
}
& robocopy @robocopyArgs | Out-Null
if ($LASTEXITCODE -ge 8) {
throw "Backup robocopy failed (exit=$LASTEXITCODE) for source '$($item.Source)' to '$backupDest'"
}
Copy-Item -LiteralPath $item.Source -Destination (Join-Path $backupRoot $item.Name) -Recurse -Force
}
}
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json'
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json'
)
Set-StrictMode -Version Latest
@@ -13,26 +13,49 @@ $config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath
$installRoot = [string]$config.paths.installRoot
$stateRoot = [string]$config.paths.stateRoot
$collectorScript = [string]$config.paths.collectorScript
$endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' }
$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' }
$rulesPath = [string]$config.paths.rulesPath
$policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$config.paths.policyPath } else { Join-Path $stateRoot 'dlp-policy.json' }
$launchScript = [string]$config.paths.launchScript
$recoveryScript = [string]$config.paths.recoveryScript
$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 }
$requiredFiles = @(
(Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe'),
(Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe'),
$collectorScript,
$endpointCollectorScript,
$sessionCollectorScript,
$rulesPath,
$policyPath,
$launchScript,
$recoveryScript,
$ConfigPath
)
if ($afkExpected) {
$requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe')
}
if ($windowExpected) {
$requiredFiles += (Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe')
}
$missingFiles = @(
$requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_) }
)
$processNames = @('aw-watcher-afk', 'aw-watcher-window')
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
$processNames = @()
if ($afkExpected) { $processNames += 'aw-watcher-afk' }
if ($windowExpected) { $processNames += 'aw-watcher-window' }
$runningProcesses = @()
if ($processNames.Count -gt 0) {
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
}
$sessionCollectorProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and
$_.CommandLine -match [Regex]::Escape($sessionCollectorScript)
} |
Select-Object Name, ProcessId, SessionId, CommandLine
$taskNames = @()
if ($config.userTasks) {
@@ -42,7 +65,7 @@ $taskNames += [string]$config.recovery.taskName
$taskNames = $taskNames | Sort-Object -Unique
$tasks = foreach ($taskName in $taskNames) {
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
$task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1
if ($task) {
[pscustomobject]@{
taskName = $task.TaskName
@@ -53,7 +76,7 @@ $tasks = foreach ($taskName in $taskNames) {
else {
[pscustomobject]@{
taskName = $taskName
state = 'Missing'
state = 'Отсутствует'
present = $false
}
}
@@ -76,8 +99,16 @@ $result = [ordered]@{
ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present }))
}
processes = [ordered]@{
expected = $processNames
list = @($runningProcesses)
ok = [bool](($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge 2)
sessionCollectors = @($sessionCollectorProcesses)
ok = [bool](
(
($processNames.Count -eq 0) -or
(($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge $processNames.Count)
) -and
($sessionCollectorProcesses.Count -ge 1)
)
}
}
@@ -1,44 +1,4 @@
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$Hostname,
[int]$PollSeconds = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Конфигурация не найдена: $Path"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
function Ensure-Bucket {
param(
[Parameter(Mandatory = $true)][string]$ApiBase,
[Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue
)
try {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return
}
catch { Write-Error param(
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$Hostname,
[int]$PollSeconds = 30
@@ -87,12 +47,7 @@ function Ensure-Bucket {
hostname = $HostnameValue
} | ConvertTo-Json -Compress
try {
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
catch {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
}
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
function Get-SessionRecords {
@@ -204,458 +159,3 @@ while ($true) {
Start-Sleep -Seconds $sleepSec
}
; }
$body = @{
client = 'aw-worktime-session-collector'
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
try {
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
catch {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
}
}
function Get-SessionRecords {
$records = @()
try {
$lines = quser 2>$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
}
$sessionName = ''
$sessionIdIndex = 2
if ($parts[1] -match '^\d+$') {
$sessionIdIndex = 1
}
else {
$sessionName = $parts[1]
}
$sessionId = 0
if ($parts[$sessionIdIndex] -match '^\d+$') {
$sessionId = [int]$parts[$sessionIdIndex]
}
$records += [pscustomobject]@{
username = $parts[0]
sessionName = $sessionName
sessionId = $sessionId
state = $parts[$sessionIdIndex + 1]
}
}
}
catch { Write-Error param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$Hostname,
[int]$PollSeconds = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Конфигурация не найдена: $Path"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
function Ensure-Bucket {
param(
[Parameter(Mandatory = $true)][string]$ApiBase,
[Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue
)
try {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return
}
catch {
}
$body = @{
client = 'aw-worktime-session-collector'
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
try {
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
catch {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
}
}
function Get-SessionRecords {
$records = @()
try {
$lines = quser 2>$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
}
$sessionName = ''
$sessionIdIndex = 2
if ($parts[1] -match '^\d+$') {
$sessionIdIndex = 1
}
else {
$sessionName = $parts[1]
}
$sessionId = 0
if ($parts[$sessionIdIndex] -match '^\d+$') {
$sessionId = [int]$parts[$sessionIdIndex]
}
$records += [pscustomobject]@{
username = $parts[0]
sessionName = $sessionName
sessionId = $sessionId
state = $parts[$sessionIdIndex + 1]
}
}
}
catch {
}
return $records
}
function Test-SessionIsActive {
param([AllowNull()][string]$State)
if ([string]::IsNullOrWhiteSpace($State)) { return $false }
$s = $State.Trim().ToLowerInvariant()
return ($s -eq 'active') -or ($s -like 'актив*')
}
$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 = (Test-SessionIsActive -State ([string]$rec.state))
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
}
; }
return $records
}
function Test-SessionIsActive {
param([AllowNull()][string]$State)
if ([string]::IsNullOrWhiteSpace($State)) { return $false }
$s = $State.Trim().ToLowerInvariant()
return ($s -eq 'active') -or ($s -like 'актив*')
}
$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 = (Test-SessionIsActive -State ([string]$rec.state))
hostname = $hostValue
source = 'worktime-session-collector'
}
} | ConvertTo-Json -Depth 6 -Compress
try {
Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
}
catch { Write-Error param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$Hostname,
[int]$PollSeconds = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Конфигурация не найдена: $Path"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
function Ensure-Bucket {
param(
[Parameter(Mandatory = $true)][string]$ApiBase,
[Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue
)
try {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return
}
catch {
}
$body = @{
client = 'aw-worktime-session-collector'
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
try {
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
catch {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
}
}
function Get-SessionRecords {
$records = @()
try {
$lines = quser 2>$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
}
$sessionName = ''
$sessionIdIndex = 2
if ($parts[1] -match '^\d+$') {
$sessionIdIndex = 1
}
else {
$sessionName = $parts[1]
}
$sessionId = 0
if ($parts[$sessionIdIndex] -match '^\d+$') {
$sessionId = [int]$parts[$sessionIdIndex]
}
$records += [pscustomobject]@{
username = $parts[0]
sessionName = $sessionName
sessionId = $sessionId
state = $parts[$sessionIdIndex + 1]
}
}
}
catch {
}
return $records
}
function Test-SessionIsActive {
param([AllowNull()][string]$State)
if ([string]::IsNullOrWhiteSpace($State)) { return $false }
$s = $State.Trim().ToLowerInvariant()
return ($s -eq 'active') -or ($s -like 'актив*')
}
$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 = (Test-SessionIsActive -State ([string]$rec.state))
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
}
; }
}
Start-Sleep -Seconds $sleepSec
}
-1
View File
@@ -1,5 +1,4 @@
#!/bin/sh
# shellcheck disable=SC1007
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-1
View File
@@ -1,5 +1,4 @@
#!/bin/sh
# shellcheck disable=SC1007
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+1 -10
View File
@@ -18,12 +18,7 @@ JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
DEFAULT_BUCKET_PREFIXES = (
"aw-file-operations_",
"aw-dlp-incidents_",
"aw-dlp-endpoint-signals_",
"aw-email-monitor_",
)
DEFAULT_BUCKET_PREFIXES = ("aw-file-operations_", "aw-dlp-incidents_")
DEFAULT_SQLITE_PATH = "data/dlp-events.sqlite3"
EVENT_COLUMNS = (
"bucket_id",
@@ -139,10 +134,6 @@ def bucket_stream_type(bucket: Bucket) -> str | None:
return "file_operation"
if bucket.id.startswith("aw-dlp-incidents_") or bucket.type == "aw.dlp.incident":
return "dlp_incident"
if bucket.id.startswith("aw-dlp-endpoint-signals_") or bucket.type == "aw.dlp.endpoint.signal":
return "dlp_endpoint_signal"
if bucket.id.startswith("aw-email-monitor_") or bucket.type == "aw.email.signal":
return "email_monitor"
return None
+1 -28
View File
@@ -4,26 +4,11 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
# shellcheck disable=SC2034
KIT_DIR="install-kit-awindows-20260427-211240"
PY_BIN="${PY_BIN:-}"
if [[ -z "$PY_BIN" ]]; then
if command -v python3 >/dev/null 2>&1; then
PY_BIN="python3"
elif command -v python >/dev/null 2>&1; then
PY_BIN="python"
else
echo "ERROR: python3/python not found"
exit 127
fi
fi
"$PY_BIN" - <<'PY'
python - <<'PY'
from pathlib import Path
import hashlib
import os
import sys
root=Path('.')
kit=Path('install-kit-awindows-20260427-211240')
@@ -39,13 +24,6 @@ missing_in_repo=[]
for kp in sorted(p for p in kit.rglob('*') if p.is_file() and p.name!='MANIFEST.txt'):
rel=kp.relative_to(kit)
rel_s=str(rel)
if rel_s.startswith("server-configs-192.168.100.21/"):
continue
if rel_s == "README-INSTALL-KIT.txt":
continue
if "__pycache__" in kp.parts or kp.suffix == ".pyc":
continue
rp=root/rel
if not rp.exists():
missing_in_repo.append(str(rel))
@@ -72,9 +50,4 @@ if ps_mismatches:
print('--- PowerShell mismatches ---')
for p in ps_mismatches:
print(p)
strict = os.getenv("ALLOW_KIT_DRIFT", "").lower() not in {"1", "true", "yes"}
if strict and (missing_in_repo or mismatches):
print("ERROR: install-kit drift detected. Set ALLOW_KIT_DRIFT=1 to bypass.")
sys.exit(1)
PY
@@ -49,7 +49,6 @@ while [ "$#" -gt 0 ]; do
esac
done
# shellcheck disable=SC1007
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
sh "${SCRIPT_DIR}/install_aw_linux_client.sh" \
+2 -14
View File
@@ -21,11 +21,9 @@ prompt_secret() {
if [[ -n "${!var_name:-}" ]]; then
return 0
fi
local _val
read -r -s -p "${prompt}: " _val
read -r -s -p "${prompt}: " "$var_name"
echo
printf -v "$var_name" '%s' "$_val"
declare -gx "$var_name"
export "$var_name"
}
require_cmd git
@@ -35,12 +33,6 @@ require_cmd ansible
log "Repo: ${ROOT_DIR}"
log "Branch: $(git branch --show-current)"
if [[ "${AW_MAINTENANCE_ACK:-}" != "YES" ]]; then
log "ERROR: maintenance window is required."
log "Set AW_MAINTENANCE_ACK=YES to proceed."
exit 4
fi
log "Running local quality gate..."
./scripts/quality-gate.sh | tee -a "${LOG_DIR}/quality-gate.log"
@@ -73,10 +65,6 @@ log "Preflight connectivity..."
ansible -i ansible/inventory.ini aw_server -m ping | tee -a "${LOG_DIR}/ping_aw_server.log"
ansible -i ansible/inventory.ini aw_windows -m win_ping | tee -a "${LOG_DIR}/ping_aw_windows.log"
log "Preflight ActivityWatch API/data checks..."
./check-aw-data.sh | tee -a "${LOG_DIR}/check_aw_data.log"
./check-aw-full.sh | tee -a "${LOG_DIR}/check_aw_full.log"
log "Dry-run aw_server..."
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_server.yml --check --diff | tee -a "${LOG_DIR}/check_aw_server.log"
+6 -15
View File
@@ -4,42 +4,33 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
echo "[1/6] Bash syntax check"
echo "[1/4] Bash syntax check"
find aw-server proxmox -type f -name "*.sh" -print0 | xargs -0 -r -n1 bash -n
echo "[2/6] Shellcheck (if available)"
echo "[2/4] Shellcheck (if available)"
if command -v shellcheck >/dev/null 2>&1; then
find aw-server proxmox -type f -name "*.sh" -print0 | xargs -0 -r shellcheck -e SC1007,SC1090,SC2016
else
echo "shellcheck not found, skipping."
fi
echo "[3/6] PowerShell parse check (if pwsh available)"
echo "[3/4] PowerShell parse check (if pwsh available)"
if command -v pwsh >/dev/null 2>&1; then
if ! pwsh -NoLogo -NoProfile -Command '
pwsh -NoLogo -NoProfile -Command '
$ErrorActionPreference = "Stop"
Get-ChildItem windows -Filter *.ps1 | ForEach-Object {
[void][System.Management.Automation.Language.Parser]::ParseFile($_.FullName,[ref]$null,[ref]$null)
}
[void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path "windows/ActivityWatch.Windows.Common.psm1"),[ref]$null,[ref]$null)
[void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path "windows/ActivityWatch.Windows.Common.psd1"),[ref]$null,[ref]$null)
'; then
echo "pwsh parse check failed due runtime environment; skipping."
fi
'
else
echo "pwsh not found, skipping."
fi
echo "[4/6] Install-kit consistency check"
./scripts/check_install_kit_vs_repo.sh
echo "[5/6] Generated-artifacts guard"
if git status --short | grep -E '^(\\?\\?| M|M ) (\\.graphify_|graphify-out/|reports/|tmp/|data/)'; then
echo "ERROR: generated artifacts detected in working tree. Clean or ignore them before rollout."
exit 1
fi
echo "[6/6] Ansible syntax check (if ansible-playbook available)"
echo "[4/4] Ansible syntax check (if ansible-playbook available)"
if command -v ansible-playbook >/dev/null 2>&1; then
for playbook in ansible/*.yml; do
ansible-playbook --syntax-check "$playbook" -i ansible/inventory.example.ini >/dev/null
-3
View File
@@ -6,10 +6,7 @@ cd "$ROOT_DIR"
KIT_DIR="install-kit-awindows-20260427-211240"
MANIFEST="$KIT_DIR/MANIFEST.txt"
# ZIP/TAR variables are declared for archive checks in this script; keep them for clarity
# shellcheck disable=SC2034
ZIP_ARCHIVE="install-kit-awindows-20260427-211240.zip"
# shellcheck disable=SC2034
TAR_ARCHIVE="install-kit-awindows-20260427-211240.tar.gz"
required_files=(
+34 -59
View File
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost,
@@ -59,7 +59,6 @@ $resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $P
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) }
$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{0}.log" -f $env:USERNAME) }
$resolvedHealthPath = Join-Path $resolvedLogsRoot ("health-browser-domains-{0}.json" -f $env:USERNAME)
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
@@ -87,7 +86,6 @@ $script:DlpDefaults = [ordered]@{
action = 'log'
severity = 'low'
}
$script:HealthPath = $resolvedHealthPath
$script:BrowserMap = @{
msedge = 'edge'
chrome = 'chrome'
@@ -137,23 +135,6 @@ function Write-DlpIncidentLog {
}
}
function Write-CollectorHealth {
param([string]$Status = 'running')
try {
$health = @{
collector = 'browser-domains-native'
hostname = $script:Hostname
sessionId = $script:SessionId
status = $Status
apiBase = $script:ApiBase
ts = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json -Depth 4
Set-Content -LiteralPath $script:HealthPath -Value $health -Encoding UTF8
}
catch {
}
}
function Test-DomainMatch {
param(
[string]$DomainHost,
@@ -810,52 +791,46 @@ Load-CustomCategoryRules -Path $resolvedRulesPath
Load-DlpPolicy -Path $resolvedPolicyPath
Write-CollectorLog ("коллектор запущен для {0}" -f $script:ApiBase)
try {
while ($true) {
try {
Write-CollectorHealth -Status 'running'
$context = Get-ForegroundWindowContext
if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) {
$url = Get-BrowserUrlFromWindow -Handle $context.Handle
if ($url) {
$browserKey = $script:BrowserMap[$context.ProcessName]
$domain = Get-HostFromUrl -Url $url
if (-not $domain) {
$domain = 'unknown'
}
while ($true) {
try {
$context = Get-ForegroundWindowContext
if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) {
$url = Get-BrowserUrlFromWindow -Handle $context.Handle
if ($url) {
$browserKey = $script:BrowserMap[$context.ProcessName]
$domain = Get-HostFromUrl -Url $url
if (-not $domain) {
$domain = 'unknown'
}
$rootDomain = Get-RootDomain -DomainHost $domain
if (-not $rootDomain) {
$rootDomain = $domain
}
$rootDomain = Get-RootDomain -DomainHost $domain
if (-not $rootDomain) {
$rootDomain = $domain
}
$category = Get-WebCategory -DomainHost $domain
$bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey)
Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName
Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule
$category = Get-WebCategory -DomainHost $domain
$bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey)
Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName
Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule
$decision = Get-DlpDecision -Domain $domain -RootDomain $rootDomain -Url $url -Title $context.Title -BrowserKey $browserKey -Category $category.Name -CategoryGroup $category.Group
if ($decision) {
$fingerprint = '{0}|{1}|{2}|{3}' -f $decision.id, $browserKey, $rootDomain, $env:USERNAME
$cooldown = [Math]::Max([int]$decision.cooldownSeconds, 30)
if (Should-EmitIncident -Fingerprint $fingerprint -CooldownSeconds $cooldown) {
Write-DlpIncidentLog ("{0} {1} {2} {3}" -f $decision.severity, $decision.action, $decision.id, $url)
if (@('alert', 'block', 'quarantine') -contains ([string]$decision.action).ToLowerInvariant()) {
Send-DlpIncidentHeartbeat -Decision $decision -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group
}
$decision = Get-DlpDecision -Domain $domain -RootDomain $rootDomain -Url $url -Title $context.Title -BrowserKey $browserKey -Category $category.Name -CategoryGroup $category.Group
if ($decision) {
$fingerprint = '{0}|{1}|{2}|{3}' -f $decision.id, $browserKey, $rootDomain, $env:USERNAME
$cooldown = [Math]::Max([int]$decision.cooldownSeconds, 30)
if (Should-EmitIncident -Fingerprint $fingerprint -CooldownSeconds $cooldown) {
Write-DlpIncidentLog ("{0} {1} {2} {3}" -f $decision.severity, $decision.action, $decision.id, $url)
if (@('alert', 'block', 'quarantine') -contains ([string]$decision.action).ToLowerInvariant()) {
Send-DlpIncidentHeartbeat -Decision $decision -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group
}
}
}
}
}
catch {
Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
}
}
finally {
Write-CollectorHealth -Status 'stopped'
catch {
Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
}
+6 -6
View File
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
@@ -114,8 +114,8 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Output 'ActivityWatch развёрнут для пользователей:'
$targetUsers | ForEach-Object { Write-Output " - $_" }
Write-Output "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Output "Каталог данных: $StateRoot"
Write-Output "Файл DLP-политики: $($assetResult.ActivePolicy)"
Write-Host 'ActivityWatch развёрнут для пользователей:'
$targetUsers | ForEach-Object { Write-Host " - $_" }
Write-Host "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "Каталог данных: $StateRoot"
Write-Host "Файл DLP-политики: $($assetResult.ActivePolicy)"
+4 -4
View File
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
@@ -139,6 +139,6 @@ if ($reportDirectory) {
$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $effectiveReportPath -Encoding UTF8
Write-Output 'Комплексное развёртывание ActivityWatch завершено.'
Write-Output "Пользователи: $($resolvedUsers -join ', ')"
Write-Output "Отчёт: $effectiveReportPath"
Write-Host 'Комплексное развёртывание ActivityWatch завершено.'
Write-Host "Пользователи: $($resolvedUsers -join ', ')"
Write-Host "Отчёт: $effectiveReportPath"
+7 -7
View File
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
@@ -106,9 +106,9 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Output "ActivityWatch развёрнут для пользователя: $TargetUser"
Write-Output "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Output "Каталог установки: $InstallRoot"
Write-Output "Каталог данных: $StateRoot"
Write-Output "Файл правил: $($assetResult.ActiveRules)"
Write-Output "Файл DLP-политики: $($assetResult.ActivePolicy)"
Write-Host "ActivityWatch развёрнут для пользователя: $TargetUser"
Write-Host "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "Каталог установки: $InstallRoot"
Write-Host "Каталог данных: $StateRoot"
Write-Host "Файл правил: $($assetResult.ActiveRules)"
Write-Host "Файл DLP-политики: $($assetResult.ActivePolicy)"
File diff suppressed because it is too large Load Diff
+30 -122
View File
@@ -53,79 +53,13 @@ function Write-CollectorLog {
catch { }
}
function Add-WalEntry {
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
if ([string]::IsNullOrWhiteSpace($script:WalPath)) { return }
try {
$entry = @{ ts = (Get-Date).ToUniversalTime().ToString('o'); uri = $Uri; json = $Json } | ConvertTo-Json -Compress
Add-Content -LiteralPath $script:WalPath -Value $entry -Encoding UTF8
} catch {}
}
function Flush-Wal {
if ([string]::IsNullOrWhiteSpace($script:WalPath) -or -not (Test-Path -LiteralPath $script:WalPath)) { return }
$remaining = New-Object System.Collections.Generic.List[string]
try {
$script:WalFlushing = $true
foreach ($line in (Get-Content -LiteralPath $script:WalPath -ErrorAction SilentlyContinue)) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
try {
$entry = $line | ConvertFrom-Json
if ($null -eq $entry -or -not $entry.uri -or -not $entry.json) { continue }
if (-not (Invoke-AwJsonPost -Uri ([string]$entry.uri) -Json ([string]$entry.json))) { $remaining.Add($line) }
} catch { $remaining.Add($line) }
}
if ($remaining.Count -eq 0) {
Remove-Item -LiteralPath $script:WalPath -Force -ErrorAction SilentlyContinue
} else {
Set-Content -LiteralPath $script:WalPath -Value ($remaining -join [Environment]::NewLine) -Encoding UTF8
}
} finally {
$script:WalFlushing = $false
}
}
function Write-CollectorHealth {
param([string]$Status = 'running')
if ([string]::IsNullOrWhiteSpace($script:HealthPath)) { return }
try {
$walDepth = 0
if ($script:WalPath -and (Test-Path -LiteralPath $script:WalPath)) { $walDepth = @((Get-Content -LiteralPath $script:WalPath)).Count }
$health = @{
collector = 'email-outbound'; hostname = $script:Hostname; sessionId = $script:SessionId;
status = $Status; apiBase = $script:ApiBase; walDepth = $walDepth; ts = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json -Depth 5
Set-Content -LiteralPath $script:HealthPath -Value $health -Encoding UTF8
} catch {}
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json,
[int]$MaxAttempts = 5,
[int]$InitialBackoffMs = 500
)
$attempt = 1
$backoff = [Math]::Max(100, $InitialBackoffMs)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
while ($attempt -le $MaxAttempts) {
try {
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
return $true
} catch {
if ($attempt -ge $MaxAttempts) {
if (-not $script:WalFlushing) { Add-WalEntry -Uri $Uri -Json $Json }
return $false
}
Start-Sleep -Milliseconds $backoff
$backoff = [Math]::Min($backoff * 2, 10000)
$attempt++
}
}
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
function Ensure-Bucket {
@@ -583,10 +517,6 @@ $script:SeenSmtpConnections = @{}
$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30)
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
$script:LogPath = $resolvedLogPath
$stateRoot = if ($deploymentConfig -and $deploymentConfig.paths -and $deploymentConfig.paths.stateRoot) { [string]$deploymentConfig.paths.stateRoot } else { 'C:\ProgramData\AWatch-rus' }
$script:WalPath = Join-Path $stateRoot 'wal-email-outbound.ndjson'
$script:HealthPath = Join-Path $stateRoot 'health-email-outbound.json'
$script:WalFlushing = $false
$script:OutlookApp = $null
$script:OutlookNamespace = $null
$script:SentFolder = $null
@@ -610,65 +540,43 @@ if ($useOutlook) {
# Main loop
# ---------------------------------------------------------------------------
try {
while ($true) {
try {
Flush-Wal
Write-CollectorHealth -Status 'running'
if (-not $script:Policy.defaults.enabled) {
Start-Sleep -Seconds $resolvedPollSeconds
continue
}
while ($true) {
try {
if (-not $script:Policy.defaults.enabled) {
Start-Sleep -Seconds $resolvedPollSeconds
continue
}
if ($useOutlook) {
if (-not $outlookReady) {
$outlookReady = Initialize-OutlookCom
}
if ($outlookReady) {
try {
Poll-OutlookSentItems
}
catch {
Write-CollectorLog ("outlook poll error: {0}" -f $_.Exception.Message)
$outlookReady = $false
$script:OutlookApp = $null
$script:OutlookNamespace = $null
$script:SentFolder = $null
}
}
if ($useOutlook) {
if (-not $outlookReady) {
$outlookReady = Initialize-OutlookCom
}
if ($useSmtp) {
if ($outlookReady) {
try {
Poll-SmtpConnections
Poll-OutlookSentItems
}
catch {
Write-CollectorLog ("smtp poll error: {0}" -f $_.Exception.Message)
Write-CollectorLog ("outlook poll error: {0}" -f $_.Exception.Message)
$outlookReady = $false
$script:OutlookApp = $null
$script:OutlookNamespace = $null
$script:SentFolder = $null
}
}
}
catch {
Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
}
}
finally {
Write-CollectorHealth -Status 'stopped'
try {
if ($null -ne $script:SentFolder) {
[void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($script:SentFolder)
$script:SentFolder = $null
}
if ($null -ne $script:OutlookNamespace) {
[void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($script:OutlookNamespace)
$script:OutlookNamespace = $null
}
if ($null -ne $script:OutlookApp) {
[void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($script:OutlookApp)
$script:OutlookApp = $null
if ($useSmtp) {
try {
Poll-SmtpConnections
}
catch {
Write-CollectorLog ("smtp poll error: {0}" -f $_.Exception.Message)
}
}
}
catch {}
catch {
Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
}
+15 -84
View File
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost,
@@ -22,9 +22,6 @@ Add-Type -AssemblyName System.Net.Http
$script:KnownBuckets = @{}
$script:Hostname = $env:COMPUTERNAME
$script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId
$script:WalPath = $null
$script:HealthPath = $null
$script:WalFlushing = $false
# Настройка логирования
$script:LogPath = $LogPath
@@ -46,88 +43,28 @@ function Write-FileCollectorLog {
} catch {}
}
function Add-WalEntry {
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
if ([string]::IsNullOrWhiteSpace($script:WalPath)) { return }
$httpClient = $null
try {
$entry = @{ ts = (Get-Date).ToUniversalTime().ToString('o'); uri = $Uri; json = $Json } | ConvertTo-Json -Compress
Add-Content -LiteralPath $script:WalPath -Value $entry -Encoding UTF8
} catch {}
}
function Flush-Wal {
if ([string]::IsNullOrWhiteSpace($script:WalPath) -or -not (Test-Path -LiteralPath $script:WalPath)) { return }
$remaining = New-Object System.Collections.Generic.List[string]
try {
$script:WalFlushing = $true
foreach ($line in (Get-Content -LiteralPath $script:WalPath -ErrorAction SilentlyContinue)) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
try {
$entry = $line | ConvertFrom-Json
if ($null -eq $entry -or -not $entry.uri -or -not $entry.json) { continue }
if (-not (Invoke-AwJsonPost -Uri ([string]$entry.uri) -Json ([string]$entry.json))) { $remaining.Add($line) }
} catch { $remaining.Add($line) }
}
if ($remaining.Count -eq 0) {
Remove-Item -LiteralPath $script:WalPath -Force -ErrorAction SilentlyContinue
} else {
Set-Content -LiteralPath $script:WalPath -Value ($remaining -join [Environment]::NewLine) -Encoding UTF8
$httpClient = New-Object System.Net.Http.HttpClient
$content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json")
$response = $httpClient.PostAsync($Uri, $content).Result
if (-not $response.IsSuccessStatusCode) {
$status = [int]$response.StatusCode
$reason = [string]$response.ReasonPhrase
$body = $response.Content.ReadAsStringAsync().Result
Write-FileCollectorLog ("POST failed: uri={0} status={1} reason={2} body={3}" -f $Uri, $status, $reason, $body)
}
} catch {
Write-FileCollectorLog "POST Error: $($_.Exception.Message)"
} finally {
$script:WalFlushing = $false
}
}
function Write-CollectorHealth {
param([string]$Status = 'running')
if ([string]::IsNullOrWhiteSpace($script:HealthPath)) { return }
try {
$walDepth = 0
if ($script:WalPath -and (Test-Path -LiteralPath $script:WalPath)) { $walDepth = @((Get-Content -LiteralPath $script:WalPath)).Count }
$health = @{
collector = 'file-operations'; hostname = $script:Hostname; sessionId = $script:SessionId;
status = $Status; apiBase = $script:ApiBase; walDepth = $walDepth; ts = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json -Depth 5
Set-Content -LiteralPath $script:HealthPath -Value $health -Encoding UTF8
} catch {}
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json,
[int]$MaxAttempts = 5,
[int]$InitialBackoffMs = 500
)
$attempt = 1
$backoff = [Math]::Max(100, $InitialBackoffMs)
while ($attempt -le $MaxAttempts) {
$httpClient = $null
try {
$httpClient = New-Object System.Net.Http.HttpClient
$content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json")
$response = $httpClient.PostAsync($Uri, $content).Result
if ($response.IsSuccessStatusCode) { return $true }
if ($attempt -ge $MaxAttempts) {
if (-not $script:WalFlushing) { Add-WalEntry -Uri $Uri -Json $Json }
return $false
}
} catch {
if ($attempt -ge $MaxAttempts) {
if (-not $script:WalFlushing) { Add-WalEntry -Uri $Uri -Json $Json }
return $false
}
} finally {
if ($null -ne $httpClient) {
$httpClient.Dispose()
}
if ($null -ne $httpClient) {
$httpClient.Dispose()
}
Start-Sleep -Milliseconds $backoff
$backoff = [Math]::Min($backoff * 2, 10000)
$attempt++
}
}
@@ -214,9 +151,6 @@ $scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) {
$hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' }
$port = if ($ServerPort) { $ServerPort } elseif ($config.server.port) { $config.server.port } else { 5600 }
$script:ApiBase = "{0}://{1}:{2}/api/0" -f $scheme, $hostName, $port
$stateRoot = if ($config.paths -and $config.paths.stateRoot) { [string]$config.paths.stateRoot } else { 'C:\ProgramData\AWatch-rus' }
$script:WalPath = Join-Path $stateRoot 'wal-file-operations.ndjson'
$script:HealthPath = Join-Path $stateRoot 'health-file-operations.json'
$bucketId = 'aw-file-operations_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation'
@@ -273,14 +207,11 @@ Write-FileCollectorLog "Collector started. Waiting for events..."
try {
while ($true) {
Flush-Wal
Write-CollectorHealth -Status 'running'
Start-Sleep -Seconds $PollSeconds
}
}
finally {
Write-FileCollectorLog "Stopping collector..."
Write-CollectorHealth -Status 'stopped'
foreach ($sub in @($subscriptions)) {
try {
if ($sub -and $sub.Id) {
+4 -4
View File
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost,
@@ -154,6 +154,6 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $effectiveRecoveryScript -ConfigPath $effectiveConfigPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Output 'Укрепление и восстановление ActivityWatch завершены.'
Write-Output "Конфигурация: $effectiveConfigPath"
Write-Output "Пользователи восстановлены: $($effectiveUsers -join ', ')"
Write-Host 'Укрепление и восстановление ActivityWatch завершены.'
Write-Host "Конфигурация: $effectiveConfigPath"
Write-Host "Пользователи восстановлены: $($effectiveUsers -join ', ')"
+16 -6
View File
@@ -25,11 +25,21 @@ The resulting installer `AWatch-rus-InstallKit.exe` is written to the same direc
./build_with_wine.sh
```
## Install-time parameters
## Install-time parameters (Standalone agent mode)
The installer wizard asks for:
The installer wizard asks only for:
- `ServerHost` / `ServerPort` (defaults to our AW server `10.10.10.13:5600`)
- `Users` (CSV)
- Whether to use offline payload (auto-enabled when the ZIP exists at compile time)
- Whether to validate after deploy (`-ValidateAfterDeploy`, report written to `C:\ProgramData\AWatch-rus\ensemble-report-*.json`)
- `ServerHost` / `ServerPort` (defaults to `10.10.10.13:5600`)
All other values are taken from defaults embedded in installer scripts.
## Runtime mode
- Installer runs `windows\install-standalone-service.ps1`.
- A Windows service `AWatchRusStandaloneAgent` is created with auto-start and restart-on-failure.
- Service wrapper (`windows\aw-standalone-service.ps1`) keeps DLP collectors running:
- `browser-domains-native-collector.ps1`
- `dlp-endpoint-signals-collector.ps1`
- `file-operations-collector.ps1`
- `email-outbound-collector.ps1` (if present)
- `worktime-session-collector.ps1` (if present)
+1 -1
View File
@@ -1,4 +1,4 @@
[CmdletBinding(SupportsShouldProcess = $true)]
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string]$OldInstallRoot = 'C:\Program Files\ActivityWatch-Phase2',
[string]$OldStateRoot = 'C:\ProgramData\ActivityWatch-Phase2',
+82 -22
View File
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json'
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json'
)
Set-StrictMode -Version Latest
@@ -13,26 +13,70 @@ $config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath
$installRoot = [string]$config.paths.installRoot
$stateRoot = [string]$config.paths.stateRoot
$collectorScript = [string]$config.paths.collectorScript
$endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' }
$fileCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$config.paths.fileCollectorScript } else { Join-Path $stateRoot 'file-operations-collector.ps1' }
$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' }
$rulesPath = [string]$config.paths.rulesPath
$policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$config.paths.policyPath } else { Join-Path $stateRoot 'dlp-policy.json' }
$launchScript = [string]$config.paths.launchScript
$recoveryScript = [string]$config.paths.recoveryScript
$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 }
$printServiceOperationalEnabled = $false
try {
$printServiceLog = Get-WinEvent -ListLog 'Microsoft-Windows-PrintService/Operational' -ErrorAction Stop
$printServiceOperationalEnabled = [bool]$printServiceLog.IsEnabled
}
catch {
}
$printJobTitlePolicyEnabled = $false
try {
$printPolicy = Get-ItemProperty -LiteralPath 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' -Name 'ShowJobTitleInEventLogs' -ErrorAction Stop
$printJobTitlePolicyEnabled = ([int]$printPolicy.ShowJobTitleInEventLogs -eq 1)
}
catch {
}
$requiredFiles = @(
(Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe'),
(Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe'),
$collectorScript,
$endpointCollectorScript,
$sessionCollectorScript,
$rulesPath,
$policyPath,
$launchScript,
$recoveryScript,
$ConfigPath
)
if ($fileOpsExpected) {
$requiredFiles += $fileCollectorScript
}
if ($afkExpected) {
$requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe')
}
if ($windowExpected) {
$requiredFiles += (Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe')
}
$missingFiles = @(
$requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_) }
)
$processNames = @('aw-watcher-afk', 'aw-watcher-window')
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
$processNames = @()
if ($afkExpected) { $processNames += 'aw-watcher-afk' }
if ($windowExpected) { $processNames += 'aw-watcher-window' }
$runningProcesses = @()
if ($processNames.Count -gt 0) {
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
}
$sessionCollectorProcesses = @(
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and
$_.CommandLine -match [Regex]::Escape($sessionCollectorScript)
} |
Select-Object Name, ProcessId, SessionId, CommandLine
)
$taskNames = @()
if ($config.userTasks) {
@@ -41,25 +85,28 @@ if ($config.userTasks) {
$taskNames += [string]$config.recovery.taskName
$taskNames = $taskNames | Sort-Object -Unique
$tasks = foreach ($taskName in $taskNames) {
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($task) {
[pscustomobject]@{
taskName = $task.TaskName
state = [string]$task.State
present = $true
$tasks = @(
foreach ($taskName in $taskNames) {
$task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1
if ($task) {
[pscustomobject]@{
taskName = $task.TaskName
state = [string]$task.State
present = $true
}
}
else {
[pscustomobject]@{
taskName = $taskName
state = 'Отсутствует'
present = $false
}
}
}
else {
[pscustomobject]@{
taskName = $taskName
state = 'Missing'
present = $false
}
}
}
)
$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port
$uniqueRunningProcessNames = @($runningProcesses | Select-Object -ExpandProperty Name -Unique)
$result = [ordered]@{
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
configPath = $ConfigPath
@@ -76,11 +123,24 @@ $result = [ordered]@{
ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present }))
}
processes = [ordered]@{
expected = $processNames
list = @($runningProcesses)
ok = [bool](($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge 2)
sessionCollectors = @($sessionCollectorProcesses)
ok = [bool](
(
($processNames.Count -eq 0) -or
($uniqueRunningProcessNames.Count -ge $processNames.Count)
) -and
($sessionCollectorProcesses.Count -ge 1)
)
}
printTelemetry = [ordered]@{
operationalLogEnabled = $printServiceOperationalEnabled
jobTitlePolicyEnabled = $printJobTitlePolicyEnabled
ok = [bool]($printServiceOperationalEnabled -and $printJobTitlePolicyEnabled)
}
}
$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok)
$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok -and $result.printTelemetry.ok)
$result
+144 -582
View File
@@ -1,20 +1,69 @@
param(
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$Hostname,
[int]$PollSeconds = 30
)
# Force UTF-8 for console I/O
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
try { [Console]::InputEncoding = [System.Text.Encoding]::UTF8 } catch {}
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Continue'
function Decode-Bytes-Auto {
param([byte[]]$Bytes)
if (-not $Bytes) { return '' }
$candidates = @()
# Try strict UTF8 first (detect invalid sequences)
try {
$utf8Strict = New-Object System.Text.UTF8Encoding($false,$true)
$txt = $utf8Strict.GetString($Bytes)
$candidates += @{enc='utf8'; text=$txt}
}
catch {
# invalid UTF8 sequences; ignore
}
# Try CP866 and CP1251
try { $cp866 = [System.Text.Encoding]::GetEncoding(866); $txt866 = $cp866.GetString($Bytes); $candidates += @{enc='cp866'; text=$txt866} } catch {}
try { $cp1251 = [System.Text.Encoding]::GetEncoding(1251); $txt1251 = $cp1251.GetString($Bytes); $candidates += @{enc='cp1251'; text=$txt1251} } catch {}
# If nothing decoded yet, fallback to UTF8 permissive
if ($candidates.Count -eq 0) {
try { $txt = [System.Text.Encoding]::UTF8.GetString($Bytes); return $txt } catch { return '' }
}
# Score decodings by count of Cyrillic letters; prefer highest
$best = $null; $bestScore = -1
foreach ($c in $candidates) {
$t = $c.text
if (-not $t) { continue }
$score = 0
try { $score = ([regex]::Matches($t,'\p{IsCyrillic}')).Count } catch { $score = 0 }
if ($score -gt $bestScore) { $best = $c; $bestScore = $score }
}
if ($best -ne $null) { return $best.text }
# Final fallback: first candidate text
return $candidates[0].text
}
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Конфигурация не найдена: $Path"
throw "Config not found: $Path"
}
try {
$bytes = [System.IO.File]::ReadAllBytes($Path)
$text = Decode-Bytes-Auto -Bytes $bytes
return $text | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "Failed to read config: $Path - $($_.Exception.Message)"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Invoke-AwJsonPost {
@@ -22,9 +71,15 @@ function Invoke-AwJsonPost {
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -ErrorAction Stop | Out-Null
return $true
}
catch {
Write-Verbose "POST error: $($_.Exception.Message)"
return $false
}
}
function Ensure-Bucket {
@@ -33,629 +88,136 @@ function Ensure-Bucket {
[Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue
)
try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null; return } catch { Write-Verbose "Bucket not found, creating: $BucketId" }
try {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return
$body = @{ client='aw-worktime-session-collector'; type='aw.worktime.session'; hostname=$HostnameValue } | ConvertTo-Json -Compress
$attempts = 0
while ($attempts -lt 3) {
$attempts++
$ok = Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
if ($ok) { return }
Start-Sleep -Seconds (2 * $attempts)
}
catch { Write-Error param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$Hostname,
[int]$PollSeconds = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Конфигурация не найдена: $Path"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null } catch { Write-Verbose "Ensure-Bucket final check failed: $BucketId" }
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
function Ensure-Bucket {
param(
[Parameter(Mandatory = $true)][string]$ApiBase,
[Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue
)
try {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return
}
catch {
}
$body = @{
client = 'aw-worktime-session-collector'
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
try {
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
catch {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
}
}
function Get-SessionRecords {
$records = @()
try {
$lines = quser 2>$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
}
$sessionName = ''
$sessionIdIndex = 2
if ($parts[1] -match '^\d+$') {
$sessionIdIndex = 1
}
else {
$sessionName = $parts[1]
}
$sessionId = 0
if ($parts[$sessionIdIndex] -match '^\d+$') {
$sessionId = [int]$parts[$sessionIdIndex]
}
$records += [pscustomobject]@{
username = $parts[0]
sessionName = $sessionName
sessionId = $sessionId
state = $parts[$sessionIdIndex + 1]
}
}
}
catch {
}
return $records
}
function Test-SessionIsActive {
param([AllowNull()][string]$State)
if ([string]::IsNullOrWhiteSpace($State)) { return $false }
$s = $State.Trim().ToLowerInvariant()
return ($s -eq 'active') -or ($s -like 'актив*')
}
$cfg = Get-Config -Path $ConfigPath
$hostValue = if ($Hostname) { $Hostname } elseif ($cfg.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$cfg.awHostname)) { [string]$cfg.awHostname } 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 = (Test-SessionIsActive -State ([string]$rec.state))
hostname = $hostValue
source = 'worktime-session-collector'
}
} | ConvertTo-Json -Depth 6 -Compress
function Run-QueryUser {
$tries = @(@{File='quser';Args=''},@{File='query';Args='user'})
foreach ($t in $tries) {
try {
Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $t.File
if ($t.Args) { $psi.Arguments = $t.Args }
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$stream = $proc.StandardOutput.BaseStream
$ms = New-Object System.IO.MemoryStream
$buffer = New-Object byte[] 4096
while (($read = $stream.Read($buffer,0,$buffer.Length)) -gt 0) { $ms.Write($buffer,0,$read) }
$proc.WaitForExit()
$bytes = $ms.ToArray()
$text = Decode-Bytes-Auto -Bytes $bytes
if ($text -and $text.Trim()) { return ($text -split "\r?\n") | Where-Object { $_ -ne '' } }
}
catch {
# try next
}
}
Start-Sleep -Seconds $sleepSec
}
; }
$body = @{
client = 'aw-worktime-session-collector'
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
try {
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
catch {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
}
return @()
}
function Get-SessionRecords {
function Parse-SessionLines {
param([string[]]$Lines)
$records = @()
if (-not $Lines) { return $records }
try {
$lines = quser 2>$null
if (-not $lines) {
return @()
$startIndex = 0
if ($Lines.Count -gt 0 -and $Lines[0] -match '\b(USERNAME|Имя|Имя пользователя|Имя_пользователя)\b') { $startIndex = 1 }
for ($i = $startIndex; $i -lt $Lines.Count; $i++) {
$line = $Lines[$i].Trim()
if (-not $line) { continue }
$m = [regex]::Match($line, '^\s*(?<user>\S+)\s+(?<sess>\S+)?\s+(?<id>\d+)\s+(?<state>\S+)', [System.Text.RegularExpressions.RegexOptions]::None)
if ($m.Success) {
$user = $m.Groups['user'].Value; $sess = $m.Groups['sess'].Value; $id = [int]$m.Groups['id'].Value; $state = $m.Groups['state'].Value
}
else {
$parts = $line -split '\s+'
if ($parts.Count -lt 4) { continue }
$user = $parts[0]
if ($parts[1] -match '^\d+$') { $sess = ''; $id = [int]$parts[1]; $state = $parts[2] } else { $sess = $parts[1]; $id = [int]$parts[2]; $state = $parts[3] }
}
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
}
$sessionName = ''
$sessionIdIndex = 2
if ($parts[1] -match '^\d+$') {
$sessionIdIndex = 1
}
else {
$sessionName = $parts[1]
}
$sessionId = 0
if ($parts[$sessionIdIndex] -match '^\d+$') {
$sessionId = [int]$parts[$sessionIdIndex]
}
$records += [pscustomobject]@{
username = $parts[0]
sessionName = $sessionName
sessionId = $sessionId
state = $parts[$sessionIdIndex + 1]
}
}
$records += [pscustomobject]@{ username=$user; sessionName=$sess; sessionId=$id; state=$state }
}
catch { Write-Error param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$Hostname,
[int]$PollSeconds = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Конфигурация не найдена: $Path"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
function Ensure-Bucket {
param(
[Parameter(Mandatory = $true)][string]$ApiBase,
[Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue
)
try {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return
}
catch {
}
$body = @{
client = 'aw-worktime-session-collector'
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
try {
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
catch {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
}
}
function Get-SessionRecords {
$records = @()
try {
$lines = quser 2>$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
}
$sessionName = ''
$sessionIdIndex = 2
if ($parts[1] -match '^\d+$') {
$sessionIdIndex = 1
}
else {
$sessionName = $parts[1]
}
$sessionId = 0
if ($parts[$sessionIdIndex] -match '^\d+$') {
$sessionId = [int]$parts[$sessionIdIndex]
}
$records += [pscustomobject]@{
username = $parts[0]
sessionName = $sessionName
sessionId = $sessionId
state = $parts[$sessionIdIndex + 1]
}
}
}
catch {
}
return $records
}
function Test-SessionIsActive {
param([AllowNull()][string]$State)
if ([string]::IsNullOrWhiteSpace($State)) { return $false }
param([string]$State)
if (-not $State) { return $false }
$s = $State.Trim().ToLowerInvariant()
return ($s -eq 'active') -or ($s -like 'актив*')
return ($s -match 'active') -or ($s -match 'актив')
}
# Main
$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
$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
$pulse = 120
$sleepSec = if ($PollSeconds -gt 0) {
$PollSeconds
}
elseif ($cfg.collector -and $cfg.collector.pollSeconds) {
[int]$cfg.collector.pollSeconds
}
else {
30
}
$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
try {
$lines = Run-QueryUser
$records = Parse-SessionLines -Lines $lines
}
catch {
Write-Verbose "Session parse error: $($_.Exception.Message)"
$records = @()
}
if (-not $records -or $records.Count -eq 0) {
$records = @([pscustomobject]@{
username = $env:USERNAME
sessionName = ''
sessionId = (Get-Process -Id $PID).SessionId
state = 'Unknown'
})
$records = @([pscustomobject]@{ username=$env:USERNAME; sessionName=''; sessionId=(Get-Process -Id $PID).SessionId; state='Unknown' })
}
foreach ($rec in $records) {
$payload = @{
$payloadObj = [PSCustomObject]@{
timestamp = $now
duration = 0
data = @{
data = [PSCustomObject]@{
username = [string]$rec.username
userId = "$($env:USERDOMAIN)\$($rec.username)"
userId = "${env:USERDOMAIN}\$($rec.username)"
sessionId = [int]$rec.sessionId
sessionName = [string]$rec.sessionName
state = [string]$rec.state
active = (Test-SessionIsActive -State ([string]$rec.state))
active = Test-SessionIsActive -State ([string]$rec.state)
hostname = $hostValue
source = 'worktime-session-collector'
}
} | ConvertTo-Json -Depth 6 -Compress
}
$payload = $payloadObj | ConvertTo-Json -Depth 6 -Compress
try {
Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
$ok = Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
if (-not $ok) { Write-Verbose "Heartbeat not confirmed for user $($rec.username)" }
}
catch {
Write-Verbose "Heartbeat error: $($_.Exception.Message)"
}
}
Start-Sleep -Seconds $sleepSec
}
; }
return $records
}
function Test-SessionIsActive {
param([AllowNull()][string]$State)
if ([string]::IsNullOrWhiteSpace($State)) { return $false }
$s = $State.Trim().ToLowerInvariant()
return ($s -eq 'active') -or ($s -like 'актив*')
}
$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 = (Test-SessionIsActive -State ([string]$rec.state))
hostname = $hostValue
source = 'worktime-session-collector'
}
} | ConvertTo-Json -Depth 6 -Compress
try {
Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
}
catch { Write-Error param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$Hostname,
[int]$PollSeconds = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Конфигурация не найдена: $Path"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
function Ensure-Bucket {
param(
[Parameter(Mandatory = $true)][string]$ApiBase,
[Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue
)
try {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return
}
catch {
}
$body = @{
client = 'aw-worktime-session-collector'
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
try {
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
catch {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
}
}
function Get-SessionRecords {
$records = @()
try {
$lines = quser 2>$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
}
$sessionName = ''
$sessionIdIndex = 2
if ($parts[1] -match '^\d+$') {
$sessionIdIndex = 1
}
else {
$sessionName = $parts[1]
}
$sessionId = 0
if ($parts[$sessionIdIndex] -match '^\d+$') {
$sessionId = [int]$parts[$sessionIdIndex]
}
$records += [pscustomobject]@{
username = $parts[0]
sessionName = $sessionName
sessionId = $sessionId
state = $parts[$sessionIdIndex + 1]
}
}
}
catch {
}
return $records
}
function Test-SessionIsActive {
param([AllowNull()][string]$State)
if ([string]::IsNullOrWhiteSpace($State)) { return $false }
$s = $State.Trim().ToLowerInvariant()
return ($s -eq 'active') -or ($s -like 'актив*')
}
$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 = (Test-SessionIsActive -State ([string]$rec.state))
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
}
; }
}
Start-Sleep -Seconds $sleepSec
}