Step 1: the settings page, read-only, live on NA Ingest at port 8020

Overview (problems in the room with the hold action, the live deck, one line per computer),
Keys (both pages, what each key will say and what holding does), Computers (map with the keys
that turn red, cards with a light per thing that runs there from the watchdog's snapshot and a
read-only probe, Gear, Setup), Logs, History (the installer's copies for now), Help (four cards,
the generated key guide, cheat sheet and Where things are, glossary, Companion facts), Site setup
and Fixes read-only. The editing code is checked (with a lockout) but nothing can change yet.

Also: faults are attributed to the computers their check is about; the installer refuses while a
HyperDeck is recording (--now overrides); the Lighting PC's MIDI session is listed; the probes read
sudo's answer correctly and expand $HOME outside the quotes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 12:18:36 -04:00
co-authored by Claude Fable 5.1
parent 2343a27529
commit 01c18ba5ef
9 changed files with 348 additions and 45 deletions
+34 -13
View File
@@ -1,6 +1,7 @@
"""The teaching pages, built from the config: "What each key does", the cheat sheet, "Where things are"."""
import html
import os
import re
from settings import PARENT # noqa: F401
import schema
@@ -23,7 +24,7 @@ table { border-collapse: collapse; width: 100%; font-size: .95rem; } th, td { te
FAULT_WORDS = {
"conn": ["NO ANSWER", "UNREACHABLE", "REFUSED", "LOGIN", "OFFLINE", "WARNING", "DISABLED", "NOT IN COMPANION", "NO DATA"],
"hyperdeck": ["NO DISK", "FULL <minutes>", "LOW <minutes>", "NOT REC", "OFFLINE", "NO ANSWER"],
"hyperdeck": ["NO DISK", "FULL (minutes left)", "LOW (minutes left)", "NOT REC", "OFFLINE", "NO ANSWER"],
"dante": ["<device> GONE"], "shure": ["<channel> BATT", "<channel> INTERF"], "psm": ["<channel> RF MUTE"],
"tascam": ["<the recorder's own error or caution>"], "ping": ["<name> NO PING"], "launchd": ["<name> NOT LOADED", "<name> STOPPED"],
"process": ["<name> STOPPED"], "http": ["<name> UNREACH"], "disk": ["DISK <free>GB"],
@@ -94,26 +95,46 @@ def fix_words(fix_id, cfg):
return e(fix.get("label", fix_id)) if fix else e(fix_id)
def plain(markup):
"""The same words without tags or entities, for the page's JSON."""
return html.unescape(re.sub(r"<[^>]+>", "", markup))
def hold_words(tile, cfg):
fixes = []
always = tile.get("hold_always")
fixes, per_sub = [], []
for chk in tile.get("checks", []):
fx = chk.get("fix")
if isinstance(fx, dict) and len(set(fx.values())) == 1:
fx = next(iter(fx.values())) # one fix for every sub-check: a plain fix
if isinstance(fx, dict):
for sub, f in fx.items():
fixes.append("%s: %s" % ("for anything else" if sub == "*" else "for " + e(sub), fix_words(f, cfg)))
if f == sp.DANTE_AUTO or sub == "*":
per_sub.append(fix_words(f, cfg) if sub == "*" else "%s: %s" % (e(sub), fix_words(f, cfg)))
else:
per_sub.append("%s: %s" % (e(sub), fix_words(f, cfg)))
elif fx:
fixes.append(fix_words(fx, cfg))
fixes.append(fx)
elif chk["type"] in sp.RESTARTABLE and chk.get("conn"):
fixes.append("reconnects Companion's %s connection" % e(chk["conn"]))
out = []
if tile.get("hold_always"):
out.append("<b>also when the key is green:</b> " + fix_words(tile["hold_always"], cfg))
seen = []
fixes.append(sp.RESTART_CONNECTION + ":" + chk["conn"])
words, seen = [], set()
for f in fixes:
if f not in seen:
seen.append(f)
if seen:
out.append("fixes what is failing: " + "; ".join(seen))
if f in seen:
continue
seen.add(f)
if f.startswith(sp.RESTART_CONNECTION + ":"):
words.append("reconnects Companion's %s connection" % e(f.split(":", 1)[1]))
elif f == always:
words.append(fix_words(f, cfg) + " <b>(also when the key is green)</b>")
else:
words.append(fix_words(f, cfg))
if always and always not in seen:
words.insert(0, fix_words(always, cfg) + " <b>(also when the key is green)</b>")
out = []
if words:
out.append("; ".join(words))
if per_sub:
out.append("for a named device, " + "; ".join(dict.fromkeys(per_sub)))
if tile.get("kind") == "network":
out.append("clears a remembered network blip")
if tile.get("members"):
+18 -5
View File
@@ -39,10 +39,17 @@ WIN_PROBE = (
)
def shell_path(path):
"""A path for a shell line: quoted, except that a leading ~ becomes $HOME outside the quotes."""
if path.startswith("~/"):
return '"$HOME"' + shlex.quote(path[1:])
return shlex.quote(path)
def quoted_paths(cfg):
site = cfg.get("site", {})
return {"helper": shlex.quote(site.get("dvs_helper", "/usr/local/sbin/status-panel-dvs-start")),
"solo": shlex.quote(site.get("solo_dir", "~/Library/Application Support/status-panel").replace("~", "$HOME"))}
return {"helper": shell_path(site.get("dvs_helper", "/usr/local/sbin/status-panel-dvs-start")),
"solo": shell_path(site.get("solo_dir", "~/Library/Application Support/status-panel"))}
def run_ssh(cfg, alias, command, timeout=TIMEOUT):
@@ -146,11 +153,17 @@ def mac_items(host, parsed, cfg):
label = run.get("label", "")
item.update(light(label not in agents, "loaded - it must not run here"))
elif kind == "daemon":
proc = run.get("process", "")
item.update(light(any(proc in p for p in procs), "not running"))
want = run.get("process", "").lower().replace(" ", "")
running = any(want in p.lower().replace(" ", "") for p in procs) or any(want in a.lower() for a in agents) \
or (want.startswith("dantevirtual") and any("dvsd" in p for p in procs))
item.update(light(running, "not running"))
elif kind == "helper_dvs":
# `sudo -n -l <helper>` answers with the command itself when it is allowed without a password.
lines = [l for l in helper.splitlines() if l.strip()]
installed = "No such file" not in helper and "-rwx" in helper
allowed = "NOPASSWD" in helper or "(root)" in helper or "may run" in helper.lower()
last = lines[-1] if lines else ""
allowed = installed and "status-panel-dvs-start" in last and "not allowed" not in last.lower() and "may not" not in last.lower() \
and "password" not in last.lower()
item.update(light(installed and allowed, "not installed" if not installed else "sudo rule missing"))
elif kind == "helper_solo":
label = run.get("label", "org.waterschurch.status-panel.solo-mode")
+70 -24
View File
@@ -163,11 +163,27 @@ class Site:
if f["level"] not in ("err", "warn"):
continue
text = f["msg"] if f["msg"] and f["msg"] != tile["label"] else tile["label"]
hold = keyguide.hold_words(tile, cfg)
hold = keyguide.plain(keyguide.hold_words(tile, cfg))
problems.append({"key": tid, "label": tile["label"], "text": "%s: %s" % (tile["label"], f["msg"]),
"level": f["level"], "acked": f.get("acked"), "since": f.get("since"),
"action": "hold the %s key (%s)" % (tile["label"], hold) if hold and not hold.startswith("nothing")
else "no automatic fix: check the device"})
fresh = snap.get("time") and now - snap["time"] < 30
if not problems and deck and not fresh:
# The watchdog has not written a snapshot (older code, or just started): read the deck itself.
for tid, s in deck.items():
tile = tiles.get(tid)
if not tile or tile.get("members") or tile.get("kind") == "master":
continue # groups and ALL repeat their members
if s.get("b") not in (0xFF0000, 0x8B0000, 0xFFA000, 0x3A0000):
continue
lines = (s.get("x") or "").split("\n")
text = " ".join(lines[1:]) if len(lines) > 1 else lines[0]
hold = keyguide.plain(keyguide.hold_words(tile, cfg))
problems.append({"key": tid, "label": tile["label"], "text": "%s: %s" % (tile["label"], text),
"level": "warn" if s.get("b") == 0xFFA000 else "err", "acked": s.get("b") == 0x8B0000, "since": None,
"action": "hold the %s key (%s)" % (tile["label"], hold) if hold and not hold.startswith("nothing")
else "no automatic fix: check the device"})
if started and started.get("config_sha") and started["config_sha"] != self.cfg_sha:
needs.append("The watchdog is running an older settings file than the one on disk (restart it to pick up the change).")
pages = self.pages_state()
@@ -176,7 +192,8 @@ class Site:
return {
"time": now, "site": cfg.get("site", {}), "deck": deck, "heartbeat_age": (now - hb) if hb else None,
"companion_ok": companion_ok, "watchdog": self.watchdog_state(started, hb, now),
"live": live, "running": running, "service_on": snap.get("service_on"), "alarms_override": snap.get("alarms_override"),
"live": live, "running": running, "service_on": snap.get("service_on") if fresh else None,
"alarms_override": snap.get("alarms_override") if fresh else None,
"auto_fix": {"phrase": schema.phrases(cfg)["auto_fix"], "state": snap.get("auto_fix")},
"problems": sorted(problems, key=lambda p: (p["level"] != "err", p["since"] or 0)), "needs": needs,
"computers": self.computer_lines(snap), "pages": pages, "snapshot_age": (now - snap["time"]) if snap.get("time") else None,
@@ -196,34 +213,63 @@ class Site:
state["heartbeat_fresh"] = bool(hb and now - hb < cfg_stale(self.cfg))
return state
def check_hosts(self, cfg):
"""{(key id, check identity): {computer aliases}}: which computers a check is about - the Mac a
MIDI session or remote command runs on, the peer's computer when a computer is named like it,
the computer a check's fix runs on, this Mac for local things."""
hosts = cfg.get("hosts", {})
me = next((a for a, h in hosts.items() if h.get("self")), None)
by_session = {}
for alias, h in hosts.items():
for run in h.get("runs", []):
if run.get("kind") == "midi_session" and run.get("name"):
by_session[run["name"]] = alias
if h.get("name"):
by_session.setdefault(h["name"], alias)
out = {}
for t in cfg["tiles"]:
for c in t.get("checks", []):
who = set()
if c["type"] in ("remote", "midi_peer") and c.get("host"):
who.add(c["host"])
elif c["type"] == "midi_peer" and me:
who.add(me)
if c["type"] == "midi_peer" and c.get("peer") in by_session:
who.add(by_session[c["peer"]])
if c["type"] in ("launchd", "process", "disk", "pref", "spotify", "atem", "midi_rx") and me:
who.add(me)
fx = c.get("fix")
for f in (fx.values() if isinstance(fx, dict) else [fx]):
fix = cfg.get("fixes", {}).get(f) if f else None
if fix and fix.get("host"):
who.add(fix["host"])
elif fix and fix.get("kind") in ("local", "spotify") and me:
who.add(me)
if t.get("hold_always"):
fix = cfg["fixes"].get(t["hold_always"], {})
if fix.get("host"):
who.add(fix["host"])
out[(t["id"], sp.check_identity(c))] = who
return out
def computer_lines(self, snap):
"""One line per computer: a status word and the reason, from what the watchdog sees."""
cfg = self.cfg
live = self.read_json("machines-live.json", {}) or {}
tiles_by_host = {}
for t in cfg["tiles"]:
for c in t.get("checks", []):
h = c.get("host") if c["type"] in ("remote", "midi_peer") else None
if h:
tiles_by_host.setdefault(h, set()).add(t["id"])
for fid, fix in cfg.get("fixes", {}).items():
if fix.get("host"):
for t in cfg["tiles"]:
own = set()
for c in t.get("checks", []):
fx = c.get("fix")
own |= set(fx.values()) if isinstance(fx, dict) else ({fx} if fx else set())
if fid in own or t.get("hold_always") == fid:
tiles_by_host.setdefault(fix["host"], set()).add(t["id"])
owners = self.check_hosts(cfg)
tiles_by_host, labels = {}, {t["id"]: t["label"] for t in cfg["tiles"]}
for (tid, _), who in owners.items():
for alias in who:
tiles_by_host.setdefault(alias, set()).add(tid)
lines = []
for alias, h in cfg.get("hosts", {}).items():
probe = live.get(alias) or {}
word, reason = "OK", ""
faults = []
for tid in sorted(tiles_by_host.get(alias, [])):
for f in (snap.get("faults") or {}).get(tid, []):
if f["level"] == "err" and (f["type"] in ("remote", "midi_peer") or True):
faults.append("%s %s" % (tiles_by_host and next(t["label"] for t in cfg["tiles"] if t["id"] == tid), f["msg"]))
for tid, fs in (snap.get("faults") or {}).items():
for f in fs:
if f["level"] == "err" and alias in owners.get((tid, f.get("check")), set()):
faults.append("%s %s" % (labels.get(tid, tid), f["msg"]))
if probe and not probe.get("reachable") and not h.get("self"):
word, reason = "Problem", "cannot be reached (%s)" % probe.get("error", "no answer")
elif faults:
@@ -453,14 +499,14 @@ class Site:
if c.get("tag"):
ws = ["%s %s" % (c["tag"], w) for w in ws]
words += ws
tiles.append(dict(t, says=sorted(set(words)), holding=re.sub(r"<[^>]+>", "", keyguide.hold_words(t, cfg)),
checks=[dict(c, sentence=re.sub(r"<[^>]+>", "", keyguide.check_sentence(c, cfg)),
tiles.append(dict(t, says=sorted(set(words)), holding=keyguide.plain(keyguide.hold_words(t, cfg)),
checks=[dict(c, sentence=keyguide.plain(keyguide.check_sentence(c, cfg)),
identity=sp.check_identity(c)) for c in t.get("checks", [])]))
rp = cfg.get("restart_page")
keys = []
if rp:
for k in rp["keys"]:
keys.append(dict(k, holding=re.sub(r"<[^>]+>", "", keyguide.fix_words(k["fix"], cfg))))
keys.append(dict(k, holding=keyguide.plain(keyguide.fix_words(k["fix"], cfg))))
return {"page": cfg["page"], "page_name": cfg["page_name"], "tiles": tiles, "restart_page": dict(rp, keys=keys) if rp else None,
"hold": schema.phrases(cfg)["hold"], "grid": cfg.get("page_grid", schema.F["page_grid"]["default"])}
+2 -2
View File
@@ -95,7 +95,7 @@
<dl class="kv">
<dt>Watchdog</dt><dd>${w.running ? "OK, running" : "<b>not running</b>"}${w.since ? " since " + esc(when(w.since)) : ""}${w.version ? ` <span class="pill">code ${esc(w.version)}</span>` : ""}${o.heartbeat_age != null ? ` · heartbeat ${esc(ago(o.heartbeat_age))}` : ""}</dd>
<dt>Companion</dt><dd>${o.companion_ok ? "OK" : "<b>not answering</b>"}${o.pages.status ? (o.pages.status.ok ? ", pages OK" : ", page " + o.pages.status.page + " is called " + esc(o.pages.status.live || "?")) : ""}</dd>
<dt>Alarms</dt><dd>${o.service_on ? "ON" : "silenced until midnight"}${o.alarms_override != null ? " (by hand)" : ""} <span class="muted small">· ${esc(o.phrases.alarms)}</span></dd>
<dt>Alarms</dt><dd>${o.service_on == null ? "<span class='muted'>not reported yet</span>" : o.service_on ? "ON" : "silenced until midnight"}${o.alarms_override != null ? " (by hand)" : ""} <span class="muted small">· ${esc(o.phrases.alarms)}</span></dd>
<dt>Auto-fix</dt><dd>${esc(o.auto_fix.phrase)}${o.auto_fix.state && o.auto_fix.state.on ? " · <b>may act now</b>" : ""}${o.auto_fix.state && o.auto_fix.state.paused ? " (waiting out a network blip)" : ""}</dd>
<dt>Running now</dt><dd>${Object.keys(o.running || {}).length ? Object.values(o.running).map((r) => esc(r.what + " on " + r.key)).join(", ") : "no fix is running"}</dd>
<dt>Last change</dt><dd>${o.last_change ? esc(o.last_change.when + " · " + o.last_change.note) : "—"} <a href="#history" class="small">History</a></dd>
@@ -180,7 +180,7 @@
}
function computerCard(c) {
const items = c.items.map((i) => `<tr><td>${lightDot(i.light)}${esc(i.light === "ok" ? "OK" : i.light === "problem" ? "Problem" : i.light === "not_needed" ? "Not needed" : "Can't see from here")}</td><td>${esc(i.name)}${i.watched === false ? ' <span class="muted small">(not watched)</span>' : ""}</td><td class="muted small">${esc(i.reason || i.detail || (i.detail && JSON.stringify(i.detail)) || "")}</td></tr>`).join("");
const items = c.items.map((i) => `<tr><td>${lightDot(i.light)}${esc(i.light === "ok" ? "OK" : i.light === "problem" ? "Problem" : i.light === "not_needed" ? "Not needed" : "Can't see from here")}</td><td>${esc(i.name)}${i.watched === false ? ' <span class="muted small">(not watched)</span>' : ""}</td><td class="muted small">${esc(i.reason || (typeof i.detail === "object" && i.detail ? Object.entries(i.detail).map(([k, v]) => k + " " + (v === true ? "yes" : v === false ? "no" : k === "age" ? ago(v) : v)).join(", ") : i.detail || ""))}</td></tr>`).join("");
const found = c.found || {};
const foundHtml = Object.entries(found).filter(([, v]) => Array.isArray(v) && v.length).map(([k, v]) => `<p class="small"><b>${esc(k)}:</b> <span class="mono">${esc(typeof v[0] === "object" ? v.map((x) => x.iface + " " + x.ip + (x.active ? "" : " (down)")).join(", ") : v.join(", "))}</span></p>`).join("") || `<p class="muted small">Press "Check computers now" to look.</p>`;
const done = Object.entries(c.manual_done || {}).map(([k, v]) => `<li>${lightDot("hand")}${esc(k.replace(/_/g, " "))}: done by hand (${esc(v.by)}, ${esc(v.on)})</li>`).join("");
+1
View File
@@ -11,6 +11,7 @@
--bg: #14181a; --surface: #1d2326; --fg: #e6ebe6; --muted: #9aa69e; --line: #333c3f; --accent: #5fcf8a; --accent-soft: #1f3a2b;
--deck: #0c0e0d; --ok: #3fbf6f; --problem: #ff6a5e; --warn: #f0b84a; --unknown: #8b96a0; --none: #4c5553; --hand: #7fb3ef; color-scheme: dark }
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 var(--font); }
.top { position: sticky; top: env(safe-area-inset-top, 0px); z-index: 5; display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--surface); border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 8px; white-space: nowrap; }