From 8849354ddac233af7dbcee52f4840b5cb704d604 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 7 May 2026 07:20:14 +0300 Subject: [PATCH] fix(worktime): codify ui-bridge in ansible and install flow --- ansible/deploy_aw_server.yml | 43 ++++++ .../provision_proxmox_ct_and_deploy_aw.yml | 3 + ...vision_proxmox_ct_matrix_and_deploy_aw.yml | 3 + aw-server/aw-worktime-ui-bridge.py | 138 ++++++++++++++++++ aw-server/aw-worktime-ui-bridge.service | 15 ++ aw-server/aw-worktime-ui-bridge.timer | 11 ++ aw-server/install_aw_server.sh | 21 +++ .../innosetup/AWatch-rus-InnoSetup.iss | 3 +- 8 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 aw-server/aw-worktime-ui-bridge.py create mode 100644 aw-server/aw-worktime-ui-bridge.service create mode 100644 aw-server/aw-worktime-ui-bridge.timer diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 9c775b7..996eb4b 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -316,6 +316,30 @@ 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 @@ -326,6 +350,25 @@ 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" diff --git a/ansible/provision_proxmox_ct_and_deploy_aw.yml b/ansible/provision_proxmox_ct_and_deploy_aw.yml index 40c6382..1ac786e 100644 --- a/ansible/provision_proxmox_ct_and_deploy_aw.yml +++ b/ansible/provision_proxmox_ct_and_deploy_aw.yml @@ -11,6 +11,9 @@ - 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 diff --git a/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml b/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml index 5452935..e0ff9bc 100644 --- a/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml +++ b/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml @@ -11,6 +11,9 @@ - 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 diff --git a/aw-server/aw-worktime-ui-bridge.py b/aw-server/aw-worktime-ui-bridge.py new file mode 100644 index 0000000..d1a4cc3 --- /dev/null +++ b/aw-server/aw-worktime-ui-bridge.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +import json +import os +import urllib.error +import urllib.request +from datetime import datetime, timezone + + +AW_URL = os.environ.get("AW_SERVER_URL", "http://127.0.0.1:5600") +HOST = os.environ.get("AW_WORKTIME_HOST", "SHARKON2025") +STATE_PATH = os.environ.get( + "AW_WORKTIME_UI_BRIDGE_STATE", + "/var/lib/activitywatch/aw-worktime-ui-bridge-state.json", +) +TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "20")) + + +SESSIONS_BUCKET = f"aw-worktime-sessions_{HOST}" +AFK_BUCKET = f"aw-watcher-afk_{HOST}" +WINDOW_BUCKET = f"aw-watcher-window_{HOST}" + + +def _req(method: str, path: str, payload=None): + data = None + headers = {} + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(AW_URL + path, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + raw = r.read() + if not raw: + return None + return json.loads(raw.decode("utf-8")) + + +def ensure_bucket(bucket_id: str, event_type: str, client: str): + payload = {"client": client, "type": event_type, "hostname": HOST} + try: + _req("POST", f"/api/0/buckets/{bucket_id}", payload) + except urllib.error.HTTPError as e: + if e.code != 304: + raise + + +def load_state(): + try: + with open(STATE_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and "last_ts" in data: + return data + except FileNotFoundError: + pass + except json.JSONDecodeError: + pass + return {"last_ts": "1970-01-01T00:00:00Z"} + + +def save_state(state): + os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) + tmp = STATE_PATH + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False) + os.replace(tmp, STATE_PATH) + + +def to_iso_utc(ts): + if ts.endswith("Z"): + return ts + return ts.replace("+00:00", "Z") + + +def build_window_title(users, active_count): + if not users: + return "RDP idle" + return f"RDP active ({active_count}): " + ", ".join(users) + + +def transform(events): + out_afk = [] + out_win = [] + last_ts = None + + for e in events: + ts = e.get("timestamp") + if not ts: + continue + duration = float(e.get("duration", 0.0)) + data = e.get("data") or {} + active_users = data.get("activeUsers") or [] + active_count = int(data.get("activeCount", len(active_users))) + is_active = active_count > 0 + + afk_data = {"status": "not-afk" if is_active else "afk", "source": "aw-worktime-ui-bridge"} + out_afk.append({"timestamp": ts, "duration": duration, "data": afk_data}) + + win_data = { + "app": "RDP", + "title": build_window_title(active_users, active_count), + "source": "aw-worktime-ui-bridge", + } + out_win.append({"timestamp": ts, "duration": duration, "data": win_data}) + last_ts = ts + + return out_afk, out_win, last_ts + + +def main(): + state = load_state() + last_ts = state.get("last_ts", "1970-01-01T00:00:00Z") + + ensure_bucket(AFK_BUCKET, "afkstatus", "aw-worktime-ui-bridge") + ensure_bucket(WINDOW_BUCKET, "currentwindow", "aw-worktime-ui-bridge") + + query = { + "query": [ + "events = query_bucket(find_bucket($bid));", + "RETURN = sort_by_timestamp(events);", + ], + "timeperiods": [[last_ts, to_iso_utc(datetime.now(timezone.utc).isoformat())]], + } + rows = _req("POST", f"/api/0/query/?bid={SESSIONS_BUCKET}", query) or [] + if not rows or not rows[0]: + return + + events = rows[0] + afk_events, win_events, new_last_ts = transform(events) + if not afk_events or not win_events or not new_last_ts: + return + + _req("POST", f"/api/0/buckets/{AFK_BUCKET}/events", afk_events) + _req("POST", f"/api/0/buckets/{WINDOW_BUCKET}/events", win_events) + save_state({"last_ts": new_last_ts}) + print(f"posted_afk={len(afk_events)} posted_win={len(win_events)} last_ts={new_last_ts}") + + +if __name__ == "__main__": + main() diff --git a/aw-server/aw-worktime-ui-bridge.service b/aw-server/aw-worktime-ui-bridge.service new file mode 100644 index 0000000..0a48ba5 --- /dev/null +++ b/aw-server/aw-worktime-ui-bridge.service @@ -0,0 +1,15 @@ +[Unit] +Description=AW Worktime UI bridge (sessions -> afk/window) +After=network-online.target activitywatch-server.service +Wants=network-online.target + +[Service] +Type=oneshot +Environment=AW_SERVER_URL=http://127.0.0.1:5600 +Environment=AW_WORKTIME_HOST=SHARKON2025 +ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-ui-bridge.py +User=root +Group=root + +[Install] +WantedBy=multi-user.target diff --git a/aw-server/aw-worktime-ui-bridge.timer b/aw-server/aw-worktime-ui-bridge.timer new file mode 100644 index 0000000..d56cb22 --- /dev/null +++ b/aw-server/aw-worktime-ui-bridge.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run AW Worktime UI bridge every 30 seconds + +[Timer] +OnBootSec=20s +OnUnitActiveSec=30s +Unit=aw-worktime-ui-bridge.service +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/aw-server/install_aw_server.sh b/aw-server/install_aw_server.sh index 50ba3e7..4ac7a30 100755 --- a/aw-server/install_aw_server.sh +++ b/aw-server/install_aw_server.sh @@ -26,6 +26,9 @@ 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 @@ -103,6 +106,24 @@ 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 diff --git a/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss b/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss index 31ac5cf..0ed495e 100644 --- a/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss +++ b/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss @@ -149,7 +149,8 @@ begin 'Укажите сервер ActivityWatch (куда агенты будут отправлять данные).', 'Если нужно, измените host/port. По умолчанию — наша конфигурация.' ); - { Worktime CSV/JSON reports are served by aw-worktime-api on :5610 (AwDefaultWorktimeReportBase). } + { Worktime CSV/JSON reports are served by aw-worktime-api on :5610 (AwDefaultWorktimeReportBase). + Standard AW "Сегодня" is backed by server-side aw-worktime-ui-bridge timer on AW host. } ServerHostPage.Add('ServerHost', False); ServerHostPage.Add('ServerPort', False); ServerHostPage.Values[0] := '{#AwDefaultServerHost}';