fix(worktime): codify ui-bridge in ansible and install flow
This commit is contained in:
@@ -316,6 +316,30 @@
|
|||||||
group: root
|
group: root
|
||||||
mode: "0644"
|
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
|
- name: Перезагрузить systemd после установки AW worktime API
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
@@ -326,6 +350,25 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
state: restarted
|
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)
|
- name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper)
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh"
|
cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh"
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
- activitywatch-server.service
|
- activitywatch-server.service
|
||||||
- aw-worktime-api.py
|
- aw-worktime-api.py
|
||||||
- aw-worktime-api.service
|
- aw-worktime-api.service
|
||||||
|
- aw-worktime-ui-bridge.py
|
||||||
|
- aw-worktime-ui-bridge.service
|
||||||
|
- aw-worktime-ui-bridge.timer
|
||||||
- aw-worktime-panel.js
|
- aw-worktime-panel.js
|
||||||
- aw-server.env.example
|
- aw-server.env.example
|
||||||
- aw-ru-patch.js
|
- aw-ru-patch.js
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
- activitywatch-server.service
|
- activitywatch-server.service
|
||||||
- aw-worktime-api.py
|
- aw-worktime-api.py
|
||||||
- aw-worktime-api.service
|
- aw-worktime-api.service
|
||||||
|
- aw-worktime-ui-bridge.py
|
||||||
|
- aw-worktime-ui-bridge.service
|
||||||
|
- aw-worktime-ui-bridge.timer
|
||||||
- aw-worktime-panel.js
|
- aw-worktime-panel.js
|
||||||
- aw-server.env.example
|
- aw-server.env.example
|
||||||
- aw-ru-patch.js
|
- aw-ru-patch.js
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -26,6 +26,9 @@ VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
|
|||||||
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
|
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
|
||||||
WORKTIME_API_SRC="$BOOTSTRAP_DIR/aw-worktime-api.py"
|
WORKTIME_API_SRC="$BOOTSTRAP_DIR/aw-worktime-api.py"
|
||||||
WORKTIME_API_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-api.service"
|
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
|
for var_name in "${required_vars[@]}"; do
|
||||||
if [[ -z "${!var_name:-}" ]]; then
|
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
|
systemctl --no-pager --full status aw-worktime-api.service || true
|
||||||
fi
|
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
|
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
|
if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -149,7 +149,8 @@ begin
|
|||||||
'Укажите сервер ActivityWatch (куда агенты будут отправлять данные).',
|
'Укажите сервер ActivityWatch (куда агенты будут отправлять данные).',
|
||||||
'Если нужно, измените host/port. По умолчанию — наша конфигурация.'
|
'Если нужно, измените 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('ServerHost', False);
|
||||||
ServerHostPage.Add('ServerPort', False);
|
ServerHostPage.Add('ServerPort', False);
|
||||||
ServerHostPage.Values[0] := '{#AwDefaultServerHost}';
|
ServerHostPage.Values[0] := '{#AwDefaultServerHost}';
|
||||||
|
|||||||
Reference in New Issue
Block a user