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
+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 status_panel as sp
from settings import apply as applying
from settings import builders
from settings import keyguide, probes
log = logging.getLogger("status-settings")
@@ -644,6 +645,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
if path == "/api/live/midi":
snap = site.read_json("snapshot.json", {}) or {}
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":
return self.send(200, {"facts": COMPANION_FACTS})
if path == "/api/snapshot":
@@ -734,6 +737,15 @@ class Handler(http.server.BaseHTTPRequestHandler):
site.draft.clear()
self.discard_atem()
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":
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))
+81 -13
View File
@@ -69,8 +69,8 @@
// ---- the draft ------------------------------------------------------------
async function loadAll() {
const [c, s, d] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft")]);
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending;
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.builders = b;
document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
badge();
}
@@ -309,7 +309,13 @@
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>`;
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>`
: `<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>` : "";
@@ -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(); };
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 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 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(); };
@@ -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 -------------------------------------------------------------------------
async function fixes(rest) {
const c = cfg(), auto = new Set(c.auto_fix.fixes || []);
@@ -621,20 +667,42 @@
let detail = "";
if (f) {
const kind = f.kind || "ssh";
const fields = ["label", "short"].concat((state.schema.fix_kind_fields || {})[kind] || []).concat(["restart_ok", "never_auto", "timeout"]);
const inputs = editing() ? [...new Set(fields)].map((fl) => {
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 tpls = state.builders.fixes, tplName = f.template || "custom", tpl = tpls.find((t) => t.name === tplName) || tpls.find((t) => t.name === "custom");
const kindOnly = !!tpl.kind;
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>` : "";
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 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; };
}