From e4a25ab9c0c29a597495b0a82f0f3c45c6dc6589 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Fri, 29 May 2026 00:35:00 +0300 Subject: [PATCH] feat(worktime): add true active app evidence and contour smoke --- .gitignore | 3 + ansible/deploy_grafana_dashboards.yml | 4 + aw-server/aw-worktime-api.py | 290 +++++++++++- aw-server/test_aw_worktime_api.py | 63 +++ grafana/detmir-aw-main-dashboard.json | 230 ++++++++++ scripts/aw-contour-smoke-10.10.10.2.sh | 211 +++++++++ scripts/aw-contour-smoke-local.sh | 597 +++++++++++++++++++++++++ 7 files changed, 1396 insertions(+), 2 deletions(-) create mode 100644 grafana/detmir-aw-main-dashboard.json create mode 100644 scripts/aw-contour-smoke-10.10.10.2.sh create mode 100644 scripts/aw-contour-smoke-local.sh diff --git a/.gitignore b/.gitignore index 919af6c..32b55b0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ secrets/runtime.env *.bak windows/*.report.json .rollout-logs/ +output/ +.ai/ +.autonomous/ # IDE .idea/ diff --git a/ansible/deploy_grafana_dashboards.yml b/ansible/deploy_grafana_dashboards.yml index f59ed49..1ff04fc 100644 --- a/ansible/deploy_grafana_dashboards.yml +++ b/ansible/deploy_grafana_dashboards.yml @@ -12,6 +12,10 @@ grafana_folder_title_eff: "{{ grafana_folder_title | default('AWatch-rus') }}" grafana_dashboards_import_overwrite_eff: "{{ grafana_dashboards_import_overwrite | default(true) }}" grafana_dashboards: + - file: "grafana/detmir-aw-main-dashboard.json" + uid: "detmir-aw-main" + title: "DetMir ActivityWatch" + folder_uid: "{{ grafana_detmir_folder_uid | default(grafana_folder_uid_eff) }}" - file: "grafana/detmir-rdp-user-activity-dashboard.json" uid: "detmir-rdp-user-activity" title: "DetMir: Работа пользователей в RDP" diff --git a/aw-server/aw-worktime-api.py b/aw-server/aw-worktime-api.py index 19247e4..f12324a 100644 --- a/aw-server/aw-worktime-api.py +++ b/aw-server/aw-worktime-api.py @@ -49,6 +49,8 @@ MANAGER_ALIASES_JSON = Path(os.environ.get("AW_WORKTIME_MANAGER_ALIASES_JSON", " MANAGER_EXCLUDE_USERS = {item.strip().lower() for item in os.environ.get("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "").split(",") if item.strip()} EVENTS_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", "30"))) WORKTIME_EVENTS_LIMIT = max(1000, int(os.environ.get("AW_WORKTIME_EVENTS_LIMIT", "50000"))) +TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS = max(30, int(os.environ.get("AW_WORKTIME_TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS", "180"))) +TRUE_ACTIVE_MAX_EVENT_SECONDS = max(30, int(os.environ.get("AW_WORKTIME_TRUE_ACTIVE_MAX_EVENT_SECONDS", "600"))) MODULE_PATH = Path(__file__).resolve() _ALIASES_CACHE = {"mtime": None, "users": {}, "owners": {}, "raw": {}} _EVENTS_CACHE_LOCK = threading.Lock() @@ -85,6 +87,19 @@ def hhmm(total_seconds): return "%02d:%02d" % (total_seconds // 3600, (total_seconds % 3600) // 60) +def human_duration_ru(total_seconds): + total_seconds = max(0, int(total_seconds)) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + if hours and minutes: + return f"{hours} ч {minutes} мин" + if hours: + return f"{hours} ч" + if minutes: + return f"{minutes} мин" + return f"{total_seconds} сек" + + def now_utc(): return datetime.now(timezone.utc) @@ -397,6 +412,246 @@ def _merge_intervals(intervals): return merged +def _overlap_interval(left, right): + start = max(left[0], right[0]) + end = min(left[1], right[1]) + if end <= start: + return None + return start, end + + +def _interval_contains(interval, ts): + return interval[0] <= ts < interval[1] + + +def _event_timestamp(event): + try: + return pts(event.get("timestamp")) + except Exception: + return None + + +def _event_duration_seconds(event, default_seconds=DEFAULT_SAMPLE_SECONDS): + try: + duration = float(event.get("duration") or 0.0) + except Exception: + duration = 0.0 + if duration <= 0: + duration = default_seconds + return max(1.0, min(float(TRUE_ACTIVE_MAX_EVENT_SECONDS), duration)) + + +def _normalize_app_name(app, title=""): + app_raw = str(app or "").strip() + app_l = app_raw.lower() + title_raw = str(title or "").strip() + if app_l.startswith(("1cv8", "1cestart")): + return "1С" + if app_l in {"chrome.exe", "google chrome"} or "google chrome" in title_raw.lower(): + return "Chrome" + if app_l in {"msedge.exe", "microsoft edge"} or "microsoft edge" in title_raw.lower(): + return "Edge" + if app_l in {"browser.exe", "browser"} or "яндекс" in title_raw.lower(): + return "Яндекс Браузер" + if app_l in {"excel.exe"}: + return "Excel" + if app_l in {"winword.exe"}: + return "Word" + if app_l in {"powerpnt.exe"}: + return "PowerPoint" + if app_l in {"outlook.exe"}: + return "Outlook" + if app_l in {"explorer.exe"}: + return "Проводник" + if app_l in {"totalcmd.exe", "totalcmd64.exe"}: + return "Total Commander" + if app_l in {"cmd.exe"}: + return "Command Prompt" + if app_l in {"powershell.exe", "pwsh.exe"}: + return "PowerShell" + if app_l in {"acrord32.exe", "acrobat.exe"}: + return "Adobe Acrobat Reader" + if app_l in {"windowsterminal.exe", "windowsterminal"}: + return "Windows Terminal" + if app_raw: + return app_raw[:-4] if app_l.endswith(".exe") else app_raw + if title_raw: + return title_raw + return "Неизвестное приложение" + + +def _event_context(event): + data = event.get("data") or {} + for key in ("title", "url", "path", "filePath", "targetPath", "windowTitle", "foregroundTitle", "signalType"): + value = str(data.get(key) or "").strip() + if value: + return value + return "активность" + + +def _is_not_afk_event(event): + data = event.get("data") or {} + status = str(data.get("status") or data.get("state") or "").strip().lower() + return status in {"not-afk", "not_afk", "active", "активно"} + + +def _is_real_evidence_event(event): + data = event.get("data") or {} + signal_type = str(data.get("signalType") or data.get("type") or "").strip().lower() + if signal_type in {"collector_health", "self_test", "heartbeat", "health"}: + return False + if data.get("url") or data.get("title") or data.get("path") or data.get("filePath") or data.get("targetPath"): + return True + if signal_type: + return True + return False + + +def _events_for_bounds(events, start, end): + result = [] + for event in events: + ts = _event_timestamp(event) + if ts is None or ts < start or ts > end: + continue + result.append((ts, event)) + result.sort(key=lambda item: item[0]) + return result + + +def _build_window_intervals(window_events, start, end): + intervals = [] + previous_key = None + for ts, event in _events_for_bounds(window_events, start, end): + data = event.get("data") or {} + app = str(data.get("app") or data.get("process") or data.get("processName") or "").strip() + title = str(data.get("title") or data.get("windowTitle") or "").strip() + if not app and not title: + continue + duration = _event_duration_seconds(event, default_seconds=DEFAULT_SAMPLE_SECONDS) + interval = (max(ts, start), min(ts + timedelta(seconds=duration), end + timedelta(seconds=1))) + if interval[1] <= interval[0]: + continue + app_name = _normalize_app_name(app, title) + current_key = (app_name, title) + title_changed = previous_key is not None and current_key != previous_key + previous_key = current_key + intervals.append( + { + "app": app_name, + "raw_app": app, + "title": title, + "start": interval[0], + "end": interval[1], + "title_changed": title_changed, + "timestamp": ts, + } + ) + return intervals + + +def _build_not_afk_intervals(afk_events, start, end): + intervals = [] + for ts, event in _events_for_bounds(afk_events, start, end): + if not _is_not_afk_event(event): + continue + duration = _event_duration_seconds(event, default_seconds=5) + interval = (max(ts, start), min(ts + timedelta(seconds=duration), end + timedelta(seconds=1))) + if interval[1] > interval[0]: + intervals.append(interval) + return _merge_intervals(intervals) + + +def _find_window_at(window_intervals, ts): + for item in window_intervals: + if item["start"] <= ts < item["end"]: + return item + return None + + +def _add_app_evidence(evidence_by_app, app, ts, context): + evidence_by_app.setdefault(app, []).append((ts, str(context or "").strip() or "активность")) + + +def build_true_active_apps_from_events(window_events, afk_events, evidence_events_by_bucket, start, end): + window_intervals = _build_window_intervals(window_events, start, end) + not_afk_intervals = _build_not_afk_intervals(afk_events, start, end) + evidence_by_app = {} + + for item in window_intervals: + if item["title_changed"]: + _add_app_evidence(evidence_by_app, item["app"], item["timestamp"], item["title"] or item["raw_app"]) + + for events in evidence_events_by_bucket.values(): + for ts, event in _events_for_bounds(events, start, end): + if not _is_real_evidence_event(event): + continue + window = _find_window_at(window_intervals, ts) + if window is None: + continue + _add_app_evidence(evidence_by_app, window["app"], ts, _event_context(event)) + + rows = [] + evidence_delta = timedelta(seconds=TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS) + for app in sorted({item["app"] for item in window_intervals} | set(evidence_by_app)): + app_evidence = sorted(evidence_by_app.get(app, []), key=lambda item: item[0]) + if not app_evidence: + continue + evidence_windows = _merge_intervals([(ts - evidence_delta, ts + evidence_delta) for ts, _context in app_evidence]) + proved_intervals = [] + for window in [item for item in window_intervals if item["app"] == app]: + base = (window["start"], window["end"]) + for afk_interval in not_afk_intervals: + active_overlap = _overlap_interval(base, afk_interval) + if active_overlap is None: + continue + for evidence_interval in evidence_windows: + proved = _overlap_interval(active_overlap, evidence_interval) + if proved is not None: + proved_intervals.append(proved) + proved_intervals = _merge_intervals(proved_intervals) + proved_seconds = int(sum((right - left).total_seconds() for left, right in proved_intervals)) + if proved_seconds <= 0: + continue + last_ts, last_context = app_evidence[-1] + rows.append( + { + "application": app, + "proved_work_seconds": proved_seconds, + "proved_work_hhmm": hhmm(proved_seconds), + "proved_work_human": human_duration_ru(proved_seconds), + "last_action_utc": to_iso_utc(last_ts), + "last_action_local": last_ts.astimezone(REPORT_TZ).strftime("%H:%M"), + "last_action": last_context, + "evidence_events": len(app_evidence), + } + ) + rows.sort(key=lambda item: (-item["proved_work_seconds"], item["application"].lower())) + return rows + + +def build_true_active_apps(host, report_date): + bounds = get_report_bounds(report_date) + host = resolve_host(host) + window_events = fetch_bucket_events(f"aw-watcher-window_{host}", host) or fetch_bucket_events(f"aw-rdp-window_{host}", host) + afk_events = fetch_bucket_events(f"aw-watcher-afk_{host}", host) or fetch_bucket_events(f"aw-rdp-afk_{host}", host) + evidence_events_by_bucket = {} + for bucket_id in ( + f"aw-file-operations_{host}", + f"aw-dlp-endpoint-signals_{host}", + f"aw-watcher-web-chrome_{host}", + f"aw-watcher-web-edge_{host}", + f"aw-detmir-web-category_{host}", + ): + evidence_events_by_bucket[bucket_id] = fetch_bucket_events(bucket_id, host) + return build_true_active_apps_from_events( + window_events, + afk_events, + evidence_events_by_bucket, + bounds["start"], + bounds["end"], + ) + + def _collect_user_rows(events, start, end, host): end_exclusive = end + timedelta(seconds=1) by_user = {} @@ -1537,7 +1792,7 @@ def report_for_date_fresh(host, report_date): return module.report_for_date(host, report_date) -def render_html(rows, host, report_date, selected_day=None): +def render_html(rows, host, report_date, selected_day=None, true_active_apps=None): generated = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") date_local = report_date.strftime("%Y-%m-%d") day_query = f"&day={selected_day}" if selected_day in {"today", "yesterday"} else "" @@ -1556,6 +1811,20 @@ def render_html(rows, host, report_date, selected_day=None): ] trs = [] detail_cards = [] + true_active_apps = true_active_apps or [] + true_active_rows = [] + for app_row in true_active_apps: + last_action = app_row.get("last_action") or "-" + last_time = app_row.get("last_action_local") or "-" + true_active_rows.append( + "" + f"{html.escape(app_row.get('application') or '-')}" + f"{html.escape(app_row.get('proved_work_human') or app_row.get('proved_work_hhmm') or '0 сек')}" + f"{html.escape(last_time)} · {html.escape(last_action)}" + "" + ) + if not true_active_rows: + true_active_rows.append("Пока нет доказанной активной работы по приложениям за выбранную дату.") for row in rows: user_slug = safe_slug(row["user"]) active_seconds = int(row.get("active_seconds", 0) or 0) @@ -1811,6 +2080,21 @@ def render_html(rows, host, report_date, selected_day=None): {''.join(f"
{html.escape(label)}{html.escape(value)}
" for label, value in cards)} +
+

Доказанная работа по приложениям

+ + + + + + + + + + {''.join(true_active_rows)} + +
ПриложениеДоказанная работаПоследнее действие
+

Таблица по пользователям

@@ -2397,6 +2681,7 @@ class H(BaseHTTPRequestHandler): is_management = parsed.path == "/reports/worktime/management" management_payload = management_report_for_date(host, report_date, owner_filter=owner_filter, department_filter=department_filter) if is_management else None rows = report_for_date_fresh(host, report_date) if not is_management else management_payload["rows"] + true_active_apps = [] if is_management else build_true_active_apps(host, report_date) if fmt == "csv": if is_management: @@ -2437,7 +2722,7 @@ class H(BaseHTTPRequestHandler): if is_management: data = render_management_html(management_payload, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") else: - data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") + data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None, true_active_apps=true_active_apps).encode("utf-8") send_bytes(self, data, "text/html; charset=utf-8") return @@ -2454,6 +2739,7 @@ class H(BaseHTTPRequestHandler): "report_date": report_date.isoformat(), "bucket_id": get_sessions_bucket_id(host), "rows": rows, + "true_active_apps": true_active_apps, } data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") send_bytes(self, data, "application/json; charset=utf-8") diff --git a/aw-server/test_aw_worktime_api.py b/aw-server/test_aw_worktime_api.py index 3f34fc2..7e0f031 100644 --- a/aw-server/test_aw_worktime_api.py +++ b/aw-server/test_aw_worktime_api.py @@ -128,6 +128,69 @@ def test_aggregate_hourly_rows_splits_interval_by_local_hour(): assert [row["active_seconds"] for row in rows] == [300, 300] +def test_build_true_active_apps_requires_foreground_not_afk_and_evidence(): + start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc) + end = datetime(2026, 5, 14, 7, 59, 59, tzinfo=timezone.utc) + window_events = [ + {"timestamp": "2026-05-14T06:00:00Z", "duration": 120, "data": {"app": "1cv8.exe", "title": "ИНФОВЕСТ"}}, + {"timestamp": "2026-05-14T06:02:00Z", "duration": 180, "data": {"app": "1cv8.exe", "title": "Счета учета: Материалы"}}, + {"timestamp": "2026-05-14T07:00:00Z", "duration": 600, "data": {"app": "totalcmd.exe", "title": "Total Commander"}}, + ] + afk_events = [ + {"timestamp": "2026-05-14T06:00:00Z", "duration": 600, "data": {"status": "not-afk"}}, + {"timestamp": "2026-05-14T07:00:00Z", "duration": 600, "data": {"status": "not-afk"}}, + ] + evidence_events_by_bucket = { + "aw-file-operations_SHARKON2025": [ + { + "timestamp": "2026-05-14T07:05:00Z", + "duration": 0, + "data": {"signalType": "file_write", "path": "C:\\data\\report.xlsx"}, + } + ], + "aw-dlp-endpoint-signals_SHARKON2025": [ + { + "timestamp": "2026-05-14T07:06:00Z", + "duration": 0, + "data": {"signalType": "collector_health", "eventsFlushed": 100}, + } + ], + } + + rows = MODULE.build_true_active_apps_from_events(window_events, afk_events, evidence_events_by_bucket, start, end) + + by_app = {row["application"]: row for row in rows} + assert "1С" in by_app + assert "Total Commander" in by_app + assert by_app["1С"]["proved_work_seconds"] == 300 + assert by_app["1С"]["last_action"] == "Счета учета: Материалы" + assert by_app["Total Commander"]["proved_work_seconds"] == 480 + assert by_app["Total Commander"]["last_action"] == "C:\\data\\report.xlsx" + + +def test_render_html_contains_true_active_apps_table(): + html = MODULE.render_html( + [], + "SHARKON2025", + datetime(2026, 5, 14, tzinfo=timezone.utc).date(), + selected_day="today", + true_active_apps=[ + { + "application": "1С", + "proved_work_human": "34 мин", + "proved_work_hhmm": "00:34", + "last_action_local": "15:31", + "last_action": "Счета учета: Материалы", + } + ], + ) + assert "Доказанная работа по приложениям" in html + assert "Приложение" in html + assert "Доказанная работа" in html + assert "Последнее действие" in html + assert "Счета учета: Материалы" in html + + def test_build_management_payload_creates_actions_for_missing_and_late_users(): rows = [ { diff --git a/grafana/detmir-aw-main-dashboard.json b/grafana/detmir-aw-main-dashboard.json new file mode 100644 index 0000000..3ed9480 --- /dev/null +++ b/grafana/detmir-aw-main-dashboard.json @@ -0,0 +1,230 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 27, + "links": [], + "panels": [ + { + "datasource": { + "type": "influxdb", + "uid": "influxdb_aw" + }, + "fieldConfig": { + "defaults": { + "decimals": 0, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 5, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -7d)\n |> filter(fn: (r) => (r._measurement == \"aw_window_event\" or r._measurement == \"aw_afk_event\") and r._field == \"duration_s\")\n |> last()\n |> group()\n |> sort(columns: [\"_time\"], desc: true)\n |> limit(n: 1)\n |> map(fn: (r) => ({ r with _value: float(v: uint(v: now()) - uint(v: r._time)) / 1000000000.0 }))\n |> keep(columns: [\"_value\"])\n |> yield(name: \"freshness_seconds\")", + "refId": "A" + } + ], + "title": "Свежесть данных AW", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "influxdb_aw" + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 18, + "x": 6, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "query": "import \"date\"\nstartBound = if v.timeRangeStart > date.sub(d: 2h, from: now()) then date.sub(d: 2h, from: now()) else v.timeRangeStart\nfrom(bucket: \"aw_metrics\")\n |> range(start: startBound, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"aw_window_event\" and r._field == \"duration_s\")\n |> aggregateWindow(every: 5m, fn: sum, createEmpty: false)\n |> keep(columns: [\"_time\", \"_value\", \"host\"])\n |> group(columns: [\"host\"])\n |> yield(name: \"active\")", + "refId": "A" + } + ], + "title": "Активность окон (сумма за 5 минут)", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "influxdb_aw" + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 2, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "query": "import \"date\"\nstartBound = if v.timeRangeStart > date.sub(d: 2h, from: now()) then date.sub(d: 2h, from: now()) else v.timeRangeStart\nfrom(bucket: \"aw_metrics\")\n |> range(start: startBound, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"aw_afk_event\" and r._field == \"duration_s\" and r.status == \"afk\")\n |> aggregateWindow(every: 5m, fn: sum, createEmpty: false)\n |> keep(columns: [\"_time\", \"_value\", \"host\"])\n |> group(columns: [\"host\"])\n |> yield(name: \"afk\")", + "refId": "A" + } + ], + "title": "AFK (сумма за 5 минут)", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "influxdb_aw" + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "query": "import \"date\"\nstartBound = if v.timeRangeStart > date.sub(d: 2h, from: now()) then date.sub(d: 2h, from: now()) else v.timeRangeStart\nfrom(bucket: \"aw_metrics\")\n |> range(start: startBound, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"aw_window_event\" and r._field == \"duration_s\")\n |> map(fn: (r) => ({ r with category_view: if r.category == \"erp_1c\" then \"1С-Бухгалтерия\" else if r.category == \"file_manager\" then \"Файловый менеджер\" else if r.category == \"terminal_shell\" then \"Терминальная оболочка\" else if r.category == \"browser\" then \"Интернет-браузер\" else if r.category == \"office_docs\" then \"Офисные документы\" else if r.category == \"remote_admin\" then \"Удаленное администрирование\" else if r.category == \"development\" then \"Разработка и программирование\" else if r.category == \"communication\" then \"Средства связи\" else if r.category == \"database\" then \"Работа с базами данных\" else if r.category == \"monitoring\" then \"Мониторинг и диагностика\" else if r.category == \"virtualization\" then \"Виртуализация\" else if r.category == \"system\" or r.category == \"misc_user\" or r.category == \"other\" or r.category == \"\" then \"Системные приложения\" else r.category }))\n |> group(columns: [\"category_view\"])\n |> sum()\n |> group()\n |> sort(columns: [\"_value\"], desc: true)\n |> yield(name: \"categories\")", + "refId": "A" + } + ], + "title": "Категории по активному времени", + "type": "barchart" + }, + { + "datasource": { + "type": "influxdb", + "uid": "influxdb_aw" + }, + "fieldConfig": { + "defaults": { + "decimals": 0, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "sum" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "import \"date\"\nstartBound = if v.timeRangeStart > date.sub(d: 2h, from: now()) then date.sub(d: 2h, from: now()) else v.timeRangeStart\nfrom(bucket: \"aw_metrics\")\n |> range(start: startBound, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"aw_window_event\" and r._field == \"duration_s\")\n |> group()\n |> sum()\n |> yield(name: \"total_active\")", + "refId": "A" + } + ], + "title": "Общее активное время", + "type": "stat" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "style": "dark", + "tags": [ + "detmir", + "activitywatch" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-12h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "DetMir ActivityWatch", + "uid": "detmir-aw-main", + "version": 3, + "weekStart": "" +} diff --git a/scripts/aw-contour-smoke-10.10.10.2.sh b/scripts/aw-contour-smoke-10.10.10.2.sh new file mode 100644 index 0000000..798c507 --- /dev/null +++ b/scripts/aw-contour-smoke-10.10.10.2.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# Smoke checks for the Proxmox/gateway/1C host 10.10.10.2. + +set -uo pipefail + +OK_COUNT=0 +WARN_COUNT=0 +FAIL_COUNT=0 +SKIP_COUNT=0 + +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + CYAN='\033[0;36m' + NC='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + CYAN='' + NC='' +fi + +pass() { OK_COUNT=$((OK_COUNT + 1)); printf "%b[OK]%b %s\n" "$GREEN" "$NC" "$*"; } +warn() { WARN_COUNT=$((WARN_COUNT + 1)); printf "%b[WARN]%b %s\n" "$YELLOW" "$NC" "$*"; } +fail() { FAIL_COUNT=$((FAIL_COUNT + 1)); printf "%b[FAIL]%b %s\n" "$RED" "$NC" "$*"; } +skip() { SKIP_COUNT=$((SKIP_COUNT + 1)); printf "%b[SKIP]%b %s\n" "$YELLOW" "$NC" "$*"; } + +section() { + printf "\n%b== %s ==%b\n" "$CYAN" "$*" "$NC" +} + +have() { + command -v "$1" >/dev/null 2>&1 +} + +check_command() { + local name="$1" + shift + local output + if output="$("$@" 2>&1)"; then + pass "$name" + [ -n "$output" ] && printf "%s\n" "$output" | sed 's/^/ /' + else + fail "$name" + [ -n "$output" ] && printf "%s\n" "$output" | sed 's/^/ /' + fi +} + +check_service() { + local unit="$1" + if ! systemctl list-unit-files "$unit" >/dev/null 2>&1; then + skip "$unit is not installed" + return + fi + if systemctl is-active --quiet "$unit"; then + pass "$unit active" + else + fail "$unit inactive or failed" + systemctl --no-pager --lines=8 status "$unit" 2>&1 | sed 's/^/ /' + fi +} + +check_timer() { + local unit="$1" + if ! systemctl list-unit-files "$unit" >/dev/null 2>&1; then + skip "$unit is not installed" + return + fi + if systemctl is-active --quiet "$unit"; then + pass "$unit active" + else + fail "$unit inactive or failed" + systemctl --no-pager --lines=8 status "$unit" 2>&1 | sed 's/^/ /' + fi +} + +check_tcp() { + local name="$1" + local host="$2" + local port="$3" + if timeout 4 bash -c ":/dev/null 2>&1; then + pass "$name TCP ${host}:${port}" + else + fail "$name TCP ${host}:${port}" + fi +} + +check_http_code() { + local name="$1" + local url="$2" + local expected="${3:-^2[0-9][0-9]$}" + local tmp code + tmp="$(mktemp)" + code="$(curl -k -sS --connect-timeout 5 --max-time 15 -o "$tmp" -w '%{http_code}' "$url" 2>"$tmp.err")" + if printf "%s" "$code" | grep -Eq "$expected"; then + pass "$name HTTP $code $url" + else + fail "$name HTTP $code $url" + sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -40 + fi + rm -f "$tmp" "$tmp.err" +} + +check_http_redirect() { + local name="$1" + local url="$2" + local expected_code="${3:-^30[1278]$}" + local expected_location="${4:-}" + local tmp code location + tmp="$(mktemp)" + code="$(curl -k -sS -I --connect-timeout 5 --max-time 15 -o "$tmp" -w '%{http_code}' "$url" 2>"$tmp.err")" + location="$(awk 'BEGIN{IGNORECASE=1} /^location:/ {sub(/\r$/,""); print $0}' "$tmp" | tail -1)" + if printf "%s" "$code" | grep -Eq "$expected_code" && { [ -z "$expected_location" ] || printf "%s" "$location" | grep -Fq "$expected_location"; }; then + pass "$name HTTP $code ${location:-$url}" + else + fail "$name HTTP $code ${location:-$url}" + sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -40 + fi + rm -f "$tmp" "$tmp.err" +} + +check_docker_container() { + local name="$1" + if ! have docker; then + skip "docker command unavailable" + return + fi + if docker ps --format '{{.Names}}' 2>/dev/null | grep -Fxq "$name"; then + pass "docker container $name running" + docker ps --filter "name=^/${name}$" --format ' {{.Names}} {{.Status}} {{.Ports}}' + else + fail "docker container $name not running" + docker ps -a --filter "name=^/${name}$" --format ' {{.Names}} {{.Status}} {{.Ports}}' 2>/dev/null || true + fi +} + +section "Host" +hostnamectl 2>/dev/null | sed 's/^/ /' || hostname | sed 's/^/ /' +date -Is | sed 's/^/ /' +uptime | sed 's/^/ /' + +section "Core Services" +for unit in \ + nginx.service \ + pveproxy.service \ + pvedaemon.service \ + pvestatd.service \ + pve-cluster.service \ + docker.service \ + aw-1c-company-api.service \ + aw-pve-webadmin-logger.service +do + check_service "$unit" +done + +section "Timers" +for unit in \ + aw-1c-ingest.timer \ + aw-1c-proofcheck.timer \ + aw-1c-manager-brief.timer \ + aw-1c-recovery-brief.timer \ + aw-1c-weekly-digest.timer +do + check_timer "$unit" +done +systemctl list-timers --all --no-pager 2>/dev/null | grep -E 'aw-1c|NEXT|LEFT|PASSED' | sed 's/^/ /' || true + +section "Ports" +check_tcp "nginx http" 127.0.0.1 80 +check_tcp "nginx https" 127.0.0.1 443 +check_tcp "proxmox web" 127.0.0.1 8006 +check_tcp "1C company API" 10.10.10.2 8710 +check_tcp "clickhouse native" 127.0.0.1 9000 +check_tcp "clickhouse http" 127.0.0.1 8123 +ss -tulpn | grep -E ':(80|443|8006|8710|8123|9000)\b' | sed 's/^/ /' || true + +section "Gateway HTTP" +check_http_code "nginx healthz" "https://127.0.0.1/healthz" '^200$' +check_http_redirect "go proxmox gui" "https://127.0.0.1/go/proxmox-gui" '^30[1278]$' 'https://10.10.10.2:8006/' +check_http_redirect "go file1c brief" "https://127.0.0.1/go/file1c-brief" '^30[1278]$' 'http://10.10.10.2:8710/manager/brief' +check_http_redirect "go file1c actions" "https://127.0.0.1/go/file1c-actions" '^30[1278]$' 'http://10.10.10.2:8710/manager/actions' + +section "1C Company API" +check_http_code "1C root redirect" "http://10.10.10.2:8710/" '^307$' +check_http_code "1C /health" "http://10.10.10.2:8710/health" '^200$' +check_http_code "1C /api/health" "http://10.10.10.2:8710/api/health" '^200$' +check_http_code "1C manager brief" "http://10.10.10.2:8710/manager/brief" '^200$' +check_http_code "1C manager actions" "http://10.10.10.2:8710/manager/actions" '^200$' +check_http_code "1C manager recovery" "http://10.10.10.2:8710/manager/recovery" '^200$' +check_http_code "1C weekly digest" "http://10.10.10.2:8710/manager/digest/weekly" '^200$' + +section "ClickHouse" +check_docker_container "aw-rus-1c-clickhouse" +check_http_code "ClickHouse ping" "http://127.0.0.1:8123/ping" '^200$' +if have docker && docker ps --format '{{.Names}}' | grep -Fxq aw-rus-1c-clickhouse; then + check_command "ClickHouse SELECT 1" docker exec aw-rus-1c-clickhouse clickhouse-client --query "SELECT 1" +fi + +section "System Capacity" +df -h / /var /opt 2>/dev/null | sed 's/^/ /' +free -h 2>/dev/null | sed 's/^/ /' || true + +section "Summary" +printf "OK=%s WARN=%s FAIL=%s SKIP=%s\n" "$OK_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$SKIP_COUNT" + +if [ "$FAIL_COUNT" -gt 0 ]; then + exit 2 +fi +exit 0 diff --git a/scripts/aw-contour-smoke-local.sh b/scripts/aw-contour-smoke-local.sh new file mode 100644 index 0000000..1226443 --- /dev/null +++ b/scripts/aw-contour-smoke-local.sh @@ -0,0 +1,597 @@ +#!/usr/bin/env bash +# End-to-end smoke checks from Igor's laptop for ActivityWatch-Russian. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SMOKE_ENV_FILE="${AW_SMOKE_ENV_FILE:-}" +for env_candidate in "$REPO_ROOT/secrets/runtime.env" "$HOME/.config/aw-contour-smoke.env" "$SMOKE_ENV_FILE"; do + if [ -n "$env_candidate" ] && [ -f "$env_candidate" ]; then + # Load local credentials and site-specific overrides without committing them. + # Later files override earlier defaults. + set -a + . "$env_candidate" + set +a + fi +done + +ANSIBLE_DIR="$REPO_ROOT/ansible" +INVENTORY="${AW_SMOKE_INVENTORY:-$ANSIBLE_DIR/inventory.ini}" +REMOTE_SCRIPT_SRC="$REPO_ROOT/scripts/aw-contour-smoke-10.10.10.2.sh" +REMOTE_SCRIPT_DST="${AW_SMOKE_REMOTE_SCRIPT:-/usr/local/sbin/aw-contour-smoke.sh}" +AW_SERVER="${AW_SMOKE_AW_SERVER:-http://10.10.10.13:5600}" +WORKTIME_API="${AW_SMOKE_WORKTIME_API:-http://10.10.10.13:5610}" +GRAFANA_URL="${AW_SMOKE_GRAFANA_URL:-http://10.10.10.11:3000}" +GRAFANA_USER="${GRAFANA_USER:-igor}" +GRAFANA_PASSWORD="${GRAFANA_PASSWORD:-}" +PROXMOX_HOST="${AW_SMOKE_PROXMOX_HOST:-10.10.10.2}" +AW_HOST="${AW_SMOKE_AW_HOST:-10.10.10.13}" +GRAFANA_HOST="${AW_SMOKE_GRAFANA_HOST:-10.10.10.11}" +WINDOWS_HOST="${AW_SMOKE_WINDOWS_HOST:-192.168.100.18}" +AW_SOURCE_HOSTNAME="${AW_SMOKE_SOURCE_HOSTNAME:-SHARKON2025}" +LOG_DIR="${AW_SMOKE_LOG_DIR:-$REPO_ROOT/output/smoke}" +RUN_REMOTE="${AW_SMOKE_RUN_REMOTE:-1}" +RUN_WINRM="${AW_SMOKE_RUN_WINRM:-1}" +RUN_SERVER_SYSTEMD="${AW_SMOKE_RUN_SERVER_SYSTEMD:-1}" + +NO_PROXY_REQUIRED="localhost,127.0.0.1,$PROXMOX_HOST,$AW_HOST,$GRAFANA_HOST,$WINDOWS_HOST,10.10.10.0/24,192.168.100.0/24" +if [ -n "${no_proxy:-}" ]; then + export no_proxy="$no_proxy,$NO_PROXY_REQUIRED" +else + export no_proxy="$NO_PROXY_REQUIRED" +fi +if [ -n "${NO_PROXY:-}" ]; then + export NO_PROXY="$NO_PROXY,$NO_PROXY_REQUIRED" +else + export NO_PROXY="$no_proxy" +fi + +OK_COUNT=0 +WARN_COUNT=0 +FAIL_COUNT=0 +SKIP_COUNT=0 + +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + CYAN='\033[0;36m' + NC='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + CYAN='' + NC='' +fi + +usage() { + cat <&2; usage >&2; exit 64 ;; + esac + shift +done + +pass() { OK_COUNT=$((OK_COUNT + 1)); printf "%b[OK]%b %s\n" "$GREEN" "$NC" "$*"; } +warn() { WARN_COUNT=$((WARN_COUNT + 1)); printf "%b[WARN]%b %s\n" "$YELLOW" "$NC" "$*"; } +fail() { FAIL_COUNT=$((FAIL_COUNT + 1)); printf "%b[FAIL]%b %s\n" "$RED" "$NC" "$*"; } +skip() { SKIP_COUNT=$((SKIP_COUNT + 1)); printf "%b[SKIP]%b %s\n" "$YELLOW" "$NC" "$*"; } + +section() { + printf "\n%b== %s ==%b\n" "$CYAN" "$*" "$NC" +} + +have() { + command -v "$1" >/dev/null 2>&1 +} + +check_local_command() { + local cmd="$1" + if have "$cmd"; then + pass "command available: $cmd" + else + fail "command missing: $cmd" + fi +} + +check_tcp() { + local name="$1" + local host="$2" + local port="$3" + if timeout 4 bash -c ":/dev/null 2>&1; then + pass "$name TCP ${host}:${port}" + else + fail "$name TCP ${host}:${port}" + fi +} + +check_ssh_banner() { + local name="$1" + local host="$2" + local port="${3:-22}" + local banner + banner="$( + timeout 5 bash -c "exec 3<>/dev/tcp/${host}/${port}; IFS= read -r line <&3; printf '%s' \"\$line\"" 2>/dev/null || true + )" + if printf "%s" "$banner" | grep -Eq '^SSH-[0-9]+\.[0-9]+'; then + pass "$name SSH banner ${host}:${port} ${banner}" + else + fail "$name SSH banner ${host}:${port} unavailable" + fi +} + +check_http_code() { + local name="$1" + local url="$2" + local expected="${3:-^2[0-9][0-9]$}" + local tmp code + tmp="$(mktemp)" + code="$(curl -k -sS --connect-timeout 5 --max-time 20 -o "$tmp" -w '%{http_code}' "$url" 2>"$tmp.err")" + if printf "%s" "$code" | grep -Eq "$expected"; then + pass "$name HTTP $code $url" + else + fail "$name HTTP $code $url" + sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -40 + fi + rm -f "$tmp" "$tmp.err" +} + +check_http_json_key() { + local name="$1" + local url="$2" + local jq_filter="$3" + local tmp + tmp="$(mktemp)" + if curl -k -fsS --connect-timeout 5 --max-time 20 "$url" -o "$tmp" 2>"$tmp.err" && jq -e "$jq_filter" "$tmp" >/dev/null 2>&1; then + pass "$name JSON $jq_filter" + jq -r "$jq_filter" "$tmp" 2>/dev/null | sed 's/^/ /' | head -5 + else + fail "$name JSON $jq_filter" + sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -40 + fi + rm -f "$tmp" "$tmp.err" +} + +check_http_json_key_basic_auth() { + local name="$1" + local url="$2" + local jq_filter="$3" + local tmp + tmp="$(mktemp)" + if curl -k -fsS -u "$GRAFANA_USER:$GRAFANA_PASSWORD" --connect-timeout 5 --max-time 20 "$url" -o "$tmp" 2>"$tmp.err" && jq -e "$jq_filter" "$tmp" >/dev/null 2>&1; then + pass "$name JSON $jq_filter" + jq -r "$jq_filter" "$tmp" 2>/dev/null | sed 's/^/ /' | head -5 + else + fail "$name JSON $jq_filter" + sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -40 + fi + rm -f "$tmp" "$tmp.err" +} + +check_http_code_basic_auth() { + local name="$1" + local url="$2" + local expected="${3:-^2[0-9][0-9]$}" + local tmp code + tmp="$(mktemp)" + code="$(curl -k -sS -u "$GRAFANA_USER:$GRAFANA_PASSWORD" --connect-timeout 5 --max-time 20 -o "$tmp" -w '%{http_code}' "$url" 2>"$tmp.err")" + if printf "%s" "$code" | grep -Eq "$expected"; then + pass "$name HTTP $code $url" + else + fail "$name HTTP $code $url" + sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -40 + fi + rm -f "$tmp" "$tmp.err" +} + +check_grafana_loki_proxy() { + local name="Grafana Loki datasource proxy" + local tmp uid code body + tmp="$(mktemp)" + if ! curl -k -fsS -u "$GRAFANA_USER:$GRAFANA_PASSWORD" --connect-timeout 5 --max-time 20 "$GRAFANA_URL/api/datasources" -o "$tmp" 2>"$tmp.err"; then + fail "$name cannot list datasources" + sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -40 + rm -f "$tmp" "$tmp.err" + return + fi + uid="$(jq -r '.[] | select(.type == "loki") | .uid' "$tmp" 2>/dev/null | head -1)" + if [ -z "$uid" ]; then + fail "$name no loki datasource found" + jq -r '.[] | "\(.name) \(.type) \(.uid)"' "$tmp" 2>/dev/null | sed 's/^/ /' | head -20 + rm -f "$tmp" "$tmp.err" + return + fi + body="$(mktemp)" + code="$(curl -k -sS -u "$GRAFANA_USER:$GRAFANA_PASSWORD" --connect-timeout 5 --max-time 20 -o "$body" -w '%{http_code}' "$GRAFANA_URL/api/datasources/proxy/uid/$uid/loki/api/v1/labels" 2>"$tmp.err")" + if [ "$code" = "200" ]; then + pass "$name HTTP $code uid=$uid" + jq -r '.status // .data[0] // .' "$body" 2>/dev/null | sed 's/^/ /' | head -5 + else + fail "$name HTTP $code uid=$uid" + sed 's/^/ /' "$tmp.err" "$body" 2>/dev/null | head -40 + fi + rm -f "$tmp" "$tmp.err" "$body" +} + +check_grafana_influx_health() { + local name="Grafana InfluxDB-AW datasource health" + local tmp + tmp="$(mktemp)" + if curl -k -fsS -u "$GRAFANA_USER:$GRAFANA_PASSWORD" --connect-timeout 5 --max-time 20 "$GRAFANA_URL/api/datasources/uid/influxdb_aw/health" -o "$tmp" 2>"$tmp.err" && jq -e '.status == "OK"' "$tmp" >/dev/null 2>&1; then + pass "$name" + jq -r '.message // .status // .' "$tmp" 2>/dev/null | sed 's/^/ /' | head -5 + else + fail "$name" + sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -40 + fi + rm -f "$tmp" "$tmp.err" +} + +check_grafana_aw_main_dashboard_queries() { + local name="Grafana detmir-aw-main window/AFK panel queries" + local tmp + tmp="$(mktemp)" + if curl -k -fsS -u "$GRAFANA_USER:$GRAFANA_PASSWORD" --connect-timeout 5 --max-time 20 "$GRAFANA_URL/api/dashboards/uid/detmir-aw-main" -o "$tmp" 2>"$tmp.err" && \ + jq -e ' + def fixed: + (.targets[0].query // "") | contains("keep(columns: [\"_time\", \"_value\", \"host\"]"); + ([.dashboard.panels[] | select(.title == "Активность окон (сумма за 5 минут)" or .title == "AFK (сумма за 5 минут)") | fixed] | length == 2 and all(. == true)) + ' "$tmp" >/dev/null 2>&1; then + pass "$name" + else + fail "$name missing keep(_time,_value,host) after aggregateWindow" + jq -r '.dashboard.panels[]? | select(.title == "Активность окон (сумма за 5 минут)" or .title == "AFK (сумма за 5 минут)") | "\(.title): " + ((.targets[0].query // "") | gsub("\n"; " "))' "$tmp" 2>/dev/null | sed 's/^/ /' | head -20 + sed 's/^/ /' "$tmp.err" 2>/dev/null | head -20 + fi + rm -f "$tmp" "$tmp.err" +} + +check_http_redirect() { + local name="$1" + local url="$2" + local expected_code="${3:-^30[1278]$}" + local expected_location="${4:-}" + local tmp code location + tmp="$(mktemp)" + code="$(curl -k -sS -I --connect-timeout 5 --max-time 15 -o "$tmp" -w '%{http_code}' "$url" 2>"$tmp.err")" + location="$(awk 'BEGIN{IGNORECASE=1} /^location:/ {sub(/\r$/,""); print $0}' "$tmp" | tail -1)" + if printf "%s" "$code" | grep -Eq "$expected_code" && { [ -z "$expected_location" ] || printf "%s" "$location" | grep -Fq "$expected_location"; }; then + pass "$name HTTP $code ${location:-$url}" + else + fail "$name HTTP $code ${location:-$url}" + sed 's/^/ /' "$tmp.err" "$tmp" 2>/dev/null | head -40 + fi + rm -f "$tmp" "$tmp.err" +} + +ansible_shell() { + local group="$1" + local command="$2" + ANSIBLE_NOCOLOR=1 ansible "$group" -i "$INVENTORY" -m shell -a "$command" +} + +ansible_win_shell() { + local group="$1" + local command="$2" + ANSIBLE_NOCOLOR=1 ansible "$group" -i "$INVENTORY" -m win_shell -a "$command" +} + +check_ansible_shell() { + local name="$1" + local group="$2" + local command="$3" + local tmp + tmp="$(mktemp)" + if ansible_shell "$group" "$command" >"$tmp" 2>&1; then + pass "$name" + sed 's/^/ /' "$tmp" | head -80 + else + fail "$name" + sed 's/^/ /' "$tmp" | head -120 + fi + rm -f "$tmp" +} + +check_ansible_win_shell() { + local name="$1" + local group="$2" + local command="$3" + local tmp + tmp="$(mktemp)" + if ansible_win_shell "$group" "$command" >"$tmp" 2>&1; then + pass "$name" + sed 's/^/ /' "$tmp" | head -80 + else + fail "$name" + sed 's/^/ /' "$tmp" | head -120 + fi + rm -f "$tmp" +} + +check_ansible_win_shell_warn() { + local name="$1" + local group="$2" + local command="$3" + local tmp + tmp="$(mktemp)" + if ansible_win_shell "$group" "$command" >"$tmp" 2>&1; then + pass "$name" + sed 's/^/ /' "$tmp" | head -80 + else + warn "$name returned non-zero" + sed 's/^/ /' "$tmp" | head -120 + fi + rm -f "$tmp" +} + +check_ansible_module() { + local name="$1" + local group="$2" + local module="$3" + local args="${4:-}" + local tmp + tmp="$(mktemp)" + if ANSIBLE_NOCOLOR=1 ansible "$group" -i "$INVENTORY" -m "$module" ${args:+-a "$args"} >"$tmp" 2>&1; then + pass "$name" + sed 's/^/ /' "$tmp" | head -80 + else + fail "$name" + sed 's/^/ /' "$tmp" | head -120 + fi + rm -f "$tmp" +} + +classify_bucket_age() { + local bucket="$1" + local age_sec="$2" + case "$bucket" in + aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events) + if [ "$age_sec" -lt 86400 ]; then + printf "fresh" + else + printf "event-driven" + fi + ;; + *) + if [ "$age_sec" -lt 3600 ]; then + printf "fresh" + elif [ "$age_sec" -lt 86400 ]; then + printf "stale" + else + printf "dead" + fi + ;; + esac +} + +check_bucket_freshness() { + local bucket="$1" + local bucket_id="${bucket}_${AW_SOURCE_HOSTNAME}" + local tmp last_ts last_id event_epoch now age_sec status + tmp="$(mktemp)" + if ! curl -fsS --connect-timeout 5 --max-time 20 "$AW_SERVER/api/0/buckets/$bucket_id/events?limit=1" -o "$tmp" 2>"$tmp.err"; then + fail "bucket $bucket_id query failed" + sed 's/^/ /' "$tmp.err" | head -20 + rm -f "$tmp" "$tmp.err" + return + fi + + last_ts="$(jq -r '.[0].timestamp // empty' "$tmp" 2>/dev/null)" + last_id="$(jq -r '.[0].id // 0' "$tmp" 2>/dev/null)" + rm -f "$tmp" "$tmp.err" + + if [ -z "$last_ts" ]; then + case "$bucket" in + aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events) + warn "bucket $bucket_id empty/event-driven" + ;; + *) + fail "bucket $bucket_id empty" + ;; + esac + return + fi + + event_epoch="$(date -d "$last_ts" +%s 2>/dev/null || printf "0")" + now="$(date -u +%s)" + if [ "$event_epoch" -le 0 ]; then + warn "bucket $bucket_id has unparsable timestamp: $last_ts" + return + fi + age_sec=$((now - event_epoch)) + status="$(classify_bucket_age "$bucket" "$age_sec")" + case "$status" in + fresh|event-driven) + pass "bucket $bucket_id $status age=${age_sec}s id=$last_id" + ;; + stale) + warn "bucket $bucket_id stale age=${age_sec}s id=$last_id" + ;; + *) + fail "bucket $bucket_id dead age=${age_sec}s id=$last_id" + ;; + esac +} + +run_remote_proxmox_script() { + if [ "$RUN_REMOTE" != "1" ]; then + skip "remote 10.10.10.2 smoke skipped" + return + fi + if ! have ansible; then + fail "ansible unavailable; cannot deploy/run remote script" + return + fi + if [ ! -f "$REMOTE_SCRIPT_SRC" ]; then + fail "remote script source missing: $REMOTE_SCRIPT_SRC" + return + fi + + section "Deploy Remote Script To 10.10.10.2" + if ANSIBLE_NOCOLOR=1 ansible proxmox -i "$INVENTORY" -m copy -a "src=$REMOTE_SCRIPT_SRC dest=$REMOTE_SCRIPT_DST owner=root group=root mode=0755" >/tmp/aw-smoke-copy.$$ 2>&1; then + pass "remote script deployed to $REMOTE_SCRIPT_DST" + else + fail "remote script deploy failed" + sed 's/^/ /' /tmp/aw-smoke-copy.$$ | head -120 + rm -f /tmp/aw-smoke-copy.$$ + return + fi + rm -f /tmp/aw-smoke-copy.$$ + + section "Remote 10.10.10.2 Smoke" + local tmp + tmp="$(mktemp)" + if ANSIBLE_NOCOLOR=1 ansible proxmox -i "$INVENTORY" -m shell -a "$REMOTE_SCRIPT_DST" >"$tmp" 2>&1; then + pass "remote 10.10.10.2 smoke completed" + sed 's/^/ /' "$tmp" + else + fail "remote 10.10.10.2 smoke failed" + sed 's/^/ /' "$tmp" + fi + rm -f "$tmp" +} + +mkdir -p "$LOG_DIR" +LOG_FILE="$LOG_DIR/aw-contour-smoke-$(date +%Y%m%d-%H%M%S).log" +exec > >(tee "$LOG_FILE") 2>&1 + +section "Run Context" +printf "repo=%s\ninventory=%s\nlog=%s\n" "$REPO_ROOT" "$INVENTORY" "$LOG_FILE" | sed 's/^/ /' +date -Is | sed 's/^/ /' + +section "Local Prerequisites" +for cmd in bash curl jq timeout ansible ssh; do + check_local_command "$cmd" +done +printf " no_proxy=%s\n" "$no_proxy" + +section "Laptop Network" +ip -br addr 2>/dev/null | sed 's/^/ /' || true +ip route get "$PROXMOX_HOST" 2>/dev/null | sed 's/^/ /' || warn "no route detail for $PROXMOX_HOST" +ip route get "$AW_HOST" 2>/dev/null | sed 's/^/ /' || warn "no route detail for $AW_HOST" + +section "TCP Surface" +check_tcp "Proxmox SSH" "$PROXMOX_HOST" 22 +check_tcp "Proxmox HTTP" "$PROXMOX_HOST" 80 +check_tcp "Proxmox HTTPS" "$PROXMOX_HOST" 443 +check_tcp "Proxmox GUI" "$PROXMOX_HOST" 8006 +check_tcp "1C company API" "$PROXMOX_HOST" 8710 +check_tcp "AW server" "$AW_HOST" 5600 +check_tcp "AW worktime API" "$AW_HOST" 5610 +check_tcp "Grafana" "$GRAFANA_HOST" 3000 +check_tcp "Windows WinRM" "$WINDOWS_HOST" 5985 +check_tcp "Windows SSH" "$WINDOWS_HOST" 22 +check_ssh_banner "Windows SSH" "$WINDOWS_HOST" 22 + +section "ActivityWatch HTTP" +check_http_json_key "AW server info" "$AW_SERVER/api/0/info" '.version' +check_http_code "AW settings CORS" "$AW_SERVER/api/0/settings/" '^200$' +check_http_code "AW WebUI" "$AW_SERVER/" '^200$' +check_http_json_key "AW buckets list" "$AW_SERVER/api/0/buckets/" 'keys | length' + +section "ActivityWatch Buckets" +for bucket in \ + aw-watcher-afk \ + aw-watcher-window \ + aw-worktime-sessions \ + aw-session-events \ + aw-dlp-endpoint-signals \ + aw-dlp-incidents \ + aw-dlp-review \ + aw-dlp-rules +do + check_bucket_freshness "$bucket" +done + +section "Worktime API" +check_http_json_key "worktime health" "$WORKTIME_API/health" '.status // .ok // .' +check_http_code "worktime today html" "$WORKTIME_API/reports/worktime/today?host=$AW_SOURCE_HOSTNAME&day=today&format=html" '^200$' +check_http_json_key "worktime today json" "$WORKTIME_API/reports/worktime/today?host=$AW_SOURCE_HOSTNAME&day=today" '.host // .report.host // .[0].host // .' +check_http_code "worktime management html" "$WORKTIME_API/reports/worktime/management?host=$AW_SOURCE_HOSTNAME&day=today&format=html" '^200$' + +section "Gateway And 1C HTTP" +check_http_code "gateway healthz" "https://$PROXMOX_HOST/healthz" '^200$' +check_http_redirect "gateway proxmox redirect" "https://$PROXMOX_HOST/go/proxmox-gui" '^30[1278]$' "https://$PROXMOX_HOST:8006/" +check_http_redirect "gateway file1c brief redirect" "https://$PROXMOX_HOST/go/file1c-brief" '^30[1278]$' "http://$PROXMOX_HOST:8710/manager/brief" +check_http_code "1C /health" "http://$PROXMOX_HOST:8710/health" '^200$' +check_http_code "1C /api/health" "http://$PROXMOX_HOST:8710/api/health" '^200$' +check_http_code "1C manager brief" "http://$PROXMOX_HOST:8710/manager/brief" '^200$' +check_http_code "1C manager actions" "http://$PROXMOX_HOST:8710/manager/actions" '^200$' +check_http_code "1C manager recovery" "http://$PROXMOX_HOST:8710/manager/recovery" '^200$' +check_http_code "1C weekly digest" "http://$PROXMOX_HOST:8710/manager/digest/weekly" '^200$' + +section "Grafana HTTP" +check_http_json_key "Grafana health" "$GRAFANA_URL/api/health" '.database // .version // .commit' +check_http_code "Grafana dashboards page" "$GRAFANA_URL/dashboards" '^200$|^302$' +check_http_code "Grafana pfSense dashboard" "$GRAFANA_URL/d/pfsense-loki-dashboard/pfsense-firewall-overview?orgId=1&from=now-1h&to=now&timezone=browser" '^200$|^302$' + +if [ -n "${GRAFANA_USER:-}" ] && [ -n "${GRAFANA_PASSWORD:-}" ]; then + section "Grafana Authenticated API" + check_http_json_key_basic_auth "Grafana datasources" "$GRAFANA_URL/api/datasources" 'length' + check_grafana_loki_proxy + check_grafana_influx_health + check_grafana_aw_main_dashboard_queries +else + skip "Grafana authenticated datasource checks need GRAFANA_USER and GRAFANA_PASSWORD env" +fi + +if [ "$RUN_SERVER_SYSTEMD" = "1" ] && have ansible; then + section "AW Server Systemd" + check_ansible_shell "AW server core units" aw_server 'systemctl is-active activitywatch-server aw-worktime-api aw-worktime-ui-bridge.timer aw-rus-healthd.timer aw-worktime-influx-exporter.timer aw-dlp-influx-exporter.timer aw-worktime-autoheal.timer' + check_ansible_shell "AW server failed units" aw_server 'failed=$(systemctl --failed --no-legend | awk "{print \$1}" | grep -E "activitywatch|aw-|influx|grafana|prometheus|loki" || true); test -z "$failed" && echo "no AW-related failed units" || { echo "$failed"; exit 1; }' + check_ansible_shell "AW server local health script" aw_server 'test -x /opt/activitywatch/health-check.sh && /opt/activitywatch/health-check.sh || test -x /usr/local/bin/health-check.sh && /usr/local/bin/health-check.sh || echo "health-check script not installed"' +else + skip "AW server systemd checks skipped" +fi + +if [ "$RUN_WINRM" = "1" ] && have ansible; then + section "Windows WinRM And Collectors" + check_ansible_module "Windows win_ping" aw_windows win_ping + check_ansible_win_shell "Windows sessions" aw_windows '$psi = [System.Diagnostics.ProcessStartInfo]::new(); $psi.FileName = "$env:SystemRoot\System32\query.exe"; $psi.Arguments = "user"; $psi.UseShellExecute = $false; $psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true; $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866); $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866); $p = [System.Diagnostics.Process]::Start($psi); $out = $p.StandardOutput.ReadToEnd(); $err = $p.StandardError.ReadToEnd(); $p.WaitForExit(); [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $out; if ($err) { $err }; if ($out -match "USERNAME|ПОЛЬЗОВАТЕЛЬ|администратор|Администратор") { exit 0 } else { exit $p.ExitCode }' + check_ansible_win_shell_warn "Windows collector processes" aw_windows '$p = Get-Process aw-watcher-afk,aw-watcher-window -ErrorAction SilentlyContinue; if ($p) { $p | Select-Object Name,Id,SessionId,StartTime | Format-Table -AutoSize } else { "no aw-watcher-afk/window process visible to this WinRM session" }' + check_ansible_win_shell "Windows ActivityWatch tasks" aw_windows 'schtasks /Query /TN "ActivityWatch Recovery" /FO LIST /V; schtasks /Query /TN "ActivityWatch Launch [SHARKON2025_Администратор]" /FO LIST /V' +else + skip "Windows WinRM checks skipped" +fi + +run_remote_proxmox_script + +section "Existing Repo Checks" +if [ -x "$REPO_ROOT/check-aw-data.sh" ]; then + if "$REPO_ROOT/check-aw-data.sh"; then pass "check-aw-data.sh completed"; else fail "check-aw-data.sh failed"; fi +else + skip "check-aw-data.sh missing" +fi +if [ -x "$REPO_ROOT/check-aw-full.sh" ]; then + if "$REPO_ROOT/check-aw-full.sh"; then pass "check-aw-full.sh completed"; else fail "check-aw-full.sh failed"; fi +else + skip "check-aw-full.sh missing" +fi + +section "Summary" +printf "OK=%s WARN=%s FAIL=%s SKIP=%s\n" "$OK_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$SKIP_COUNT" +printf "Log: %s\n" "$LOG_FILE" + +if [ "$FAIL_COUNT" -gt 0 ]; then + exit 2 +fi +exit 0