From 2343a2752959e39c680446e8e7e4df196ebf06b4 Mon Sep 17 00:00:00 2001 From: christian Date: Tue, 13 Oct 2026 16:23:59 -0400 Subject: [PATCH] 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 --- config.json | 356 ++++++++++++++++++- install.sh | 57 ++- settings/__init__.py | 8 + settings/keyguide.py | 271 +++++++++++++++ settings/probes.py | 363 ++++++++++++++++++++ settings/server.py | 685 +++++++++++++++++++++++++++++++++++++ settings/ui/app.js | 316 +++++++++++++++++ settings/ui/index.html | 44 +++ settings/ui/style.css | 73 ++++ status_panel.py | 85 ++++- tests/test_status_panel.py | 27 ++ 11 files changed, 2266 insertions(+), 19 deletions(-) create mode 100644 settings/__init__.py create mode 100644 settings/keyguide.py create mode 100644 settings/probes.py create mode 100644 settings/server.py create mode 100644 settings/ui/app.js create mode 100644 settings/ui/index.html create mode 100644 settings/ui/style.css diff --git a/config.json b/config.json index ba529ff..e9711dc 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,8 @@ "site": { "id": "na", "name": "North Attleboro", "ingest_name": "NA Ingest", "ingest_hostname": "na-ingest.local", "ingest_ips": ["10.1.1.1", "10.6.3.222"], - "live_services": [] + "live_services": [], + "editing_code": "stingo serried warp" }, "companion": { "host": "127.0.0.1", "port": 8000 }, "page": 19, @@ -38,15 +39,354 @@ "dante_switches": ["10.66.1.100", "10.66.1.102"], "hosts": { - "prop": { "ssh": "napropresenter@10.1.14.225" }, - "tracks": { "ssh": "waterschurchmedia@10.1.3.227" }, - "translation": { "ssh": "studio@10.1.7.39" }, - "bibletv": { "ssh": "media@10.6.7.13", "os": "windows" }, - "lighting": { "ssh": "media@10.1.10.206", "os": "windows" }, - "studiob": { "ssh": "studiob@10.1.13.175" }, - "livestream": { "ssh": "media@10.1.9.126", "os": "windows", "mac": "6c:24:08:d7:fb:57", "wake_broadcast": "10.1.255.255" } + "ingest": { + "self": true, + "name": "NA Ingest", + "os": "mac", + "role": "the watchdog, Companion, the settings page, lobby captions, transcription", + "runs": [ + { + "kind": "launchagent", + "label": "org.waterschurch.status-panel", + "name": "the watchdog" + }, + { + "kind": "launchagent", + "label": "org.waterschurch.status-settings", + "name": "the settings page" + }, + { + "kind": "app", + "process": "Companion", + "name": "Companion 5.0.7", + "http": "http://127.0.0.1:8000/api/connections" + }, + { + "kind": "app", + "process": "Companion Satellite", + "name": "Companion Satellite", + "watched": false + }, + { + "kind": "launchagent", + "label": "org.waterschurch.resi-propresenter", + "name": "the Resi bridge" + }, + { + "kind": "launchagent", + "label": "org.waterschurch.lobby-captions", + "name": "lobby captions" + }, + { + "kind": "launchagent", + "label": "org.waterschurch.lobby-captions-display", + "name": "the caption screen" + }, + { + "kind": "app", + "process": "ProdCom", + "name": "ProdCom (transcription)", + "http": "http://127.0.0.1:24480/api/v1/ping" + }, + { + "kind": "app", + "process": "ProdDash", + "name": "ProdDash", + "http": "http://127.0.0.1:24500/" + }, + { + "kind": "app", + "process": "Spotify", + "name": "Spotify" + }, + { + "kind": "app", + "process": "ATEM Software Control", + "name": "ATEM Software Control" + }, + { + "kind": "daemon", + "process": "DanteVirtualSoundcard", + "name": "Dante Virtual Soundcard", + "dante_name": "STUDIO-A-Ingest" + }, + { + "kind": "helper_dvs", + "name": "the DVS helper" + }, + { + "kind": "folder", + "path": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Production Team/Backup/NA/ATEM", + "name": "Google Drive (the saved switcher files)" + }, + { + "kind": "launchagent", + "label": "org.waterschurch.checklist-build-runner", + "name": "Gitea runner (not the panel)", + "watched": false + }, + { + "kind": "launchagent", + "label": "org.waterschurch.checklist-drive-backup", + "name": "checklist backup (not the panel)", + "watched": false + }, + { + "kind": "launchagent", + "label": "org.waterschurch.checklist-weekly-health", + "name": "checklist health (not the panel)", + "watched": false + }, + { + "kind": "disabled", + "label": "org.waterschurch.translate", + "name": "Church Translate (must NOT run here)" + } + ], + "networks": [ + { + "iface": "en0", + "use": "LAN" + }, + { + "iface": "en1", + "use": "LAN" + }, + { + "iface": "en17", + "use": "Dante" + }, + { + "iface": "en20", + "use": "Netgear management" + } + ] + }, + "prop": { + "ssh": "napropresenter@10.1.14.225", + "name": "ProPresenter Mac", + "os": "mac", + "role": "ProPresenter, its stage and Prop TV pages, Dante sound card", + "runs": [ + { + "kind": "sshd" + }, + { + "kind": "app", + "process": "ProPresenter", + "name": "ProPresenter (newest installed)", + "http": "http://10.1.14.225:1025/" + }, + { + "kind": "daemon", + "process": "DanteVirtualSoundcard", + "name": "Dante Virtual Soundcard", + "dante_name": "NA-ProPresenter-Mac" + }, + { + "kind": "helper_dvs", + "name": "the DVS helper" + }, + { + "kind": "midi_session", + "name": "NA ProP" + } + ] + }, + "tracks": { + "ssh": "waterschurchmedia@10.1.3.227", + "name": "Tracks Mac", + "os": "mac", + "role": "Ableton (tracks and FX lighting cues), Dante sound card", + "runs": [ + { + "kind": "sshd" + }, + { + "kind": "app", + "process": "Live", + "name": "Ableton Live 11 Suite" + }, + { + "kind": "daemon", + "process": "DanteVirtualSoundcard", + "name": "Dante Virtual Soundcard", + "dante_name": "NA-Tracks" + }, + { + "kind": "helper_dvs", + "name": "the DVS helper" + }, + { + "kind": "midi_session", + "name": "NA Tracks" + } + ] + }, + "translation": { + "ssh": "studio@10.1.7.39", + "name": "NA Translation", + "os": "mac", + "role": "Church Translate, Dante sound card", + "runs": [ + { + "kind": "sshd" + }, + { + "kind": "app", + "process": "Church Translate", + "name": "Church Translate" + }, + { + "kind": "launchagent", + "label": "org.waterschurch.translate.engine", + "name": "the translation engine", + "watched": false + }, + { + "kind": "daemon", + "process": "DanteVirtualSoundcard", + "name": "Dante Virtual Soundcard", + "dante_name": "NA-Translation" + }, + { + "kind": "helper_dvs", + "name": "the DVS helper" + } + ] + }, + "studiob": { + "ssh": "studiob@10.1.13.175", + "name": "Studio B Mac Studio", + "os": "mac", + "role": "Pro Tools", + "runs": [ + { + "kind": "sshd" + }, + { + "kind": "app", + "process": "Pro Tools", + "name": "Pro Tools" + }, + { + "kind": "helper_solo", + "label": "org.waterschurch.status-panel.solo-mode", + "name": "the Pro Tools solo-mode helper" + }, + { + "kind": "midi_session", + "name": "Studio B Mac Studio", + "peer_of": "NA Tracks" + } + ] + }, + "lighting": { + "ssh": "media@10.1.10.206", + "name": "Lighting PC", + "os": "windows", + "role": "Vista, Resolume Arena, rtpMIDI", + "pingable": false, + "notes": "Keep the network profile on Public: rtpMIDI's Bonjour rules only allow Public.", + "runs": [ + { + "kind": "sshd" + }, + { + "kind": "task", + "name": "Start Vista", + "exe": "Vista.exe" + }, + { + "kind": "task", + "name": "Start Arena", + "exe": "Arena.exe" + }, + { + "kind": "task", + "name": "Start rtpMIDI", + "exe": "rtpMIDI.exe" + }, + { + "kind": "service", + "name": "rtpMIDIService" + }, + { + "kind": "artnet", + "name": "Vista Art-Net", + "source": "10.1.10.206" + } + ], + "manual_done": { + "public_profile": { + "by": "Chris", + "on": "2026-10-08" + }, + "vista_autoload": { + "by": "Chris", + "on": "2026-10-08" + } + } + }, + "livestream": { + "ssh": "media@10.1.9.126", + "os": "windows", + "mac": "6c:24:08:d7:fb:57", + "wake_broadcast": "10.1.255.255", + "name": "LiveStream PC", + "role": "OBS (YouTube)", + "runs": [ + { + "kind": "sshd" + }, + { + "kind": "task", + "name": "Start OBS", + "exe": "obs64.exe", + "elevated": true + } + ], + "manual_done": { + "bios_wol": { + "by": "Chris", + "on": "2026-10-08" + } + } + }, + "bibletv": { + "ssh": "media@10.6.7.13", + "os": "windows", + "name": "Bible TV PC", + "role": "the Bible app on the sanctuary TV", + "pingable": false, + "notes": "Wi-Fi only; Ethernet recommended. Wi-Fi power saving is off.", + "runs": [ + { + "kind": "sshd" + }, + { + "kind": "task", + "name": "Start BibleApp", + "exe": "Bible App.exe", + "at_logon": true + }, + { + "kind": "web", + "url": "http://10.6.7.13:8080/", + "name": "the Bible app's web page" + } + ], + "manual_done": { + "wifi_power": { + "by": "Chris", + "on": "2026-10-11" + } + } + } }, + "gear": {}, + "fixes": { "propresenter": { "host": "prop", "short": "PROPRES", "label": "restart ProPresenter (newest installed)", "run": "APP=$(ls -d /Applications/ProPresenter*.app | sort -V | tail -1); if pgrep -xq ProPresenter; then pkill -TERM -x ProPresenter; for i in 1 2 3 4 5 6 7 8 9 10; do pgrep -xq ProPresenter || break; sleep 1; done; pkill -KILL -x ProPresenter; sleep 1; fi; open \"$APP\"" }, diff --git a/install.sh b/install.sh index 8386d98..1e3f2ff 100644 --- a/install.sh +++ b/install.sh @@ -12,11 +12,12 @@ set -e SRC=${0:A:h} LABEL=org.waterschurch.status-panel +SETTINGS_LABEL=org.waterschurch.status-settings # App Support, not ~/Documents: launchd-run python3 cannot read ~/Documents. DEST="$HOME/Library/Application Support/status-panel" PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" DOMAIN="gui/$(id -u)" -CODE=(status_panel.py coremidi.py netgear.py make_page.py push_page.py nav_keys.py atem_watch.js atem_state.js nav_templates.json) +CODE=(status_panel.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py atem_watch.js atem_state.js nav_templates.json) KEEP_CONFIG=0 for arg in "$@"; do case "$arg" in @@ -26,17 +27,45 @@ for arg in "$@"; do esac done -restart_agent() { - launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true +restart_agent() { # $1 = label, $2 = plist + local label=${1:-$LABEL} plist=${2:-$PLIST} + launchctl bootout "$DOMAIN/$label" 2>/dev/null || true # bootout returns before the old job is gone; bootstrapping too early fails with "5: Input/output error". - for i in {1..50}; do launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || break; sleep 0.2; done + for i in {1..50}; do launchctl print "$DOMAIN/$label" >/dev/null 2>&1 || break; sleep 0.2; done for attempt in 1 2 3; do - launchctl bootstrap "$DOMAIN" "$PLIST" && break - [[ $attempt == 3 ]] && { echo "launchctl bootstrap failed" >&2; exit 1; } + launchctl bootstrap "$DOMAIN" "$plist" && break + [[ $attempt == 3 ]] && { echo "launchctl bootstrap failed for $label" >&2; exit 1; } sleep 1 done } +SETTINGS_PLIST="$HOME/Library/LaunchAgents/$SETTINGS_LABEL.plist" +write_settings_plist() { + cat > "$SETTINGS_PLIST" < + + + + Label$SETTINGS_LABEL + ProgramArguments + + /usr/bin/python3 + -m + settings.server + --data-dir + $DEST + + WorkingDirectory$DEST + RunAtLoad + KeepAlive + ThrottleInterval10 + AbandonProcessGroup + StandardErrorPath$HOME/Library/Logs/status-settings.err.log + + +EOF +} + write_plist() { # $1 = exit timeout in seconds cat > "$PLIST" < @@ -80,8 +109,9 @@ PY if [[ "$1" == "--remove" ]]; then launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true - rm -f "$PLIST" - echo "removed $LABEL (files left in $DEST)" + launchctl bootout "$DOMAIN/$SETTINGS_LABEL" 2>/dev/null || true + rm -f "$PLIST" "$SETTINGS_PLIST" + echo "removed $LABEL and $SETTINGS_LABEL (files left in $DEST)" exit 0 fi @@ -121,6 +151,11 @@ if [[ $KEEP_CONFIG == 0 ]]; then fi for f in $CODE; do [[ -f "$SRC/$f" ]] && cp "$SRC/$f" "$DEST/"; done [[ -f "$SRC/atem-reference.xml" ]] && cp "$SRC/atem-reference.xml" "$DEST/" +# The settings page (its own LaunchAgent) and the record of coloured page-number keys (site-owned: never overwritten). +if [[ -d "$SRC/settings" ]]; then + rm -rf "$DEST/settings" && cp -R "$SRC/settings" "$DEST/" && find "$DEST/settings" -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null +fi +[[ -f "$SRC/nav_locations.json" && ! -f "$DEST/nav_locations.json" ]] && cp "$SRC/nav_locations.json" "$DEST/" if [[ -d "$SRC/companion-objects" ]]; then rm -rf "$DEST/companion-objects" && cp -R "$SRC/companion-objects" "$DEST/" fi @@ -132,3 +167,9 @@ restart_agent echo "installed $LABEL -> $DEST (exit timeout ${EXIT_TIMEOUT}s)" echo "log: ~/Library/Logs/status-panel.log" wait_for_start "$T" || true +if [[ -d "$DEST/settings" ]]; then + write_settings_plist + restart_agent "$SETTINGS_LABEL" "$SETTINGS_PLIST" + PORT=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("site",{}).get("settings_port",8020))' "$DEST/config.json") + echo "settings page: http://$(hostname -s).local:$PORT/ (log: ~/Library/Logs/status-settings.log)" +fi diff --git a/settings/__init__.py b/settings/__init__.py new file mode 100644 index 0000000..eb42248 --- /dev/null +++ b/settings/__init__.py @@ -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) diff --git a/settings/keyguide.py b/settings/keyguide.py new file mode 100644 index 0000000..21e6381 --- /dev/null +++ b/settings/keyguide.py @@ -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 ", "LOW ", "NOT REC", "OFFLINE", "NO ANSWER"], + "dante": [" GONE"], "shure": [" BATT", " INTERF"], "psm": [" RF MUTE"], + "tascam": [""], "ping": [" NO PING"], "launchd": [" NOT LOADED", " STOPPED"], + "process": [" STOPPED"], "http": [" UNREACH"], "disk": ["DISK GB"], + "midi_peer": ["MIDI LOST", "NO SESSION", "MIDI "], "pref": [], "spotify": ["SPOTIFY CLOSED", "NOT PLAYING", "WRONG PLAYLIST"], + "mdns": [" GONE"], "midi_rx": [" 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("also when the key is green: " + 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 = ["What each key does" % CSS, + "

What each key does

Made from the panel's settings; it changes whenever they do. " + "Press a key = acknowledge its alarm. Hold %s = run its fix.

" % 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("

%s

" % (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("

%s%s

" % (pos, "" if t.get("kind") != "master" else " - sums up every key")) + if t.get("members"): + parts.append("

Shows the worst of: %s. Hold: %s.

" % (", ".join(e(tiles[m]["label"]) for m in t["members"]), hold_words(t, cfg))) + if t.get("kind") == "service": + parts.append("

%s

" % e(ph["alarms"])) + if t.get("kind") == "network": + n = cfg.get("network", {}) + parts.append("

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.

" % ( + (" 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("

Checks:

    ") + for chk in t["checks"]: + words = FAULT_WORDS.get(chk["type"], []) + if chk.get("msg"): + words = [chk["msg"]] + parts.append("
  • %s%s
  • " % (check_sentence(chk, cfg), (" → %s" % e(", ".join(words))) if words else "")) + parts.append("
") + parts.append("

Holding: %s.

" % 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("

Auto-fix %s: %s.

" % (e(ph["auto_fix"]), ", ".join(e(cfg["fixes"][f].get("short", f)) for f in byself))) + parts.append("
") + rp = cfg.get("restart_page") + if rp: + parts.append("

Restart page (page %d)

Each key restarts one thing even when the Status page says it is fine. " + "It flashes HOLD TO FIX while that thing is down.

" % rp["page"]) + for k in sorted(rp.get("keys", []), key=lambda k: (k["pos"][0], k["pos"][1])): + parts.append("

%s

row %d, column %d

Hold: %s.

" + % (e(k["label"]), k["pos"][0], k["pos"][1], fix_words(k["fix"], cfg))) + parts.append("

Colours

green: fine" + "flashing red: the problem, named" + "dark red: acknowledged" + "amber: a warning, or a fault that keeps coming back (xN)" + "purple: the watchdog is down" + "blue: a fix is running

") + 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 = ["
"] + for r in range(4): + for c in range(8): + if (r, c) in nav: + out.append("" % e(nav[(r, c)])) + elif (r, c) in cells: + out.append("
%s
" % e(cells[(r, c)])) + else: + out.append("
") + out.append("
") + return "".join(out) + + +def cheat_sheet(cfg, address): + ph = schema.phrases(cfg) + site = cfg.get("site", {}) + hosts = cfg.get("hosts", {}) + rows = "".join("%s%s" % (e(h.get("name", a)), e(h.get("role", ""))) for a, h in hosts.items()) + return "\n".join([ + "Status panel cheat sheet" % CSS, + "

Status panel - %s

" % e(site.get("name", "")), + "

Green = fine. Flashing red = the problem, named on the key. Press = acknowledge (it stops flashing). " + "%s = run the fix. Purple = the watchdog is down: open the settings page and press Restart the watchdog.

" % e(ph["hold"].replace("HOLD ", "Hold ")), + "

%s Auto-fix %s. %s.

" % (e(ph["alarms"]), e(ph["auto_fix"]), e(ph["live"][0].upper() + ph["live"][1:])), + "

Status page (page %d)

" % cfg["page"], grid_html(cfg), + ("

Restart page (page %d)

" % cfg["restart_page"]["page"] + grid_html(cfg, restart=True)) if cfg.get("restart_page") else "", + "

The computers

%s
" % rows, + "

Where to look

The settings page: %s (every key, every computer, the logs, the history, help). " + "Contacts: %s

" % (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 = ["Where things are" % CSS, + "

Where things are

For the person who maintains it.

", + "

%s (this Mac)

    " % e(site.get("ingest_name", "the Ingest Mac")), + "
  • The watchdog and the settings page: LaunchAgents org.waterschurch.status-panel and " + "org.waterschurch.status-settings; files in %s
  • " % e(data_dir or "~/Library/Application Support/status-panel"), + "
  • Logs: ~/Library/Logs/status-panel.log, status-panel.err.log, status-settings.log; " + "auto-fix daily logs in %s
  • " % e((cfg.get("auto_fix") or {}).get("log_dir", "~/Desktop/Auto-Fix Logs")), + "
  • Working files: config.json (the settings), state.json (what survives a restart), " + "started.json, live-now.json, fixes-running.json, snapshot.json (what the watchdog sees), " + "machines-live.json (the last look at the computers), dante-map.json, atem-reference.xml (the saved switcher file), " + "prev/ (the previous code, for install.sh --rollback)
  • ", + "
  • Companion: http://127.0.0.1:%d, custom variables %s, %s, %s, blink clock %s; " + "pages %d (Status)%s
  • " % (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 ""), + "
  • The login key for the other computers: %s; the Netgear login: keychain item netgear-status-panel
  • " % e(cfg.get("ssh_key", "~/.ssh/id_status_panel")), + "
  • This page: %s
" % e(address)] + for alias, h in hosts.items(): + if h.get("self"): + continue + parts.append("

%s

  • Log in: %s%s
  • " % (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("
  • Scheduled task \\StatusPanel\\%s starts %s
  • " % (e(run["name"]), e(run.get("exe", "")))) + elif k == "service": + parts.append("
  • Windows service %s
  • " % e(run["name"])) + elif k == "launchagent": + parts.append("
  • LaunchAgent %s (%s)
  • " % (e(run.get("label", "")), e(run.get("name", "")))) + elif k == "helper_dvs": + parts.append("
  • DVS helper %s with a sudoers rule in /etc/sudoers.d/status-panel
  • " % e(site.get("dvs_helper", "/usr/local/sbin/status-panel-dvs-start"))) + elif k == "helper_solo": + parts.append("
  • Pro Tools helper in %s (LaunchAgent %s; needs Accessibility)
  • " % (e(site.get("solo_dir", "")), e(run.get("label", "")))) + elif k == "app": + parts.append("
  • %s (process %s)
  • " % (e(run.get("name", "")), e(run.get("process", "")))) + parts.append("
") + return "\n".join(parts) diff --git a/settings/probes.py b/settings/probes.py new file mode 100644 index 0000000..b102e5c --- /dev/null +++ b/settings/probes.py @@ -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 diff --git a/settings/server.py b/settings/server.py new file mode 100644 index 0000000..023adb2 --- /dev/null +++ b/settings/server.py @@ -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() diff --git a/settings/ui/app.js b/settings/ui/app.js new file mode 100644 index 0000000..efe115b --- /dev/null +++ b/settings/ui/app.js @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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) => ``; + const wordOf = (w) => `${esc(w)}`; + + // ---- 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(`

Switch Editing on

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.

+ +

`, (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 = `
${html}
`; 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]) => `${esc(t)}`).join("") || `Nothing called "${esc(q)}"`; + 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 = `

Could not load this screen: ${esc(e.message)}

`; } + 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) => `
  • ${esc(p.text)}${p.acked ? ' (acknowledged)' : ""} — ${esc(p.action)} open the key
  • `).join("") : `
  • Everything is fine.
  • `; + const needs = o.needs.length ? o.needs.map((n) => `
    Needs Chris: ${esc(n)}
    `).join("") : ""; + const live = o.live && o.live.live ? `
    Live now: ${esc(o.live.reason)}. Changes to the panel wait.
    ` : ""; + const comps = o.computers.map((c) => `${wordOf(c.word)}${esc(c.name)}${esc(c.reason)}`).join(""); + const w = o.watchdog; + main.innerHTML = ` +

    ${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") + " in the room" : "Everything is fine"}

    +

    Is the room OK, and what do I do. Problems mirror the red keys; hold the key, or open it for the details.

    +
      ${prob}
    ${needs}${live} +
    +

    Live deck ${o.deck ? "as the Stream Deck looks now · click a key" : "(Companion is not answering)"}

    ${deckHtml(o.deck, state.config, "status")} +
    +

    Computers

    ${comps}
    +
    +
    +
    +
    Watchdog
    ${w.running ? "OK, running" : "not running"}${w.since ? " since " + esc(when(w.since)) : ""}${w.version ? ` code ${esc(w.version)}` : ""}${o.heartbeat_age != null ? ` · heartbeat ${esc(ago(o.heartbeat_age))}` : ""}
    +
    Companion
    ${o.companion_ok ? "OK" : "not answering"}${o.pages.status ? (o.pages.status.ok ? ", pages OK" : ", page " + o.pages.status.page + " is called " + esc(o.pages.status.live || "?")) : ""}
    +
    Alarms
    ${o.service_on ? "ON" : "silenced until midnight"}${o.alarms_override != null ? " (by hand)" : ""} · ${esc(o.phrases.alarms)}
    +
    Auto-fix
    ${esc(o.auto_fix.phrase)}${o.auto_fix.state && o.auto_fix.state.on ? " · may act now" : ""}${o.auto_fix.state && o.auto_fix.state.paused ? " (waiting out a network blip)" : ""}
    +
    Running now
    ${Object.keys(o.running || {}).length ? Object.values(o.running).map((r) => esc(r.what + " on " + r.key)).join(", ") : "no fix is running"}
    +
    Last change
    ${o.last_change ? esc(o.last_change.when + " · " + o.last_change.note) : "—"} History
    +
    +
    +
    `; + 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 = `
    `; + 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 += ``; + else if (cell && cell.nav) out += ``; + 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 += `
    ${esc(s ? s.x : cell.label)}
    `; + } else out += `
    `; + } + return out + `
    `; + } + + // ---- 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 = `
    ${k.restart_page ? `` : ""}
    `; + let panel = `

    Click a key to see what it watches and what holding it does.

    `; + 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 = `

    ${esc(r.label)}

    row ${r.pos[0]}, column ${r.pos[1]}

    HOLDING ${esc(k.hold.replace("HOLD ", ""))} WILL: ${esc(r.holding)}, even when the Status page says it is fine.

    ${r.watch ? `

    Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}

    ` : ""}
    Advanced
    id
    ${esc(r.id)}
    fix
    ${esc(r.fix)}

    Editing arrives with step 2.

    `; + } + const tray = k.tiles.filter((t) => !t.pos && t.kind !== "master").map((t) => ``).join(" "); + main.innerHTML = `

    Keys

    The deck as people hold it. In this step you can look; moving and editing keys arrive with steps 2 and 3.

    ${tabs} +
    ${deckHtml(o.deck, state.config, restart ? "restart" : "status", sel)}${!restart && tray ? `

    Shown inside their group, no key of their own: ${tray}

    ` : ""}
    ${panel}
    `; + } + + function keyPanel(t, k) { + const cfg = state.config; + const checks = (t.checks || []).map((c, i) => `
  • ${esc(c.sentence)}
    Advanced
    ${Object.entries(c).filter(([n]) => !["sentence", "identity"].includes(n)).map(([n, v]) => `
    ${esc(n)}
    ${esc(typeof v === "object" ? JSON.stringify(v) : v)}
    `).join("")}
  • `).join(""); + const members = t.members ? `

    Sums up: ${t.members.map((m) => `${esc((cfg.tiles.find((x) => x.id === m) || {}).label || m)}`).join(", ")}

    ` : ""; + return `

    ${esc(t.label)}

    ${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)}` : ""}

    + ${t.says && t.says.length ? `

    WHAT THIS KEY WILL SAY: ${esc(t.says.join(", "))}

    ` : ""} +

    HOLDING ${esc(k.hold.replace("HOLD ", ""))} WILL: ${esc(t.holding)}${t.hold_always ? " also when green" : ""}

    + ${members}${checks ? `

    Checks:

      ${checks}
    ` : ""} +
    Advanced
    id
    ${esc(t.id)}

    Editing arrives with step 2.

    `; + } + + // ---- 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 = `
    +
    `; + 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) => `${wordOf(c.word)}${esc(c.name)}${c.self ? ' (this Mac)' : ""}${esc(c.role)}${c.keys.map((k) => `${esc((state.config.tiles.find((t) => t.id === k) || { label: k }).label)}`).join(" ")}${esc(c.reason)}${c.looked ? `
    looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}`).join(""); + body = `
    ${rows}
    statuscomputerwhat it does for the panelkeys that turn red if it dies
    `; + const c = m.computers.find((x) => x.alias === selected); + if (c) body += computerCard(c); + } + main.innerHTML = `

    Computers

    What runs where. One row per computer; click it for everything that runs there, with a light each.

    ${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) => `${lightDot(i.light)}${esc(i.light === "ok" ? "OK" : i.light === "problem" ? "Problem" : i.light === "not_needed" ? "Not needed" : "Can't see from here")}${esc(i.name)}${i.watched === false ? ' (not watched)' : ""}${esc(i.reason || i.detail || (i.detail && JSON.stringify(i.detail)) || "")}`).join(""); + const found = c.found || {}; + const foundHtml = Object.entries(found).filter(([, v]) => Array.isArray(v) && v.length).map(([k, v]) => `

    ${esc(k)}: ${esc(typeof v[0] === "object" ? v.map((x) => x.iface + " " + x.ip + (x.active ? "" : " (down)")).join(", ") : v.join(", "))}

    `).join("") || `

    Press "Check computers now" to look.

    `; + const done = Object.entries(c.manual_done || {}).map(([k, v]) => `
  • ${lightDot("hand")}${esc(k.replace(/_/g, " "))}: done by hand (${esc(v.by)}, ${esc(v.on)})
  • `).join(""); + return `

    ${esc(c.name)} ${wordOf(c.word)}

    ${esc(c.role)}${c.ssh ? ` · logs in as ${esc(c.ssh)}` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "no answer"}` : ""}${c.error ? ` · ${esc(c.error)}` : ""}

    + ${c.notes ? `

    ${esc(c.notes)}

    ` : ""} +

    What runs here

    ${items}
    + ${done ? `
      ${done}
    ` : ""} +
    Everything found on this computer${foundHtml}
    + ${c.self ? `
    This Mac's set-up lights
    ${Object.entries(c.setup || {}).map(([k, v]) => `
    ${esc(k.replace(/_/g, " "))}
    ${typeof v === "boolean" ? lightDot(v ? "ok" : "problem") + (v ? "OK" : "Problem") : esc(JSON.stringify(v))}
    `).join("")}
    ` : ""} +
    `; + } + + async function gearHtml() { + const g = await api("/api/gear"); + return `

    Devices that are not computers, from the keys that watch them. Where each one lives is typed once on this list (step 2).

    + ${g.map((r) => ``).join("")}
    lightdevicekindaddresswhere / noteswatched by
    ${lightDot(r.light)}${esc(r.light === "ok" ? "OK" : r.light === "problem" ? "Problem" : "–")}${esc(r.name)}${esc(r.kind)}${esc(r.address || "")}${r.detail ? `
    ${esc(r.detail)}` : ""}
    ${esc(r.location || "")} ${esc(r.notes || "")} ${esc(r.reason || "")}${r.watched_by.map((k) => `${esc((state.config.tiles.find((t) => t.id === k) || { label: k }).label)}`).join(", ")}
    `; + } + + 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 `

    ${esc(c.name)}

      ${steps.map((s) => { const l = lightFor(s); return `
    • ${lightDot(l)}${esc(s)} ${l === "hand" ? "(done by hand)" : l === "unknown" ? "– can't see from here" : ""}
    • `; }).join("")}

    Generated set-up scripts and the [how] steps arrive with step 5.

    `; + }).join(""); + } + + // ---- Logs ---------------------------------------------------------------- + let logTab = "watchdog", logFilter = ""; + async function logs(rest) { + logTab = rest[0] || logTab; + const tabs = ["watchdog", "errors", "autofix", "settings"].map((n) => ``).join(""); + const filters = logTab === "watchdog" ? `
    ${["", "faults", "holds", "fixes", "auto", "errors"].map((f) => ``).join("")}
    ` : ""; + 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" ? `

    ${(data.files || []).slice().reverse().map((f) => `${esc(f.replace(" Auto-Fix.log", ""))}`).join(" · ") || "no auto-fix days yet"}

    ` : ""; + main.innerHTML = `

    Logs

    What the watchdog did and saw. Newest lines at the bottom.

    ${tabs}
    ${filters}${files}
    ${esc((data.lines || []).join("\n")) || "(empty)"}
    `; + 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 = `

    History

    Every version of the settings that was ever applied. ${esc(h.note)}

    ${h.entries.map((e) => ``).join("") || ""}
    whenfilesize
    ${esc(e.when)}${esc(e.file)}${esc(e.size)}
    nothing yet
    `; + } + + // ---- 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]) => `

    ${t}

    ${b}

    `).join(""); + const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `
    ${esc(g)}

    ${esc(words.join(" · "))}

    `).join(""); + main.innerHTML = `

    Help

    Start with the four cards. The pages below are made from the settings, so they are never out of date.

    ${cards}
    +

    Pages

    What each key does · Cheat sheet (one page) · Where things are

    +

    First-day walkthrough (10 minutes, no code needed)

    1. Overview: the live deck. Green, red, purple.
    2. Click a red key (or any key): read what it checks and what holding it will do.
    3. Logs › Watchdog, filter "holds": find a hold and what it did.
    4. Keys › Restart page: each key restarts one thing, even when green.
    5. Overview: when alarms are on and when auto-fix may act by itself.
    6. Computers › Map: which computer a key talks to, and what runs there.
    7. Help › What each key does: the whole deck in words.
    +

    The words a key can show

    ${glossary} +

    Things only a person can do

    • 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.
    • Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.
    • Type the Netgear login into the keychain; keep Google Drive signed in for the saved switcher files.
    • Save a new switcher file from ATEM Software Control when the show changes.
    +

    Companion facts (found by trying)

    ${facts.facts.map(([q, a]) => `
    ${esc(q)}
    ${esc(a)}
    `).join("")}
    `; + } + + // ---- 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]) => ``).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 `
    ${v === undefined ? `(default: ${esc(JSON.stringify(f.default))})` : esc(typeof v === "object" ? JSON.stringify(v) : String(v))}
    `; }; + const own = Object.entries(F).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !["tiles", "fixes", "hosts", "gear"].includes(p)); + let body = `

    Settings

    ${own.filter(([, f]) => !f.advanced).map(([p, f]) => fieldHtml(p, f)).join("")}
    Advanced${own.filter(([, f]) => f.advanced).map(([p, f]) => fieldHtml(p, f)).join("")}
    `; + if (which === "companion") { + const c = await api("/api/companion"); + const lights = Object.entries(c.lights).map(([k, v]) => `
  • ${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}
  • `).join(""); + const conns = c.connections.map((r) => `${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed")}${esc(r.label)}${esc(r.module || "")}${esc(r.enabled ? r.category || "" : "off")}${r.message ? ` ${esc(r.message)}` : ""}${r.watched_by.length ? r.watched_by.map((k) => `${esc(k.toUpperCase())}`).join(", ") : 'not watched'}`).join(""); + body = `

    Companion ${c.ok ? "OK" : "not answering"}${c.build ? ` ${esc(c.build)}` : ""}

    Set up once by the person who installed Companion. You normally only come here to see which connection a key watches.

      ${lights}
    + ${c.missing.length ? `

    Keys watch connections Companion does not have: ${esc(c.missing.join(", "))}

    ` : ""} +

    Page-number buttons coloured on ${c.page_number_buttons.pages_coloured} pages (${esc(c.page_number_buttons.recorded_in)}).

    +

    Connections (live)

    ${conns}
    connectionmodulestatuswatched by
    ` + 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 += `

    Dante device list (the DANTE key's devices)

    ${Object.entries(d).map(([n, a]) => { const e = map[n] || {}; return ``; }).join("")}
    Dante namekey textaddressswitch / portPoE
    ${esc(n)}${esc(a)}${esc(e.ip || "")}${e.switch ? esc(e.switch + " port " + e.port) : ""}${e.poe ? "yes" : ""}

    Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.

    `; + } + if (which === "schedules") { + const ph = await api("/api/overview").then((o) => o.phrases).catch(() => ({})); + body = `
    ${esc(ph.alarms || "")} Auto-fix ${esc(ph.auto_fix || "")}. ${esc(((ph.live || "")[0] || "").toUpperCase() + (ph.live || "").slice(1))}.

    Fixes auto-fix may run

    ${(cfg.auto_fix.fixes || []).map((f) => `${esc((cfg.fixes[f] || {}).short || f)}`).join(" ")}

    ` + body; + } + main.innerHTML = `

    Site setup

    Set up once. Each section opens on the few things a person changes; the numbers sit under Advanced. Editing arrives with step 2.

    ${tabs}
    ${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]) => `${esc(f.short || id)}${esc(f.label || "")}${esc(f.kind || "ssh")}${f.host ? " on " + esc((cfg.hosts[f.host] || {}).name || f.host) : f.kind === "local" || f.kind === "spotify" ? " on this Mac" : ""}${[...(used[id] || [])].join(", ")}${auto.has(id) ? "may run by itself" : f.never_auto ? "never by itself" : "hold only"}`).join(""); + const f = cfg.fixes[rest[0]]; + const detail = f ? `

    ${esc(f.short || rest[0])}

    ${esc(f.label || "")}

    Advanced
    ${Object.entries(f).map(([k, v]) => `
    ${esc(k)}
    ${esc(typeof v === "object" ? JSON.stringify(v) : v)}
    `).join("")}

    Builders (fixes made from fields instead of commands) arrive with step 2.

    ` : ""; + main.innerHTML = `

    Fixes

    Everything a hold can run. ${Object.keys(cfg.fixes).length} today.

    ${rows}
    key textplain wordsbuilt asused byby itself
    ${detail}`; + } + + function alertBox(msg) { dialog(`

    ${esc(msg)}

    `, (b) => { b.querySelector("#dok").onclick = closeDialog; }); } +})(); diff --git a/settings/ui/index.html b/settings/ui/index.html new file mode 100644 index 0000000..2cf4b4d --- /dev/null +++ b/settings/ui/index.html @@ -0,0 +1,44 @@ + + + + + +Status Panel + + + +
    +
    STATUS PANEL
    + +
    + + +
    +
    + + +
    + +

    Loading…

    +
    + + + + diff --git a/settings/ui/style.css b/settings/ui/style.css new file mode 100644 index 0000000..3e07a44 --- /dev/null +++ b/settings/ui/style.css @@ -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%; } +} diff --git a/status_panel.py b/status_panel.py index 0c6a10d..c4811ef 100644 --- a/status_panel.py +++ b/status_panel.py @@ -1783,14 +1783,39 @@ def remember_peers(snap, host, sessions): def companion_db_path(cfg): + """Companion's settings database: "db" in config "companion", else the folder of the build the + pages are written for ("companion.build", e.g. 5.0.7 -> v5.0), else the newest folder whose + database opens (an upgrade that was tried and abandoned leaves a folder with a dead copy).""" if cfg["companion"].get("db"): return os.path.expanduser(cfg["companion"]["db"]) root = os.path.expanduser("~/Library/Application Support/companion") vers = [d for d in os.listdir(root) if re.fullmatch(r"v\d+\.\d+", d)] if os.path.isdir(root) else [] if not vers: return None - newest = max(vers, key=lambda d: tuple(int(x) for x in d[1:].split("."))) - return os.path.join(root, newest, "db.sqlite") + build = str(cfg["companion"].get("build") or "").split("+")[0] + wanted = "v" + ".".join(build.split(".")[:2]) if build else None + order = sorted(vers, key=lambda d: tuple(int(x) for x in d[1:].split(".")), reverse=True) + if wanted in order: + order.remove(wanted) + order.insert(0, wanted) + for d in order: + path = os.path.join(root, d, "db.sqlite") + if os.path.exists(path) and db_opens(path): + return path + return None + + +def db_opens(path): + import sqlite3 + try: + db = sqlite3.connect("file:%s?mode=ro" % urllib.parse.quote(path), uri=True, timeout=2) + try: + db.execute("SELECT 1 FROM instances LIMIT 1").fetchall() + finally: + db.close() + return True + except sqlite3.Error: + return False def companion_conn_hosts(path): @@ -2628,6 +2653,54 @@ def start_pollers(cfg, snap, stop, data_dir=None): return threads +def snapshot_summary(snap, engine, now): + """Everything the watchdog knows right now, in plain JSON (the settings page reads it instead of + asking the gear again): what Companion reports, pings, MIDI sessions here and on the other Macs, + helpers and programs, web checks, Spotify, the switcher, the Dante map, and every key's faults.""" + out = { + "time": now, + "conns": {label: {"enabled": c.get("enabled"), "category": c.get("category"), "message": c.get("message")} + for label, c in snap.conns.items()}, + "conns_fresh": snap.conns_fresh(now), + "conn_hosts": dict(snap.conn_hosts), + "ping": dict(snap.ping), + "link": snap.link, + "midi": snap.midi if snap.midi_time else None, + "remote_midi": {h: v for h, v in snap.remote_midi.items()}, + "remote_midi_age": {h: round(now - t, 1) for h, t in snap.remote_midi_time.items()}, + "mdns": sorted(snap.mdns) if snap.mdns_time else None, + "launchd": dict(snap.launchd), + "procs": dict(snap.procs), + "http": dict(snap.http), + "disk": dict(snap.disk), + "prefs": {"%s/%s" % k: v for k, v in snap.prefs.items()}, + "remote": {k.replace("\0", "|"): v for k, v in snap.remote.items()}, + "spotify": snap.spotify, + "spotify_known_songs": len(snap.spotify_tracks), + "atem": ({"connected": snap.atem.get("connected"), "audio": snap.atem.get("audio"), "camera": snap.atem.get("camera"), + "age": round(now - snap.atem.get("time", now), 1)} if snap.atem else None), + "dante_map": snap.dante_map, + "dante_map_age": round(now - snap.dante_map_time, 1) if snap.dante_map_time else None, + "artnet": {src: round(now - t, 1) for src, t in snap.artnet_last.items()}, + "midi_rx": {name: {"connected": l.connected, "age": None if l.last_time is None else round(now - l.last_time, 1)} + for name, l in snap.listeners.items()}, + "service_on": engine.service.on(now), + "alarms_override": engine.service.override, + "auto_fix": {"on": engine.auto_on, "paused": engine.auto_paused}, + "burst": engine.burst and {"start": engine.burst["start"], "conns": sorted(engine.burst["conns"]), + "cleared": engine.burst["cleared"]}, + "live": list(engine.live_now(now)), + "faults": {}, + } + for key, tr in engine.trackers.items(): + if tr.confirmed and key in engine.meta: + tile_id, chk, sub = engine.meta[key] + out["faults"].setdefault(tile_id, []).append( + {"check": check_identity(chk), "type": chk["type"], "sub": sub, "level": tr.result.level, + "msg": tr.result.msg, "since": tr.since, "acked": tr.acked}) + return out + + def post_webhook(url, text): if not url: return @@ -2712,7 +2785,7 @@ def run(cfg, data_dir, dry_run=False, stop=None, config_sha=None): except (OSError, http.client.HTTPException) as e: log.warning("could not read the command variable at start: %s", e) stop.wait(2.5) # let the first polls land before judging anything - last_json, last_write, last_eval = None, 0.0, 0.0 + last_json, last_write, last_eval, last_snapshot = None, 0.0, 0.0, 0.0 down_since, alerted, warned_missing = None, False, set() log.info("status panel running (code %s, %s)", VERSION, "dry run" if dry_run else "live") try: @@ -2740,6 +2813,12 @@ def run(cfg, data_dir, dry_run=False, stop=None, config_sha=None): for kind, text in engine.events: log.info("%s %s", kind, text) engine.events = [] + if now - last_snapshot >= 5: + last_snapshot = now + try: + write_json(os.path.join(data_dir, "snapshot.json"), snapshot_summary(snap, engine, now)) + except (OSError, TypeError, ValueError) as e: + log.warning("snapshot.json: %s", e) js = json.dumps(tiles, separators=(",", ":"), sort_keys=True) if dry_run: if js != last_json: diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index 1e3c473..b64f682 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -380,6 +380,21 @@ class TestChecks(unittest.TestCase): tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, self.now, 4) self.assertEqual(tiles["rf"]["x"], "RF\nPSM 3 NO PING") + def test_companion_db_path_skips_a_dead_newer_folder(self): + import sqlite3 + root = tempfile.mkdtemp(prefix="sp-companion-") + for v in ("v5.0", "v5.1"): + os.makedirs(os.path.join(root, v)) + good = os.path.join(root, "v5.0", "db.sqlite") + db = sqlite3.connect(good); db.execute("CREATE TABLE instances (id TEXT, value TEXT)"); db.commit(); db.close() + open(os.path.join(root, "v5.1", "db.sqlite"), "w").write("not a database") # an abandoned upgrade + from unittest import mock + real = os.path.expanduser + with mock.patch.object(sp.os.path, "expanduser", lambda p: root if "companion" in p else real(p)): + self.assertEqual(sp.companion_db_path({"companion": {"build": "5.0.7+9763"}}), good) + self.assertEqual(sp.companion_db_path({"companion": {}}), good) # newest that opens + self.assertEqual(sp.companion_db_path({"companion": {"db": "/x/y.sqlite"}}), "/x/y.sqlite") + def test_companion_conn_hosts_reads_its_database_read_only(self): import sqlite3 d = tempfile.mkdtemp() @@ -1489,6 +1504,18 @@ class TestFixes(unittest.TestCase): with self.assertRaises(ValueError): sp.load_config(path) + def test_snapshot_summary_is_plain_json(self): + snap = self.snap + snap.conns["ATEM"]["category"] = "error" + run_steps(self.engine, snap, SUNDAY, 5) + out = sp.snapshot_summary(snap, self.engine, SUNDAY + 5) + json.dumps(out) # nothing in it that JSON cannot hold + self.assertEqual(out["conns"]["ATEM"]["category"], "error") + self.assertEqual(out["faults"]["atem"][0]["msg"], "OFFLINE") + self.assertEqual(out["faults"]["atem"][0]["check"], "ATEM") + self.assertEqual(out["live"], [False, ""]) + self.assertEqual(out["midi"]["NA Ingest"]["peers"][0]["name"], "Lighting PC") + def test_every_fix_is_valid(self): for f in CONFIG["fixes"].values(): self.assertIn(f.get("kind", "ssh"), sp.FIX_KINDS)