diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index c88246b..2eb2888 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -294,9 +294,36 @@ 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: Перезагрузить 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: Применить хотфиксы 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 9c92acf..171b218 100644 --- a/ansible/group_vars/all.example.yml +++ b/ansible/group_vars/all.example.yml @@ -9,6 +9,7 @@ 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 2be8bca..fdefb38 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -9,6 +9,7 @@ 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/provision_proxmox_ct_and_deploy_aw.yml b/ansible/provision_proxmox_ct_and_deploy_aw.yml index dd9dadf..206a37c 100644 --- a/ansible/provision_proxmox_ct_and_deploy_aw.yml +++ b/ansible/provision_proxmox_ct_and_deploy_aw.yml @@ -9,6 +9,8 @@ - install_aw_server.sh - apply_webui_ru_patch.sh - activitywatch-server.service + - aw-worktime-api.py + - aw-worktime-api.service - 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..ec1a4f3 100644 --- a/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml +++ b/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml @@ -9,6 +9,8 @@ - install_aw_server.sh - apply_webui_ru_patch.sh - activitywatch-server.service + - aw-worktime-api.py + - aw-worktime-api.service - 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 e1c503f..0551e2f 100644 --- a/ansible/tasks/provision_ct_and_deploy_aw.yml +++ b/ansible/tasks/provision_ct_and_deploy_aw.yml @@ -160,6 +160,7 @@ 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/aw-server.env.example b/aw-server/aw-server.env.example index c203b3a..03693fb 100755 --- a/aw-server/aw-server.env.example +++ b/aw-server/aw-server.env.example @@ -10,3 +10,4 @@ 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..29f7d11 --- /dev/null +++ b/aw-server/aw-worktime-api.py @@ -0,0 +1,116 @@ +#!/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 + + +class H(BaseHTTPRequestHandler): + def do_GET(self): + if not self.path.startswith("/reports/worktime/today"): + self.send_response(404) + self.end_headers() + return + fmt = "json" + if "format=csv" in self.path: + fmt = "csv" + rows = report_today() + if fmt == "csv": + out = io.StringIO() + writer = csv.DictWriter( + out, + fieldnames=[ + "user", + "active_seconds", + "active_hhmm", + "first_activity", + "last_activity", + "idle_seconds", + "sessions_count", + ], + ) + writer.writeheader() + writer.writerows(rows) + data = out.getvalue().encode() + self.send_response(200) + self.send_header("Content-Type", "text/csv; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + return + obj = { + "generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "report_timezone": str(REPORT_TZ), + "rows": rows, + } + data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +HTTPServer(("0.0.0.0", 5610), H).serve_forever() diff --git a/aw-server/aw-worktime-api.service b/aw-server/aw-worktime-api.service new file mode 100644 index 0000000..4225fb5 --- /dev/null +++ b/aw-server/aw-worktime-api.service @@ -0,0 +1,16 @@ +[Unit] +Description=AW Worktime Report API +After=network.target activitywatch-server.service +Wants=activitywatch-server.service + +[Service] +Type=simple +EnvironmentFile=/etc/activitywatch/aw-server.env +ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-api.py +Restart=always +RestartSec=2 +User=activitywatch +Group=activitywatch + +[Install] +WantedBy=multi-user.target diff --git a/aw-server/install_aw_server.sh b/aw-server/install_aw_server.sh index 4d6262e..50ba3e7 100755 --- a/aw-server/install_aw_server.sh +++ b/aw-server/install_aw_server.sh @@ -24,6 +24,8 @@ required_vars=( BOOTSTRAP_DIR="/root/bootstrap" 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" for var_name in "${required_vars[@]}"; do if [[ -z "${!var_name:-}" ]]; then @@ -89,6 +91,18 @@ systemctl enable activitywatch-server.service systemctl restart activitywatch-server.service systemctl --no-pager --full status activitywatch-server.service || true +if [[ -f "$WORKTIME_API_SRC" ]]; then + install -m 0755 "$WORKTIME_API_SRC" /usr/local/bin/aw-worktime-api.py +fi + +if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then + install -m 0644 "$WORKTIME_API_SERVICE_SRC" /etc/systemd/system/aw-worktime-api.service + systemctl daemon-reload + systemctl enable aw-worktime-api.service + systemctl restart aw-worktime-api.service + systemctl --no-pager --full status aw-worktime-api.service || 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/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml b/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml index 5cdaa01..040ec77 100644 --- a/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml +++ b/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml @@ -234,9 +234,36 @@ 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: Перезагрузить 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: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper) ansible.builtin.command: cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh" diff --git a/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml b/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml index 37ddb3c..be28ad3 100644 --- a/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml +++ b/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml @@ -8,6 +8,7 @@ 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/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml index dd9dadf..206a37c 100644 --- a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml +++ b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml @@ -9,6 +9,8 @@ - install_aw_server.sh - apply_webui_ru_patch.sh - activitywatch-server.service + - aw-worktime-api.py + - aw-worktime-api.service - aw-server.env.example - aw-ru-patch.js - aw-sw-cleanup.js diff --git a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml index 44564da..ec1a4f3 100644 --- a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml +++ b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml @@ -9,6 +9,8 @@ - install_aw_server.sh - apply_webui_ru_patch.sh - activitywatch-server.service + - aw-worktime-api.py + - aw-worktime-api.service - aw-server.env.example - aw-ru-patch.js - aw-sw-cleanup.js diff --git a/install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml b/install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml index e1c503f..0551e2f 100644 --- a/install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml +++ b/install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml @@ -160,6 +160,7 @@ 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/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example b/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example index c203b3a..03693fb 100755 --- a/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example +++ b/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example @@ -10,3 +10,4 @@ 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/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py new file mode 100644 index 0000000..29f7d11 --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py @@ -0,0 +1,116 @@ +#!/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 + + +class H(BaseHTTPRequestHandler): + def do_GET(self): + if not self.path.startswith("/reports/worktime/today"): + self.send_response(404) + self.end_headers() + return + fmt = "json" + if "format=csv" in self.path: + fmt = "csv" + rows = report_today() + if fmt == "csv": + out = io.StringIO() + writer = csv.DictWriter( + out, + fieldnames=[ + "user", + "active_seconds", + "active_hhmm", + "first_activity", + "last_activity", + "idle_seconds", + "sessions_count", + ], + ) + writer.writeheader() + writer.writerows(rows) + data = out.getvalue().encode() + self.send_response(200) + self.send_header("Content-Type", "text/csv; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + return + obj = { + "generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "report_timezone": str(REPORT_TZ), + "rows": rows, + } + data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +HTTPServer(("0.0.0.0", 5610), H).serve_forever() diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service new file mode 100644 index 0000000..4225fb5 --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service @@ -0,0 +1,16 @@ +[Unit] +Description=AW Worktime Report API +After=network.target activitywatch-server.service +Wants=activitywatch-server.service + +[Service] +Type=simple +EnvironmentFile=/etc/activitywatch/aw-server.env +ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-api.py +Restart=always +RestartSec=2 +User=activitywatch +Group=activitywatch + +[Install] +WantedBy=multi-user.target diff --git a/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh b/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh index 4d6262e..50ba3e7 100755 --- a/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh +++ b/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh @@ -24,6 +24,8 @@ required_vars=( BOOTSTRAP_DIR="/root/bootstrap" 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" for var_name in "${required_vars[@]}"; do if [[ -z "${!var_name:-}" ]]; then @@ -89,6 +91,18 @@ systemctl enable activitywatch-server.service systemctl restart activitywatch-server.service systemctl --no-pager --full status activitywatch-server.service || true +if [[ -f "$WORKTIME_API_SRC" ]]; then + install -m 0755 "$WORKTIME_API_SRC" /usr/local/bin/aw-worktime-api.py +fi + +if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then + install -m 0644 "$WORKTIME_API_SERVICE_SRC" /etc/systemd/system/aw-worktime-api.service + systemctl daemon-reload + systemctl enable aw-worktime-api.service + systemctl restart aw-worktime-api.service + systemctl --no-pager --full status aw-worktime-api.service || 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