diff --git a/README.md b/README.md index efaca4b..556cf03 100755 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ## Что входит - `docs/preparation.md` — подготовка инфраструктуры и входных параметров. +- `docs/codebase-onboarding.md` — обзор структуры репозитория и маршрут изучения для новичка. - `docs/deployment.md` — пошаговый деплой LXC и ActivityWatch Server. - `docs/runbook.md` — быстрый runbook для оператора. - `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback. diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 42b88fc..996eb4b 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -254,6 +254,7 @@ 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 @@ -270,6 +271,7 @@ 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 скрипт @@ -293,9 +295,80 @@ 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" diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index 92a352b..2f599cc 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -201,7 +201,12 @@ - aw_windows_afk_enabled | bool - aw_windows_hostname_result.stdout is defined ansible.builtin.set_fact: - aw_windows_api_smoke_check_bucket_effective: "aw-watcher-afk_{{ aw_windows_hostname_result.stdout | trim }}" + aw_windows_api_smoke_check_bucket_effective: >- + {{ + aw_windows_api_smoke_check_bucket + if (aw_windows_api_smoke_check_bucket | default('') | string | length) > 0 + else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim) + }} - name: Выполнить AW API smoke-check (проверка наличия свежих событий в AFK бакете) when: diff --git a/ansible/group_vars/all.example.yml b/ansible/group_vars/all.example.yml index 2091344..171b218 100644 --- a/ansible/group_vars/all.example.yml +++ b/ansible/group_vars/all.example.yml @@ -8,6 +8,8 @@ aw_server_db_path: "/var/lib/activitywatch/.local/share/activitywatch/aw-server- aw_server_log_dir: "/var/log/activitywatch" aw_server_user: "activitywatch" aw_server_group: "activitywatch" +aw_worktime_report_base: "http://10.10.10.13:5610" +aw_worktime_timezone: "Europe/Moscow" aw_repo_root: "{{ playbook_dir | dirname }}" diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index 3298ded..fdefb38 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -8,6 +8,8 @@ aw_server_log_dir: "/var/log/activitywatch" aw_server_db_path: "/var/lib/activitywatch/aw-server-rust/sqlite.db" aw_server_user: "activitywatch" aw_server_group: "activitywatch" +aw_worktime_report_base: "http://10.10.10.13:5610" +aw_worktime_timezone: "Europe/Moscow" aw_repo_root: "/mnt/usb_hdd2/Projects/ActivityWatch-Russian" diff --git a/ansible/group_vars/aw_windows.yml b/ansible/group_vars/aw_windows.yml index 4ab2f5f..f335973 100644 --- a/ansible/group_vars/aw_windows.yml +++ b/ansible/group_vars/aw_windows.yml @@ -42,7 +42,7 @@ aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ans aw_windows_validation_local_dir: "/tmp/aw-rus-validation" aw_windows_fail_on_validation_error: true -aw_windows_migration_enabled: true +aw_windows_migration_enabled: false aw_windows_legacy_install_root: "C:\\Program Files\\ActivityWatch-Phase2" aw_windows_legacy_state_root: "C:\\ProgramData\\ActivityWatch-Phase2" aw_windows_migration_report_remote_path: "{{ aw_windows_state_root }}\\aw_migration_ansible.json" @@ -50,4 +50,3 @@ aw_windows_migration_report_remote_path: "{{ aw_windows_state_root }}\\aw_migrat aw_windows_api_smoke_check_enabled: true aw_windows_api_smoke_check_bucket: "" aw_windows_api_smoke_check_limit: 10 - diff --git a/ansible/post_validate_aw_windows.yml b/ansible/post_validate_aw_windows.yml index 2f28634..db8dfda 100644 --- a/ansible/post_validate_aw_windows.yml +++ b/ansible/post_validate_aw_windows.yml @@ -4,6 +4,14 @@ gather_facts: false vars: + aw_windows_deploy_root: "C:\\Program Files\\AWatch-rus" + aw_windows_state_root: "C:\\ProgramData\\AWatch-rus" + aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ansible.json" + aw_windows_validation_local_dir: "/tmp/aw-rus-validation" + aw_windows_server_scheme: "http" + aw_windows_server_host: "10.10.10.13" + aw_windows_server_port: 5600 + aw_windows_fail_on_validation_error: true aw_windows_launch_task_pattern: "ActivityWatch Launch *" aw_windows_recovery_task_name: "ActivityWatch Recovery" aw_windows_force_task_restart: true @@ -44,6 +52,7 @@ 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 return_content: true register: aw_windows_api_smoke until: > @@ -87,4 +96,3 @@ msg: - "Validation OK on {{ inventory_hostname }}." - "Report: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json" - diff --git a/ansible/provision_proxmox_ct_and_deploy_aw.yml b/ansible/provision_proxmox_ct_and_deploy_aw.yml index dd9dadf..1ac786e 100644 --- a/ansible/provision_proxmox_ct_and_deploy_aw.yml +++ b/ansible/provision_proxmox_ct_and_deploy_aw.yml @@ -9,6 +9,12 @@ - install_aw_server.sh - apply_webui_ru_patch.sh - 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 - aw-sw-cleanup.js diff --git a/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml b/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml index 44564da..e0ff9bc 100644 --- a/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml +++ b/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml @@ -9,6 +9,12 @@ - install_aw_server.sh - apply_webui_ru_patch.sh - 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 - aw-sw-cleanup.js diff --git a/ansible/tasks/provision_ct_and_deploy_aw.yml b/ansible/tasks/provision_ct_and_deploy_aw.yml index 975e6ef..0551e2f 100644 --- a/ansible/tasks/provision_ct_and_deploy_aw.yml +++ b/ansible/tasks/provision_ct_and_deploy_aw.yml @@ -159,6 +159,8 @@ AW_SERVER_LOG_DIR={{ aw_server_log_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 }} no_log: true - name: Передать AW server env внутрь CT diff --git a/aw-server/apply_webui_ru_patch.sh b/aw-server/apply_webui_ru_patch.sh index 37a898a..32e53fa 100755 --- a/aw-server/apply_webui_ru_patch.sh +++ b/aw-server/apply_webui_ru_patch.sh @@ -10,14 +10,17 @@ fi source "$ENV_FILE" WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}" +REPORT_BASE="${AW_WORKTIME_REPORT_BASE:-http://10.10.10.13:5610}" PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js" SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js" +WORKTIME_PANEL_SRC="/root/bootstrap/aw-worktime-panel.js" HOST_GROUPS_SRC="/root/bootstrap/aw-host-groups.json" INDEX_HTML="$WEBUI_DIR/index.html" SERVICE_WORKER="$WEBUI_DIR/service-worker.js" TS=$(date +%Y%m%d%H%M%S) PATCH_TARGET="$WEBUI_DIR/js/ru-patch-v5.js" SW_TARGET="$WEBUI_DIR/js/sw-cleanup.js" +WORKTIME_PANEL_TARGET="$WEBUI_DIR/js/aw-worktime-panel.js" HOST_GROUPS_TARGET="$WEBUI_DIR/js/aw-host-groups.json" TRENDS_NEEDLE='this.activityStore.query_category_time_by_period(r)' TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)' @@ -28,21 +31,76 @@ CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){retu [[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; } [[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; } +[[ -f "$WORKTIME_PANEL_SRC" ]] || { echo "missing $WORKTIME_PANEL_SRC" >&2; exit 1; } [[ -f "$HOST_GROUPS_SRC" ]] || { echo "missing $HOST_GROUPS_SRC" >&2; exit 1; } [[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; } install -d "$WEBUI_DIR/js" install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET" install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET" +install -m 0644 "$WORKTIME_PANEL_SRC" "$WORKTIME_PANEL_TARGET" install -m 0644 "$HOST_GROUPS_SRC" "$HOST_GROUPS_TARGET" cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS" patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')" sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')" +worktime_panel_hash="$(sha1sum "$WORKTIME_PANEL_TARGET" | awk '{print substr($1,1,12)}')" -sed -i '/ru-patch-v5.js/d;/sw-cleanup.js/d;/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML" -sed -i "s###" "$INDEX_HTML" -sed -i "s#
+| User | +Active | +Active sec | +First activity | +Last activity | +Idle sec | +Samples | +
|---|
##" "$INDEX_HTML" +python3 - "$WORKTIME_PANEL_TARGET" "$REPORT_BASE" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +report_base = sys.argv[2] +text = path.read_text() +text = text.replace("__AW_WORKTIME_REPORT_BASE__", report_base) +path.write_text(text) +PY + +python3 - "$INDEX_HTML" "$sw_hash" "$patch_hash" "$worktime_panel_hash" "$REPORT_BASE" <<'PY' +from pathlib import Path +import re +import sys + +path = Path(sys.argv[1]) +sw_hash = sys.argv[2] +patch_hash = sys.argv[3] +panel_hash = sys.argv[4] +report_base = sys.argv[5] +content = path.read_text() + +content = re.sub( + r'', + '', + content, +) +content = re.sub(r"; frame-src 'self' [^\";>]*", "", content) +content = content.replace( + "script-src 'self' 'unsafe-eval'", + f"script-src 'self' 'unsafe-eval'; frame-src 'self' {report_base}", + 1, +) +content = content.replace( + "", + f'', + 1, +) +content = content.replace( + "", + ( + f'' + f'' + ), + 1, +) +if 'id="aw-report-links"' not in content: + content = content.replace( + "", + '
', + 1, + ) +path.write_text(content) +PY cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER" trends_chunk="$(grep -Rsl "$TRENDS_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)" diff --git a/aw-server/aw-server.env.example b/aw-server/aw-server.env.example index 93ef939..03693fb 100755 --- a/aw-server/aw-server.env.example +++ b/aw-server/aw-server.env.example @@ -9,3 +9,5 @@ AW_SERVER_DATA_DIR=/var/lib/activitywatch AW_SERVER_LOG_DIR=/var/log/activitywatch AW_SERVER_USER=activitywatch AW_SERVER_GROUP=activitywatch +AW_WORKTIME_REPORT_BASE=http://10.10.10.13:5610 +AW_WORKTIME_TZ=Europe/Moscow diff --git a/aw-server/aw-worktime-api.py b/aw-server/aw-worktime-api.py new file mode 100644 index 0000000..6f6611d --- /dev/null +++ b/aw-server/aw-worktime-api.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +from http.server import BaseHTTPRequestHandler, HTTPServer +import csv +import io +import json +import os +import urllib.request +from datetime import datetime, timezone, timedelta +from zoneinfo import ZoneInfo + +AW = "http://127.0.0.1:5600/api/0" +REPORT_TZ = ZoneInfo(os.environ.get("AW_WORKTIME_TZ", "Europe/Moscow")) + + +def get(u): + with urllib.request.urlopen(u, timeout=30) as r: + return json.loads(r.read().decode()) + + +def pts(s): + return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc) + + +def report_today(): + now_local = datetime.now(REPORT_TZ) + start_local = datetime(now_local.year, now_local.month, now_local.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) + b = get(AW + "/buckets") + sb = next((k for k in b if k.startswith("aw-worktime-sessions_")), None) + if not sb: + return [] + ev = get(f"{AW}/buckets/{sb}/events?limit=50000") + by = {} + for e in ev: + ts = pts(e.get("timestamp")) + if ts < start or ts > end: + continue + d = e.get("data") or {} + user = (d.get("username") or "").strip() + if not user: + continue + state = (d.get("state") or "").lower() + active = ("актив" in state) or (state == "active") + row = by.setdefault(user, {"active": set(), "first": None, "last": None, "rows": 0}) + row["rows"] += 1 + if active: + second = ts.replace(microsecond=0) + row["active"].add(second) + row["first"] = second if row["first"] is None or second < row["first"] else row["first"] + row["last"] = second if row["last"] is None or second > row["last"] else row["last"] + rows = [] + full = int((end_local - start_local).total_seconds()) + for user in sorted(by): + row = by[user] + active_seconds = len(row["active"]) + rows.append({ + "user": user, + "active_seconds": active_seconds, + "active_hhmm": "%02d:%02d" % (active_seconds // 3600, (active_seconds % 3600) // 60), + "first_activity": row["first"].isoformat().replace("+00:00", "Z") if row["first"] else "", + "last_activity": row["last"].isoformat().replace("+00:00", "Z") if row["last"] else "", + "idle_seconds": max(0, full - active_seconds), + "sessions_count": row["rows"], + }) + return rows + + +def render_html(rows): + generated = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + date_local = datetime.now(REPORT_TZ).strftime("%Y-%m-%d") + trs = [] + for row in rows: + trs.append( + "
" + ) + if not trs: + trs.append('
') + return f""" + +
+ + +
+ + +
+