From 873e43fb2e39bec74c7d57ec7a0b818bd4705804 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Fri, 15 May 2026 01:23:23 +0300 Subject: [PATCH] feat(worktime): publish RDP activity to Grafana --- ansible/deploy_aw_server.yml | 48 +++ ansible/group_vars/all.example.yml | 7 + ansible/group_vars/all.yml | 6 + aw-server/aw-worktime-api.py | 98 ++++- aw-server/aw-worktime-influx-exporter.py | 158 ++++++++ aw-server/aw-worktime-influx-exporter.service | 15 + aw-server/aw-worktime-influx-exporter.timer | 11 + aw-server/test_aw_worktime_api.py | 16 + aw-server/test_aw_worktime_influx_exporter.py | 35 ++ .../detmir-rdp-user-activity-dashboard.json | 350 ++++++++++++++++++ 10 files changed, 726 insertions(+), 18 deletions(-) create mode 100644 aw-server/aw-worktime-influx-exporter.py create mode 100644 aw-server/aw-worktime-influx-exporter.service create mode 100644 aw-server/aw-worktime-influx-exporter.timer create mode 100644 aw-server/test_aw_worktime_influx_exporter.py create mode 100644 grafana/detmir-rdp-user-activity-dashboard.json diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 7f9341d..0d10324 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -313,6 +313,13 @@ AW_SERVER_URL=http://127.0.0.1:5600 XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config + AW_WORKTIME_INFLUX_ENABLED={{ 'true' if (aw_worktime_influx_enabled | default(false) | bool) else 'false' }} + AW_WORKTIME_INFLUX_URL={{ aw_worktime_influx_url | default('') }} + AW_WORKTIME_INFLUX_ORG={{ aw_worktime_influx_org | default('proxmox') }} + AW_WORKTIME_INFLUX_BUCKET={{ aw_worktime_influx_bucket | default('aw_metrics') }} + AW_WORKTIME_INFLUX_HOSTS={{ aw_worktime_influx_hosts | default('SHARKON2025') }} + AW_WORKTIME_INFLUX_DAYS={{ aw_worktime_influx_days | default('today,yesterday') }} + AW_WORKTIME_INFLUX_TOKEN={{ aw_worktime_influx_token | default('') }} - name: Создать каталог DLP policy engine ansible.builtin.file: @@ -652,6 +659,15 @@ group: root mode: "0755" + - name: Установить скрипт AW worktime Influx exporter + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.py" + dest: /usr/local/bin/aw-worktime-influx-exporter.py + owner: root + group: root + mode: "0755" + when: aw_worktime_influx_enabled | default(false) | bool + - name: Установить скрипт aw-health-check ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/health-check.sh" @@ -700,6 +716,24 @@ group: root mode: "0644" + - name: Установить systemd unit AW worktime Influx exporter + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.service" + dest: /etc/systemd/system/aw-worktime-influx-exporter.service + owner: root + group: root + mode: "0644" + when: aw_worktime_influx_enabled | default(false) | bool + + - name: Установить systemd timer AW worktime Influx exporter + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.timer" + dest: /etc/systemd/system/aw-worktime-influx-exporter.timer + owner: root + group: root + mode: "0644" + when: aw_worktime_influx_enabled | default(false) | bool + - name: Перезагрузить systemd после установки AW worktime API ansible.builtin.systemd: daemon_reload: true @@ -790,6 +824,20 @@ state: started failed_when: false + - name: Включить и перезапустить AW worktime Influx exporter timer + ansible.builtin.systemd: + name: aw-worktime-influx-exporter.timer + enabled: true + state: restarted + when: aw_worktime_influx_enabled | default(false) | bool + + - name: Выполнить разовый прогон AW worktime Influx exporter + ansible.builtin.systemd: + name: aw-worktime-influx-exporter.service + state: started + failed_when: false + when: aw_worktime_influx_enabled | default(false) | bool + - name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper) ansible.builtin.command: cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh" diff --git a/ansible/group_vars/all.example.yml b/ansible/group_vars/all.example.yml index c114265..d5c0773 100644 --- a/ansible/group_vars/all.example.yml +++ b/ansible/group_vars/all.example.yml @@ -10,6 +10,13 @@ aw_server_user: "activitywatch" aw_server_group: "activitywatch" aw_worktime_report_base: "http://10.10.10.13:5610" aw_worktime_timezone: "Europe/Moscow" +aw_worktime_influx_enabled: false +aw_worktime_influx_url: "http://10.10.10.10:8086" +aw_worktime_influx_org: "proxmox" +aw_worktime_influx_bucket: "aw_metrics" +aw_worktime_influx_hosts: "SHARKON2025" +aw_worktime_influx_days: "today,yesterday" +aw_worktime_influx_token: "" aw_repo_root: "{{ playbook_dir | dirname }}" diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index 7ac7650..6b294cc 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -10,6 +10,12 @@ aw_server_user: "activitywatch" aw_server_group: "activitywatch" aw_worktime_report_base: "http://10.10.10.13:5610" aw_worktime_timezone: "Europe/Moscow" +aw_worktime_influx_enabled: false +aw_worktime_influx_url: "http://10.10.10.10:8086" +aw_worktime_influx_org: "proxmox" +aw_worktime_influx_bucket: "aw_metrics" +aw_worktime_influx_hosts: "SHARKON2025" +aw_worktime_influx_days: "today,yesterday" aw_repo_root: "/mnt/usb_hdd2/Projects/ActivityWatch-Russian" diff --git a/aw-server/aw-worktime-api.py b/aw-server/aw-worktime-api.py index a753b92..a1ad20b 100644 --- a/aw-server/aw-worktime-api.py +++ b/aw-server/aw-worktime-api.py @@ -99,6 +99,21 @@ def resolve_report_date(day=None, date_text=None): return now_local.date() +def get_report_bounds(report_date): + start_local = datetime(report_date.year, report_date.month, report_date.day, tzinfo=REPORT_TZ) + end_local = start_local + timedelta(days=1) - timedelta(seconds=1) + start = start_local.astimezone(timezone.utc) + end = end_local.astimezone(timezone.utc) + end_exclusive = end + timedelta(seconds=1) + return { + "start_local": start_local, + "end_local": end_local, + "start": start, + "end": end, + "end_exclusive": end_exclusive, + } + + def _is_machine_user(user: str): u = (user or "").strip().lower() return u.endswith("$") or u in {"system", "localservice", "networkservice"} @@ -169,7 +184,8 @@ def _merge_intervals(intervals): return merged -def aggregate_rows(events, start, end, host): +def _collect_user_rows(events, start, end, host): + end_exclusive = end + timedelta(seconds=1) by_user = {} by_identity = {} @@ -212,10 +228,14 @@ def aggregate_rows(events, start, end, host): if active: row["active_samples"] += 1 interval_start = sample["_ts"] - interval_end = min(sample["_ts"] + timedelta(seconds=sample_seconds), end + timedelta(seconds=1)) + interval_end = min(sample["_ts"] + timedelta(seconds=sample_seconds), end_exclusive) if interval_end > interval_start: row["intervals"].append((interval_start, interval_end)) + return by_user + +def aggregate_rows(events, start, end, host): + by_user = _collect_user_rows(events, start, end, host) rows = [] full_range = int((end - start).total_seconds()) + 1 for username in sorted(by_user): @@ -242,6 +262,62 @@ def aggregate_rows(events, start, end, host): return rows +def aggregate_hourly_rows(events, start, end, host): + by_user = _collect_user_rows(events, start, end, host) + rows = [] + for username in sorted(by_user): + row = by_user[username] + merged = _merge_intervals(row["intervals"]) + per_bucket = {} + for interval_start, interval_end in merged: + cursor = interval_start + while cursor < interval_end: + bucket_local = cursor.astimezone(REPORT_TZ).replace(minute=0, second=0, microsecond=0) + bucket_start = bucket_local.astimezone(timezone.utc) + bucket_end = (bucket_local + timedelta(hours=1)).astimezone(timezone.utc) + overlap_start = max(interval_start, bucket_start) + overlap_end = min(interval_end, bucket_end) + if overlap_end > overlap_start: + key = bucket_start + per_bucket[key] = per_bucket.get(key, 0) + int((overlap_end - overlap_start).total_seconds()) + cursor = bucket_end + + for bucket_start in sorted(per_bucket): + active_seconds = per_bucket[bucket_start] + if active_seconds <= 0: + continue + bucket_local = bucket_start.astimezone(REPORT_TZ) + rows.append( + { + "user": row["user"], + "user_id": row["user_id"], + "bucket_start_utc": to_iso_utc(bucket_start), + "bucket_start_local": bucket_local.isoformat(), + "report_date": bucket_local.date().isoformat(), + "hour_local": bucket_local.strftime("%H:00"), + "active_seconds": active_seconds, + "active_hhmm": hhmm(active_seconds), + } + ) + return rows + + +def fetch_events_for_date(host, report_date): + bounds = get_report_bounds(report_date) + bucket_id = get_sessions_bucket_id(host) + try: + get(f"{AW}/buckets/{bucket_id}") + except Exception: + log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}") + return bounds, [] + try: + events = get(f"{AW}/buckets/{bucket_id}/events?limit=50000") + except Exception: + log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}") + return bounds, [] + return bounds, events + + def build_report_summary(rows): if not rows: return { @@ -270,22 +346,8 @@ def build_report_summary(rows): def report_for_date(host, report_date): - start_local = datetime(report_date.year, report_date.month, report_date.day, tzinfo=REPORT_TZ) - end_local = start_local + timedelta(days=1) - timedelta(seconds=1) - start = start_local.astimezone(timezone.utc) - end = end_local.astimezone(timezone.utc) - bucket_id = get_sessions_bucket_id(host) - try: - get(f"{AW}/buckets/{bucket_id}") - except Exception: - log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}") - return [] - try: - events = get(f"{AW}/buckets/{bucket_id}/events?limit=50000") - except Exception: - log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}") - return [] - return aggregate_rows(events, start, end, host) + bounds, events = fetch_events_for_date(host, report_date) + return aggregate_rows(events, bounds["start"], bounds["end"], host) def report_today(host): diff --git a/aw-server/aw-worktime-influx-exporter.py b/aw-server/aw-worktime-influx-exporter.py new file mode 100644 index 0000000..fe3ca15 --- /dev/null +++ b/aw-server/aw-worktime-influx-exporter.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +import importlib.util +import os +import sys +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("aw-worktime-api.py") +SPEC = importlib.util.spec_from_file_location("aw_worktime_api", MODULE_PATH) +WORKTIME = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(WORKTIME) + +INFLUX_URL = os.environ.get("AW_WORKTIME_INFLUX_URL", "").strip().rstrip("/") +INFLUX_ORG = os.environ.get("AW_WORKTIME_INFLUX_ORG", "proxmox").strip() or "proxmox" +INFLUX_BUCKET = os.environ.get("AW_WORKTIME_INFLUX_BUCKET", "aw_metrics").strip() or "aw_metrics" +INFLUX_TOKEN = os.environ.get("AW_WORKTIME_INFLUX_TOKEN", "").strip() +INFLUX_ENABLED = os.environ.get("AW_WORKTIME_INFLUX_ENABLED", "").strip().lower() in {"1", "true", "yes", "on"} +HOSTS = [item.strip() for item in os.environ.get("AW_WORKTIME_INFLUX_HOSTS", WORKTIME.DEFAULT_HOST).split(",") if item.strip()] +DAYS = [item.strip() for item in os.environ.get("AW_WORKTIME_INFLUX_DAYS", "today,yesterday").split(",") if item.strip()] + + +def _escape_tag(value): + return ( + str(value or "") + .replace("\\", "\\\\") + .replace(" ", "\\ ") + .replace(",", "\\,") + .replace("=", "\\=") + ) + + +def _line(measurement, tags, fields, timestamp_ns): + tag_part = ",".join(f"{key}={_escape_tag(value)}" for key, value in sorted(tags.items()) if value is not None and value != "") + field_parts = [] + for key, value in fields.items(): + if isinstance(value, bool): + field_parts.append(f"{key}={'true' if value else 'false'}") + elif isinstance(value, int): + field_parts.append(f"{key}={value}i") + elif isinstance(value, float): + field_parts.append(f"{key}={value}") + else: + text = str(value or "").replace("\\", "\\\\").replace('"', '\\"') + field_parts.append(f'{key}="{text}"') + if not field_parts: + return "" + if tag_part: + return f"{measurement},{tag_part} {','.join(field_parts)} {timestamp_ns}" + return f"{measurement} {','.join(field_parts)} {timestamp_ns}" + + +def _timestamp_ns(dt): + return int(dt.astimezone(timezone.utc).timestamp() * 1_000_000_000) + + +def build_lines_for_day(host, report_date): + bounds, events = WORKTIME.fetch_events_for_date(host, report_date) + rows = WORKTIME.aggregate_rows(events, bounds["start"], bounds["end"], host) + hourly_rows = WORKTIME.aggregate_hourly_rows(events, bounds["start"], bounds["end"], host) + summary = WORKTIME.build_report_summary(rows) + + lines = [] + daily_ts = _timestamp_ns(bounds["start"]) + + for row in rows: + lines.append( + _line( + "aw_rdp_worktime_daily", + { + "host": host, + "user": row["user"], + "user_id": row["user_id"], + "report_date": report_date.isoformat(), + }, + { + "active_seconds": int(row["active_seconds"]), + "idle_seconds": int(row["idle_seconds"]), + "sessions_count": int(row["sessions_count"]), + "samples_count": int(row["samples_count"]), + "active_samples": int(row["active_samples"]), + }, + daily_ts, + ) + ) + + for row in hourly_rows: + lines.append( + _line( + "aw_rdp_worktime_hourly", + { + "host": host, + "user": row["user"], + "user_id": row["user_id"], + "report_date": row["report_date"], + "hour_local": row["hour_local"], + }, + { + "active_seconds": int(row["active_seconds"]), + }, + _timestamp_ns(WORKTIME.pts(row["bucket_start_utc"])), + ) + ) + + lines.append( + _line( + "aw_rdp_worktime_summary_daily", + { + "host": host, + "report_date": report_date.isoformat(), + }, + { + "users_count": int(summary["users_count"]), + "total_active_seconds": int(summary["total_active_seconds"]), + "top_user": summary["top_user"], + }, + daily_ts, + ) + ) + return [line for line in lines if line] + + +def write_lines(lines): + if not lines: + return 0 + if not INFLUX_URL or not INFLUX_TOKEN: + raise RuntimeError("InfluxDB destination is not configured") + payload = ("\n".join(lines) + "\n").encode("utf-8") + req = urllib.request.Request( + f"{INFLUX_URL}/api/v2/write?org={INFLUX_ORG}&bucket={INFLUX_BUCKET}&precision=ns", + data=payload, + method="POST", + headers={"Authorization": f"Token {INFLUX_TOKEN}", "Content-Type": "text/plain; charset=utf-8"}, + ) + with urllib.request.urlopen(req, timeout=30) as response: + if response.status not in {204, 200}: + raise RuntimeError(f"InfluxDB write failed with status={response.status}") + return len(lines) + + +def main(): + if not INFLUX_ENABLED: + print("[aw-worktime-influx-exporter] disabled by AW_WORKTIME_INFLUX_ENABLED", file=sys.stderr) + return 0 + + lines = [] + for host in HOSTS: + for day in DAYS: + report_date = WORKTIME.resolve_report_date(day=day) + lines.extend(build_lines_for_day(host, report_date)) + written = write_lines(lines) + print(f"[aw-worktime-influx-exporter] wrote {written} points to {INFLUX_BUCKET}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/aw-server/aw-worktime-influx-exporter.service b/aw-server/aw-worktime-influx-exporter.service new file mode 100644 index 0000000..4cdf451 --- /dev/null +++ b/aw-server/aw-worktime-influx-exporter.service @@ -0,0 +1,15 @@ +[Unit] +Description=AW Worktime InfluxDB exporter +After=network-online.target aw-worktime-api.service +Wants=network-online.target aw-worktime-api.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/activitywatch/aw-server.env +ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-influx-exporter.py +User=activitywatch +Group=activitywatch +StandardOutput=journal +StandardError=journal +SyslogIdentifier=aw-worktime-influx-exporter + diff --git a/aw-server/aw-worktime-influx-exporter.timer b/aw-server/aw-worktime-influx-exporter.timer new file mode 100644 index 0000000..a2091e6 --- /dev/null +++ b/aw-server/aw-worktime-influx-exporter.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run AW Worktime InfluxDB exporter every 10 minutes + +[Timer] +OnBootSec=3min +OnUnitActiveSec=10min +AccuracySec=1min +Unit=aw-worktime-influx-exporter.service + +[Install] +WantedBy=timers.target diff --git a/aw-server/test_aw_worktime_api.py b/aw-server/test_aw_worktime_api.py index 3f73855..001496b 100644 --- a/aw-server/test_aw_worktime_api.py +++ b/aw-server/test_aw_worktime_api.py @@ -71,3 +71,19 @@ def test_build_aw_api_base_accepts_root_and_api_urls(): assert MODULE.build_aw_api_base("http://127.0.0.1:5600") == "http://127.0.0.1:5600/api/0" assert MODULE.build_aw_api_base("http://127.0.0.1:5600/") == "http://127.0.0.1:5600/api/0" assert MODULE.build_aw_api_base("http://127.0.0.1:5600/api/0") == "http://127.0.0.1:5600/api/0" + + +def test_aggregate_hourly_rows_splits_interval_by_local_hour(): + start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc) + end = datetime(2026, 5, 14, 8, 59, 59, tzinfo=timezone.utc) + rows = MODULE.aggregate_hourly_rows( + [ + _event("2026-05-14T06:50:00Z", "user5", 4, True, sampleSeconds=1800), + _event("2026-05-14T07:20:00Z", "user5", 4, True, sampleSeconds=1800), + ], + start, + end, + "SHARKON2025", + ) + assert [row["hour_local"] for row in rows] == ["09:00", "10:00"] + assert [row["active_seconds"] for row in rows] == [600, 3000] diff --git a/aw-server/test_aw_worktime_influx_exporter.py b/aw-server/test_aw_worktime_influx_exporter.py new file mode 100644 index 0000000..1c2d5f1 --- /dev/null +++ b/aw-server/test_aw_worktime_influx_exporter.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +import importlib.util +from datetime import date +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("aw-worktime-influx-exporter.py") +SPEC = importlib.util.spec_from_file_location("aw_worktime_influx_exporter", MODULE_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def test_build_lines_for_day_emits_daily_hourly_and_summary(monkeypatch): + bounds = MODULE.WORKTIME.get_report_bounds(date(2026, 5, 14)) + events = [ + { + "timestamp": "2026-05-14T06:00:00Z", + "duration": 0.0, + "data": { + "username": "user5", + "userId": "WORKGROUP\\user5", + "sessionId": 4, + "state": "Активно", + "active": True, + "sampleSeconds": 1800, + }, + } + ] + + monkeypatch.setattr(MODULE.WORKTIME, "fetch_events_for_date", lambda host, report_date: (bounds, events)) + lines = MODULE.build_lines_for_day("SHARKON2025", date(2026, 5, 14)) + + assert any(line.startswith("aw_rdp_worktime_daily,") for line in lines) + assert any(line.startswith("aw_rdp_worktime_hourly,") for line in lines) + assert any(line.startswith("aw_rdp_worktime_summary_daily,") for line in lines) diff --git a/grafana/detmir-rdp-user-activity-dashboard.json b/grafana/detmir-rdp-user-activity-dashboard.json new file mode 100644 index 0000000..26959b6 --- /dev/null +++ b/grafana/detmir-rdp-user-activity-dashboard.json @@ -0,0 +1,350 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "influxdb", + "uid": "influxdb_aw" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -3d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_summary_daily\" and r._field == \"users_count\" and r.host == \"${host}\")\n |> group()\n |> last()", + "refId": "A" + } + ], + "title": "Пользователи в последнем отчёте", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "influxdb_aw" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -3d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_summary_daily\" and r._field == \"total_active_seconds\" and r.host == \"${host}\")\n |> group()\n |> last()", + "refId": "A" + } + ], + "title": "Активное время в последнем отчёте", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "influxdb_aw" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "h" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "displayMode": "basic", + "namePlacement": "left", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto" + }, + "targets": [ + { + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -3d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_daily\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> group(columns: [\"host\", \"user\"])\n |> last()\n |> map(fn: (r) => ({ r with _value: float(v: r._value) / 3600.0 }))", + "refId": "A" + } + ], + "title": "Пользователи: последнее активное время, часы", + "type": "bargauge" + }, + { + "datasource": { + "type": "influxdb", + "uid": "influxdb_aw" + }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "unit": "h" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 4 + }, + "id": 4, + "options": { + "colorMode": "value", + "displayMode": "basic", + "namePlacement": "left", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto" + }, + "targets": [ + { + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_daily\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> group(columns: [\"host\", \"user\"])\n |> sort(columns: [\"_time\"])\n |> tail(n: 2)\n |> first()\n |> map(fn: (r) => ({ r with _value: float(v: r._value) / 3600.0 }))", + "refId": "A" + } + ], + "title": "Пользователи: предыдущий отчёт, часы", + "type": "bargauge" + }, + { + "datasource": { + "type": "influxdb", + "uid": "influxdb_aw" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "h" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 4 + }, + "id": 5, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -48h)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_hourly\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> map(fn: (r) => ({ r with _value: float(v: r._value) / 3600.0 }))\n |> group(columns: [\"user\"])", + "refId": "A" + } + ], + "title": "Почасовая активность пользователей, последние 48 часов", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "influxdb_aw" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 12 + }, + "id": 6, + "options": { + "cellHeight": "sm", + "footer": { + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"aw_rdp_worktime_daily\" and r._field == \"active_seconds\" and r.host == \"${host}\")\n |> map(fn: (r) => ({ r with _value: float(v: r._value) / 3600.0 }))\n |> keep(columns: [\"_time\", \"report_date\", \"user\", \"user_id\", \"_value\"])\n |> sort(columns: [\"_time\", \"user\"], desc: true)", + "refId": "A" + } + ], + "title": "Ежедневная сводка по пользователям, последние 7 дней", + "type": "table" + } + ], + "refresh": "10m", + "schemaVersion": 41, + "tags": [ + "detmir", + "activitywatch", + "rdp", + "worktime" + ], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "SHARKON2025", + "value": "SHARKON2025" + }, + "datasource": { + "type": "influxdb", + "uid": "influxdb_aw" + }, + "definition": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"aw_metrics\", tag: \"host\", predicate: (r) => r._measurement == \"aw_rdp_worktime_daily\", start: -30d)", + "hide": 0, + "includeAll": false, + "label": "Хост", + "multi": false, + "name": "host", + "options": [], + "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"aw_metrics\", tag: \"host\", predicate: (r) => r._measurement == \"aw_rdp_worktime_daily\", start: -30d)", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-48h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "DetMir RDP User Activity", + "uid": "detmir-rdp-user-activity", + "version": 1, + "weekStart": "" +}