Step 1 in progress: the settings page server, probes, teaching pages and UI (not deployed yet)

- settings/server.py: read-only routes (overview, keys, computers, gear, companion, logs, history,
  help facts, key guide / cheat sheet / where things are), the editing-code check with a lockout,
  LAN-only binding with a per-minute rebind
- settings/probes.py: fixed read-only probes per computer; whole lists fetched and matched locally
- settings/keyguide.py: the generated teaching pages
- settings/ui: the page, every screen read-only
- status_panel.py: snapshot.json every 5 s for the page; the Companion database lookup prefers the
  running build's folder and skips a database that will not open (a stale v5.1 folder was breaking it)
- config.json: every computer's record (what runs there, network ports, done-by-hand ticks), the
  editing code (kept in the repo, the user's choice), an empty gear list
- install.sh: the settings page as a second LaunchAgent; settings/ and schema.py deployed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 16:23:59 -04:00
co-authored by Claude Fable 5.1
parent b05ff408d5
commit 2343a27529
11 changed files with 2266 additions and 19 deletions
+8
View File
@@ -0,0 +1,8 @@
"""The settings page: a small web server on the Ingest Mac beside the watchdog."""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
PARENT = os.path.dirname(HERE)
if PARENT not in sys.path: # status_panel, schema, make_page live one folder up
sys.path.insert(0, PARENT)
+271
View File
@@ -0,0 +1,271 @@
"""The teaching pages, built from the config: "What each key does", the cheat sheet, "Where things are"."""
import html
import os
from settings import PARENT # noqa: F401
import schema
import status_panel as sp
CSS = """
body { font-family: -apple-system, 'Helvetica Neue', Arial, sans-serif; margin: 0; padding: 24px 16px; color: #1d2420; background: #fff; }
h1 { font-size: 1.7rem; margin: 0 0 6px; } h2 { font-size: 1.2rem; margin: 28px 0 8px; border-top: 2px solid #d6dcd6; padding-top: 10px; }
h3 { font-size: 1.05rem; margin: 18px 0 4px; }
p, li { line-height: 1.45; } .muted { color: #5a665f; } code { background: #eef2ee; padding: 1px 5px; border-radius: 4px; font-size: .9em; }
.grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px; max-width: 560px; margin: 10px 0; }
.cell { background: #1b1f1d; color: #e8ebe6; font: 600 .72rem/1.1 -apple-system, sans-serif; border-radius: 5px; aspect-ratio: 1; display: flex; align-items: center; justify-content: center; text-align: center; padding: 2px; }
.cell.nav { background: #37474f; } .cell.empty { background: #2a2e2c; opacity: .4; }
table { border-collapse: collapse; width: 100%; font-size: .95rem; } th, td { text-align: left; vertical-align: top; padding: 6px 10px; border-bottom: 1px solid #e3e7e3; }
.key { margin: 14px 0 18px; padding: 10px 14px; border: 1px solid #d6dcd6; border-radius: 8px; }
.key h3 { margin-top: 0; } .words { font-family: ui-monospace, Menlo, monospace; font-size: .85rem; }
.legend span { display: inline-block; margin-right: 14px; } .legend i { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
@media print { body { padding: 0; } .key { break-inside: avoid; } }
"""
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"],
"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"],
"midi_peer": ["MIDI LOST", "NO SESSION", "MIDI <state>"], "pref": [], "spotify": ["SPOTIFY CLOSED", "NOT PLAYING", "WRONG PLAYLIST"],
"mdns": ["<name> GONE"], "midi_rx": ["<name> SILENT"], "remote": [], "artnet": ["SILENT"], "atem": [],
}
def e(text):
return html.escape(str(text))
def check_sentence(chk, cfg):
t = chk["type"]
hosts = cfg.get("hosts", {})
who = lambda alias: hosts.get(alias, {}).get("name", alias)
if t == "conn":
return "Companion's connection %s is good" % e(chk["conn"])
if t == "hyperdeck":
s = "HyperDeck %s is connected" % e(chk["conn"])
s += ", may have no disk" if chk.get("optional") else ", has a disk with time left"
return s + (", and records whenever another deck records" if chk.get("rec_sync", True) else "")
if t == "dante":
devices = chk.get("devices", {})
return "all %d Dante devices are present (%s)" % (len(devices), ", ".join(sorted(devices.values() if isinstance(devices, dict) else devices)))
if t == "shure":
return "Shure receiver %s: %d channel%s, batteries and interference" % (e(chk["conn"]), chk.get("channels", 1), "" if chk.get("channels", 1) == 1 else "s")
if t == "psm":
return "PSM transmitter %s: %d channel%s not RF-muted" % (e(chk["conn"]), chk.get("channels", 1), "" if chk.get("channels", 1) == 1 else "s")
if t == "tascam":
return "the Tascam recorder (%s) reports no error or caution" % e(chk["conn"])
if t == "ping":
return "%s answers ping (%s)" % (e(chk.get("name") or chk.get("conn") or chk.get("host")), e(chk.get("conn") and "at Companion's address for %s" % chk["conn"] or chk.get("host")))
if t == "launchd":
return "%s (a helper on this Mac) is loaded and running" % e(chk.get("name") or chk["label"])
if t == "process":
return "%s is running on this Mac" % e(chk.get("name") or chk["match"])
if t == "http":
return "%s answers (%s)" % (e(chk.get("name") or chk.get("msg") or "a web address"), e(chk["url"]))
if t == "disk":
return "the disk %s has more than %d GB free" % (e(chk["path"]), chk.get("warn_gb", 20))
if t == "midi_peer":
where = ("on %s" % who(chk["host"])) if chk.get("host") else "on this Mac"
return "the network MIDI session %s %s has %s in it" % (e(chk["session"]), where, e(chk["peer"]))
if t == "pref":
return "the setting %s in %s is %s" % (e(chk["key"]), e(chk["domain"]), e(chk["want"]))
if t == "spotify":
return "Spotify on this Mac is playing %s" % e((cfg.get("spotify") or {}).get("name") or "the service playlist")
if t == "mdns":
return "%s is advertised on the network (Bonjour)" % e(chk.get("name") or chk["instance"])
if t == "midi_rx":
return "MIDI is arriving from %s" % e(chk.get("name") or chk["source"])
if t == "remote":
return "on %s: %s" % (who(chk["host"]), e(chk.get("msg", "a command succeeds").lower().replace(" not ", " is ")))
if t == "artnet":
return "the lighting program at %s keeps sending Art-Net" % e(chk["source"])
if t == "atem":
return "the switcher's %s settings match the saved file" % e(chk["part"])
return e(t)
def fix_words(fix_id, cfg):
if fix_id == sp.RESTART_CONNECTION:
return "reconnects its Companion connection"
if fix_id == sp.DANTE_AUTO:
return "power-cycles that device's PoE port if it has one, otherwise reconnects Companion's Dante module"
fix = cfg.get("fixes", {}).get(fix_id)
return e(fix.get("label", fix_id)) if fix else e(fix_id)
def hold_words(tile, cfg):
fixes = []
for chk in tile.get("checks", []):
fx = chk.get("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)))
elif fx:
fixes.append(fix_words(fx, cfg))
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 = []
for f in fixes:
if f not in seen:
seen.append(f)
if seen:
out.append("fixes what is failing: " + "; ".join(seen))
if tile.get("kind") == "network":
out.append("clears a remembered network blip")
if tile.get("members"):
out.append("fixes whatever is failing on %s" % ", ".join(e(m.upper()) for m in tile["members"]))
return "; ".join(out) if out else "nothing (there is no automatic fix for this key)"
def key_guide(cfg):
"""What each key does: one entry per key, in deck order, plus the restart keys."""
ph = schema.phrases(cfg)
auto = set((cfg.get("auto_fix") or {}).get("fixes", []))
tiles = {t["id"]: t for t in cfg["tiles"]}
parts = ["<!doctype html><meta charset='utf-8'><title>What each key does</title><style>%s</style>" % CSS,
"<h1>What each key does</h1><p class='muted'>Made from the panel's settings; it changes whenever they do. "
"Press a key = acknowledge its alarm. Hold %s = run its fix.</p>" % e(ph["hold"].replace("HOLD ", "")),
grid_html(cfg)]
order = sorted((t for t in cfg["tiles"] if "pos" in t), key=lambda t: (t["pos"][0], t["pos"][1]))
order += [t for t in cfg["tiles"] if "pos" not in t]
for t in order:
parts.append("<div class='key' id='key-%s'><h3>%s</h3>" % (e(t["id"]), e(t["label"])))
pos = "row %d, column %d" % tuple(t["pos"]) if "pos" in t else "no key of its own (shown inside its group)"
parts.append("<p class='muted'>%s%s</p>" % (pos, "" if t.get("kind") != "master" else " - sums up every key"))
if t.get("members"):
parts.append("<p>Shows the worst of: %s. Hold: %s.</p>" % (", ".join(e(tiles[m]["label"]) for m in t["members"]), hold_words(t, cfg)))
if t.get("kind") == "service":
parts.append("<p>%s</p>" % e(ph["alarms"]))
if t.get("kind") == "network":
n = cfg.get("network", {})
parts.append("<p>Watches this Mac's network port%s, and calls it a blip when %d connections from %d groups drop within %d s "
"(shown for %d minutes). Hold: clears the blip.</p>" % (
(" and pings %s" % e(n["gateway"])) if n.get("gateway") else "", n.get("burst_connections", 4),
n.get("burst_groups", 3), n.get("burst_window_seconds", 10), n.get("remember_seconds", 300) // 60))
if t.get("checks"):
parts.append("<p><b>Checks:</b></p><ul>")
for chk in t["checks"]:
words = FAULT_WORDS.get(chk["type"], [])
if chk.get("msg"):
words = [chk["msg"]]
parts.append("<li>%s%s</li>" % (check_sentence(chk, cfg), (" <span class='words muted'>&rarr; %s</span>" % e(", ".join(words))) if words else ""))
parts.append("</ul>")
parts.append("<p><b>Holding:</b> %s.</p>" % hold_words(t, cfg))
own = set()
for chk in t["checks"]:
fx = chk.get("fix")
own |= set(fx.values()) if isinstance(fx, dict) else ({fx} if fx else set())
if t.get("hold_always"):
own.add(t["hold_always"])
byself = sorted(f for f in own if f in auto)
if byself:
parts.append("<p class='muted'>Auto-fix %s: %s.</p>" % (e(ph["auto_fix"]), ", ".join(e(cfg["fixes"][f].get("short", f)) for f in byself)))
parts.append("</div>")
rp = cfg.get("restart_page")
if rp:
parts.append("<h2>Restart page (page %d)</h2><p>Each key restarts one thing even when the Status page says it is fine. "
"It flashes HOLD TO FIX while that thing is down.</p>" % rp["page"])
for k in sorted(rp.get("keys", []), key=lambda k: (k["pos"][0], k["pos"][1])):
parts.append("<div class='key'><h3>%s</h3><p class='muted'>row %d, column %d</p><p>Hold: %s.</p></div>"
% (e(k["label"]), k["pos"][0], k["pos"][1], fix_words(k["fix"], cfg)))
parts.append("<h2>Colours</h2><p class='legend'><span><i style='background:#007A2E'></i>green: fine</span>"
"<span><i style='background:#FF0000'></i>flashing red: the problem, named</span>"
"<span><i style='background:#8B0000'></i>dark red: acknowledged</span>"
"<span><i style='background:#FFA000'></i>amber: a warning, or a fault that keeps coming back (xN)</span>"
"<span><i style='background:#6A1B9A'></i>purple: the watchdog is down</span>"
"<span><i style='background:#2962FF'></i>blue: a fix is running</span></p>")
return "\n".join(parts)
def grid_html(cfg, restart=False):
rp = cfg.get("restart_page")
cells = {}
if restart and rp:
for k in rp.get("keys", []):
cells[tuple(k["pos"])] = k["label"]
cells[tuple(rp["back_pos"])] = "STATUS PAGE"
else:
for t in cfg["tiles"]:
if "pos" in t:
cells[tuple(t["pos"])] = t["label"]
if rp:
cells[tuple(rp["jump_pos"])] = "RESTART PAGE"
nav = {(0, 0): "UP", (1, 0): str(rp["page"] if restart and rp else cfg["page"]), (2, 0): "DOWN"}
out = ["<div class='grid'>"]
for r in range(4):
for c in range(8):
if (r, c) in nav:
out.append("<div class='cell nav'>%s</div>" % e(nav[(r, c)]))
elif (r, c) in cells:
out.append("<div class='cell'>%s</div>" % e(cells[(r, c)]))
else:
out.append("<div class='cell empty'></div>")
out.append("</div>")
return "".join(out)
def cheat_sheet(cfg, address):
ph = schema.phrases(cfg)
site = cfg.get("site", {})
hosts = cfg.get("hosts", {})
rows = "".join("<tr><td>%s</td><td>%s</td></tr>" % (e(h.get("name", a)), e(h.get("role", ""))) for a, h in hosts.items())
return "\n".join([
"<!doctype html><meta charset='utf-8'><title>Status panel cheat sheet</title><style>%s</style>" % CSS,
"<h1>Status panel - %s</h1>" % e(site.get("name", "")),
"<p><b>Green</b> = fine. <b>Flashing red</b> = the problem, named on the key. <b>Press</b> = acknowledge (it stops flashing). "
"<b>%s</b> = run the fix. <b>Purple</b> = the watchdog is down: open the settings page and press Restart the watchdog.</p>" % e(ph["hold"].replace("HOLD ", "Hold ")),
"<p>%s Auto-fix %s. %s.</p>" % (e(ph["alarms"]), e(ph["auto_fix"]), e(ph["live"][0].upper() + ph["live"][1:])),
"<h2>Status page (page %d)</h2>" % cfg["page"], grid_html(cfg),
("<h2>Restart page (page %d)</h2>" % cfg["restart_page"]["page"] + grid_html(cfg, restart=True)) if cfg.get("restart_page") else "",
"<h2>The computers</h2><table>%s</table>" % rows,
"<h2>Where to look</h2><p>The settings page: <b>%s</b> (every key, every computer, the logs, the history, help). "
"Contacts: %s</p>" % (e(address), e(site.get("contacts") or "-")),
])
def where_things_are(cfg, data_dir, address):
"""Every file, folder, log, helper and port, per computer. The one page allowed to show file names."""
site = cfg.get("site", {})
hosts = cfg.get("hosts", {})
parts = ["<!doctype html><meta charset='utf-8'><title>Where things are</title><style>%s</style>" % CSS,
"<h1>Where things are</h1><p class='muted'>For the person who maintains it.</p>",
"<h2>%s (this Mac)</h2><ul>" % e(site.get("ingest_name", "the Ingest Mac")),
"<li>The watchdog and the settings page: LaunchAgents <code>org.waterschurch.status-panel</code> and "
"<code>org.waterschurch.status-settings</code>; files in <code>%s</code></li>" % e(data_dir or "~/Library/Application Support/status-panel"),
"<li>Logs: <code>~/Library/Logs/status-panel.log</code>, <code>status-panel.err.log</code>, <code>status-settings.log</code>; "
"auto-fix daily logs in <code>%s</code></li>" % e((cfg.get("auto_fix") or {}).get("log_dir", "~/Desktop/Auto-Fix Logs")),
"<li>Working files: <code>config.json</code> (the settings), <code>state.json</code> (what survives a restart), "
"<code>started.json</code>, <code>live-now.json</code>, <code>fixes-running.json</code>, <code>snapshot.json</code> (what the watchdog sees), "
"<code>machines-live.json</code> (the last look at the computers), <code>dante-map.json</code>, <code>atem-reference.xml</code> (the saved switcher file), "
"<code>prev/</code> (the previous code, for <code>install.sh --rollback</code>)</li>",
"<li>Companion: <code>http://127.0.0.1:%d</code>, custom variables <code>%s</code>, <code>%s</code>, <code>%s</code>, blink clock <code>%s</code>; "
"pages %d (Status)%s</li>" % (cfg["companion"].get("port", 8000), e(cfg["variables"]["state"]), e(cfg["variables"]["heartbeat"]),
e(cfg["variables"]["command"]), e(cfg["variables"].get("flash", "flash_tick")), cfg["page"],
(" and %d (Restart)" % cfg["restart_page"]["page"]) if cfg.get("restart_page") else ""),
"<li>The login key for the other computers: <code>%s</code>; the Netgear login: keychain item <code>netgear-status-panel</code></li>" % e(cfg.get("ssh_key", "~/.ssh/id_status_panel")),
"<li>This page: <code>%s</code></li></ul>" % e(address)]
for alias, h in hosts.items():
if h.get("self"):
continue
parts.append("<h2>%s</h2><ul><li>Log in: <code>%s</code>%s</li>" % (e(h.get("name", alias)), e(h.get("ssh", "")),
(" (wake-on-LAN %s)" % e(h["mac"])) if h.get("mac") else ""))
for run in h.get("runs", []):
k = run.get("kind")
if k == "task":
parts.append("<li>Scheduled task <code>\\StatusPanel\\%s</code> starts <code>%s</code></li>" % (e(run["name"]), e(run.get("exe", ""))))
elif k == "service":
parts.append("<li>Windows service <code>%s</code></li>" % e(run["name"]))
elif k == "launchagent":
parts.append("<li>LaunchAgent <code>%s</code> (%s)</li>" % (e(run.get("label", "")), e(run.get("name", ""))))
elif k == "helper_dvs":
parts.append("<li>DVS helper <code>%s</code> with a sudoers rule in <code>/etc/sudoers.d/status-panel</code></li>" % e(site.get("dvs_helper", "/usr/local/sbin/status-panel-dvs-start")))
elif k == "helper_solo":
parts.append("<li>Pro Tools helper in <code>%s</code> (LaunchAgent <code>%s</code>; needs Accessibility)</li>" % (e(site.get("solo_dir", "")), e(run.get("label", ""))))
elif k == "app":
parts.append("<li>%s (process <code>%s</code>)</li>" % (e(run.get("name", "")), e(run.get("process", ""))))
parts.append("</ul>")
return "\n".join(parts)
+363
View File
@@ -0,0 +1,363 @@
"""Read-only looks at the computers, for the Computers screen.
Every probe is a fixed command. Whole lists come back (every agent, every process, every task,
every service) and are matched here, so no name from the settings is ever put into a command
line. The only values that reach a command are two folder paths from the site block, shell-quoted.
Nothing is ever changed, nothing is woken, and a probe never runs while the watchdog runs a fix.
"""
import glob
import json
import os
import re
import shlex
import socket
import subprocess
import threading
import time
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
import status_panel as sp
TIMEOUT = 8
MAC_PROBE = (
'echo "SP_NAME=$(uname -n)"; echo "SP_OSV=$(sw_vers -productVersion 2>/dev/null)"; '
'echo "SP_PY=$(/usr/bin/python3 --version 2>&1)"; '
'echo "SP_KEY=$(grep -c status-panel ~/.ssh/authorized_keys 2>/dev/null)"; '
'echo SP_AGENTS; launchctl list 2>/dev/null; echo SP_PROCS; ps -axo comm= 2>/dev/null; '
'echo SP_APPS; ls /Applications 2>/dev/null; echo SP_HELPER; ls -l {helper} 2>&1; sudo -n -l {helper} 2>&1 | tail -1; '
'echo SP_SOLO; cat {solo}/solo-mode.json 2>/dev/null; echo; stat -f %m {solo}/solo-mode.json 2>/dev/null; '
'echo SP_NOW; date +%s; echo SP_END'
)
WIN_PROBE = (
'echo SP_NAME=%COMPUTERNAME% & echo SP_TASKS & schtasks /Query /FO CSV /NH 2>nul & echo SP_PROCS & tasklist /FO CSV /NH 2>nul & '
'echo SP_SVC & sc query type= service state= all 2>nul & echo SP_PS & powershell -NoProfile -Command '
'"(Get-NetConnectionProfile | Select -First 1 -ExpandProperty NetworkCategory); '
'(Get-NetAdapterPowerManagement | Select -First 1 -ExpandProperty WakeOnMagicPacket); '
'((Get-NetFirewallRule -Name OpenSSH-Server-In-TCP -ErrorAction SilentlyContinue | Get-NetFirewallAddressFilter | '
'Select -ExpandProperty RemoteAddress) -join \',\')" 2>nul & echo SP_END'
)
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"))}
def run_ssh(cfg, alias, command, timeout=TIMEOUT):
try:
r = subprocess.run(sp.ssh_cmd(cfg, alias) + [command], capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout, r.stderr
except subprocess.TimeoutExpired:
return None, "", "no answer within %d s" % timeout
except OSError as e:
return None, "", str(e)
def sections(out, markers):
"""Split probe output at the SP_... marker lines: {marker: [lines]}."""
parts, current = {}, None
for line in out.replace("\r", "").split("\n"):
key = line.strip()
if key in markers:
current = key
parts[current] = []
elif current:
parts[current].append(line)
return parts
def values(lines_or_out):
"""SP_KEY=value lines -> {"KEY": "value"}."""
text = "\n".join(lines_or_out) if isinstance(lines_or_out, list) else lines_or_out
return dict(re.findall(r"^SP_([A-Z]+)=(.*)$", text.replace("\r", ""), re.M))
def parse_launchctl(lines):
"""{label: pid or None} from `launchctl list`."""
agents = {}
for line in lines:
parts = line.split("\t")
if len(parts) == 3 and parts[2] and parts[2] != "Label":
agents[parts[2]] = int(parts[0]) if parts[0].isdigit() else None
return agents
def parse_csv_rows(lines):
out = []
for line in lines:
if not line.startswith('"'):
continue
out.append([c.strip('"') for c in line.strip().split('","')])
return out
def parse_sc(lines):
"""{service name: state word} from `sc query`."""
services, name = {}, None
for line in lines:
m = re.match(r"^\s*SERVICE_NAME:\s*(.+?)\s*$", line)
if m:
name = m.group(1)
m = re.match(r"^\s*STATE\s*:\s*\d+\s+(\w+)", line)
if m and name:
services[name] = m.group(1)
return services
def light(ok, reason=""):
return {"light": "ok" if ok else "problem", "reason": reason if not ok else ""}
def mac_items(host, parsed, cfg):
"""One line per thing that runs on a Mac, judged from the probe's lists."""
agents = parse_launchctl(parsed.get("SP_AGENTS", []))
procs = set(l.strip() for l in parsed.get("SP_PROCS", []))
proc_names = {os.path.basename(p) for p in procs} | {p.split("/")[-1] for p in procs}
apps = set(l.strip() for l in parsed.get("SP_APPS", []))
helper = "\n".join(parsed.get("SP_HELPER", []))
solo_lines = [l for l in parsed.get("SP_SOLO", []) if l.strip()]
now = int(("\n".join(parsed.get("SP_NOW", [])).strip() or "0") or 0)
items = []
for run in host.get("runs", []):
kind, name = run.get("kind"), run.get("name") or run.get("label") or run.get("process") or run.get("kind")
item = {"name": name, "kind": kind, "watched": run.get("watched", True)}
if kind == "sshd":
item.update(light(True))
elif kind == "app":
proc = run.get("process", "")
running = any(p == proc or p.endswith("/" + proc) for p in procs) or proc in proc_names
installed = (not run.get("path")) or True
item.update(light(running, "not running" if installed else "not installed"))
if not running and run.get("watched", True) is False:
item["light"] = "not_needed"
elif kind == "launchagent":
label = run.get("label", "")
if label not in agents:
item.update(light(False, "not loaded"))
elif agents[label] is None:
item.update(light(False, "loaded but not running"))
else:
item.update(light(True))
if run.get("watched", True) is False and item["light"] == "problem":
item["light"] = "not_needed"
elif kind == "disabled":
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"))
elif kind == "helper_dvs":
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()
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")
try:
data = json.loads(solo_lines[0]) if solo_lines else {}
age = now - int(solo_lines[1]) if len(solo_lines) > 1 and now else None
except (ValueError, IndexError):
data, age = {}, None
running = agents.get(label) is not None
fresh = age is not None and age < 60
trusted = bool(data.get("trusted"))
if not running:
item.update(light(False, "helper not running"))
elif not trusted:
item.update(light(False, "Accessibility not allowed on that Mac"))
elif not fresh:
item.update(light(False, "helper not reporting"))
else:
item.update(light(True))
item["detail"] = {"afl": data.get("afl"), "latch": data.get("latch"), "age": age}
elif kind == "midi_session":
item.update({"light": "unknown", "reason": "see the MIDI key"})
elif kind in ("folder",):
item.update({"light": "unknown", "reason": "not checked here"})
else:
item.update({"light": "unknown", "reason": ""})
items.append(item)
return items, {"agents": sorted(a for a in agents if not a.startswith(("com.apple.", "application."))),
"processes": sorted(proc_names)[:200]}
def windows_items(host, parsed):
tasks = {row[0]: row for row in parse_csv_rows(parsed.get("SP_TASKS", [])) if row}
procs = {row[0].lower() for row in parse_csv_rows(parsed.get("SP_PROCS", [])) if row}
services = parse_sc(parsed.get("SP_SVC", []))
ps = [l.strip() for l in parsed.get("SP_PS", []) if l.strip()]
profile, wol, allow = (ps + ["", "", ""])[:3]
items = []
for run in host.get("runs", []):
kind, name = run.get("kind"), run.get("name") or run.get("kind")
item = {"name": name, "kind": kind, "watched": run.get("watched", True)}
if kind == "sshd":
item.update(light(services.get("sshd") == "RUNNING", "the SSH service is %s" % services.get("sshd", "missing")))
elif kind == "task":
full = "\\StatusPanel\\" + run["name"]
if full not in tasks:
item.update(light(False, "no scheduled task %s" % full))
else:
exe = (run.get("exe") or "").lower()
running = exe in procs if exe else None
if running is False:
item.update(light(False, "%s is not running" % run.get("exe")))
else:
item.update(light(True))
item["task_state"] = tasks.get(full, ["", "", ""])[2] if full in tasks else None
elif kind == "service":
state = services.get(run["name"])
item.update(light(state == "RUNNING", "service %s is %s" % (run["name"], state or "missing")))
elif kind in ("web", "artnet"):
item.update({"light": "unknown", "reason": "see the key that watches it"})
else:
item.update({"light": "unknown", "reason": ""})
items.append(item)
setup = {"profile": profile, "wake_on_lan": wol, "firewall_allow": allow}
return items, {"tasks": sorted(t for t in tasks if "StatusPanel" in t), "services": sorted(k for k, v in services.items()
if v == "RUNNING" and not k.lower().startswith(("w", "s", "d", "e", "n", "p", "t", "u", "c")))[:60]}, setup
def probe_host(cfg, alias, host):
"""One computer, over ssh. Returns the record the Computers screen shows."""
started = time.time()
rec = {"alias": alias, "name": host.get("name", alias), "os": host.get("os", "mac"), "time": started,
"reachable": False, "items": [], "found": {}, "setup": {}}
if host.get("self"):
return probe_self(cfg, alias, host)
if host.get("os") == "windows":
rc, out, err = run_ssh(cfg, alias, WIN_PROBE, TIMEOUT + 6)
if rc is None or "SP_END" not in out:
rec["error"] = err.strip() or "no answer"
return rec
rec["reachable"] = True
parsed = sections(out, ("SP_TASKS", "SP_PROCS", "SP_SVC", "SP_PS", "SP_END"))
rec["computer_name"] = values(out).get("NAME", "").strip()
rec["items"], rec["found"], rec["setup"] = windows_items(host, parsed)
rec["setup"]["key_ok"] = True
rec["setup"]["shell"] = "cmd" if rec["computer_name"] and "%" not in rec["computer_name"] else "not cmd"
else:
rc, out, err = run_ssh(cfg, alias, MAC_PROBE.format(**quoted_paths(cfg)))
if rc is None or "SP_END" not in out:
rec["error"] = err.strip() or "no answer"
return rec
rec["reachable"] = True
v = values(out)
parsed = sections(out, ("SP_AGENTS", "SP_PROCS", "SP_APPS", "SP_HELPER", "SP_SOLO", "SP_NOW", "SP_END"))
rec["computer_name"], rec["os_version"], rec["python"] = v.get("NAME", ""), v.get("OSV", ""), v.get("PY", "")
rec["items"], rec["found"] = mac_items(host, parsed, cfg)
rec["setup"] = {"key_ok": (v.get("KEY") or "0").strip() not in ("", "0"), "python_ok": "3." in v.get("PY", "")}
rec["took"] = round(time.time() - started, 1)
return rec
def listening_ports():
try:
out = subprocess.run(["/usr/sbin/lsof", "-nP", "-iTCP", "-sTCP:LISTEN"], capture_output=True, text=True, timeout=6).stdout
except (OSError, subprocess.TimeoutExpired):
return []
ports = set()
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) > 8:
ports.add("%s %s" % (parts[0][:12], parts[8]))
return sorted(ports)
def interfaces():
"""[(name, ipv4, active?)] of this Mac's network ports."""
try:
out = subprocess.run(["/sbin/ifconfig"], capture_output=True, text=True, timeout=5).stdout
except (OSError, subprocess.TimeoutExpired):
return []
found = []
for block in re.split(r"\n(?=\w)", out):
name = block.split(":")[0]
ip = re.search(r"inet (\d+\.\d+\.\d+\.\d+)", block)
if ip and name != "lo0":
found.append((name, ip.group(1), "status: active" in block))
return found
def probe_self(cfg, alias, host):
"""This Mac: no ssh; the same questions asked locally, plus the things only it has."""
started = time.time()
rec = {"alias": alias, "name": host.get("name", "this Mac"), "os": "mac", "time": started, "reachable": True,
"items": [], "found": {}, "setup": {}}
try:
agents_out = subprocess.run(["/bin/launchctl", "list"], capture_output=True, text=True, timeout=5).stdout
procs_out = subprocess.run(["/bin/ps", "-axo", "comm="], capture_output=True, text=True, timeout=5).stdout
except (OSError, subprocess.TimeoutExpired) as e:
rec["error"] = str(e)
return rec
paths = quoted_paths(cfg)
helper = subprocess.run(["/bin/zsh", "-c", "ls -l %s 2>&1; sudo -n -l %s 2>&1 | tail -1" % (paths["helper"], paths["helper"])],
capture_output=True, text=True, timeout=6).stdout
parsed = {"SP_AGENTS": agents_out.splitlines(), "SP_PROCS": procs_out.splitlines(), "SP_APPS": [],
"SP_HELPER": helper.splitlines(), "SP_SOLO": [], "SP_NOW": [str(int(time.time()))]}
rec["items"], rec["found"] = mac_items(host, parsed, cfg)
for item, run in zip(rec["items"], host.get("runs", [])):
if run.get("kind") == "folder":
path = os.path.expanduser(run["path"])
item.update(light(os.path.isdir(path), "folder not found (is Google Drive signed in?)"))
rec["found"]["ports"] = listening_ports()
ifaces = interfaces()
rec["found"]["interfaces"] = [{"iface": n, "ip": ip, "active": a} for n, ip, a in ifaces]
key = os.path.expanduser(cfg.get("ssh_key", "~/.ssh/id_status_panel"))
atem = cfg.get("atem") or {}
rec["setup"] = {
"login_key": os.path.exists(key) and os.path.exists(key + ".pub"),
"python_ok": (3, 9) <= tuple(int(x) for x in subprocess.run(["/usr/bin/python3", "-c", "import sys; print('%d.%d' % sys.version_info[:2])"],
capture_output=True, text=True, timeout=5).stdout.strip().split(".")) <= (3, 12),
"companion_db": bool(sp.companion_db_path(cfg)),
"netgear_keychain": "acct" in subprocess.run(["/usr/bin/security", "find-generic-password", "-s", "netgear-status-panel"],
capture_output=True, text=True, timeout=5).stdout if cfg.get("dante_switches") else None,
"atem_node": bool(sp.newest(atem["node"])) if atem.get("node") else None,
"atem_library": bool(sp.newest(atem["library"])) if atem.get("library") else None,
"switches": {s: port_open(s, 23) for s in cfg.get("dante_switches", [])},
"lan_ports": [n for n in host.get("networks", []) if n.get("use") == "LAN"],
}
rec["took"] = round(time.time() - started, 1)
return rec
def port_open(host, port, timeout=2):
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def probe_all(cfg, data_dir=None, only=None):
"""Every computer at once (threads), results cached in machines-live.json."""
results, threads = {}, []
for alias, host in cfg.get("hosts", {}).items():
if only and alias not in only:
continue
def work(alias=alias, host=host):
try:
results[alias] = probe_host(cfg, alias, host)
except Exception as e: # one broken probe must not hide the others
results[alias] = {"alias": alias, "name": host.get("name", alias), "reachable": False, "error": str(e),
"items": [], "found": {}, "setup": {}, "time": time.time()}
t = threading.Thread(target=work, daemon=True)
t.start()
threads.append(t)
for t in threads:
t.join(TIMEOUT + 10)
if data_dir:
path = os.path.join(data_dir, "machines-live.json")
try:
old = json.load(open(path)) if os.path.exists(path) else {}
except (OSError, ValueError):
old = {}
old.update(results)
sp.write_json(path, old)
try:
os.chmod(path, 0o600)
except OSError:
pass
return results
+685
View File
@@ -0,0 +1,685 @@
#!/usr/bin/env python3
"""The settings page's server: a small stdlib HTTP server on the Ingest Mac, beside the watchdog.
Everything is readable by anyone on the church network (the alert web hook and the editing code
are masked); nothing changes without the editing code. The server never runs a command before an
Apply, never writes a file the watchdog writes, and reads the watchdog's own files for what it knows.
python3 -m settings.server --data-dir DIR [--port 8020] [--bind 127.0.0.1 ...]
"""
import argparse
import gzip
import glob
import hashlib
import http.server
import ipaddress
import json
import logging
import logging.handlers
import os
import re
import socket
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from settings import PARENT, HERE # noqa: F401
import schema
import status_panel as sp
from settings import keyguide, probes
log = logging.getLogger("status-settings")
UI = os.path.join(HERE, "ui")
PRIVATE = [ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "127.0.0.0/8")]
LOGS = {"watchdog": "~/Library/Logs/status-panel.log", "errors": "~/Library/Logs/status-panel.err.log",
"settings": "~/Library/Logs/status-settings.log"}
LOG_FILTERS = {"holds": r"\b(HOLD|FORCE|TEST|FIX|ENABLE|RESTART|ACK)\b", "fixes": r"\bFIX\b|-> ",
"auto": r"\bAUTO\b|auto-fix", "errors": r"WARNING|ERROR|Traceback", "faults": r"\b(FAULT|CLEAR)\b"}
# What the Companion experiments found (the Help page shows this; filled in as they are run).
COMPANION_FACTS = [
("Which module a connection uses", "The HTTP API reports it (moduleId) - no need to read Companion's database for it."),
("Does OBS say when it is streaming?", "Yes: the OBS-Youtube connection's variable 'streaming' reads 'Off-Air' when it is not; "
"the on-air value is recorded the first time it streams."),
("Can the page create the custom variables?", "Not yet known; they are made by hand in Companion for now."),
("Can an old page's keys be blanked over the API?", "Not yet tried; after a page-number change the old keys are deleted by hand."),
]
def allowed_client(ip):
try:
addr = ipaddress.ip_address(ip.split("%")[0])
except ValueError:
return False
return any(addr in n for n in PRIVATE)
def bind_addresses(cfg, ifaces=None):
"""127.0.0.1 plus the IPv4 address of every LAN-use port named for this Mac (every private
address when none is named)."""
ifaces = probes.interfaces() if ifaces is None else ifaces
me = next((h for h in cfg.get("hosts", {}).values() if h.get("self")), {})
lan = [n["iface"] for n in me.get("networks", []) if n.get("use") == "LAN"]
out = ["127.0.0.1"]
for name, ip, active in ifaces:
if ip in out:
continue
if (name in lan) if lan else allowed_client(ip):
out.append(ip)
return out
def tail(path, lines=400, pattern=None):
path = os.path.expanduser(path)
try:
with open(path, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 512 * 1024))
text = f.read().decode("utf-8", "replace")
except OSError:
return []
rows = text.split("\n")[1:] if len(text) >= 512 * 1024 else text.split("\n")
if pattern:
rx = re.compile(pattern)
rows = [r for r in rows if rx.search(r)]
return [r for r in rows if r][-lines:]
class Site:
"""Everything the handlers share: the config, the data dir, Companion, caches."""
def __init__(self, data_dir, config_path=None, companion=None):
self.data_dir = data_dir
self.config_path = config_path or os.path.join(data_dir, "config.json")
self.cfg, self.cfg_sha, self.cfg_mtime = None, None, 0
self.load()
self.comp = companion or sp.make_companion(self.cfg)
self.lock = threading.Lock()
self.probing = None
self.attempts = {} # client ip -> [count, first attempt time]
self.page_cache = {} # name -> (time, value)
def load(self):
st = os.stat(self.config_path)
if st.st_mtime != self.cfg_mtime:
with open(self.config_path, "rb") as f:
raw = f.read()
self.cfg = sp.load_config(self.config_path)
self.cfg_sha, self.cfg_mtime = hashlib.sha256(raw).hexdigest(), st.st_mtime
return self.cfg
def read_json(self, name, default=None):
try:
with open(os.path.join(self.data_dir, name)) as f:
return json.load(f)
except (OSError, ValueError):
return default
def companion_get(self, path, timeout=3):
comp = self.cfg["companion"]
url = "http://%s:%d%s" % (comp.get("host", "127.0.0.1"), comp.get("port", 8000), path)
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.status, r.read()
def cached(self, name, seconds, fn):
now = time.time()
hit = self.page_cache.get(name)
if hit and now - hit[0] < seconds:
return hit[1]
value = fn()
self.page_cache[name] = (now, value)
return value
# --- what the screens show ---------------------------------------------
def overview(self):
cfg = self.load()
names = cfg["variables"]
deck, hb, companion_ok = None, None, True
try:
raw = self.comp.get_custom(names["state"])
deck = json.loads(raw) if raw else None
hb = self.comp.get_custom(names["heartbeat"])
except Exception as e: # Companion down: the page still works
companion_ok, deck = False, None
log.warning("Companion: %s", e)
now = time.time()
hb = float(hb) if hb and str(hb).strip().replace(".", "").isdigit() else None
snap = self.read_json("snapshot.json", {}) or {}
started = self.read_json("started.json", {}) or {}
live = self.read_json("live-now.json", {}) or {}
running = self.read_json("fixes-running.json", {}) or {}
tiles = {t["id"]: t for t in cfg["tiles"]}
problems, needs = [], []
for tid, faults in (snap.get("faults") or {}).items():
tile = tiles.get(tid)
if not tile:
continue
for f in faults:
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)
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"})
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()
if pages.get("status") and not pages["status"]["ok"]:
needs.append("Companion's page %d is not called %s." % (cfg["page"], pages["status"]["expected"]))
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"),
"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,
"last_change": self.last_change(), "phrases": schema.phrases(cfg), "version": started.get("version"),
}
def watchdog_state(self, started, hb, now):
state = {"running": False, "since": started.get("started_at"), "version": started.get("version"), "pid": started.get("pid")}
try:
out = sp.subprocess.run(["/bin/launchctl", "print", "gui/%d/org.waterschurch.status-panel" % os.getuid()],
capture_output=True, text=True, timeout=5).stdout
m = re.search(r"state = (\w+)", out)
state["launchd"] = m.group(1) if m else "not loaded"
state["running"] = bool(m and m.group(1) == "running")
except (OSError, sp.subprocess.TimeoutExpired):
state["launchd"] = "unknown"
state["heartbeat_fresh"] = bool(hb and now - hb < cfg_stale(self.cfg))
return state
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"])
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"]))
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:
word, reason = "Problem", "; ".join(faults[:2])
elif probe and any(i.get("light") == "problem" and i.get("watched", True) for i in probe.get("items", [])):
bad = [i for i in probe.get("items", []) if i.get("light") == "problem" and i.get("watched", True)]
word, reason = "Problem", "; ".join("%s %s" % (i["name"], i.get("reason", "")) for i in bad[:2])
elif not probe and not h.get("self"):
word, reason = "Not looked yet", ""
lines.append({"alias": alias, "name": h.get("name", alias), "role": h.get("role", ""), "word": word, "reason": reason,
"keys": sorted(tiles_by_host.get(alias, [])), "self": bool(h.get("self")),
"looked": probe.get("time")})
return lines
def pages_state(self):
cfg = self.cfg
def look():
out = {}
for which, page, name in (("status", cfg["page"], cfg["page_name"]),) + (
(("restart", cfg["restart_page"]["page"], cfg["restart_page"]["page_name"]),) if cfg.get("restart_page") else ()):
try:
status, raw = self.companion_get("/api/variable/internal/page_number_%d_name/value" % page)
live = raw.decode().replace("\\n", "\n") if status == 200 else None
except (OSError, urllib.error.URLError):
live = None
out[which] = {"page": page, "expected": name.replace("\\n", " "), "live": live.replace("\n", " ") if live else None,
"ok": live is not None and live.replace("\n", " ") == name.replace("\\n", " ")}
return out
return self.cached("pages", 60, look)
def last_change(self):
baks = sorted(glob.glob(os.path.join(self.data_dir, "config.json.bak-*")))
if not baks:
return None
m = re.search(r"bak-(\d{8})-(\d{6})$", baks[-1])
return {"when": "%s-%s-%s %s:%s" % (m.group(1)[:4], m.group(1)[4:6], m.group(1)[6:], m.group(2)[:2], m.group(2)[2:4]) if m else None,
"by": "the installer", "note": "config.json replaced"}
def companion_view(self):
cfg = self.load()
watched = {}
for t in cfg["tiles"]:
for c in t.get("checks", []):
if c.get("conn"):
watched.setdefault(c["conn"], set()).add(t["id"])
for k in (cfg.get("restart_page") or {}).get("keys", []):
for w in k.get("watch", []):
if w.startswith("conn:"):
watched.setdefault(w[5:], set()).add(k["id"])
try:
conns = self.comp.connections()
ok = True
except Exception as e:
conns, ok = [], False
rows = []
for c in sorted(conns, key=lambda c: c["label"].lower()):
st = c.get("status") or {}
rows.append({"label": c["label"], "module": c.get("moduleId"), "enabled": c.get("enabled"), "category": st.get("category"),
"message": st.get("message"), "watched_by": sorted(watched.get(c["label"], []))})
labels = {c["label"] for c in conns}
missing = sorted(l for l in watched if ok and l not in labels)
names = cfg["variables"]
lights = {}
for key in ("state", "heartbeat", "command"):
try:
lights["variable " + names[key]] = self.comp.get_custom(names[key]) is not None
except Exception:
lights["variable " + names[key]] = None
try:
lights["blink clock " + names.get("flash", "flash_tick")] = self.comp.get_custom(names.get("flash", "flash_tick")) is not None
except Exception:
lights["blink clock " + names.get("flash", "flash_tick")] = None
modules = {c.get("moduleId") for c in conns}
lights["ATEM module"] = ("bmd-atem" in modules) if ok else None
lights["Tascam module"] = ("tascam-da-6400" in modules) if ok else None
pages = self.pages_state()
for which, p in pages.items():
lights["page %d is the %s page" % (p["page"], which.capitalize())] = p["ok"]
nav = self.read_json("nav_locations.json", {}) or {}
try:
here = json.load(open(os.path.join(PARENT, "nav_locations.json")))
except (OSError, ValueError):
here = {}
coloured = len({k.split("/")[0] for k in (nav or here)})
return {"ok": ok, "build": self.cached("build", 600, self.companion_build), "connections": rows, "missing": missing,
"lights": lights, "page_number_buttons": {"pages_coloured": coloured, "recorded_in": "data dir" if nav else "repo copy"},
"address": "%s:%d" % (cfg["companion"].get("host", "127.0.0.1"), cfg["companion"].get("port", 8000)),
"variables": names}
def companion_build(self):
try:
status, raw = self.companion_get("/int/export/page/%d?includeSecrets=false" % self.cfg["page"], timeout=8)
try:
raw = gzip.decompress(raw)
except OSError:
pass
return json.loads(raw).get("companionBuild")
except (OSError, ValueError, urllib.error.URLError):
return None
def machines_view(self):
cfg = self.load()
live = self.read_json("machines-live.json", {}) or {}
snap = self.read_json("snapshot.json", {}) or {}
out = []
for line in self.computer_lines(snap):
alias = line["alias"]
h = cfg["hosts"][alias]
probe = dict(live.get(alias) or {})
items = probe.get("items") or [{"name": r.get("name") or r.get("label") or r.get("kind"), "kind": r.get("kind"),
"light": "unknown", "reason": "not looked yet", "watched": r.get("watched", True)}
for r in h.get("runs", [])]
# what the watchdog already knows beats a stale probe
for item, run in zip(items, h.get("runs", [])):
k = run.get("kind")
if k == "midi_session":
sessions = snap.get("midi") if h.get("self") else (snap.get("remote_midi") or {}).get(alias)
if sessions is not None and run.get("name") in sessions:
peers = sessions[run["name"]].get("peers", [])
item.update({"light": "ok", "reason": "", "detail": "%d peer%s: %s" % (len(peers), "" if len(peers) == 1 else "s",
", ".join(p["name"] for p in peers))})
elif run.get("peer_of"):
for host_sessions in list((snap.get("remote_midi") or {}).values()) + [snap.get("midi") or {}]:
sess = (host_sessions or {}).get(run["peer_of"])
if sess and any(p["name"] == run["name"] for p in sess.get("peers", [])):
item.update({"light": "ok", "reason": "", "detail": "in %s" % run["peer_of"]})
break
else:
item.update({"light": "problem", "reason": "not in the %s session" % run.get("peer_of")})
elif sessions is not None:
item.update({"light": "problem", "reason": "no such MIDI session"})
elif k == "web" or (k == "app" and run.get("http")):
ok = (snap.get("http") or {}).get(run.get("url") or run.get("http"))
if ok is not None:
item.update({"light": "ok" if ok else "problem", "reason": "" if ok else "its web page does not answer"})
elif k == "artnet":
age = (snap.get("artnet") or {}).get(run.get("source"))
if age is not None:
item.update({"light": "ok" if age < 10 else "problem", "reason": "" if age < 10 else "silent for %d s" % age})
probe["items"] = items
probe.update({"alias": alias, "name": h.get("name", alias), "os": h.get("os", "mac"), "role": h.get("role", ""),
"notes": h.get("notes", ""), "ssh": h.get("ssh"), "mac": h.get("mac"), "pingable": h.get("pingable", True),
"self": bool(h.get("self")), "networks": h.get("networks", []), "manual_done": h.get("manual_done", {}),
"word": line["word"], "reason": line["reason"], "keys": line["keys"]})
if not h.get("self") and h.get("pingable", True):
ip = h.get("ssh", "").split("@")[-1]
fails = (snap.get("ping") or {}).get(ip)
probe["ping"] = None if fails is None else (fails == 0)
out.append(probe)
return {"computers": out, "probing": bool(self.probing and self.probing.is_alive()),
"setup_steps": self.setup_steps(cfg)}
def setup_steps(self, cfg):
"""The one-time checklist per computer kind, with the light each line gets from the probe."""
return {
"mac": ["Remote Login is on", "the panel's login key is accepted", "python3 is present (for MIDI reads)",
"the DVS helper and its sudo rule (Macs that run Dante Virtual Soundcard)", "Audio MIDI Setup sessions named as the keys expect",
"Studio B only: the Pro Tools helper runs and Accessibility is allowed"],
"windows": ["the SSH service runs and starts with Windows", "the firewall lets this Mac in", "the panel's login key is accepted",
"a \\StatusPanel\\ task per program", "rtpMIDI's service restarts by itself (Lighting PC)", "wake-on-LAN is on in Windows and in the BIOS",
"the network profile is Public where rtpMIDI runs", "Wi-Fi power saving is off (Bible TV)"],
"self": ["Command Line Tools / python 3.9-3.12", "the watchdog and the settings page are loaded", "Companion's custom variables and blink clock exist",
"the login key pair exists", "the Netgear login is in the keychain", "the Spotify permission was allowed once",
"the DVS helper and its sudo rule", "the Dante and switch-management ports are up", "Google Drive is signed in (saved switcher files)"],
}
def gear_view(self):
cfg = self.load()
snap = self.read_json("snapshot.json", {}) or {}
conns, pings, http = snap.get("conns") or {}, snap.get("ping") or {}, snap.get("http") or {}
hosts_ips = {h.get("ssh", "").split("@")[-1] for h in cfg.get("hosts", {}).values()}
gear = cfg.get("gear", {})
kinds = {"hyperdeck": "recorder", "tascam": "recorder", "shure": "wireless receiver", "psm": "in-ear transmitter",
"dante": "Dante", "atem": "switcher"}
rows = {}
for t in cfg["tiles"]:
for c in t.get("checks", []):
name, kind, address = None, None, None
if c.get("conn") and c["type"] != "dante":
name, address = c["conn"], (snap.get("conn_hosts") or {}).get(c["conn"])
mod = next((r.get("module") for r in [] if False), None)
kind = kinds.get(c["type"], "device")
elif c["type"] == "ping" and c.get("host") and c["host"] not in hosts_ips:
name, address, kind = c.get("name") or c["host"], c["host"], "device"
elif c["type"] == "http" and not any(ip in c["url"] for ip in hosts_ips) and "127.0.0.1" not in c["url"]:
name, address, kind = c.get("name") or c.get("msg") or c["url"], c["url"], "web address"
elif c["type"] == "artnet":
continue
if not name:
continue
row = rows.setdefault(name, {"name": name, "kind": kind, "address": address, "watched_by": set(), "light": "unknown",
"reason": "", "location": gear.get(name, {}).get("location", ""), "notes": gear.get(name, {}).get("notes", "")})
row["watched_by"].add(t["id"])
if address is None and row["address"] is None and c.get("host"):
row["address"] = c["host"]
if c.get("conn") and c["conn"] in conns:
st = conns[c["conn"]]
row["light"] = "ok" if st.get("category") == "good" else ("problem" if st.get("enabled") else "not_needed")
row["reason"] = "" if row["light"] == "ok" else (st.get("message") or st.get("category") or "")
if c["type"] == "ping" and c.get("host") in pings and row["light"] != "problem":
row["light"] = "ok" if pings[c["host"]] == 0 else "problem"
row["reason"] = "" if pings[c["host"]] == 0 else "does not answer ping"
if c["type"] == "http" and c["url"] in http and row["light"] == "unknown":
row["light"] = "ok" if http[c["url"]] else "problem"
for name in (snap.get("dante_map") or {}):
e = snap["dante_map"][name]
row = rows.setdefault(name, {"name": name, "kind": "Dante device", "address": e.get("ip"), "watched_by": {"dante"}, "light": "unknown",
"reason": "", "location": gear.get(name, {}).get("location", ""), "notes": gear.get(name, {}).get("notes", "")})
row["detail"] = "switch %s port %s%s" % (e.get("switch", "?"), e.get("port", "?"), ", PoE" if e.get("poe") else "")
for s in cfg.get("dante_switches", []):
rows.setdefault("Netgear " + s, {"name": "Netgear " + s, "kind": "Dante switch", "address": s, "watched_by": {"dante"}, "light": "unknown",
"reason": "", "location": gear.get("Netgear " + s, {}).get("location", ""), "notes": ""})
out = []
for r in rows.values():
r["watched_by"] = sorted(r["watched_by"])
out.append(r)
return sorted(out, key=lambda r: (r["light"] != "problem", r["kind"], r["name"].lower()))
def keys_view(self):
cfg = self.load()
tiles = []
for t in cfg["tiles"]:
words = []
for c in t.get("checks", []):
ws = [c["msg"]] if c.get("msg") else list(keyguide.FAULT_WORDS.get(c["type"], []))
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)),
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))))
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"])}
def history_view(self):
rows = []
for path in sorted(glob.glob(os.path.join(self.data_dir, "config.json.bak-*")), reverse=True):
m = re.search(r"bak-(\d{8})-(\d{6})$", path)
rows.append({"file": os.path.basename(path), "when": "%s-%s-%s %s:%s:%s" % (m.group(1)[:4], m.group(1)[4:6], m.group(1)[6:],
m.group(2)[:2], m.group(2)[2:4], m.group(2)[4:]) if m else "?",
"size": os.path.getsize(path)})
return {"entries": rows, "note": "Full history (who, what, Go back) arrives with step 2; these are the installer's copies."}
# --- editing code (the gate; nothing to gate yet in step 1) --------------
def check_code(self, ip, code):
count, first = self.attempts.get(ip, (0, 0))
now = time.time()
if count >= 5 and now - first < 600:
return {"ok": False, "locked_for": int(600 - (now - first))}
wanted = (self.cfg.get("site") or {}).get("editing_code") or ""
if wanted and code and code.strip().lower() == wanted.strip().lower():
self.attempts.pop(ip, None)
return {"ok": True}
if now - first >= 600:
count, first = 0, now
self.attempts[ip] = (count + 1, first)
return {"ok": False, "tries_left": max(0, 5 - count - 1), "no_code": not wanted}
def cfg_stale(cfg):
return cfg.get("page_stale_seconds", 15)
class Handler(http.server.BaseHTTPRequestHandler):
site = None # set by serve()
server_version = "status-settings"
def log_message(self, fmt, *args):
log.info("%s %s", self.client_address[0], fmt % args)
def send(self, status, body, ctype="application/json; charset=utf-8"):
data = body if isinstance(body, bytes) else (json.dumps(body, default=str) if ctype.startswith("application/json") else body).encode()
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if not allowed_client(self.client_address[0]):
return self.send(403, {"error": "only from the church network"})
url = urllib.parse.urlparse(self.path)
q = urllib.parse.parse_qs(url.query)
path = url.path
site = self.site
try:
if path in ("/", "/index.html"):
return self.static("index.html", "text/html; charset=utf-8")
if path in ("/app.js", "/style.css"):
return self.static(path[1:], "text/javascript; charset=utf-8" if path.endswith(".js") else "text/css; charset=utf-8")
if path == "/key-guide.html":
return self.send(200, keyguide.key_guide(site.load()), "text/html; charset=utf-8")
if path == "/cheat.html":
return self.send(200, keyguide.cheat_sheet(site.load(), self.my_address()), "text/html; charset=utf-8")
if path == "/where.html":
return self.send(200, keyguide.where_things_are(site.load(), site.data_dir, self.my_address()), "text/html; charset=utf-8")
if path == "/api/config":
cfg = site.load()
return self.send(200, {"config": schema.mask_secrets(cfg), "sha": site.cfg_sha, "mtime": site.cfg_mtime})
if path == "/api/schema":
return self.send(200, {"fields": schema.F, "checks": schema.CHECKS, "fix_kinds": list(schema.FIX_KINDS),
"fix_kind_fields": schema.FIX_KIND_FIELDS, "glossary": schema.GLOSSARY})
if path == "/api/overview":
return self.send(200, site.overview())
if path == "/api/companion":
return self.send(200, site.companion_view())
if path == "/api/machines":
return self.send(200, site.machines_view())
if path == "/api/gear":
return self.send(200, site.gear_view())
if path == "/api/keys":
return self.send(200, site.keys_view())
if path == "/api/history":
return self.send(200, site.history_view())
if path == "/api/facts":
return self.send(200, {"facts": COMPANION_FACTS})
if path == "/api/snapshot":
return self.send(200, site.read_json("snapshot.json", {}) or {})
if path.startswith("/api/logs/"):
name = path[len("/api/logs/"):]
if name == "autofix":
folder = os.path.expanduser((site.cfg.get("auto_fix") or {}).get("log_dir", "~/Desktop/Auto-Fix Logs"))
files = sorted(f for f in (os.listdir(folder) if os.path.isdir(folder) else []) if sp.AutoLog.NAME.match(f))
want = q.get("file", [files[-1] if files else ""])[0]
if want and want in files:
return self.send(200, {"files": files, "file": want, "lines": tail(os.path.join(folder, want), 1000)})
return self.send(200, {"files": files, "file": None, "lines": []})
if name not in LOGS:
return self.send(404, {"error": "no such log"})
flt = q.get("filter", [""])[0]
return self.send(200, {"lines": tail(LOGS[name], int(q.get("lines", ["400"])[0]), LOG_FILTERS.get(flt))})
return self.send(404, {"error": "not found"})
except Exception as e:
log.exception("GET %s", path)
return self.send(500, {"error": str(e)})
def do_POST(self):
if not allowed_client(self.client_address[0]):
return self.send(403, {"error": "only from the church network"})
path = urllib.parse.urlparse(self.path).path
length = int(self.headers.get("Content-Length") or 0)
body = {}
if length:
try:
body = json.loads(self.rfile.read(length) or b"{}")
except ValueError:
return self.send(400, {"error": "bad JSON"})
host = (self.headers.get("Host") or "").split(":")[0]
origin = self.headers.get("Origin")
if origin and urllib.parse.urlparse(origin).hostname not in (host, "127.0.0.1", "localhost"):
return self.send(403, {"error": "wrong origin"})
site = self.site
try:
if path == "/api/machines/probe":
if site.read_json("fixes-running.json", {}):
return self.send(409, {"error": "a fix is running; try again in a minute"})
with site.lock:
if site.probing and site.probing.is_alive():
return self.send(200, {"started": False, "already": True})
only = body.get("only")
site.probing = threading.Thread(target=probes.probe_all, args=(site.load(), site.data_dir, only), daemon=True)
site.probing.start()
return self.send(200, {"started": True})
if path == "/api/editing/check":
return self.send(200, site.check_code(self.client_address[0], str(body.get("code", ""))))
return self.send(404, {"error": "not found"})
except Exception as e:
log.exception("POST %s", path)
return self.send(500, {"error": str(e)})
def static(self, name, ctype):
try:
with open(os.path.join(UI, name), "rb") as f:
return self.send(200, f.read(), ctype)
except OSError:
return self.send(404, {"error": "missing %s" % name})
def my_address(self):
cfg = self.site.cfg
host = cfg.get("site", {}).get("ingest_hostname") or (self.headers.get("Host") or "").split(":")[0] or "127.0.0.1"
return "http://%s:%d/" % (host, self.server.server_address[1])
class Server(http.server.ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def serve(data_dir, port, binds=None, config_path=None):
site = Site(data_dir, config_path)
Handler.site = site
binds = binds or bind_addresses(site.cfg)
servers = []
for addr in binds:
try:
srv = Server((addr, port), Handler)
except OSError as e:
log.warning("cannot listen on %s:%d: %s", addr, port, e)
continue
threading.Thread(target=srv.serve_forever, name="http-" + addr, daemon=True).start()
servers.append(srv)
log.info("listening on http://%s:%d/", addr, port)
if not servers:
raise SystemExit("could not listen anywhere")
return site, servers
def rebind_loop(site, servers, port, stop):
"""Every minute: if a LAN port's address changed, listen there too (DHCP, a cable moved)."""
have = {s.server_address[0] for s in servers}
while not stop.wait(60):
try:
for addr in bind_addresses(site.load()):
if addr not in have:
srv = Server((addr, port), Handler)
threading.Thread(target=srv.serve_forever, name="http-" + addr, daemon=True).start()
servers.append(srv)
have.add(addr)
log.info("now also listening on http://%s:%d/", addr, port)
except Exception as e:
log.warning("rebind: %s", e)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--data-dir", default=PARENT)
ap.add_argument("--config")
ap.add_argument("--port", type=int)
ap.add_argument("--bind", nargs="*")
ap.add_argument("--log", default=os.path.expanduser("~/Library/Logs/status-settings.log"))
args = ap.parse_args(argv)
handler = logging.handlers.RotatingFileHandler(args.log, maxBytes=1_000_000, backupCount=2)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
log.addHandler(handler)
if sys.stderr.isatty():
log.addHandler(logging.StreamHandler())
log.setLevel(logging.INFO)
cfg = sp.load_config(args.config or os.path.join(args.data_dir, "config.json"))
port = args.port or cfg.get("site", {}).get("settings_port", 8020)
site, servers = serve(args.data_dir, port, args.bind, args.config)
stop = threading.Event()
threading.Thread(target=rebind_loop, args=(site, servers, port, stop), daemon=True).start()
try:
while True:
time.sleep(3600)
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
+316
View File
@@ -0,0 +1,316 @@
/* The settings page, step 1: every screen, read-only. Plain JS, no framework. */
(function () {
"use strict";
const main = document.getElementById("main");
const state = { config: null, schema: null, keys: null, editing: null, name: null };
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const api = async (path, opts) => {
const r = await fetch(path, opts);
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText);
return r.json();
};
const post = (path, body) => api(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) });
const ago = (s) => s == null ? "" : s < 90 ? Math.round(s) + " s ago" : s < 5400 ? Math.round(s / 60) + " min ago" : Math.round(s / 3600) + " h ago";
const when = (t) => t ? new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) : "";
const hex = (n) => "#" + (n >>> 0).toString(16).padStart(6, "0").slice(-6);
const lightDot = (l) => `<i class="light ${esc(l || "unknown")}"></i>`;
const wordOf = (w) => `<span class="word ${esc((w || "").split(" ")[0])}">${esc(w)}</span>`;
// ---- top bar -------------------------------------------------------------
document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; };
const editbtn = document.getElementById("editbtn");
editbtn.onclick = () => {
if (state.editing) { state.editing = null; editbtn.textContent = "Editing: off"; editbtn.classList.remove("on"); return; }
dialog(`<h3>Switch Editing on</h3><p class="help">The editing code is in the campus's settings file in the repo; ask Chris. In this step nothing can be changed yet: the switch only proves the code works from here.</p>
<input id="dcode" placeholder="editing code" autocomplete="off"><input id="dname" placeholder="your first name" autocomplete="off">
<div class="row"><button class="primary" id="dok">Switch on</button><button id="dcancel">Cancel</button></div><p id="dmsg" class="help"></p>`, (box) => {
box.querySelector("#dcancel").onclick = closeDialog;
box.querySelector("#dok").onclick = async () => {
const code = box.querySelector("#dcode").value, name = box.querySelector("#dname").value.trim();
const r = await post("/api/editing/check", { code });
if (r.ok && name) { state.editing = code; state.name = name; editbtn.textContent = "Editing as " + name; editbtn.classList.add("on"); closeDialog(); }
else box.querySelector("#dmsg").textContent = r.locked_for ? `Locked for ${Math.ceil(r.locked_for / 60)} minutes after too many tries.` : r.no_code ? "No editing code is set in the config yet." : !name ? "Your first name, please." : `Not the code (${r.tries_left} tries left).`;
};
});
};
function dialog(html, wire) { const d = document.getElementById("dialog"); d.innerHTML = `<div class="box">${html}</div>`; d.hidden = false; wire(d.querySelector(".box")); }
function closeDialog() { document.getElementById("dialog").hidden = true; }
// ---- search --------------------------------------------------------------
const search = document.getElementById("search"), results = document.getElementById("searchresults");
search.oninput = () => {
const q = search.value.trim().toLowerCase();
if (!q || !state.config) { results.hidden = true; return; }
const hits = [];
const cfg = state.config;
for (const t of cfg.tiles) if ((t.label + " " + t.id).toLowerCase().includes(q)) hits.push([`Key ${t.label}`, `#keys/${t.id}`]);
for (const k of (cfg.restart_page || { keys: [] }).keys) if (k.label.toLowerCase().includes(q)) hits.push([`Restart key ${k.label}`, `#keys/restart/${k.id}`]);
for (const [a, h] of Object.entries(cfg.hosts || {})) if ((h.name || a).toLowerCase().includes(q) || (h.role || "").toLowerCase().includes(q)) hits.push([`Computer ${h.name || a}`, `#computers/${a}`]);
for (const [id, f] of Object.entries(cfg.fixes || {})) if ((id + " " + (f.label || "") + " " + (f.short || "")).toLowerCase().includes(q)) hits.push([`Fix ${f.short || id}: ${f.label || ""}`, `#fixes/${id}`]);
for (const [path, f] of Object.entries(state.schema ? state.schema.fields : {})) if (!path.includes("[]") && !path.includes("*") && (path + " " + f.help).toLowerCase().includes(q)) hits.push([`Setting ${path}`, `#site/${f.screen.toLowerCase().replace(/[^a-z]+/g, "-")}`]);
results.innerHTML = hits.slice(0, 20).map(([t, h]) => `<a href="${h}">${esc(t)}</a>`).join("") || `<a>Nothing called "${esc(q)}"</a>`;
results.hidden = false;
};
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
// ---- routing -------------------------------------------------------------
const screens = { overview, keys, computers, logs, history, help, site, fixes };
let timer = null;
async function route() {
const [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
for (const a of document.querySelectorAll(".menu a")) a.classList.toggle("active", a.getAttribute("href") === "#" + name);
clearInterval(timer); timer = null;
if (!state.config) {
const [c, s] = await Promise.all([api("/api/config"), api("/api/schema")]);
state.config = c.config; state.schema = s;
document.getElementById("sitename").textContent = (state.config.site || {}).name || "";
}
const fn = screens[name] || overview;
try { await fn(rest); } catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
if (name === "overview" || name === "computers") timer = setInterval(() => fn(rest, true).catch(() => {}), 5000);
}
window.addEventListener("hashchange", route);
route();
// ---- Overview ------------------------------------------------------------
async function overview(_, quiet) {
const o = await api("/api/overview");
const dot = document.getElementById("hbdot");
dot.className = "dot " + (o.heartbeat_age != null && o.heartbeat_age < 15 ? "ok" : "bad");
const prob = o.problems.length ? o.problems.map((p) => `<li><span class="lvl" style="background:${p.level === "err" ? "var(--problem)" : "var(--warn)"}"></span><b>${esc(p.text)}</b>${p.acked ? ' <span class="muted">(acknowledged)</span>' : ""} <span class="muted">— ${esc(p.action)}</span> <a href="#keys/${esc(p.key)}" class="small">open the key</a></li>`).join("") : `<li class="muted">Everything is fine.</li>`;
const needs = o.needs.length ? o.needs.map((n) => `<div class="notice grey">Needs Chris: ${esc(n)}</div>`).join("") : "";
const live = o.live && o.live.live ? `<div class="notice">Live now: ${esc(o.live.reason)}. Changes to the panel wait.</div>` : "";
const comps = o.computers.map((c) => `<tr class="click" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}</td><td class="muted small">${esc(c.reason)}</td></tr>`).join("");
const w = o.watchdog;
main.innerHTML = `
<h1>${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") + " in the room" : "Everything is fine"}</h1>
<p class="what">Is the room OK, and what do I do. Problems mirror the red keys; hold the key, or open it for the details.</p>
<ul class="problems">${prob}</ul>${needs}${live}
<div class="cols">
<div><h2>Live deck <span class="muted small">${o.deck ? "as the Stream Deck looks now · click a key" : "(Companion is not answering)"}</span></h2>${deckHtml(o.deck, state.config, "status")}
<div class="row" style="margin-top:10px"><button disabled title="step 4">Change the playlist</button><button disabled title="step 4">Add or remove a HyperDeck</button><button disabled title="step 4">Add a MIDI link</button></div></div>
<div><h2>Computers</h2><div class="tablewrap"><table>${comps}</table></div></div>
</div>
<div class="card">
<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>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>
</dl>
<div class="row" style="margin-top:10px"><button disabled title="step 2">Restart the watchdog</button><button disabled title="step 2">Silence alarms until midnight</button></div>
</div>`;
if (!quiet) window.scrollTo(0, 0);
}
function deckHtml(deck, cfg, which, selected) {
const cells = {};
const rp = cfg.restart_page;
if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; }
else { for (const t of cfg.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : cfg.page), "2,0": "DOWN" };
let out = `<div class="deck">`;
for (let r = 0; r < 4; r++) for (let c = 0; c < 8; c++) {
const k = `${r},${c}`, cell = cells[k];
if (nav[k]) out += `<div class="key nav">${esc(nav[k])}</div>`;
else if (cell && cell.nav) out += `<div class="key nav">${esc(cell.nav)}</div>`;
else if (cell) {
const s = deck && deck[cell.id];
const style = s ? `--b1:${hex(s.b)};--f1:${hex(s.f)};--b2:${hex(s.b2)};--f2:${hex(s.f2)};background:${hex(s.b)};color:${hex(s.f)}` : "background:#444;color:#ddd";
const blink = s && s.b !== s.b2 ? " blink" : "";
out += `<div class="key${blink}${selected === cell.id ? " sel" : ""}" style="${style}" onclick="location.hash='#keys/${which === "restart" ? "restart/" : ""}${esc(cell.id)}'">${esc(s ? s.x : cell.label)}</div>`;
} else out += `<div class="key empty"></div>`;
}
return out + `</div>`;
}
// ---- Keys ----------------------------------------------------------------
async function keys(rest) {
const k = state.keys || (state.keys = await api("/api/keys"));
const o = await api("/api/overview").catch(() => ({ deck: null }));
const restart = rest[0] === "restart";
const sel = restart ? rest[1] : rest[0];
const tabs = `<div class="tabs"><button class="${restart ? "" : "on"}" onclick="location.hash='#keys'">Status page (page ${k.page})</button>${k.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page (page ${k.restart_page.page})</button>` : ""}</div>`;
let panel = `<p class="muted">Click a key to see what it watches and what holding it does.</p>`;
if (sel && !restart) {
const t = k.tiles.find((x) => x.id === sel);
if (t) panel = keyPanel(t, k);
} else if (sel && restart) {
const r = k.restart_page.keys.find((x) => x.id === sel);
if (r) panel = `<div class="card"><h2>${esc(r.label)}</h2><p class="muted">row ${r.pos[0]}, column ${r.pos[1]}</p><p><b>HOLDING ${esc(k.hold.replace("HOLD ", ""))} WILL:</b> ${esc(r.holding)}, even when the Status page says it is fine.</p>${r.watch ? `<p class="small muted">Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}</p>` : ""}<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd><dt>fix</dt><dd class="mono">${esc(r.fix)}</dd></dl></details><p class="help">Editing arrives with step 2.</p></div>`;
}
const tray = k.tiles.filter((t) => !t.pos && t.kind !== "master").map((t) => `<button onclick="location.hash='#keys/${esc(t.id)}'">${esc(t.label)}</button>`).join(" ");
main.innerHTML = `<h1>Keys</h1><p class="what">The deck as people hold it. In this step you can look; moving and editing keys arrive with steps 2 and 3.</p>${tabs}
<div class="cols"><div>${deckHtml(o.deck, state.config, restart ? "restart" : "status", sel)}${!restart && tray ? `<p class="small muted" style="margin-top:8px">Shown inside their group, no key of their own: ${tray}</p>` : ""}</div><div>${panel}</div></div>`;
}
function keyPanel(t, k) {
const cfg = state.config;
const checks = (t.checks || []).map((c, i) => `<li>${esc(c.sentence)} <details><summary>Advanced</summary><dl class="kv small">${Object.entries(c).filter(([n]) => !["sentence", "identity"].includes(n)).map(([n, v]) => `<dt>${esc(n)}</dt><dd class="mono">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details></li>`).join("");
const members = t.members ? `<p><b>Sums up:</b> ${t.members.map((m) => `<a href="#keys/${esc(m)}">${esc((cfg.tiles.find((x) => x.id === m) || {}).label || m)}</a>`).join(", ")}</p>` : "";
return `<div class="card"><h2>${esc(t.label)}</h2><p class="muted">${t.pos ? `row ${t.pos[0]}, column ${t.pos[1]}` : "inside its group"}${t.kind ? ` · ${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}` : ""}</p>
${t.says && t.says.length ? `<p><b>WHAT THIS KEY WILL SAY:</b> <span class="mono">${esc(t.says.join(", "))}</span></p>` : ""}
<p><b>HOLDING ${esc(k.hold.replace("HOLD ", ""))} WILL:</b> ${esc(t.holding)}${t.hold_always ? " <span class='pill'>also when green</span>" : ""}</p>
${members}${checks ? `<p><b>Checks:</b></p><ol>${checks}</ol>` : ""}
<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(t.id)}</dd></dl></details><p class="help">Editing arrives with step 2.</p></div>`;
}
// ---- Computers -----------------------------------------------------------
let compTab = "map";
async function computers(rest, quiet) {
const m = await api("/api/machines");
const selected = rest[0] && rest[0] !== "gear" && rest[0] !== "setup" ? rest[0] : null;
if (rest[0] === "gear" || rest[0] === "setup") compTab = rest[0]; else if (!quiet || !compTab) compTab = selected ? "map" : compTab || "map";
const tabs = `<div class="tabs"><button class="${compTab === "map" ? "on" : ""}" onclick="location.hash='#computers'">Map</button><button class="${compTab === "gear" ? "on" : ""}" onclick="location.hash='#computers/gear'">Gear</button><button class="${compTab === "setup" ? "on" : ""}" onclick="location.hash='#computers/setup'">Setup</button>
<button id="probe" ${m.probing ? "disabled" : ""}>${m.probing ? "Looking…" : "Check computers now"}</button></div>`;
let body = "";
if (compTab === "gear") body = await gearHtml();
else if (compTab === "setup") body = setupHtml(m);
else {
const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `<tr class="click${selected === c.alias ? " sel" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}${c.self ? ' <span class="muted small">(this Mac)</span>' : ""}</td><td class="small">${esc(c.role)}</td><td class="small">${c.keys.map((k) => `<span class="pill">${esc((state.config.tiles.find((t) => t.id === k) || { label: k }).label)}</span>`).join(" ")}</td><td class="muted small">${esc(c.reason)}${c.looked ? `<br>looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}</td></tr>`).join("");
body = `<div class="tablewrap"><table><tr><th>status</th><th>computer</th><th>what it does for the panel</th><th>keys that turn red if it dies</th><th></th></tr>${rows}</table></div>`;
const c = m.computers.find((x) => x.alias === selected);
if (c) body += computerCard(c);
}
main.innerHTML = `<h1>Computers</h1><p class="what">What runs where. One row per computer; click it for everything that runs there, with a light each.</p>${tabs}${body}`;
const pb = document.getElementById("probe");
if (pb) pb.onclick = async () => { pb.disabled = true; pb.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => computers(rest, true), 4000); };
}
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 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("");
return `<div class="card" id="card"><h2>${esc(c.name)} ${wordOf(c.word)}</h2><p class="muted">${esc(c.role)}${c.ssh ? ` · logs in as <span class="mono">${esc(c.ssh)}</span>` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "<b>no answer</b>"}` : ""}${c.error ? ` · <b>${esc(c.error)}</b>` : ""}</p>
${c.notes ? `<p class="notice grey">${esc(c.notes)}</p>` : ""}
<h3>What runs here</h3><div class="tablewrap"><table>${items}</table></div>
${done ? `<ul class="small" style="list-style:none;padding:0">${done}</ul>` : ""}
<details><summary>Everything found on this computer</summary>${foundHtml}</details>
${c.self ? `<details><summary>This Mac's set-up lights</summary><dl class="kv small">${Object.entries(c.setup || {}).map(([k, v]) => `<dt>${esc(k.replace(/_/g, " "))}</dt><dd>${typeof v === "boolean" ? lightDot(v ? "ok" : "problem") + (v ? "OK" : "Problem") : esc(JSON.stringify(v))}</dd>`).join("")}</dl></details>` : ""}
</div>`;
}
async function gearHtml() {
const g = await api("/api/gear");
return `<p class="small muted">Devices that are not computers, from the keys that watch them. Where each one lives is typed once on this list (step 2).</p><div class="tablewrap"><table><tr><th>light</th><th>device</th><th>kind</th><th>address</th><th>where / notes</th><th>watched by</th></tr>
${g.map((r) => `<tr><td>${lightDot(r.light)}${esc(r.light === "ok" ? "OK" : r.light === "problem" ? "Problem" : "–")}</td><td>${esc(r.name)}</td><td>${esc(r.kind)}</td><td class="mono small">${esc(r.address || "")}${r.detail ? `<br><span class="muted">${esc(r.detail)}</span>` : ""}</td><td class="small">${esc(r.location || "")} ${esc(r.notes || "")} <span class="muted">${esc(r.reason || "")}</span></td><td class="small">${r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc((state.config.tiles.find((t) => t.id === k) || { label: k }).label)}</a>`).join(", ")}</td></tr>`).join("")}</table></div>`;
}
function setupHtml(m) {
return m.computers.map((c) => {
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
const steps = m.setup_steps[kind];
const lightFor = (step) => {
const s = c.setup || {};
if (/login key/.test(step)) return s.key_ok === true || s.login_key === true ? "ok" : s.key_ok === false || s.login_key === false ? "problem" : "unknown";
if (/python/.test(step)) return s.python_ok === true ? "ok" : s.python_ok === false ? "problem" : "unknown";
if (/SSH service/.test(step)) return c.reachable ? "ok" : "problem";
if (/firewall/.test(step)) return s.firewall_allow ? "ok" : "unknown";
if (/wake-on-LAN/.test(step)) return s.wake_on_lan === "Enabled" ? "ok" : s.wake_on_lan ? "problem" : "unknown";
if (/Public/.test(step)) return s.profile === "Public" ? "ok" : s.profile ? "problem" : "unknown";
if (/DVS helper/.test(step)) { const it = (c.items || []).find((i) => i.kind === "helper_dvs"); return it ? it.light : "not_needed"; }
if (/Pro Tools helper/.test(step)) { const it = (c.items || []).find((i) => i.kind === "helper_solo"); return it ? it.light : "not_needed"; }
if (/StatusPanel/.test(step)) { const ts = (c.items || []).filter((i) => i.kind === "task"); return ts.length ? (ts.every((i) => i.light === "ok") ? "ok" : "problem") : "unknown"; }
if (/rtpMIDI/.test(step)) { const it = (c.items || []).find((i) => i.kind === "service"); return it ? it.light : "not_needed"; }
if (/custom variables/.test(step)) return "unknown";
if (/Netgear/.test(step)) return s.netgear_keychain === true ? "ok" : s.netgear_keychain === false ? "problem" : "not_needed";
if (/Google Drive/.test(step)) { const it = (c.items || []).find((i) => i.kind === "folder"); return it ? it.light : "not_needed"; }
if (/watchdog and the settings/.test(step)) { const ok = (c.items || []).filter((i) => i.kind === "launchagent" && /watchdog|settings/.test(i.name)); return ok.length && ok.every((i) => i.light === "ok") ? "ok" : "unknown"; }
if (/Wi-Fi/.test(step)) return c.manual_done && c.manual_done.wifi_power ? "hand" : "unknown";
if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown";
return "unknown";
};
return `<div class="card"><h3>${esc(c.name)}</h3><ul style="list-style:none;padding:0;margin:0">${steps.map((s) => { const l = lightFor(s); return `<li>${lightDot(l)}${esc(s)} <span class="muted small">${l === "hand" ? "(done by hand)" : l === "unknown" ? "– can't see from here" : ""}</span></li>`; }).join("")}</ul><p class="help">Generated set-up scripts and the [how] steps arrive with step 5.</p></div>`;
}).join("");
}
// ---- Logs ----------------------------------------------------------------
let logTab = "watchdog", logFilter = "";
async function logs(rest) {
logTab = rest[0] || logTab;
const tabs = ["watchdog", "errors", "autofix", "settings"].map((n) => `<button class="${logTab === n ? "on" : ""}" onclick="location.hash='#logs/${n}'">${{ watchdog: "Watchdog", errors: "Errors", autofix: "Auto-fix days", settings: "Settings page" }[n]}</button>`).join("");
const filters = logTab === "watchdog" ? `<div class="tabs">${["", "faults", "holds", "fixes", "auto", "errors"].map((f) => `<button class="${logFilter === f ? "on" : ""}" data-f="${f}">${f || "everything"}</button>`).join("")}</div>` : "";
let data;
if (logTab === "autofix") { data = await api("/api/logs/autofix" + (rest[1] ? "?file=" + encodeURIComponent(rest[1]) : "")); }
else data = await api(`/api/logs/${logTab}?filter=${encodeURIComponent(logFilter)}`);
const files = logTab === "autofix" ? `<p class="small">${(data.files || []).slice().reverse().map((f) => `<a href="#logs/autofix/${encodeURIComponent(f)}" class="${data.file === f ? "pill" : ""}">${esc(f.replace(" Auto-Fix.log", ""))}</a>`).join(" · ") || "no auto-fix days yet"}</p>` : "";
main.innerHTML = `<h1>Logs</h1><p class="what">What the watchdog did and saw. Newest lines at the bottom.</p><div class="tabs">${tabs}</div>${filters}${files}<pre class="log">${esc((data.lines || []).join("\n")) || "(empty)"}</pre>`;
for (const b of main.querySelectorAll("[data-f]")) b.onclick = () => { logFilter = b.dataset.f; logs(rest); };
const pre = main.querySelector("pre.log"); pre.scrollTop = pre.scrollHeight;
}
// ---- History -------------------------------------------------------------
async function history() {
const h = await api("/api/history");
main.innerHTML = `<h1>History</h1><p class="what">Every version of the settings that was ever applied. ${esc(h.note)}</p><div class="tablewrap"><table><tr><th>when</th><th>file</th><th>size</th></tr>${h.entries.map((e) => `<tr><td>${esc(e.when)}</td><td class="mono small">${esc(e.file)}</td><td>${esc(e.size)}</td></tr>`).join("") || "<tr><td colspan=3 class='muted'>nothing yet</td></tr>"}</table></div>`;
}
// ---- Help ----------------------------------------------------------------
async function help(rest) {
const facts = await api("/api/facts");
const gl = state.schema.glossary;
const cards = [
["Read a red key", "Green is fine. A flashing red key names the problem. Press it once to acknowledge (it stops flashing, stays dark red). Hold it " + esc(state.schema.fields.page_hold_ms ? "2 s" : "2 s") + " and it runs the fix for what is failing."],
["Purple means the watchdog is down", "Every key turns purple when the watchdog on the Ingest Mac has been silent for 15 s. Open this page: if the Overview says Companion is not answering, the problem is Companion; otherwise restart the watchdog (step 2 adds the button; until then: ask Chris)."],
["Which computer or device is that?", "Computers › Map shows every computer and the keys that turn red if it dies; Computers › Gear lists the switcher, decks, recorders, wireless and LED gear with their addresses."],
["What is a connection?", "Companion's link to one device (a HyperDeck, the Tascam, the switcher). The keys watch those connections; Site setup › Companion lists them all and which key watches each."],
].map(([t, b]) => `<div class="card"><h3>${t}</h3><p>${b}</p></div>`).join("");
const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `<details open><summary><b>${esc(g)}</b></summary><p class="mono small">${esc(words.join(" · "))}</p></details>`).join("");
main.innerHTML = `<h1>Help</h1><p class="what">Start with the four cards. The pages below are made from the settings, so they are never out of date.</p><div class="cards">${cards}</div>
<h2>Pages</h2><p><a href="/key-guide.html" target="_blank">What each key does</a> · <a href="/cheat.html" target="_blank">Cheat sheet (one page)</a> · <a href="/where.html" target="_blank">Where things are</a></p>
<h2>First-day walkthrough (10 minutes, no code needed)</h2><ol><li>Overview: the live deck. Green, red, purple.</li><li>Click a red key (or any key): read what it checks and what holding it will do.</li><li>Logs › Watchdog, filter "holds": find a hold and what it did.</li><li>Keys › Restart page: each key restarts one thing, even when green.</li><li>Overview: when alarms are on and when auto-fix may act by itself.</li><li>Computers › Map: which computer a key talks to, and what runs there.</li><li>Help › What each key does: the whole deck in words.</li></ol>
<h2>The words a key can show</h2>${glossary}
<h2>Things only a person can do</h2><ul><li>Run the set-up script on a new computer (ARD on a Mac, PowerShell as administrator on a PC), allow Accessibility on the Studio B Mac, click Allow on the Spotify prompt once.</li><li>Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.</li><li>Type the Netgear login into the keychain; keep Google Drive signed in for the saved switcher files.</li><li>Save a new switcher file from ATEM Software Control when the show changes.</li></ul>
<h2>Companion facts (found by trying)</h2><dl class="kv">${facts.facts.map(([q, a]) => `<dt>${esc(q)}</dt><dd>${esc(a)}</dd>`).join("")}</dl>`;
}
// ---- Site setup (read-only) ----------------------------------------------
async function site(rest) {
const cfg = state.config, F = state.schema.fields;
const sections = { site: "Site", schedules: "Schedules", companion: "Companion", "network-dante": "Network & Dante", spotify: "Spotify", atem: "ATEM" };
const which = sections[rest[0]] ? rest[0] : "site";
const tabs = Object.entries(sections).map(([k, n]) => `<button class="${which === k ? "on" : ""}" onclick="location.hash='#site/${k}'">${n}</button>`).join("");
const screen = sections[which];
const get = (path) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), cfg);
const fieldHtml = (path, f) => { const v = get(path); return `<div class="field"><label>${esc(path)} <span class="help">${esc(f.help)}</span></label><div class="val">${v === undefined ? `<span class="muted">(default: ${esc(JSON.stringify(f.default))})</span>` : esc(typeof v === "object" ? JSON.stringify(v) : String(v))}</div></div>`; };
const own = Object.entries(F).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !["tiles", "fixes", "hosts", "gear"].includes(p));
let body = `<div class="card"><h3>Settings</h3>${own.filter(([, f]) => !f.advanced).map(([p, f]) => fieldHtml(p, f)).join("")}<details><summary>Advanced</summary>${own.filter(([, f]) => f.advanced).map(([p, f]) => fieldHtml(p, f)).join("")}</details></div>`;
if (which === "companion") {
const c = await api("/api/companion");
const lights = Object.entries(c.lights).map(([k, v]) => `<li>${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</li>`).join("");
const conns = c.connections.map((r) => `<tr><td>${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed")}${esc(r.label)}</td><td class="mono small">${esc(r.module || "")}</td><td class="small">${esc(r.enabled ? r.category || "" : "off")}${r.message ? ` <span class="muted">${esc(r.message)}</span>` : ""}</td><td class="small">${r.watched_by.length ? r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc(k.toUpperCase())}</a>`).join(", ") : '<span class="muted">not watched</span>'}</td></tr>`).join("");
body = `<div class="card"><h3>Companion ${c.ok ? "OK" : "<b>not answering</b>"}${c.build ? ` <span class="pill">${esc(c.build)}</span>` : ""}</h3><p class="small muted">Set up once by the person who installed Companion. You normally only come here to see which connection a key watches.</p><ul style="list-style:none;padding:0">${lights}</ul>
${c.missing.length ? `<p class="notice">Keys watch connections Companion does not have: ${esc(c.missing.join(", "))}</p>` : ""}
<p class="small">Page-number buttons coloured on ${c.page_number_buttons.pages_coloured} pages (${esc(c.page_number_buttons.recorded_in)}).</p>
<h3>Connections (live)</h3><div class="tablewrap"><table><tr><th>connection</th><th>module</th><th>status</th><th>watched by</th></tr>${conns}</table></div></div>` + body;
}
if (which === "network-dante") {
const d = (cfg.tiles.flatMap((t) => t.checks || []).find((c) => c.type === "dante") || {}).devices || {};
const snap = await api("/api/snapshot");
const map = snap.dante_map || {};
body += `<div class="card"><h3>Dante device list (the DANTE key's devices)</h3><div class="tablewrap"><table><tr><th>Dante name</th><th>key text</th><th>address</th><th>switch / port</th><th>PoE</th></tr>${Object.entries(d).map(([n, a]) => { const e = map[n] || {}; return `<tr><td class="mono small">${esc(n)}</td><td>${esc(a)}</td><td class="mono small">${esc(e.ip || "")}</td><td class="small">${e.switch ? esc(e.switch + " port " + e.port) : ""}</td><td>${e.poe ? "yes" : ""}</td></tr>`; }).join("")}</table></div><p class="small muted">Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.</p></div>`;
}
if (which === "schedules") {
const ph = await api("/api/overview").then((o) => o.phrases).catch(() => ({}));
body = `<div class="notice">${esc(ph.alarms || "")} Auto-fix ${esc(ph.auto_fix || "")}. ${esc(((ph.live || "")[0] || "").toUpperCase() + (ph.live || "").slice(1))}.</div><div class="card"><h3>Fixes auto-fix may run</h3><p>${(cfg.auto_fix.fixes || []).map((f) => `<span class="pill">${esc((cfg.fixes[f] || {}).short || f)}</span>`).join(" ")}</p></div>` + body;
}
main.innerHTML = `<h1>Site setup</h1><p class="what">Set up once. Each section opens on the few things a person changes; the numbers sit under Advanced. Editing arrives with step 2.</p><div class="tabs">${tabs}</div>${body}`;
}
// ---- Fixes ---------------------------------------------------------------
async function fixes(rest) {
const cfg = state.config, auto = new Set(cfg.auto_fix.fixes || []);
const used = {};
for (const t of cfg.tiles) for (const c of t.checks || []) { const fx = typeof c.fix === "object" && c.fix ? Object.values(c.fix) : [c.fix]; for (const f of fx) if (f) (used[f] = used[f] || new Set()).add(t.label); if (t.hold_always) (used[t.hold_always] = used[t.hold_always] || new Set()).add(t.label); }
for (const k of (cfg.restart_page || { keys: [] }).keys) (used[k.fix] = used[k.fix] || new Set()).add("restart key " + k.label);
const rows = Object.entries(cfg.fixes).map(([id, f]) => `<tr class="click${rest[0] === id ? " sel" : ""}" onclick="location.hash='#fixes/${esc(id)}'"><td><b>${esc(f.short || id)}</b></td><td>${esc(f.label || "")}</td><td class="small">${esc(f.kind || "ssh")}${f.host ? " on " + esc((cfg.hosts[f.host] || {}).name || f.host) : f.kind === "local" || f.kind === "spotify" ? " on this Mac" : ""}</td><td class="small">${[...(used[id] || [])].join(", ")}</td><td class="small">${auto.has(id) ? "may run by itself" : f.never_auto ? "never by itself" : "hold only"}</td></tr>`).join("");
const f = cfg.fixes[rest[0]];
const detail = f ? `<div class="card"><h2>${esc(f.short || rest[0])}</h2><p>${esc(f.label || "")}</p><details open><summary>Advanced</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details><p class="help">Builders (fixes made from fields instead of commands) arrive with step 2.</p></div>` : "";
main.innerHTML = `<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(cfg.fixes).length} today.</p><div class="tablewrap"><table><tr><th>key text</th><th>plain words</th><th>built as</th><th>used by</th><th>by itself</th></tr>${rows}</table></div>${detail}`;
}
function alertBox(msg) { dialog(`<p>${esc(msg)}</p><button id="dok" class="primary">OK</button>`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); }
})();
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Status Panel</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="top">
<div class="brand"><span class="dot" id="hbdot" title="watchdog heartbeat"></span><b>STATUS PANEL</b> <span id="sitename" class="muted"></span></div>
<input id="search" type="search" placeholder="find a key, computer, device, fix or setting" aria-label="search" autocomplete="off">
<div class="topright">
<button class="link" id="legendbtn" title="What the status words mean">?</button>
<button id="editbtn" class="toggle">Editing: off</button>
</div>
</header>
<div id="legend" class="legend" hidden>
<b>Status words, the same everywhere:</b>
<span><i class="l-ok"></i>OK</span>
<span><i class="l-problem"></i>Problem (with the reason)</span>
<span><i class="l-unknown"></i>Can't see from here – ask at the computer</span>
<span><i class="l-none"></i>Not needed</span>
<span><i class="l-hand"></i>Done by hand (who, when)</span>
</div>
<div id="searchresults" class="searchresults" hidden></div>
<div class="shell">
<nav class="menu" id="menu">
<a href="#overview">Overview</a>
<a href="#keys">Keys</a>
<a href="#computers">Computers</a>
<a href="#logs">Logs</a>
<a href="#history">History</a>
<a href="#help">Help</a>
<div class="divider">Set up once</div>
<a href="#site">Site setup</a>
<a href="#fixes">Fixes</a>
</nav>
<main id="main"><p class="muted">Loading…</p></main>
</div>
<div id="dialog" class="dialog" hidden></div>
<script src="/app.js"></script>
</body>
</html>
+73
View File
@@ -0,0 +1,73 @@
/* Layout: a fixed top bar, a narrow menu on the left, the screen on the right; one column on a phone. */
:root {
--bg: #f4f6f3; --surface: #ffffff; --fg: #1d2420; --muted: #5d6963; --line: #d5dcd6; --accent: #0f6b3a; --accent-soft: #e1f0e7;
--deck: #1b1f1d; --ok: #007a2e; --problem: #d8322b; --warn: #e3a000; --unknown: #8b96a0; --none: #c3c9c3; --hand: #2b6cb0; --purple: #6a1b9a;
--font: -apple-system, "Helvetica Neue", Arial, sans-serif; --mono: ui-monospace, Menlo, monospace;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--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 } }
:root[data-theme="dark"] {
--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; }
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; }
.brand b { letter-spacing: .06em; }
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--unknown); display: inline-block; }
.dot.ok { background: var(--ok); } .dot.bad { background: var(--purple); }
#search { flex: 1; min-width: 0; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--fg); font: inherit; }
.topright { display: flex; gap: 8px; align-items: center; }
button { font: inherit; cursor: pointer; border: 1px solid var(--line); background: var(--surface); color: var(--fg); border-radius: 8px; padding: 6px 12px; }
button:hover { border-color: var(--accent); } button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.link { border: 0; background: none; color: var(--accent); padding: 4px 8px; font-weight: 700; }
button.toggle { font-weight: 600; } button.toggle.on { background: var(--accent-soft); border-color: var(--accent); }
button[disabled] { opacity: .5; cursor: not-allowed; }
.legend { padding: 8px 16px; background: var(--accent-soft); border-bottom: 1px solid var(--line); display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: .92rem; }
.legend i, .light { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; background: var(--unknown); }
.l-ok, .light.ok { background: var(--ok); } .l-problem, .light.problem { background: var(--problem); } .l-unknown, .light.unknown { background: var(--unknown); }
.l-none, .light.not_needed { background: var(--none); } .l-hand, .light.hand { background: var(--hand); } .light.warn { background: var(--warn); }
.shell { display: grid; grid-template-columns: 170px 1fr; min-height: calc(100vh - 50px); }
.menu { padding: 12px 8px; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 2px; }
.menu a { display: block; padding: 8px 10px; border-radius: 8px; color: var(--fg); text-decoration: none; }
.menu a.active, .menu a:hover { background: var(--accent-soft); color: var(--accent); }
.menu .divider { margin: 14px 10px 4px; font-size: .74rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); border-top: 1px solid var(--line); padding-top: 10px; }
main { padding: 18px 20px 48px; min-width: 0; max-width: 1200px; }
h1 { font-size: 1.5rem; margin: 0 0 4px; } h2 { font-size: 1.15rem; margin: 22px 0 8px; } h3 { font-size: 1rem; margin: 14px 0 6px; }
.what { color: var(--muted); margin: 0 0 14px; max-width: 70ch; }
.muted { color: var(--muted); } .small { font-size: .88rem; } .mono { font-family: var(--mono); font-size: .88em; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 10px 0; min-width: 0; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; }
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.cols { display: grid; grid-template-columns: 3fr 2fr; gap: 14px; } @media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
table { border-collapse: collapse; width: 100%; font-size: .93rem; } th, td { text-align: left; vertical-align: top; padding: 6px 9px; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; font-size: .85rem; } tr.click { cursor: pointer; } tr.click:hover { background: var(--accent-soft); }
.tablewrap { overflow-x: auto; }
.word { font-weight: 700; } .word.OK { color: var(--ok); } .word.Problem { color: var(--problem); } .word.Not { color: var(--muted); }
.problems { list-style: none; padding: 0; margin: 0; } .problems li { padding: 6px 0; border-bottom: 1px dashed var(--line); }
.problems .lvl { display: inline-block; min-width: 10px; height: 10px; border-radius: 3px; margin-right: 8px; }
.pill { display: inline-block; padding: 1px 8px; border-radius: 999px; background: var(--accent-soft); font-size: .85rem; font-family: var(--mono); }
.deck { display: grid; grid-template-columns: repeat(8, 1fr); gap: 5px; background: var(--deck); padding: 8px; border-radius: 12px; max-width: 640px; }
.key { aspect-ratio: 1; border-radius: 6px; display: flex; align-items: center; justify-content: center; text-align: center; padding: 3px; font: 600 .68rem/1.1 var(--font); white-space: pre-line; cursor: pointer; border: 2px solid transparent; }
.key.sel { border-color: #fff; } .key.empty { background: #2b2f2d; cursor: default; opacity: .5; } .key.nav { background: #37474f; color: #fff; cursor: default; }
@keyframes blink { 0%, 49% { background: var(--b1); color: var(--f1); } 50%, 100% { background: var(--b2); color: var(--f2); } }
.key.blink { animation: blink 1s steps(1) infinite; } @media (prefers-reduced-motion: reduce) { .key.blink { animation: none; } }
.tabs { display: flex; gap: 6px; margin: 8px 0 12px; flex-wrap: wrap; } .tabs button.on { background: var(--accent-soft); border-color: var(--accent); }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; align-items: baseline; } .kv dt { color: var(--muted); } .kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
details { margin: 6px 0; } summary { cursor: pointer; color: var(--muted); }
pre.log { background: var(--deck); color: #d6dbe4; padding: 10px 12px; border-radius: 10px; font: .8rem/1.5 var(--mono); overflow: auto; max-height: 70vh; white-space: pre-wrap; }
.field { margin: 6px 0; } .field label { display: block; color: var(--muted); font-size: .85rem; } .field .val { font-family: var(--mono); font-size: .9em; overflow-wrap: anywhere; }
.help { color: var(--muted); font-size: .85rem; }
.searchresults { position: absolute; left: 190px; right: 16px; top: 46px; z-index: 6; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.18); max-height: 50vh; overflow: auto; }
.searchresults a { display: block; padding: 8px 12px; color: var(--fg); text-decoration: none; border-bottom: 1px solid var(--line); } .searchresults a:hover { background: var(--accent-soft); }
.dialog { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 9; padding: 16px; }
.dialog .box { background: var(--surface); border-radius: 12px; padding: 18px; max-width: 460px; width: 100%; }
.dialog input { width: 100%; padding: 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--fg); font: inherit; margin: 8px 0; }
.notice { padding: 8px 12px; border-radius: 8px; background: var(--accent-soft); margin: 8px 0; } .notice.grey { background: var(--bg); border: 1px solid var(--line); color: var(--muted); }
@media (max-width: 760px) {
.shell { grid-template-columns: 1fr; } .menu { flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--line); }
.menu .divider { border: 0; margin: 0 6px; padding: 8px 0; } main { padding: 14px 16px 40px; } .searchresults { left: 16px; }
.top { flex-wrap: wrap; } #search { order: 3; flex-basis: 100%; }
}