Fix and check builders: every fix is built from fields; today's 27 fixes carry their templates

settings/builders.py renders a fix's command from a builder (template) and its fields; validate
refuses a config whose stored command no longer equals what its fields build; the Fixes screen and
the remote checks in the key editor rebuild through /api/build and /api/build-check. config.json
adopts templates for all 27 fixes and 5 remote checks (ProPresenter's restart loop and Ableton's
quoting normalised, same behaviour); tests/test_builders.py (174 tests pass). Deployed to NA Ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 14:17:31 -04:00
co-authored by Claude Fable 5.1
parent 02d0c77cd9
commit 187dd464c0
8 changed files with 2014 additions and 214 deletions
+5 -1
View File
@@ -127,7 +127,11 @@ A problem has to last 3 seconds before it shows, so one bad reading doesn't trig
## The settings page ## The settings page
`http://na-ingest.local:8020/` (the Ingest Mac, LaunchAgent `org.waterschurch.status-settings`, log `~/Library/Logs/status-settings.log`). Anyone on the church network can look: the live deck, every key and what holding it does, every computer and what runs on it (with a light each after "Check computers now"), the gear, the logs, the history, the help pages (What each key does, the cheat sheet, Where things are). Nothing changes without the editing code, which is `site.editing_code` in `config.json`. Step 1 is read-only; editing and Apply arrive with step 2 (see `docs/settings-plan.md`). `http://na-ingest.local:8020/` (the Ingest Mac, LaunchAgent `org.waterschurch.status-settings`, log `~/Library/Logs/status-settings.log`). Anyone on the church network can look: the live deck, every key and what holding it does, every computer and what runs on it (with a light each after "Check computers now"), the gear, the logs, the history, the help pages (What each key does, the cheat sheet, Where things are). Nothing changes without the editing code, which is `site.editing_code` in `config.json` (top right: Editing, the code and your first name).
With Editing on every screen grows inputs. Changes go into one shared draft on the Ingest Mac (two people editing see each other's version; a stale tab is told); the badge at the top counts them and opens **Review & Apply**, which reads every change back in plain words, says what will happen (the deck goes purple for about 5 s only when something the watchdog reads changed; pages re-import only when the layout changed) and runs the pipeline: check the config, keep the old version in `history/`, write, restart the watchdog and wait for its start stamp, re-import the pages (refused if any unmarked key sits on them), recolour the page-number keys. If the watchdog does not come back the previous version is put back by itself. Apply is refused while a HyperDeck is recording (the Emergency button needs a reason and the word LIVE, and is logged). History keeps every version with who/when/note; **Go back** and **Undo** make the chosen version the draft.
Fixes and the remote checks are built from fields (`settings/builders.py`): a builder (Start a Windows program, Restart a Mac program, Restart a helper, Reopen Ableton with its last set, Restart DVS...) and its fields make the command; the stored command must equal what the fields build or the config is refused. Every Mac value is shell-quoted, every Windows value is regex-checked. A Custom command (Advanced) is the one place a command is typed by hand; Review shows commands in red.
The page reads what the watchdog writes next to its config (`snapshot.json` every 5 s, `started.json`, `live-now.json`, `fixes-running.json`) instead of asking the gear again. Its own look at the computers (`settings/probes.py`) runs fixed read-only commands over the same login key, fetches whole lists and matches them here, never while a fix runs. The page reads what the watchdog writes next to its config (`snapshot.json` every 5 s, `started.json`, `live-now.json`, `fixes-running.json`) instead of asking the gear again. Its own look at the computers (`settings/probes.py`) runs fixed read-only commands over the same login key, fetches whole lists and matches them here, never while a fix runs.
+1355 -199
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -200,6 +200,10 @@ field("fixes.*.never_auto", "bool", False, "Never runs by itself.", "Fixes")
field("fixes.*.part", "enum", None, "Which part of the switcher.", "Fixes", options=["audio", "camera"]) field("fixes.*.part", "enum", None, "Which part of the switcher.", "Fixes", options=["audio", "camera"])
field("fixes.*.verify_seconds", "int", 20, "Wait this long before judging a reconnect.", "Fixes", advanced=True, field("fixes.*.verify_seconds", "int", 20, "Wait this long before judging a reconnect.", "Fixes", advanced=True,
lo=5, hi=120) lo=5, hi=120)
field("fixes.*.template", "str", None, "How it is built (the builder); custom = typed by hand under Advanced.", "Fixes", apply="none")
field("fixes.*.params", "dict", None, "The builder's fields; the command is made from them.", "Fixes", apply="none")
field("tiles[].checks[].template", "str", None, "How the check's command is built.", "Keys", apply="none")
field("tiles[].checks[].params", "dict", None, "The builder's fields.", "Keys", apply="none")
field("fixes.*.off_seconds", "int", 2, "How long a HyperDeck's connection stays off.", "Fixes", advanced=True, field("fixes.*.off_seconds", "int", 2, "How long a HyperDeck's connection stays off.", "Fixes", advanced=True,
lo=1, hi=10) lo=1, hi=10)
field("fixes.*.hung_note", "str", None, "What the key says when the device answers ping but not Companion.", field("fixes.*.hung_note", "str", None, "What the key says when the device answers ping but not Companion.",
@@ -615,6 +619,11 @@ def validate(cfg, live=None):
errors.append("%s: %r is not in the right form" % (p, v)) errors.append("%s: %r is not in the right form" % (p, v))
if f["type"] == "enum" and f["options"] and v not in f["options"]: if f["type"] == "enum" and f["options"] and v not in f["options"]:
errors.append("%s: %r is not one of %s" % (p, v, ", ".join(f["options"]))) errors.append("%s: %r is not one of %s" % (p, v, ", ".join(f["options"])))
try:
from settings import builders
errors += builders.mismatches(cfg)
except ImportError:
pass
return errors, warnings return errors, warnings
+430
View File
@@ -0,0 +1,430 @@
"""Fix and check builders: a fix is built from fields, never typed as a command.
A fix with a `template` and `params` carries its generated `run` (and kind/host) beside them; the
watchdog runs only `run`. Every Mac value is shlex-quoted, every Windows value regex-checked.
`build(cfg, fix)` regenerates; `mismatches(cfg)` lists fixes and checks whose stored command no
longer equals what their fields build (validate refuses those). `adopt(cfg)` gives today's fixes
their templates (a one-time migration; it prints what it normalised)."""
import re
import shlex
WIN_SAFE = re.compile(r"^[A-Za-z0-9 ._()\\:*-]+$")
LABEL_SAFE = re.compile(r"^[A-Za-z0-9._-]+$")
def q(value):
return shlex.quote(str(value))
def win(value, what):
value = str(value)
if not WIN_SAFE.match(value):
raise ValueError("%s: on a PC only letters, digits, space and . _ ( ) \\ : * - are allowed, not %r" % (what, value))
return value
def label(value, what):
value = str(value)
if not LABEL_SAFE.match(value):
raise ValueError("%s: a launchd label holds letters, digits, dots, - and _ only, not %r" % (what, value))
return value
def home_dir(path):
"""~/x -> $HOME/x inside double quotes; refuses quotes and $ in the rest."""
path = str(path)
if re.search(r'["$`\\]', path.replace("$HOME", "", 1)):
raise ValueError("folder %r may not hold quotes, $ or backslashes" % path)
if path.startswith("~/"):
path = "$HOME/" + path[2:]
return path.rstrip("/")
def mac_quit(process, wait_seconds, force, stop_children=False):
"""Quit a Mac program gently, give it wait_seconds, then (force) kill it."""
p = q(process)
ticks = max(1, int(round(float(wait_seconds) * 2)))
s = ""
if stop_children:
s += "pg=$(ps -o pgid= -p \"$(pgrep -x %s | head -1)\" 2>/dev/null | tr -d ' '); " % p
s += "pkill -TERM -x %s; i=0; while pgrep -xq %s && [ $i -lt %d ]; do sleep 0.5; i=$((i+1)); done; " % (p, p, ticks)
if force:
s += "pgrep -xq %s && pkill -KILL -x %s; " % (p, p)
if stop_children:
s += "[ -n \"$pg\" ] && [ \"$pg\" != \"$(ps -o pgid= -p $$ | tr -d ' ')\" ] && kill -KILL -- -\"$pg\" 2>/dev/null; "
if force or stop_children:
s += "sleep 1; "
return s
def mac_open(app, background=False, arg=None):
"""open -a Name / open /path/App.app (+ -g to stay in the background)."""
app = str(app)
cmd = "open -g" if background else "open"
if app == "$APP":
cmd += " \"$APP\""
elif app.startswith("/"):
cmd += " " + q(app)
else:
cmd += " -a " + q(app)
if arg:
cmd += " " + arg
return cmd
ABLETON_SET = ("L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" "
"| grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); ")
def ableton_open(app):
return "if [ -n \"$S\" ] && [ -f \"$S\" ]; then %s; else %s; fi" % (mac_open(app, arg="\"$S\""), mac_open(app))
def helper_restart(labels):
one = "launchctl kickstart -k gui/$(id -u)/%s || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/%s.plist"
labels = [label(x, "helper") for x in labels]
if not labels:
raise ValueError("at least one helper (launchd label) is needed")
if len(labels) == 1:
return one % (labels[0], labels[0])
return "s=0; " + " ".join("{ %s; } || s=1;" % (one % (x, x)) for x in labels) + " exit $s"
def win_running(exe):
return "tasklist /NH /FI \"IMAGENAME eq %s\" | find /I \"%s\" >nul" % (exe, exe)
# --- the templates ---------------------------------------------------------------
# params: (name, words, help, kind, default) - kind: str, host, bool, int, list, app
P_HOST = ("host", "Computer", "Which computer it runs on.", "host", None)
TEMPLATES = {
"mac_start": {
"words": "Start a Mac program if it is not running",
"os": "mac", "start_only": True,
"params": [P_HOST, ("process", "Process name", "As pgrep sees it (Activity Monitor's process name).", "str", None),
("app", "App to open", "Its name, or the full /Applications/... path.", "str", None)],
"render": lambda p, cfg: "pgrep -xq %s || %s" % (q(p["process"]), mac_open(p["app"])),
"suggest": lambda p, cfg: "start %s if it is not running" % p["app"],
},
"mac_restart": {
"words": "Restart a Mac program (quit it, then open it again)",
"os": "mac", "closes": True,
"params": [P_HOST, ("process", "Process name", "As pgrep sees it.", "str", None),
("app", "App to open", "Its name, the full /Applications/... path, or $APP with 'newest of' below.", "str", None),
("wait_seconds", "Wait for it to quit", "Seconds before it is killed.", "int", 10),
("force", "Kill it if it will not quit", "", "bool", True),
("background", "Open it in the background", "No window comes to the front.", "bool", False),
("stop_children", "Also stop everything it started", "Its helper processes (a translation server, say).", "bool", False),
("newest_of", "Newest of", "A pattern like /Applications/ProPresenter*.app: the newest installed version opens.", "str", "")],
"render": lambda p, cfg: (("APP=$(ls -d %s | sort -V | tail -1); " % str(p["newest_of"]).strip()) if p.get("newest_of") else "")
+ mac_quit(p["process"], p.get("wait_seconds", 10), p.get("force", True), p.get("stop_children", False))
+ mac_open("$APP" if p.get("newest_of") else p["app"], p.get("background", False)),
"suggest": lambda p, cfg: "restart %s" % (p["app"] if not p.get("newest_of") else "%s (newest installed)" % p["process"]),
},
"ableton": {
"words": "Open Ableton Live with the set it last had open",
"os": "mac",
"params": [P_HOST, ("app", "Ableton app name", "As in /Applications.", "str", "Ableton Live 11 Suite"),
("restart", "Quit it first if it is running", "Otherwise it only starts when it is not running.", "bool", False),
("wait_seconds", "Wait for it to quit", "Seconds before it is killed (restart only).", "int", 15)],
"render": lambda p, cfg: (ABLETON_SET + mac_quit("Live", p.get("wait_seconds", 15), True) + ableton_open(p["app"])) if p.get("restart")
else "pgrep -xq Live || { " + ABLETON_SET + ableton_open(p["app"]) + "; }",
"suggest": lambda p, cfg: ("restart Ableton and reopen the set it had open (unsaved changes are lost)" if p.get("restart")
else "start Ableton if it is not running, with the set it last had open"),
},
"dvs": {
"words": "Restart Dante Virtual Soundcard (through its helper)",
"os": "mac", "closes": True,
"params": [P_HOST, ("then_process", "Then restart this program", "Process name of a program that keeps a dead DVS input (blank: none).", "str", ""),
("then_app", "...and open it as", "Its name or /Applications/... path.", "str", "")],
"render": lambda p, cfg: "sudo -n %s && open -g -a 'Dante Virtual Soundcard'" % q(dvs_helper(cfg))
+ ((" || exit 1; sleep 5; " + mac_quit(p["then_process"], 10, True) + mac_open(p["then_app"] or p["then_process"], True)) if p.get("then_process") else ""),
"suggest": lambda p, cfg: "restart Dante Virtual Soundcard (its engine restarts; audio to and from that Mac drops for 5-10 s)"
+ (", then %s" % p["then_process"] if p.get("then_process") else ""),
},
"helpers": {
"words": "Restart a helper on this Mac (a LaunchAgent)",
"os": "self", "closes": True,
"params": [("labels", "Helpers", "launchd labels, one or more (org.waterschurch.lobby-captions, say).", "list", [])],
"render": lambda p, cfg: helper_restart(p.get("labels") or []),
"suggest": lambda p, cfg: "restart %s" % ", ".join(p.get("labels") or []),
},
"win_start": {
"words": "Start a Windows program if it is not running (a StatusPanel task)",
"os": "windows", "start_only": True,
"params": [P_HOST, ("task", "Task name", "The scheduled task under \\StatusPanel\\ (Start Vista, say).", "str", None),
("exe", "Program file", "As Task Manager shows it (Vista.exe).", "str", None)],
"render": lambda p, cfg: "%s || (schtasks /Run /TN \"\\StatusPanel\\%s\" >nul && ping -n 9 127.0.0.1 >nul && %s)"
% (win_running(win(p["exe"], "program file")), win(p["task"], "task name"), win_running(p["exe"])),
"suggest": lambda p, cfg: "start %s if it is not running" % str(p["exe"]).replace(".exe", ""),
},
"win_restart": {
"words": "Restart a Windows program (close it, then its StatusPanel task)",
"os": "windows", "closes": True,
"params": [P_HOST, ("task", "Task name", "The scheduled task under \\StatusPanel\\.", "str", None),
("exe", "Program file", "As Task Manager shows it.", "str", None)],
"render": lambda p, cfg: "taskkill /F /T /IM \"%s\" >nul 2>&1 & ping -n 3 127.0.0.1 >nul & schtasks /Run /TN \"\\StatusPanel\\%s\" >nul && ping -n 9 127.0.0.1 >nul && %s"
% (win(p["exe"], "program file"), win(p["task"], "task name"), win_running(p["exe"])),
"suggest": lambda p, cfg: "restart %s" % str(p["exe"]).replace(".exe", ""),
},
"win_service": {
"words": "Start a Windows service (and its tray program) if they are not running",
"os": "windows", "start_only": True,
"params": [P_HOST, ("service", "Service name", "As services.msc lists it (rtpMIDIService).", "str", None),
("task", "Tray program's task", "The StatusPanel task that starts the tray program (blank: none).", "str", ""),
("exe", "Tray program file", "rtpMIDI.exe, say.", "str", "")],
"render": lambda p, cfg: "(sc query %s | find \"RUNNING\" >nul || net start %s)" % (win(p["service"], "service"), win(p["service"], "service"))
+ ((" && (%s || schtasks /Run /TN \"\\StatusPanel\\%s\")" % (win_running(win(p["exe"], "program file")), win(p["task"], "task name"))) if p.get("task") else ""),
"suggest": lambda p, cfg: "start %s if it is not running" % p["service"],
},
"solo_afl": {
"words": "Put Pro Tools back to Solo Mode AFL + Latch (through the solo helper)",
"os": "mac",
"params": [P_HOST],
"render": lambda p, cfg: "touch \"%s/solo-mode.fix\"; sleep 3; ! grep -q '\"bad\":true' \"%s/solo-mode.json\"" % (solo_dir(cfg), solo_dir(cfg)),
"suggest": lambda p, cfg: "set Pro Tools back to Solo Mode AFL + Latch (clicks Options > Solo Mode)",
},
"atem_midi_off": {
"words": "Turn off MIDI control in ATEM Software Control (quits and reopens it)",
"os": "self", "closes": True, "restart_ok": True,
"params": [],
"render": lambda p, cfg: ("was=$(pgrep -f \"ATEM Software Control.app/Contents/MacOS\"); pkill -TERM -f \"ATEM Software Control.app/Contents/MacOS\"; "
"i=0; while pgrep -fq \"ATEM Software Control.app/Contents/MacOS\" && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; "
"defaults write \"com.blackmagic-design.ATEM Software Control\" midiEnabled -bool false; "
"[ -z \"$was\" ] || open -g -a \"ATEM Software Control\"; "
"[ \"$(defaults read \"com.blackmagic-design.ATEM Software Control\" midiEnabled)\" = 0 ]"),
"suggest": lambda p, cfg: "turn off MIDI control in ATEM Software Control (quits and reopens the app; the switcher is not affected)",
},
# kinds without a command: the template is the kind, the params are its fields
"reconnect": {"words": "Reconnect a Companion connection", "kind": "reconnect", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "reconnect Companion to it"},
"hyperdeck": {"words": "Switch a HyperDeck's connection off and on", "kind": "hyperdeck", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "turn Companion's connection to that HyperDeck off and on; never reboots the deck"},
"midi_connect": {"words": "Reconnect a network MIDI peer", "kind": "midi_connect", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "reconnect the peer to the network MIDI session it dropped out of"},
"atem": {"words": "Put the switcher back to the saved file", "kind": "atem", "params": [], "render": None,
"suggest": lambda p, cfg: "set the switcher back to the saved file"},
"netgear_poe": {"words": "Power-cycle a PoE port on a Dante switch", "kind": "netgear_poe", "params": [], "render": None,
"suggest": lambda p, cfg: "switch the device's PoE port off and on"},
"spotify": {"words": "Play the service playlist in Spotify", "kind": "spotify", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "play the %s playlist in Spotify on %s (opens Spotify if it is closed)"
% ((cfg.get("spotify") or {}).get("name") or "service", (cfg.get("site") or {}).get("ingest_name") or "this Mac")},
"custom": {"words": "Custom command (Advanced: typed by hand)", "params": [], "render": None, "suggest": lambda p, cfg: ""},
}
def dvs_helper(cfg):
return (cfg.get("site") or {}).get("dvs_helper") or "/usr/local/sbin/status-panel-dvs-start"
def solo_dir(cfg):
return home_dir((cfg.get("site") or {}).get("solo_dir") or "~/Library/Application Support/status-panel")
def template_list(cfg=None):
"""For the page: name, words, params with defaults, what it needs."""
out = []
for name, t in TEMPLATES.items():
out.append({"name": name, "words": t["words"], "os": t.get("os"), "kind": t.get("kind"), "start_only": bool(t.get("start_only")),
"closes": bool(t.get("closes")),
"params": [{"name": n, "words": w, "help": h, "type": k, "default": d} for n, w, h, k, d in t["params"]]})
return out
def build(cfg, fix):
"""A fix with template + params -> the same fix with kind/host/run regenerated. Raises ValueError with plain words."""
name = fix.get("template")
if not name or name == "custom":
return dict(fix)
t = TEMPLATES.get(name)
if t is None:
raise ValueError("unknown builder %s" % name)
params = dict(fix.get("params") or {})
for n, words, _, kind, default in t["params"]:
if params.get(n) in (None, "") and default is not None:
params[n] = default
if params.get(n) in (None, "") and default is None:
raise ValueError("%s: %s is needed" % (t["words"], words.lower()))
out = {k: v for k, v in fix.items() if k not in ("run", "host", "kind")}
out["template"], out["params"] = name, params
if t.get("kind"):
out["kind"] = t["kind"]
return out
hosts = cfg.get("hosts") or {}
host = params.get("host")
if t.get("os") == "self":
out["kind"] = "local"
else:
h = hosts.get(host)
if h is None:
raise ValueError("%s: unknown computer %s" % (t["words"], host))
want = t.get("os")
have = "mac" if h.get("self") else (h.get("os") or "mac")
if want and have != want:
raise ValueError("%s: %s is a %s computer, this builder is for %s" % (t["words"], h.get("name", host), have, want))
if h.get("self"):
out["kind"] = "local"
else:
out["kind"], out["host"] = "ssh", host
out["run"] = t["render"](params, cfg)
if t.get("restart_ok"):
out["restart_ok"] = True
return out
def suggest_label(cfg, fix):
t = TEMPLATES.get(fix.get("template") or "")
if not t:
return ""
try:
return t["suggest"](dict(fix.get("params") or {}), cfg)
except (KeyError, TypeError):
return ""
# --- check builders (the three remote checks that carry a command) ----------------
CHECK_TEMPLATES = {
"mac_program": {
"words": "A program is running on a Mac", "type": "remote",
"params": [P_HOST, ("process", "Process name", "As pgrep sees it.", "str", None)],
"render": lambda p, cfg: "pgrep -xq %s" % q(p["process"]),
},
"helper_flag": {
"words": "A helper file does not say something is wrong", "type": "remote",
"params": [P_HOST, ("file", "File", "In the helper's folder (solo-mode.json).", "str", "solo-mode.json"),
("bad", "The bad text", "The check fails when the file holds this.", "str", "\"bad\":true")],
"render": lambda p, cfg: "! grep -q %s \"%s/%s\"" % (q(p["bad"]), solo_dir(cfg), p["file"]),
},
"helper_fresh": {
"words": "A helper file is fresh and says it is trusted", "type": "remote",
"params": [P_HOST, ("file", "File", "In the helper's folder.", "str", "solo-mode.json"),
("max_age", "Fresh means younger than", "Seconds.", "int", 20),
("flag", "The good text", "The check fails unless the file holds this.", "str", "\"trusted\":true")],
"render": lambda p, cfg: "f=\"%s/%s\"; [ $(( $(date +%%s) - $(stat -f %%m \"$f\") )) -lt %d ] && grep -q %s \"$f\""
% (solo_dir(cfg), p["file"], int(p["max_age"]), q(p["flag"])),
},
}
def check_template_list():
return [{"name": n, "words": t["words"], "type": t["type"],
"params": [{"name": a, "words": w, "help": h, "type": k, "default": d} for a, w, h, k, d in t["params"]]} for n, t in CHECK_TEMPLATES.items()]
def build_check(cfg, chk):
name = chk.get("template")
if not name:
return dict(chk)
t = CHECK_TEMPLATES.get(name)
if t is None:
raise ValueError("unknown check builder %s" % name)
params = dict(chk.get("params") or {})
for n, words, _, kind, default in t["params"]:
if params.get(n) in (None, "") and default is not None:
params[n] = default
if params.get(n) in (None, "") and default is None:
raise ValueError("%s: %s is needed" % (t["words"], words.lower()))
h = (cfg.get("hosts") or {}).get(params.get("host"))
if h is None:
raise ValueError("%s: unknown computer %s" % (t["words"], params.get("host")))
if h.get("self"):
raise ValueError("%s: %s is this Mac - use 'A program is running on this Mac'" % (t["words"], h.get("name")))
out = dict(chk, type=t["type"], template=name, params=params, host=params["host"])
for k, v in chk.items():
if k in ("fix", "tag", "service_only", "debounce_seconds", "msg", "name"):
out[k] = v
for k in ("fix", "tag", "service_only", "debounce_seconds"):
if k in chk:
out[k] = chk[k]
out["cmd"] = t["render"](params, cfg)
return out
def mismatches(cfg):
"""Fixes and checks whose stored command differs from what their fields build (plain words)."""
out = []
for fid, fix in (cfg.get("fixes") or {}).items():
if fix.get("template") and fix["template"] != "custom":
try:
b = build(cfg, fix)
except ValueError as e:
out.append("fix %s: %s" % (fid, e))
continue
for k in ("run", "kind", "host"):
if (b.get(k) or None) != (fix.get(k) or None):
out.append("fix %s: its %s no longer matches its fields - open it on Fixes and press Save" % (fid, "command" if k == "run" else k))
break
for t in cfg.get("tiles") or []:
for i, c in enumerate(t.get("checks") or []):
if c.get("template"):
try:
b = build_check(cfg, c)
except ValueError as e:
out.append("key %s check %d: %s" % (t.get("label", t["id"]), i + 1, e))
continue
if b.get("cmd") != c.get("cmd") or b.get("host") != c.get("host"):
out.append("key %s check %d: its command no longer matches its fields" % (t.get("label", t["id"]), i + 1))
return out
# --- today's fixes get their templates (one-time) ------------------------------------
TODAY = {
"propresenter": ("mac_restart", {"host": "prop", "process": "ProPresenter", "app": "$APP", "newest_of": "/Applications/ProPresenter*.app", "wait_seconds": 10, "force": True}),
"ableton": ("ableton", {"host": "tracks", "app": "Ableton Live 11 Suite", "restart": False}),
"ableton_restart": ("ableton", {"host": "tracks", "app": "Ableton Live 11 Suite", "restart": True, "wait_seconds": 15}),
"translate": ("mac_start", {"host": "translation", "process": "Church Translate", "app": "Church Translate"}),
"translate_restart": ("mac_restart", {"host": "translation", "process": "Church Translate", "app": "Church Translate", "wait_seconds": 15, "force": True, "stop_children": True}),
"dvs_prop": ("dvs", {"host": "prop"}), "dvs_tracks": ("dvs", {"host": "tracks"}), "dvs_translation": ("dvs", {"host": "translation"}),
"dvs_ingest": ("dvs", {"host": "ingest", "then_process": "ProdCom", "then_app": "/Applications/ProdCom.app"}),
"bridge": ("helpers", {"labels": ["org.waterschurch.resi-propresenter"]}),
"captions": ("helpers", {"labels": ["org.waterschurch.lobby-captions", "org.waterschurch.lobby-captions-display"]}),
"caption_screen": ("helpers", {"labels": ["org.waterschurch.lobby-captions-display"]}),
"bibleapp": ("win_restart", {"host": "bibletv", "task": "Start BibleApp", "exe": "Bible App.exe"}),
"obs": ("win_start", {"host": "livestream", "task": "Start OBS", "exe": "obs64.exe"}),
"arena": ("win_start", {"host": "lighting", "task": "Start Arena", "exe": "Arena.exe"}),
"vista": ("win_start", {"host": "lighting", "task": "Start Vista", "exe": "Vista.exe"}),
"rtpmidi_start": ("win_service", {"host": "lighting", "service": "rtpMIDIService", "task": "Start rtpMIDI", "exe": "rtpMIDI.exe"}),
"solo_afl": ("solo_afl", {"host": "studiob"}),
"atem_midi": ("atem_midi_off", {}),
"proddash": ("mac_restart", {"host": "ingest", "process": "ProdDash", "app": "/Applications/ProdDash.app", "wait_seconds": 10, "force": False, "background": True}),
"prodcom": ("mac_restart", {"host": "ingest", "process": "ProdCom", "app": "/Applications/ProdCom.app", "wait_seconds": 10, "force": True, "background": True}),
"midi_reconnect": ("midi_connect", {}), "atem_audio": ("atem", {}), "atem_camera": ("atem", {}), "spotify_play": ("spotify", {}),
"tascam_reconnect": ("reconnect", {}), "hyperdeck": ("hyperdeck", {}),
}
TODAY_CHECKS = { # (key id, cmd) -> template + params
("apps", "pgrep -xq ProPresenter"): ("mac_program", {"host": "prop", "process": "ProPresenter"}),
("apps", "pgrep -xq Live"): ("mac_program", {"host": "tracks", "process": "Live"}),
("apps", "pgrep -xq 'Church Translate'"): ("mac_program", {"host": "translation", "process": "Church Translate"}),
("apps", "! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\""): ("helper_flag", {"host": "studiob"}),
("apps", "f=\"$HOME/Library/Application Support/status-panel/solo-mode.json\"; [ $(( $(date +%s) - $(stat -f %m \"$f\") )) -lt 20 ] && grep -q '\"trusted\":true' \"$f\""):
("helper_fresh", {"host": "studiob"}),
}
def adopt(cfg):
"""Give today's fixes and checks their templates; returns [(what, before, after)] for every command that changed shape."""
changed = []
for fid, fix in cfg.get("fixes", {}).items():
if fix.get("template") or fid not in TODAY:
continue
name, params = TODAY[fid]
fix["template"], fix["params"] = name, params
built = build(cfg, fix)
if built.get("run") != fix.get("run"):
changed.append(("fix " + fid, fix.get("run"), built.get("run")))
fix.update({k: v for k, v in built.items() if k in ("run", "kind", "host", "restart_ok")})
for k in ("host",):
if k not in built and k in fix:
del fix[k]
for t in cfg.get("tiles", []):
for c in t.get("checks", []):
key = (t["id"], c.get("cmd"))
if c.get("template") or key not in TODAY_CHECKS:
continue
name, params = TODAY_CHECKS[key]
c["template"], c["params"] = name, params
built = build_check(cfg, c)
if built["cmd"] != c.get("cmd"):
changed.append(("key %s check" % t["id"], c.get("cmd"), built["cmd"]))
c["cmd"], c["host"] = built["cmd"], built["host"]
return changed
+12
View File
@@ -31,6 +31,7 @@ from settings import PARENT, HERE # noqa: F401
import schema import schema
import status_panel as sp import status_panel as sp
from settings import apply as applying from settings import apply as applying
from settings import builders
from settings import keyguide, probes from settings import keyguide, probes
log = logging.getLogger("status-settings") log = logging.getLogger("status-settings")
@@ -644,6 +645,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
if path == "/api/live/midi": if path == "/api/live/midi":
snap = site.read_json("snapshot.json", {}) or {} snap = site.read_json("snapshot.json", {}) or {}
return self.send(200, {"local": snap.get("midi"), "remote": snap.get("remote_midi")}) return self.send(200, {"local": snap.get("midi"), "remote": snap.get("remote_midi")})
if path == "/api/builders":
return self.send(200, {"fixes": builders.template_list(site.cfg), "checks": builders.check_template_list()})
if path == "/api/facts": if path == "/api/facts":
return self.send(200, {"facts": COMPANION_FACTS}) return self.send(200, {"facts": COMPANION_FACTS})
if path == "/api/snapshot": if path == "/api/snapshot":
@@ -734,6 +737,15 @@ class Handler(http.server.BaseHTTPRequestHandler):
site.draft.clear() site.draft.clear()
self.discard_atem() self.discard_atem()
return self.send(200, {"ok": True}) return self.send(200, {"ok": True})
if path in ("/api/build", "/api/build-check"):
cfg = (site.draft.read() or {}).get("config") or site.load()
try:
if path == "/api/build":
fix = builders.build(cfg, body.get("fix") or {})
return self.send(200, {"ok": True, "fix": fix, "label": builders.suggest_label(cfg, fix)})
return self.send(200, {"ok": True, "check": builders.build_check(cfg, body.get("check") or {})})
except ValueError as e:
return self.send(200, {"ok": False, "error": str(e)})
if path == "/api/validate": if path == "/api/validate":
cfg = schema.unmask(body.get("config") or (site.draft.read() or {}).get("config") or site.load(), site.load()) cfg = schema.unmask(body.get("config") or (site.draft.read() or {}).get("config") or site.load(), site.load())
return self.send(200, self.check(cfg)) return self.send(200, self.check(cfg))
+81 -13
View File
@@ -69,8 +69,8 @@
// ---- the draft ------------------------------------------------------------ // ---- the draft ------------------------------------------------------------
async function loadAll() { async function loadAll() {
const [c, s, d] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft")]); const [c, s, d, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]);
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.builders = b;
document.getElementById("sitename").textContent = (state.live.site || {}).name || ""; document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
badge(); badge();
} }
@@ -309,7 +309,13 @@
const sentence = (kt.checks[j] || {}).sentence || chk.type; const sentence = (kt.checks[j] || {}).sentence || chk.type;
if (!editing()) return `<li>${esc(sentence)}<details><summary>Advanced</summary><dl class="kv small">${Object.entries(chk).map(([n, v]) => `<dt>${esc(n)}</dt><dd class="mono">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details></li>`; if (!editing()) return `<li>${esc(sentence)}<details><summary>Advanced</summary><dl class="kv small">${Object.entries(chk).map(([n, v]) => `<dt>${esc(n)}</dt><dd class="mono">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details></li>`;
const base = `tiles.${i}.checks.${j}.`; const base = `tiles.${i}.checks.${j}.`;
const fields = (spec.fields || []).filter((f) => f !== "fix" && f !== "type").map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join(""); let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("");
if (chk.type === "remote") {
const ctpls = state.builders.checks.concat([{ name: "custom", words: "Custom command (typed by hand)", params: [] }]);
const ctName = chk.template || "custom", ct = ctpls.find((x) => x.name === ctName);
fields = `<div class="field"><label>Built as</label><select class="ctplsel" data-check="${j}">${ctpls.map((x) => `<option value="${x.name}" ${x.name === ctName ? "selected" : ""}>${esc(x.words)}</option>`).join("")}</select></div><div class="ctplform" data-check="${j}">${paramsHtml(ct, chk.params || {}, "ck" + j)}</div>`
+ (ctName === "custom" ? fields : inputHtml(base + "msg", F("tiles[].checks[].msg"), chk.msg) + (chk.cmd ? `<div class="field"><label>The command it makes (on ${esc((c.hosts[chk.host] || {}).name || chk.host)})</label><pre class="log small">${esc(chk.cmd)}</pre></div>` : ""));
}
const fixSel = typeof chk.fix === "object" && chk.fix ? `<div class="field"><label>Fix (per sub-check)</label><textarea data-path="${base}fix" rows="2" class="fixtable">${esc(JSON.stringify(chk.fix))}</textarea><div class="help">A table: sub-check → fix, "*" for everything else.</div></div>` const fixSel = typeof chk.fix === "object" && chk.fix ? `<div class="field"><label>Fix (per sub-check)</label><textarea data-path="${base}fix" rows="2" class="fixtable">${esc(JSON.stringify(chk.fix))}</textarea><div class="help">A table: sub-check → fix, "*" for everything else.</div></div>`
: `<div class="field"><label>Fix: what holding runs for this check</label><select data-path="${base}fix" class="fixsel">${fixOptions(chk.fix)}</select></div>`; : `<div class="field"><label>Fix: what holding runs for this check</label><select data-path="${base}fix" class="fixsel">${fixOptions(chk.fix)}</select></div>`;
const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `<button class="small" data-run="${esc(t.id)}|${esc(chk.fix)}|${j}">Run this fix now</button>` : ""; const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `<button class="small" data-run="${esc(t.id)}|${esc(chk.fix)}|${j}">Run this fix now</button>` : "";
@@ -337,6 +343,19 @@
for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); }; for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); };
const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ type: add.value }); saveDraft(); route(); }; const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ type: add.value }); saveDraft(); route(); };
const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const m = pos.value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); if (m) t.pos = [+m[1], +m[2]]; else if (!pos.value.trim()) delete t.pos; else return alertBox("Row, column: two numbers, like 2, 5."); saveDraft(); route(); }; const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const m = pos.value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); if (m) t.pos = [+m[1], +m[2]]; else if (!pos.value.trim()) delete t.pos; else return alertBox("Row, column: two numbers, like 2, 5."); saveDraft(); route(); };
const rebuildCheck = async (j) => {
if (!ensureDraft()) return;
const t = state.draft.config.tiles[i], chk = t.checks[j];
const tplName = main.querySelector(`select.ctplsel[data-check="${j}"]`).value;
if (tplName === "custom") { delete chk.template; delete chk.params; saveDraft(); route(); return; }
const ct = state.builders.checks.find((x) => x.name === tplName);
const params = readParams(main.querySelector(`.ctplform[data-check="${j}"]`), ct);
const r = await post("/api/build-check", { check: Object.assign({}, chk, { template: tplName, params }) });
if (!r.ok) { chk.template = tplName; chk.params = params; saveDraft(); alertBox(r.error); return; }
t.checks[j] = r.check; saveDraft(); route();
};
for (const sel3 of main.querySelectorAll("select.ctplsel")) sel3.onchange = () => rebuildCheck(+sel3.dataset.check);
for (const form of main.querySelectorAll(".ctplform")) for (const el of form.querySelectorAll("[data-param]")) el.onchange = () => rebuildCheck(+form.dataset.check);
for (const s of main.querySelectorAll("select.fixsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); }; for (const s of main.querySelectorAll("select.fixsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
for (const ta of main.querySelectorAll("textarea.fixtable")) ta.onchange = () => { try { setPath(ta.dataset.path, JSON.parse(ta.value)); } catch (e) { alertBox("Not a valid table."); } }; for (const ta of main.querySelectorAll("textarea.fixtable")) ta.onchange = () => { try { setPath(ta.dataset.path, JSON.parse(ta.value)); } catch (e) { alertBox("Not a valid table."); } };
for (const s of main.querySelectorAll("select.holdsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); }; for (const s of main.querySelectorAll("select.holdsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
@@ -609,6 +628,33 @@
} }
} }
// ---- builders: a fix or a check is made from fields ---------------------------------
function paramsHtml(tpl, params, prefix) {
const c = cfg();
return tpl.params.map((p) => {
const v = params[p.name] != null ? params[p.name] : p.default;
const id = `${prefix}-${p.name}`;
let input;
if (p.type === "host") input = `<select id="${id}" data-param="${p.name}"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => tpl.os === "self" ? h.self : tpl.os ? (h.self ? "mac" : h.os || "mac") === tpl.os : true).map(([a, h]) => `<option value="${esc(a)}" ${v === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select>`;
else if (p.type === "bool") input = `<input type="checkbox" id="${id}" data-param="${p.name}" ${v ? "checked" : ""}>`;
else if (p.type === "int") input = `<input type="number" id="${id}" data-param="${p.name}" value="${esc(v == null ? "" : v)}">`;
else if (p.type === "list") input = `<input id="${id}" data-param="${p.name}" value="${esc(Array.isArray(v) ? v.join(", ") : v || "")}" placeholder="one or more, with commas">`;
else input = `<input id="${id}" data-param="${p.name}" value="${esc(v == null ? "" : v)}">`;
return `<div class="field"><label for="${id}">${esc(p.words)}</label>${input}<div class="help">${esc(p.help)}</div></div>`;
}).join("");
}
function readParams(root, tpl) {
const out = {};
for (const p of tpl.params) {
const el = root.querySelector(`[data-param="${p.name}"]`); if (!el) continue;
if (p.type === "bool") out[p.name] = el.checked;
else if (p.type === "int") out[p.name] = el.value === "" ? undefined : parseInt(el.value, 10);
else if (p.type === "list") out[p.name] = el.value.split(",").map((x) => x.trim()).filter(Boolean);
else out[p.name] = el.value.trim();
}
return out;
}
// ---- Fixes ------------------------------------------------------------------------- // ---- Fixes -------------------------------------------------------------------------
async function fixes(rest) { async function fixes(rest) {
const c = cfg(), auto = new Set(c.auto_fix.fixes || []); const c = cfg(), auto = new Set(c.auto_fix.fixes || []);
@@ -621,20 +667,42 @@
let detail = ""; let detail = "";
if (f) { if (f) {
const kind = f.kind || "ssh"; const kind = f.kind || "ssh";
const fields = ["label", "short"].concat((state.schema.fix_kind_fields || {})[kind] || []).concat(["restart_ok", "never_auto", "timeout"]); const tpls = state.builders.fixes, tplName = f.template || "custom", tpl = tpls.find((t) => t.name === tplName) || tpls.find((t) => t.name === "custom");
const inputs = editing() ? [...new Set(fields)].map((fl) => { const kindOnly = !!tpl.kind;
const Fd = F("fixes.*." + fl); if (!Fd) return "";
if (fl === "host") return `<div class="field"><label>Computer</label><select data-path="fixes.${esc(id)}.host"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${f.host === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select></div>`;
if (fl === "run") return `<div class="field"><label>Command (shown as a command at Review; never run before Apply)</label><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run || "")}</textarea><div class="help">zsh on a Mac, cmd.exe on a PC. ${esc(Fd.help)}</div></div>`;
return inputHtml(`fixes.${id}.${fl}`, Fd, f[fl]);
}).join("") : "";
const kindSel = editing() ? `<div class="field"><label>Built as</label><select data-path="fixes.${esc(id)}.kind" class="kindsel">${(state.schema.fix_kinds || ["ssh", "local"]).map((k) => `<option ${kind === k ? "selected" : ""}>${k}</option>`).join("")}</select></div>` : "";
const keys = [...(used[id] || [])]; const keys = [...(used[id] || [])];
detail = `<div class="card"><h2>${esc(f.short || id)}</h2><p>${esc(f.label || "")}</p>${kindSel}${inputs}${canRunNow(id) && editing() ? `<p class="help">Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).</p>` : ""}<details ${editing() ? "" : "open"}><summary>Advanced</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}<dt>id</dt><dd class="mono">${esc(id)}</dd></dl></details>${editing() && !keys.length ? `<button class="danger small" id="delfix">Delete this fix</button>` : ""}</div>`; let inputs = "";
if (editing()) {
inputs += `<div class="field"><label>Built as</label><select id="tplsel">${tpls.map((t) => `<option value="${t.name}" ${t.name === tplName ? "selected" : ""}>${esc(t.words)}</option>`).join("")}</select></div>`;
inputs += `<div id="tplform">${paramsHtml(tpl, f.params || {}, "fx")}</div><p id="tplmsg" class="help"></p>`;
inputs += inputHtml(`fixes.${id}.label`, F("fixes.*.label"), f.label) + inputHtml(`fixes.${id}.short`, F("fixes.*.short"), f.short);
if (kindOnly) inputs += (state.schema.fix_kind_fields[tpl.kind] || []).map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("");
if (tplName === "custom") {
inputs += `<div class="field"><label>Runs on</label><select data-path="fixes.${esc(id)}.kind" class="kindsel">${["ssh", "local"].map((k) => `<option ${kind === k ? "selected" : ""}>${k === "ssh" ? "another computer" : "this Mac"}</option>`).join("")}</select></div>`;
if (kind === "ssh") inputs += `<div class="field"><label>Computer</label><select data-path="fixes.${esc(id)}.host"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${f.host === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select></div>`;
inputs += `<div class="field"><label>Command (typed by hand; shown as a command at Review; never run before Apply)</label><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run || "")}</textarea><div class="help">zsh on a Mac, cmd.exe on a PC.</div></div>`;
} else if (f.run) inputs += `<div class="field"><label>The command it makes (${f.kind === "local" ? "this Mac" : "on " + esc((c.hosts[f.host] || {}).name || f.host)})</label><pre class="log small" style="max-height:160px">${esc(f.run)}</pre></div>`;
inputs += `<details><summary>Advanced</summary>${["reconnect", "conn", "restart_ok", "never_auto", "timeout"].map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("")}</details>`;
} else inputs = `<p class="small muted">Built as: ${esc(tpl.words)}${f.run ? `</p><pre class="log small" style="max-height:160px">${esc(f.run)}</pre>` : "</p>"}`;
detail = `<div class="card"><h2>${esc(f.short || id)}</h2><p>${esc(f.label || "")}</p>${inputs}${canRunNow(id) && editing() ? `<p class="help">Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).</p>` : ""}<details><summary>Everything stored</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}<dt>id</dt><dd class="mono">${esc(id)}</dd></dl></details>${editing() && !keys.length ? `<button class="danger small" id="delfix">Delete this fix</button>` : ""}</div>`;
} }
const add = editing() ? `<div class="row"><input id="newfix" placeholder="new fix id (letters, digits, _)"><button id="addfix" class="small">+ New fix</button></div>` : ""; const add = editing() ? `<div class="row"><input id="newfix" placeholder="new fix id (letters, digits, _)"><button id="addfix" class="small">+ New fix</button></div>` : "";
main.innerHTML = `<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(c.fixes).length} today.</p>${add}<div class="tablewrap"><table><tr><th>key text</th><th>plain words</th><th>built as</th><th>used by</th><th>by itself</th></tr>${rows}</table></div>${detail}`; main.innerHTML = `<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(c.fixes).length} today.</p>${add}<div class="tablewrap"><table><tr><th>key text</th><th>plain words</th><th>built as</th><th>used by</th><th>by itself</th></tr>${rows}</table></div>${detail}`;
const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value); route(); }; const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value === "another computer" ? "ssh" : ks.value === "this Mac" ? "local" : ks.value); route(); };
const rebuild = async () => {
if (!ensureDraft()) return;
const fx = state.draft.config.fixes[id], tplName = main.querySelector("#tplsel").value, tpl = state.builders.fixes.find((t) => t.name === tplName);
const params = readParams(main.querySelector("#tplform"), tpl);
if (tplName === "custom") { fx.template = "custom"; delete fx.params; saveDraft(); route(); return; }
const r = await post("/api/build", { fix: Object.assign({}, fx, { template: tplName, params }) });
const msg = main.querySelector("#tplmsg");
if (!r.ok) { msg.textContent = r.error; fx.template = tplName; fx.params = params; saveDraft(); return; }
const keep = { label: fx.label, short: fx.short };
Object.assign(fx, r.fix); for (const k of ["run", "host"]) if (!(k in r.fix)) delete fx[k];
if (!keep.label && r.label) fx.label = r.label;
msg.textContent = "";
saveDraft(); route();
};
const ts = main.querySelector("#tplsel"); if (ts) { ts.onchange = rebuild; for (const el of main.querySelectorAll("#tplform [data-param]")) el.onchange = rebuild; }
const del = main.querySelector("#delfix"); if (del) del.onclick = () => confirmBox(`Delete the fix ${id}? No key uses it.`, "Delete", () => { if (!ensureDraft()) return; delete state.draft.config.fixes[id]; const af2 = state.draft.config.auto_fix.fixes || []; state.draft.config.auto_fix.fixes = af2.filter((x) => x !== id); saveDraft(); location.hash = "#fixes"; }); const del = main.querySelector("#delfix"); if (del) del.onclick = () => confirmBox(`Delete the fix ${id}? No key uses it.`, "Delete", () => { if (!ensureDraft()) return; delete state.draft.config.fixes[id]; const af2 = state.draft.config.auto_fix.fixes || []; state.draft.config.auto_fix.fixes = af2.filter((x) => x !== id); saveDraft(); location.hash = "#fixes"; });
const af = main.querySelector("#addfix"); if (af) af.onclick = () => { const nid = main.querySelector("#newfix").value.trim(); if (!/^[A-Za-z0-9_]+$/.test(nid) || c.fixes[nid]) return alertBox("A new id: letters, digits and _ only, not in use."); if (!ensureDraft()) return; state.draft.config.fixes[nid] = { kind: "local", short: nid.toUpperCase().slice(0, 12), label: "", run: "" }; saveDraft(); location.hash = "#fixes/" + nid; }; const af = main.querySelector("#addfix"); if (af) af.onclick = () => { const nid = main.querySelector("#newfix").value.trim(); if (!/^[A-Za-z0-9_]+$/.test(nid) || c.fixes[nid]) return alertBox("A new id: letters, digits and _ only, not in use."); if (!ensureDraft()) return; state.draft.config.fixes[nid] = { kind: "local", short: nid.toUpperCase().slice(0, 12), label: "", run: "" }; saveDraft(); location.hash = "#fixes/" + nid; };
} }
+121
View File
@@ -0,0 +1,121 @@
"""Fix and check builders: every one of today's fixes regenerates from its fields exactly, values are
quoted or refused, a start builder never closes a running program, and this Mac becomes a local fix."""
import json
import os
import sys
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import schema # noqa: E402
from settings import builders as b # noqa: E402
CFG = json.load(open(os.path.join(ROOT, "config.json")))
class TestTodaysFixes(unittest.TestCase):
def test_every_fix_has_a_builder_and_regenerates_exactly(self):
for fid, fix in CFG["fixes"].items():
self.assertTrue(fix.get("template"), fid)
built = b.build(CFG, fix)
for k in ("run", "kind", "host"):
self.assertEqual(built.get(k), fix.get(k), "%s %s" % (fid, k))
self.assertEqual(b.mismatches(CFG), [])
self.assertEqual(len(CFG["fixes"]), 27)
def test_todays_remote_checks_regenerate(self):
n = 0
for t in CFG["tiles"]:
for c in t.get("checks", []):
if c.get("template"):
self.assertEqual(b.build_check(CFG, c)["cmd"], c["cmd"])
n += 1
self.assertEqual(n, 5)
def test_a_changed_command_is_refused_by_validate(self):
cfg = json.loads(json.dumps(CFG))
cfg["fixes"]["vista"]["run"] = "shutdown /s"
errors, _ = schema.validate(cfg)
self.assertTrue(any("vista" in e and "no longer matches" in e for e in errors), errors)
cfg["fixes"]["vista"]["template"] = "custom" # a hand-typed command is allowed when it says so
self.assertEqual([e for e in schema.validate(cfg)[0] if "vista" in e], [])
def test_exact_strings_of_the_shapes_that_matter(self):
vista = b.build(CFG, {"template": "win_start", "params": {"host": "lighting", "task": "Start Vista", "exe": "Vista.exe"}})
self.assertEqual(vista["run"], 'tasklist /NH /FI "IMAGENAME eq Vista.exe" | find /I "Vista.exe" >nul || (schtasks /Run /TN "\\StatusPanel\\Start Vista" >nul '
'&& ping -n 9 127.0.0.1 >nul && tasklist /NH /FI "IMAGENAME eq Vista.exe" | find /I "Vista.exe" >nul)')
self.assertEqual((vista["kind"], vista["host"]), ("ssh", "lighting"))
self.assertNotIn("%ERRORLEVEL%", vista["run"])
caps = b.build(CFG, {"template": "helpers", "params": {"labels": ["a.b", "c.d"]}})
self.assertEqual(caps["run"], "s=0; { launchctl kickstart -k gui/$(id -u)/a.b || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/a.b.plist; } || s=1; "
"{ launchctl kickstart -k gui/$(id -u)/c.d || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/c.d.plist; } || s=1; exit $s")
self.assertEqual(caps["kind"], "local")
class TestQuotingAndGuards(unittest.TestCase):
def test_mac_names_are_quoted(self):
f = b.build(CFG, {"template": "mac_start", "params": {"host": "tracks", "process": "Ableton Live 11 Suite", "app": "Rob's App"}})
self.assertEqual(f["run"], "pgrep -xq 'Ableton Live 11 Suite' || open -a 'Rob'\"'\"'s App'")
f = b.build(CFG, {"template": "mac_restart", "params": {"host": "tracks", "process": "x; rm -rf /", "app": "X"}})
self.assertIn("pkill -TERM -x 'x; rm -rf /'", f["run"])
def test_windows_values_are_checked(self):
for bad in ("Vista & del", "Start!Vista", 'a"b', "x%y", "a^b"):
with self.assertRaises(ValueError):
b.build(CFG, {"template": "win_start", "params": {"host": "lighting", "task": bad, "exe": "Vista.exe"}})
ok = b.build(CFG, {"template": "win_restart", "params": {"host": "bibletv", "task": "Start BibleApp", "exe": "Bible App.exe"}})
self.assertEqual(ok["run"], CFG["fixes"]["bibleapp"]["run"])
def test_helper_labels_and_folders_are_checked(self):
with self.assertRaises(ValueError):
b.build(CFG, {"template": "helpers", "params": {"labels": ["org.x; rm"]}})
cfg = json.loads(json.dumps(CFG))
cfg["site"]["solo_dir"] = '~/Library/"x"'
with self.assertRaises(ValueError):
b.build(cfg, {"template": "solo_afl", "params": {"host": "studiob"}})
cfg["site"]["solo_dir"] = "~/Library/Application Support/status-panel/"
self.assertEqual(b.build(cfg, {"template": "solo_afl", "params": {"host": "studiob"}})["run"], CFG["fixes"]["solo_afl"]["run"])
def test_start_builders_never_close_a_running_program(self):
for name, t in b.TEMPLATES.items():
if t.get("start_only") and t.get("render"):
params = {p[0]: (p[4] if p[4] is not None else {"host": "lighting" if t.get("os") == "windows" else "tracks"}.get(p[0], "Thing.exe" if t.get("os") == "windows" else "Thing"))
for p in t["params"]}
run = b.build(CFG, {"template": name, "params": params})["run"]
self.assertFalse(any(w in run for w in ("pkill", "taskkill", "killall", "kill ")), (name, run))
self.assertTrue("||" in run, (name, run)) # only if not already running
def test_this_mac_becomes_a_local_fix_and_the_wrong_os_is_refused(self):
f = b.build(CFG, {"template": "mac_restart", "params": {"host": "ingest", "process": "ProdDash", "app": "/Applications/ProdDash.app"}})
self.assertEqual((f["kind"], f.get("host")), ("local", None))
with self.assertRaises(ValueError):
b.build(CFG, {"template": "win_start", "params": {"host": "tracks", "task": "Start X", "exe": "X.exe"}})
with self.assertRaises(ValueError):
b.build(CFG, {"template": "mac_start", "params": {"host": "lighting", "process": "x", "app": "x"}})
with self.assertRaises(ValueError):
b.build(CFG, {"template": "mac_start", "params": {"host": "nowhere", "process": "x", "app": "x"}})
def test_missing_fields_and_suggested_words(self):
with self.assertRaises(ValueError) as cm:
b.build(CFG, {"template": "mac_start", "params": {"host": "tracks"}})
self.assertIn("process name is needed", str(cm.exception))
self.assertEqual(b.suggest_label(CFG, {"template": "spotify"}), "play the Christian EDM 2026 playlist in Spotify on NA Ingest (opens Spotify if it is closed)")
self.assertEqual(b.suggest_label(CFG, {"template": "win_start", "params": {"task": "Start OBS", "exe": "obs64.exe"}}), "start obs64 if it is not running")
names = {t["name"] for t in b.template_list(CFG)}
self.assertEqual(names, set(b.TEMPLATES))
def test_check_builders(self):
c = b.build_check(CFG, {"type": "remote", "template": "mac_program", "params": {"host": "prop", "process": "Pro Tools"}, "msg": "PT NOT RUNNING", "fix": "x"})
self.assertEqual((c["cmd"], c["host"], c["fix"], c["msg"]), ("pgrep -xq 'Pro Tools'", "prop", "x", "PT NOT RUNNING"))
with self.assertRaises(ValueError):
b.build_check(CFG, {"template": "mac_program", "params": {"host": "ingest", "process": "x"}})
cfg = json.loads(json.dumps(CFG))
for t in cfg["tiles"]:
for ch in t.get("checks", []):
if ch.get("template") == "mac_program":
ch["cmd"] = "true"
self.assertTrue(any("no longer matches" in e for e in schema.validate(cfg)[0]))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -807,7 +807,7 @@ class TestFixes(unittest.TestCase):
# what each key restarts # what each key restarts
keys = {k["id"]: k["fix"] for k in CONFIG["restart_page"]["keys"]} keys = {k["id"]: k["fix"] for k in CONFIG["restart_page"]["keys"]}
self.assertEqual(keys["rs_ableton"], "ableton_restart") self.assertEqual(keys["rs_ableton"], "ableton_restart")
self.assertIn("pkill -TERM -x 'Live'", CONFIG["fixes"]["ableton_restart"]["run"]) self.assertIn("pkill -TERM -x Live", CONFIG["fixes"]["ableton_restart"]["run"])
self.assertIn("kickstart -k system/com.audinate.dante.DanteVirtualSoundcard", self.assertIn("kickstart -k system/com.audinate.dante.DanteVirtualSoundcard",
open(os.path.join(os.path.dirname(os.path.abspath(sp.__file__)), "remote-access", "mac-dvs-helper.sh")).read()) open(os.path.join(os.path.dirname(os.path.abspath(sp.__file__)), "remote-access", "mac-dvs-helper.sh")).read())
self.assertIn("open -g /Applications/ProdCom.app", CONFIG["fixes"]["dvs_ingest"]["run"]) # ProdCom after DVS self.assertIn("open -g /Applications/ProdCom.app", CONFIG["fixes"]["dvs_ingest"]["run"]) # ProdCom after DVS