feat(aw-rus): sync deploy stack, install kit, and health checks

This commit is contained in:
igor04091968
2026-05-21 15:16:23 +03:00
parent 45f9907450
commit 99143b108b
67 changed files with 6449 additions and 681 deletions
+5 -5
View File
@@ -122,15 +122,15 @@ content = re.sub(
)
content = content.replace(
"</head>",
f'<script src="/js/sw-cleanup.js?v={sw_hash}"></script></head>',
(
f'<script src="/js/sw-cleanup.js?v={sw_hash}"></script>'
f'<script src="/js/ru-patch-v5.js?v={patch_hash}"></script></head>'
),
1,
)
content = content.replace(
"</body>",
(
f'<script defer="defer" src="/js/ru-patch-v5.js?v={patch_hash}"></script>'
f'<script defer="defer" src="/js/aw-worktime-panel.js?v={panel_hash}"></script></body>'
),
f'<script defer="defer" src="/js/aw-worktime-panel.js?v={panel_hash}"></script></body>',
1,
)
if 'id="aw-report-links"' not in content:
+31 -10
View File
@@ -1,6 +1,10 @@
(function () {
window.__awRuPatchVersion = "template-v12-activity-heading-ru";
document.documentElement.setAttribute("data-aw-ru-patch", "template-v12-activity-heading-ru");
if (window.__awRuPatchBootstrapped) {
return;
}
window.__awRuPatchBootstrapped = true;
window.__awRuPatchVersion = "template-v13-category-builder-early-fix";
document.documentElement.setAttribute("data-aw-ru-patch", "template-v13-category-builder-early-fix");
const exact = new Map([
["ActivityWatch", "АктивВотч"],
@@ -1687,6 +1691,7 @@
.filter(Boolean)
.filter(function (host) { return !/^unknown$/i.test(host); });
if (isLikelyClientHost(settingsHost) && hosts.indexOf(settingsHost) >= 0) return settingsHost;
if (isLikelyClientHost(settingsHost) && !hosts.length) return settingsHost;
hosts.sort();
return hosts[0] || "";
}
@@ -1749,20 +1754,25 @@
if (networkPatchesInstalled) return;
networkPatchesInstalled = true;
const originalFetch = window.fetch ? window.fetch.bind(window) : null;
if (originalFetch) {
window.fetch = function (input, init) {
const originalFetch = window.fetch;
if (typeof originalFetch === "function" && !originalFetch.__awRuCategoryBuilderPatched) {
const patchedFetch = function (input, init) {
let nextInput = input;
let nextInit = init;
try {
const url = typeof input === "string" ? input : String(input && input.url || "");
if (/\/api\/0\/query\/?$/i.test(url) && init && typeof init.body === "string") {
init = Object.assign({}, init, {
body: rewriteUnknownCategoryBuilderQueryBody(init.body)
const url = typeof nextInput === "string" ? nextInput : String(nextInput && nextInput.url || "");
if (/\/api\/0\/query\/?$/i.test(url) && nextInit && typeof nextInit.body === "string") {
nextInit = Object.assign({}, nextInit, {
body: rewriteUnknownCategoryBuilderQueryBody(nextInit.body)
});
}
} catch (error) {
}
return originalFetch(input, init);
return originalFetch.call(this, nextInput, nextInit);
};
patchedFetch.__awRuCategoryBuilderPatched = true;
patchedFetch.__awRuOriginalFetch = originalFetch;
window.fetch = patchedFetch;
}
if (window.XMLHttpRequest && window.XMLHttpRequest.prototype) {
@@ -1838,6 +1848,14 @@
}
}
function primeCategoryBuilderEarlyFix() {
const hash = window.location.hash || "";
if (!/^#\/settings\/category-builder(?:[/?#]|$)/i.test(hash)) return;
ensureSettingsHost();
ensureHostGroupsData().catch(function () {});
normalizeCategoryBuilderUnknownHostRefs();
}
function patchActivityHeading(root) {
const heading = root.querySelector("h3");
if (!heading) return;
@@ -1920,6 +1938,9 @@
scheduleApplyPatch();
});
installCategoryBuilderNetworkPatch();
primeCategoryBuilderEarlyFix();
window.addEventListener("load", function () {
applyPatch();
attachObserver();
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import socket
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib import request
ENV_FILE = Path("/etc/activitywatch/aw-server.env")
def load_env_file(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip("'").strip('"')
os.environ.setdefault(key, value)
def env(name: str, default: str) -> str:
value = os.environ.get(name)
return value if value not in (None, "") else default
def now_utc() -> datetime:
return datetime.now(UTC)
def parse_ts(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
except ValueError:
return None
def age_seconds(ts: datetime | None, now: datetime) -> int | None:
if ts is None:
return None
return max(0, int((now - ts).total_seconds()))
def http_json(url: str, timeout: int = 10) -> Any:
with request.urlopen(url, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def run_command(cmd: list[str]) -> tuple[int, str]:
proc = subprocess.run(
cmd,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
return proc.returncode, proc.stdout.strip()
def tcp_connect(host: str, port: int, timeout: float) -> tuple[bool, str]:
try:
with socket.create_connection((host, port), timeout=timeout):
return True, "connected"
except OSError as exc:
return False, str(exc)
@dataclass
class CheckResult:
name: str
status: str
summary: str
details: dict[str, Any]
class Report:
def __init__(self) -> None:
self.results: list[CheckResult] = []
def add(self, name: str, status: str, summary: str, **details: Any) -> None:
self.results.append(CheckResult(name=name, status=status, summary=summary, details=details))
@property
def ok(self) -> bool:
return not any(item.status == "fail" for item in self.results)
def as_dict(self) -> dict[str, Any]:
counts = {"ok": 0, "warn": 0, "fail": 0}
for item in self.results:
counts[item.status] = counts.get(item.status, 0) + 1
return {
"generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"),
"ok": self.ok,
"counts": counts,
"results": [
{
"name": item.name,
"status": item.status,
"summary": item.summary,
"details": item.details,
}
for item in self.results
],
}
def render_text(self) -> str:
icon = {"ok": "OK", "warn": "WARN", "fail": "FAIL"}
lines = ["=== AW-RUS Health ===", f"Timestamp: {now_utc().isoformat().replace('+00:00', 'Z')}", ""]
for item in self.results:
lines.append(f"[{icon.get(item.status, item.status.upper())}] {item.name}: {item.summary}")
lines.append("")
payload = self.as_dict()
lines.append(
"Counts: ok={ok} warn={warn} fail={fail}".format(
ok=payload["counts"]["ok"],
warn=payload["counts"]["warn"],
fail=payload["counts"]["fail"],
)
)
lines.append(f"Overall: {'OK' if payload['ok'] else 'FAIL'}")
return "\n".join(lines)
def latest_bucket_event(api_base: str, bucket_id: str) -> dict[str, Any] | None:
events = http_json(f"{api_base}/buckets/{bucket_id}/events?limit=20")
if isinstance(events, list) and events:
events = [item for item in events if isinstance(item, dict)]
if not events:
return None
events.sort(key=lambda item: item.get("timestamp") or "", reverse=True)
return events[0]
return None
def host_activity_from_worktime(event: dict[str, Any] | None, max_age_seconds: int) -> dict[str, Any]:
now = now_utc()
if not event:
return {"fresh": False, "active": False, "age_seconds": None, "timestamp": None}
ts = parse_ts(event.get("timestamp"))
age = age_seconds(ts, now)
data = event.get("data") or {}
is_fresh = age is not None and age <= max_age_seconds
is_active = bool(is_fresh and data.get("active"))
return {
"fresh": bool(is_fresh),
"active": bool(is_active),
"age_seconds": age,
"timestamp": event.get("timestamp"),
"data": data,
}
def bucket_health(
api_base: str,
bucket_id: str,
max_age_seconds: int,
missing_status: str,
stale_status: str,
) -> tuple[str, str, dict[str, Any]]:
try:
event = latest_bucket_event(api_base, bucket_id)
except Exception as exc:
return "fail", f"bucket query failed: {exc}", {"bucket": bucket_id}
if not event:
return missing_status, "no events", {"bucket": bucket_id}
ts = parse_ts(event.get("timestamp"))
age = age_seconds(ts, now_utc())
details = {"bucket": bucket_id, "timestamp": event.get("timestamp"), "age_seconds": age}
if age is None:
return "warn", "timestamp parse failed", details
if age > max_age_seconds:
return stale_status, f"stale ({age}s)", details
return "ok", f"fresh ({age}s)", details
def latest_validation_report(validation_dir: Path) -> Path | None:
candidates = sorted(
(path for path in validation_dir.glob("*-aw_validate_ansible.json") if path.is_file()),
key=lambda item: item.stat().st_mtime,
reverse=True,
)
return candidates[0] if candidates else None
def write_atomic(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
handle.write(content)
tmp_name = handle.name
os.replace(tmp_name, path)
def check_wrapper(report: Report, name: str, cmd: list[str], json_mode: bool = False) -> None:
if not Path(cmd[0]).exists():
report.add(name, "warn", "binary missing", command=cmd)
return
rc, output = run_command(cmd)
details: dict[str, Any] = {"command": cmd, "returncode": rc}
if json_mode:
try:
details["payload"] = json.loads(output) if output else {}
except json.JSONDecodeError:
details["raw_output"] = output
report.add(name, "fail", "invalid JSON output", **details)
return
else:
details["output"] = output
report.add(name, "ok" if rc == 0 else "fail", "passed" if rc == 0 else "failed", **details)
def main() -> int:
load_env_file(ENV_FILE)
parser = argparse.ArgumentParser(description="Unified AW-RUS health orchestrator")
parser.add_argument("--aw-server", default=env("AW_SERVER_URL", "http://127.0.0.1:5600"))
parser.add_argument("--worktime-api", default=env("AW_WORKTIME_REPORT_BASE", "http://127.0.0.1:5610"))
parser.add_argument("--rdp-host", default=env("AW_MONITORED_WINDOWS_HOST", "192.168.100.18"))
parser.add_argument("--rdp-hostname", default=env("AW_MONITORED_WINDOWS_HOSTNAME", "SHARKON2025"))
parser.add_argument("--state-dir", default=env("AW_RUS_HEALTH_STATE_DIR", "/var/lib/activitywatch/health"))
parser.add_argument("--validation-dir", default=env("AW_RUS_HEALTH_VALIDATION_DIR", "/var/lib/activitywatch/health/windows-validation"))
parser.add_argument("--session-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_MAX_AGE_SECONDS", "900")))
parser.add_argument("--interactive-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_INTERACTIVE_MAX_AGE_SECONDS", "900")))
parser.add_argument("--session-events-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS", "604800")))
parser.add_argument("--validation-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_VALIDATION_MAX_AGE_SECONDS", "259200")))
parser.add_argument("--tcp-timeout-seconds", type=float, default=float(env("AW_RUS_HEALTH_TCP_TIMEOUT_SECONDS", "3")))
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
report = Report()
aw_api_base = args.aw_server.rstrip("/")
if not aw_api_base.endswith("/api/0"):
aw_api_base = aw_api_base.rstrip("/") + "/api/0"
check_wrapper(report, "wrapper:aw-health-check", ["/usr/local/bin/aw-health-check"])
check_wrapper(report, "wrapper:dlp-health-check", ["/usr/local/bin/dlp-health-check", "--json"], json_mode=True)
try:
info = http_json(f"{aw_api_base}/info")
report.add("http:aw-server", "ok", "activitywatch API responded", version=info.get("version"))
except Exception as exc:
report.add("http:aw-server", "fail", f"activitywatch API failed: {exc}", url=f"{aw_api_base}/info")
try:
payload = http_json(args.worktime_api.rstrip("/") + "/reports/worktime/today")
rows = len(payload) if isinstance(payload, list) else None
report.add("http:worktime-api", "ok", "worktime API responded", rows=rows)
except Exception as exc:
report.add("http:worktime-api", "fail", f"worktime API failed: {exc}", url=args.worktime_api)
for port, label in ((5985, "winrm"), (3389, "rdp")):
ok, message = tcp_connect(args.rdp_host, port, args.tcp_timeout_seconds)
report.add(f"tcp:{label}", "ok" if ok else "fail", message if ok else f"unreachable: {message}", host=args.rdp_host, port=port)
try:
buckets = http_json(f"{aw_api_base}/buckets")
if not isinstance(buckets, dict):
raise RuntimeError("bucket index is not a dict")
report.add("aw:buckets-index", "ok", "bucket index loaded", total=len(buckets))
except Exception as exc:
report.add("aw:buckets-index", "fail", f"failed to load bucket index: {exc}")
buckets = {}
host = args.rdp_hostname
worktime_bucket = f"aw-worktime-sessions_{host}"
worktime_event = None
if buckets:
try:
worktime_event = latest_bucket_event(aw_api_base, worktime_bucket)
except Exception:
worktime_event = None
activity = host_activity_from_worktime(worktime_event, args.session_max_age_seconds)
if worktime_event:
status, summary, details = bucket_health(
aw_api_base,
worktime_bucket,
args.session_max_age_seconds,
missing_status="fail",
stale_status="fail",
)
details["host_activity"] = activity
report.add("bucket:worktime-sessions", status, summary, **details)
else:
report.add("bucket:worktime-sessions", "fail", "no events", bucket=worktime_bucket, host_activity=activity)
interactive_required = bool(activity["active"])
for bucket_name, label in (
("aw-watcher-afk", "bucket:afk"),
("aw-watcher-window", "bucket:window"),
("aw-dlp-endpoint-signals", "bucket:endpoint-signals"),
):
status, summary, details = bucket_health(
aw_api_base,
f"{bucket_name}_{host}",
args.interactive_max_age_seconds,
missing_status="fail" if interactive_required else "warn",
stale_status="fail" if interactive_required else "warn",
)
details["interactive_required"] = interactive_required
details["host_activity"] = activity
report.add(label, status, summary, **details)
session_status, session_summary, session_details = bucket_health(
aw_api_base,
f"aw-session-events_{host}",
args.session_events_max_age_seconds,
missing_status="fail",
stale_status="warn",
)
report.add("bucket:session-events", session_status, session_summary, **session_details)
validation_dir = Path(args.validation_dir)
validation_report = latest_validation_report(validation_dir)
if validation_report is None:
report.add("validation:windows", "warn", "no validation report snapshot", directory=str(validation_dir))
else:
try:
payload = json.loads(validation_report.read_text(encoding="utf-8-sig"))
age = age_seconds(datetime.fromtimestamp(validation_report.stat().st_mtime, tz=UTC), now_utc())
if age is not None and age > args.validation_max_age_seconds:
report.add(
"validation:windows",
"warn",
f"validation snapshot is stale ({age}s)",
path=str(validation_report),
overall_ok=payload.get("overallOk"),
failed_sections=payload.get("summary", {}).get("failedSections", []),
)
elif payload.get("overallOk") is True:
report.add("validation:windows", "ok", "validation snapshot OK", path=str(validation_report), age_seconds=age)
else:
report.add(
"validation:windows",
"fail",
"validation snapshot reports failure",
path=str(validation_report),
age_seconds=age,
failed_sections=payload.get("summary", {}).get("failedSections", []),
)
except Exception as exc:
report.add("validation:windows", "fail", f"invalid validation snapshot: {exc}", path=str(validation_report))
payload = report.as_dict()
state_dir = Path(args.state_dir)
write_atomic(state_dir / "aw-rus-health.json", json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
write_atomic(state_dir / "aw-rus-health.txt", report.render_text() + "\n")
if args.json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print(report.render_text())
return 0 if payload["ok"] else 1
if __name__ == "__main__":
sys.exit(main())
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=AW-RUS unified health orchestrator
After=network-online.target activitywatch-server.service aw-worktime-api.service
Wants=network-online.target activitywatch-server.service aw-worktime-api.service
[Service]
Type=oneshot
EnvironmentFile=/etc/activitywatch/aw-server.env
ExecStart=/usr/bin/python3 /usr/local/bin/aw-rus-healthd.py
User=root
Group=root
StandardOutput=journal
StandardError=journal
SyslogIdentifier=aw-rus-healthd
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Run AW-RUS unified health orchestrator every 2 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=2min
Unit=aw-rus-healthd.service
Persistent=true
[Install]
WantedBy=timers.target
+4
View File
@@ -37,6 +37,10 @@ AW_EXPECT_START_OF_DAY=00:00
AW_EXPECT_ALWAYS_ACTIVE_PATTERN=aw-watcher-window
AW_EXPECT_LANDINGPAGE=/activity/SHARKON2025/view/
AW_HEALTH_STRICT_FILEOPS=0
AW_MONITORED_WINDOWS_HOST=192.168.100.18
AW_MONITORED_WINDOWS_HOSTNAME=SHARKON2025
AW_RUS_HEALTH_STATE_DIR=/var/lib/activitywatch/health
AW_RUS_HEALTH_VALIDATION_DIR=/var/lib/activitywatch/health/windows-validation
# Integration Test Configuration
AW_INTEGRATION_TEST_ENABLED=false
+21
View File
@@ -29,6 +29,9 @@ 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"
HEALTHD_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.py"
HEALTHD_SERVICE_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.service"
HEALTHD_TIMER_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.timer"
for var_name in "${required_vars[@]}"; do
if [[ -z "${!var_name:-}" ]]; then
@@ -54,6 +57,7 @@ install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" /opt/activitywatch/release
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_WEBUI_DIR"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_DATA_DIR"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_LOG_DIR"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_DATA_DIR/health/windows-validation"
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
@@ -124,6 +128,23 @@ if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then
systemctl --no-pager --full status aw-worktime-ui-bridge.timer || true
fi
if [[ -f "$HEALTHD_SRC" ]]; then
install -m 0755 "$HEALTHD_SRC" /usr/local/bin/aw-rus-healthd.py
fi
if [[ -f "$HEALTHD_SERVICE_SRC" ]]; then
install -m 0644 "$HEALTHD_SERVICE_SRC" /etc/systemd/system/aw-rus-healthd.service
fi
if [[ -f "$HEALTHD_TIMER_SRC" ]]; then
install -m 0644 "$HEALTHD_TIMER_SRC" /etc/systemd/system/aw-rus-healthd.timer
systemctl daemon-reload
systemctl enable aw-rus-healthd.timer
systemctl restart aw-rus-healthd.timer
systemctl start aw-rus-healthd.service || true
systemctl --no-pager --full status aw-rus-healthd.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
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
import importlib.util
import sys
from pathlib import Path
MODULE_PATH = Path(__file__).with_name("aw-rus-healthd.py")
SPEC = importlib.util.spec_from_file_location("aw_rus_healthd", MODULE_PATH)
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
def test_host_activity_marks_recent_active_session():
MODULE.now_utc = lambda: MODULE.parse_ts("2026-05-18T10:05:00Z")
activity = MODULE.host_activity_from_worktime(
{
"timestamp": "2026-05-18T10:00:00Z",
"data": {"active": True},
},
max_age_seconds=900,
)
assert activity["active"] is True
assert activity["fresh"] is True
def test_bucket_health_reports_missing_events_without_crash(monkeypatch):
monkeypatch.setattr(MODULE, "latest_bucket_event", lambda api_base, bucket_id: None)
status, summary, details = MODULE.bucket_health(
"http://127.0.0.1:5600/api/0",
"aw-watcher-window_SHARKON2025",
900,
missing_status="warn",
stale_status="fail",
)
assert status == "warn"
assert summary == "no events"
assert details["bucket"] == "aw-watcher-window_SHARKON2025"