ON/OFF: WorldMonitor group toggle — controller supports multi-target groups (aggregate state, one switch for 4 containers), console card wired (admin), partial-state pill
This commit is contained in:
@@ -101,23 +101,49 @@ def _do(kind: str, action: str, target: str) -> str:
|
|||||||
return _systemd_user_state(target)
|
return _systemd_user_state(target)
|
||||||
|
|
||||||
|
|
||||||
def _describe(s: dict[str, Any]) -> dict[str, Any]:
|
def _targets(s: dict[str, Any]) -> list[str]:
|
||||||
state = "unknown"
|
"""A service is either a single `target` or a group `targets` list."""
|
||||||
if s["kind"] == "container":
|
t = s.get("targets")
|
||||||
state = _container_state(s["target"])
|
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:
|
else:
|
||||||
state = _systemd_user_state(s["target"])
|
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:
|
||||||
|
agg = "partial" if len(states) else "unknown"
|
||||||
|
return agg, states
|
||||||
|
|
||||||
|
|
||||||
|
def _describe(s: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
agg, states = _probe(s)
|
||||||
return {
|
return {
|
||||||
"id": s["id"],
|
"id": s["id"],
|
||||||
"kind": s["kind"],
|
"kind": s["kind"],
|
||||||
"target": s["target"],
|
"target": s.get("target", ""),
|
||||||
|
"targets": s.get("targets", []),
|
||||||
"group": s.get("group", ""),
|
"group": s.get("group", ""),
|
||||||
"label": s.get("label", s["id"]),
|
"label": s.get("label", s["id"]),
|
||||||
"ram_mb": s.get("ram_mb"),
|
"ram_mb": s.get("ram_mb"),
|
||||||
"default_state": s.get("default_state", "stopped"),
|
"default_state": s.get("default_state", "stopped"),
|
||||||
"who": s.get("who", []),
|
"who": s.get("who", []),
|
||||||
"state": "running" if state in ("running", "active") else
|
"state": agg,
|
||||||
("stopped" if state in ("stopped", "inactive", "exited") else "unknown"),
|
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +184,12 @@ async def service_action(sid: str, action: str,
|
|||||||
if s is None:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="unknown service")
|
raise HTTPException(status_code=404, detail="unknown service")
|
||||||
actor = (who or "unknown-user").strip()[:40]
|
actor = (who or "unknown-user").strip()[:40]
|
||||||
state = _do(s["kind"], action, s["target"])
|
results = {}
|
||||||
audit(f"{actor} {action} {sid} -> {state}")
|
for tgt in _targets(s):
|
||||||
return {"ok": True, "id": sid, "state": state}
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -83,3 +83,14 @@ label = "Photo pipeline dashboard"
|
|||||||
ram_mb = 112
|
ram_mb = 112
|
||||||
default_state = "running"
|
default_state = "running"
|
||||||
who = ["sam"]
|
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"]
|
||||||
|
|||||||
@@ -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-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-trans { background: var(--accent-orange, #dd5b00); animation: pulse 1.2s ease-in-out infinite; }
|
||||||
.svc-dot.svc-fail { background: #d33a2b; }
|
.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; } }
|
@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; }
|
.svc-ram { margin-left: auto; font: var(--type-caption); color: var(--ink-faint); white-space: nowrap; }
|
||||||
.button-sm { padding: 4px 10px; font-size: 12px; }
|
.button-sm { padding: 4px 10px; font-size: 12px; }
|
||||||
|
|||||||
@@ -126,8 +126,10 @@
|
|||||||
var can = svcState[id] && svcState[id].can_toggle;
|
var can = svcState[id] && svcState[id].can_toggle;
|
||||||
var transitional = st === "starting" || st === "stopping";
|
var transitional = st === "starting" || st === "stopping";
|
||||||
if (ram && svcState[id] && svcState[id].ram_mb) ram.textContent = "~" + svcState[id].ram_mb + "MB";
|
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");
|
var cls = st === "running" ? "on" : st === "stopped" ? "off" : (st === "failed" ? "fail" : st === "partial" ? "part" : "trans");
|
||||||
if (lab) lab.textContent = st === "running" ? "Running" : st === "stopped" ? "Stopped" : st === "failed" ? (svcState[id].error || "Failed") : (st[0].toUpperCase() + st.slice(1));
|
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)
|
// failed -> offer the logs deep link (dozzle)
|
||||||
var logs = strip.querySelector("[data-svc-logs]");
|
var logs = strip.querySelector("[data-svc-logs]");
|
||||||
if (logs) {
|
if (logs) {
|
||||||
|
|||||||
@@ -282,7 +282,9 @@ def _cat() -> list[Tool]:
|
|||||||
Tool("ntfy", "Ntfy", "infra",
|
Tool("ntfy", "Ntfy", "infra",
|
||||||
"Push notifications (LAN)", "https://ntfy.home.lab", login=True, lan=True),
|
"Push notifications (LAN)", "https://ntfy.home.lab", login=True, lan=True),
|
||||||
Tool("worldmonitor", "World Monitor", "infra",
|
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",
|
Tool("homepage", "Homepage", "infra",
|
||||||
"Start page", "https://homepage.lab.audasmedia.com.au", login=True),
|
"Start page", "https://homepage.lab.audasmedia.com.au", login=True),
|
||||||
Tool("librenms", "LibreNMS", "network",
|
Tool("librenms", "LibreNMS", "network",
|
||||||
|
|||||||
Reference in New Issue
Block a user