From 24227c527a536d95913f46db0712893ac6696aef Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Thu, 8 Oct 2026 21:41:54 +1100 Subject: [PATCH] =?UTF-8?q?ON/OFF:=20WorldMonitor=20group=20toggle=20?= =?UTF-8?q?=E2=80=94=20controller=20supports=20multi-target=20groups=20(ag?= =?UTF-8?q?gregate=20state,=20one=20switch=20for=204=20containers),=20cons?= =?UTF-8?q?ole=20card=20wired=20(admin),=20partial-state=20pill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service-controller/service_controller.py | 51 +++++++++++++++---- deploy/service-controller/services.toml | 11 ++++ portal/static/app.css | 1 + portal/templates/partials/_sections.html | 6 ++- portal/tools.py | 4 +- 5 files changed, 59 insertions(+), 14 deletions(-) diff --git a/deploy/service-controller/service_controller.py b/deploy/service-controller/service_controller.py index ff0fa22..8c3317b 100644 --- a/deploy/service-controller/service_controller.py +++ b/deploy/service-controller/service_controller.py @@ -101,23 +101,49 @@ def _do(kind: str, action: str, target: str) -> str: return _systemd_user_state(target) -def _describe(s: dict[str, Any]) -> dict[str, Any]: - state = "unknown" - if s["kind"] == "container": - state = _container_state(s["target"]) +def _targets(s: dict[str, Any]) -> list[str]: + """A service is either a single `target` or a group `targets` list.""" + t = s.get("targets") + if isinstance(t, list) and t: + return [str(x) for x in t] + single = s.get("target") + return [single] if single else [] + + +def _probe(s: dict[str, Any]) -> tuple[str, list[str]]: + """Collect the state of every target. Returns (aggregate, per-target list). + Aggregate: running if ANY is running; stopped if ALL stopped; else partial.""" + kind = s["kind"] + states = [] + for tgt in _targets(s): + if kind == "container": + states.append(_container_state(tgt)) + else: + states.append(_systemd_user_state(tgt)) + n_ok = sum(1 for st in states if st in ("running", "active", "started")) + n_none = sum(1 for st in states if st in ("unknown",)) or 0 + if n_ok == len(states): + agg = "running" + elif n_ok == 0 and len(states): + agg = "stopped" else: - state = _systemd_user_state(s["target"]) + agg = "partial" if len(states) else "unknown" + return agg, states + + +def _describe(s: dict[str, Any]) -> dict[str, Any]: + agg, states = _probe(s) return { "id": s["id"], "kind": s["kind"], - "target": s["target"], + "target": s.get("target", ""), + "targets": s.get("targets", []), "group": s.get("group", ""), "label": s.get("label", s["id"]), "ram_mb": s.get("ram_mb"), "default_state": s.get("default_state", "stopped"), "who": s.get("who", []), - "state": "running" if state in ("running", "active") else - ("stopped" if state in ("stopped", "inactive", "exited") else "unknown"), + "state": agg, "error": None, } @@ -158,9 +184,12 @@ async def service_action(sid: str, action: str, if s is None: raise HTTPException(status_code=404, detail="unknown service") actor = (who or "unknown-user").strip()[:40] - state = _do(s["kind"], action, s["target"]) - audit(f"{actor} {action} {sid} -> {state}") - return {"ok": True, "id": sid, "state": state} + results = {} + for tgt in _targets(s): + results[tgt] = _do(s["kind"], action, tgt) + agg, _ = _probe(s) + audit(f"{actor} {action} {sid} -> {agg} ({len(_targets(s))} targets)") + return {"ok": True, "id": sid, "state": agg, "targets": results} if __name__ == "__main__": diff --git a/deploy/service-controller/services.toml b/deploy/service-controller/services.toml index b0e684c..23d49ea 100644 --- a/deploy/service-controller/services.toml +++ b/deploy/service-controller/services.toml @@ -83,3 +83,14 @@ label = "Photo pipeline dashboard" ram_mb = 112 default_state = "running" who = ["sam"] + +# WorldMonitor — 4-container group (UI + relay + redis + rest). One switch for all. +[[services]] +id = "worldmonitor" +kind = "container" +targets = ["worldmonitor", "worldmonitor-ais-relay", "worldmonitor-redis", "worldmonitor-redis-rest"] +group = "admin" +label = "World Monitor" +ram_mb = 120 +default_state = "running" +who = ["sam"] diff --git a/portal/static/app.css b/portal/static/app.css index f7d1eef..acf7f3e 100644 --- a/portal/static/app.css +++ b/portal/static/app.css @@ -401,6 +401,7 @@ button.button, input[type="submit"].button { font-family: inherit; } .svc-dot.svc-off { background: var(--ink-faint, #a39e98); } .svc-dot.svc-trans { background: var(--accent-orange, #dd5b00); animation: pulse 1.2s ease-in-out infinite; } .svc-dot.svc-fail { background: #d33a2b; } +.svc-dot.svc-part { background: var(--accent-orange, #dd5b00); } /* partial: some group members running */ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } } .svc-ram { margin-left: auto; font: var(--type-caption); color: var(--ink-faint); white-space: nowrap; } .button-sm { padding: 4px 10px; font-size: 12px; } diff --git a/portal/templates/partials/_sections.html b/portal/templates/partials/_sections.html index bf281e6..0bc7d7c 100644 --- a/portal/templates/partials/_sections.html +++ b/portal/templates/partials/_sections.html @@ -126,8 +126,10 @@ var can = svcState[id] && svcState[id].can_toggle; var transitional = st === "starting" || st === "stopping"; if (ram && svcState[id] && svcState[id].ram_mb) ram.textContent = "~" + svcState[id].ram_mb + "MB"; - dot.className = "svc-dot svc-" + (st === "running" ? "on" : st === "stopped" ? "off" : st === "failed" ? "fail" : "trans"); - if (lab) lab.textContent = st === "running" ? "Running" : st === "stopped" ? "Stopped" : st === "failed" ? (svcState[id].error || "Failed") : (st[0].toUpperCase() + st.slice(1)); + var cls = st === "running" ? "on" : st === "stopped" ? "off" : (st === "failed" ? "fail" : st === "partial" ? "part" : "trans"); + dot.className = "svc-dot svc-" + cls; + if (lab) lab.textContent = st === "running" ? "Running" : st === "stopped" ? "Stopped" : st === "failed" ? (svcState[id].error || "Failed") : st === "partial" ? "Partial" : (st[0].toUpperCase() + st.slice(1)); + // partial behaves like running for the button (offered action: stop the rest) // failed -> offer the logs deep link (dozzle) var logs = strip.querySelector("[data-svc-logs]"); if (logs) { diff --git a/portal/tools.py b/portal/tools.py index edeffca..03991c4 100644 --- a/portal/tools.py +++ b/portal/tools.py @@ -282,7 +282,9 @@ def _cat() -> list[Tool]: Tool("ntfy", "Ntfy", "infra", "Push notifications (LAN)", "https://ntfy.home.lab", login=True, lan=True), Tool("worldmonitor", "World Monitor", "infra", - "Global monitoring", "https://worldmonitor.lab.audasmedia.com.au", login=True, admin=True), + "Global monitoring", "https://worldmonitor.lab.audasmedia.com.au", login=True, admin=True, + service_id="worldmonitor", ram_mb=120, togglable=True, + admin_service=True), Tool("homepage", "Homepage", "infra", "Start page", "https://homepage.lab.audasmedia.com.au", login=True), Tool("librenms", "LibreNMS", "network",