feat(aw-rus): sync deploy stack, install kit, and health checks
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user