Step 2: the settings page can change things - shared draft, Review & Apply, rollback, history, undo

- settings/apply.py: the Apply pipeline (check, keep history, write, restart the watchdog, re-import
  pages by the marker-only rule, recolour page keys), rollback when the watchdog does not come back,
  the recording guard with the Emergency (LIVE) override, history entries and Go back / Undo.
- settings/server.py: the write routes behind the editing code (draft PUT/DELETE with 409 on a
  conflict, validate, apply, restore, undo, watchdog restart/stop/start, commands to the watchdog,
  the switcher file upload / pick newest / discard, gear notes).
- settings/ui/app.js: every screen grows inputs with Editing on; keys (checks, fixes, hold-always,
  members), computers, fixes, site setup (schedules, Dante devices, Spotify, ATEM), Review & Apply
  with live progress, History with Go back and Undo, Run this fix now.
- schema.unmask, push_page.push, nav_keys.colour; tests for all of it (163 pass).
- Verified live on NA Ingest: a page-only Apply, a restart Apply, Undo, history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 14:06:42 -04:00
co-authored by Claude Fable 5.1
parent ae3e85f152
commit 02d0c77cd9
10 changed files with 1686 additions and 145 deletions
+269 -7
View File
@@ -11,6 +11,7 @@ import argparse
import gzip
import glob
import hashlib
import tempfile
import http.server
import ipaddress
import json
@@ -29,6 +30,7 @@ import urllib.request
from settings import PARENT, HERE # noqa: F401
import schema
import status_panel as sp
from settings import apply as applying
from settings import keyguide, probes
log = logging.getLogger("status-settings")
@@ -45,6 +47,7 @@ COMPANION_FACTS = [
("Does OBS say when it is streaming?", "Yes: the OBS-Youtube connection's variable 'streaming' reads 'Off-Air' when it is not; "
"the on-air value is recorded the first time it streams."),
("Can the page create the custom variables?", "Not yet known; they are made by hand in Companion for now."),
("Which Companion runs here", "The one listening on port 8000 (5.0.7); an abandoned 5.1 upgrade left a dead settings folder, which the panel now skips."),
("Can an old page's keys be blanked over the API?", "Not yet tried; after a page-number change the old keys are deleted by hand."),
]
@@ -102,6 +105,8 @@ class Site:
self.probing = None
self.attempts = {} # client ip -> [count, first attempt time]
self.page_cache = {} # name -> (time, value)
self.draft = applying.Draft(data_dir)
self.applier = applying.Applier(self)
def load(self):
st = os.stat(self.config_path)
@@ -536,6 +541,24 @@ class Site:
return {"ok": False, "tries_left": max(0, 5 - count - 1), "no_code": not wanted}
def spotify_info(url):
"""Name, song count and URI of a public playlist, from open.spotify.com only."""
m = re.search(r"(?:playlist[/:])([A-Za-z0-9]{22})", url or "")
if not m:
return {"ok": False, "error": "paste a Spotify playlist link (open.spotify.com/playlist/...)"}
pid = m.group(1)
try:
req = urllib.request.Request("https://open.spotify.com/embed/playlist/" + pid, headers={"User-Agent": "Mozilla/5.0"})
html = urllib.request.urlopen(req, timeout=10, context=sp._UNVERIFIED).read().decode("utf-8", "replace")
mm = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', html)
entity = json.loads(mm.group(1))["props"]["pageProps"]["state"]["data"]["entity"]
return {"ok": True, "uri": "spotify:playlist:" + pid, "name": entity.get("name") or entity.get("title"),
"songs": len(entity.get("trackList", [])), "public": True}
except Exception as e:
return {"ok": True, "uri": "spotify:playlist:" + pid, "name": None, "songs": None, "public": False,
"warning": "the page cannot read this playlist (private?): the key will show WRONG PLAYLIST less reliably (%s)" % e}
def cfg_stale(cfg):
return cfg.get("page_stale_seconds", 15)
@@ -591,7 +614,36 @@ class Handler(http.server.BaseHTTPRequestHandler):
if path == "/api/keys":
return self.send(200, site.keys_view())
if path == "/api/history":
return self.send(200, site.history_view())
return self.send(200, {"entries": applying.history_entries(site.data_dir)})
if path.startswith("/api/history/"):
entry = path[len("/api/history/"):]
cfg = applying.history_config(site.data_dir, entry)
if cfg is None:
return self.send(404, {"error": "no such version"})
live_cfg = site.load()
changes = [applying.words_for(p, a, b, cfg, live_cfg) for p, a, b in applying.changed_paths(cfg, live_cfg)]
return self.send(200, {"config": schema.mask_secrets(cfg), "changes_to_now": changes})
if path == "/api/draft":
d = site.draft.read()
return self.send(200, {"draft": self.masked_draft(d) if d else None, "live_sha": site.cfg_sha,
"stale": bool(d and d.get("base_sha") != site.cfg_sha),
"atem_pending": os.path.exists(os.path.join(site.data_dir, "draft-atem.xml"))})
if path == "/api/apply/status":
st = dict(site.applier.status)
st["live"] = dict(zip(("live", "reason"), site.applier.live()))
st["failed_last"] = site.read_json("last-apply-failed.json", None)
return self.send(200, st)
if path == "/api/live/spotify":
url = q.get("url", [""])[0]
return self.send(200, spotify_info(url))
if path == "/api/atem-reference/folder":
folder = os.path.expanduser((site.cfg.get("atem") or {}).get("source_folder") or "")
files = sorted(glob.glob(os.path.join(folder, "*.xml")), key=os.path.getmtime, reverse=True) if folder and os.path.isdir(folder) else []
return self.send(200, {"folder": folder, "mounted": bool(folder and os.path.isdir(folder)),
"files": [{"name": os.path.basename(f), "when": os.path.getmtime(f)} for f in files[:10]]})
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/facts":
return self.send(200, {"facts": COMPANION_FACTS})
if path == "/api/snapshot":
@@ -614,15 +666,38 @@ class Handler(http.server.BaseHTTPRequestHandler):
log.exception("GET %s", path)
return self.send(500, {"error": str(e)})
def gate(self):
"""Every change needs the editing code and a name. (ok, who) or (False, response sent)."""
code, who = self.headers.get("X-Edit-Key", ""), (self.headers.get("X-Editor") or "").strip()[:40]
r = self.site.check_code(self.client_address[0], code)
if not r.get("ok"):
self.send(401, dict(r, error="the editing code is needed" if not r.get("locked_for") else "locked out after too many wrong codes"))
return False, None
if not who:
self.send(400, {"error": "your first name is needed"})
return False, None
return True, who
def read_body(self):
length = int(self.headers.get("Content-Length") or 0)
return self.rfile.read(length) if length else b""
def do_PUT(self):
self.do_POST()
def do_DELETE(self):
self.do_POST()
def do_POST(self):
if not allowed_client(self.client_address[0]):
return self.send(403, {"error": "only from the church network"})
path = urllib.parse.urlparse(self.path).path
length = int(self.headers.get("Content-Length") or 0)
raw = self.read_body()
ctype = self.headers.get("Content-Type") or ""
body = {}
if length:
if raw and ctype.startswith("application/json"):
try:
body = json.loads(self.rfile.read(length) or b"{}")
body = json.loads(raw or b"{}")
except ValueError:
return self.send(400, {"error": "bad JSON"})
host = (self.headers.get("Host") or "").split(":")[0]
@@ -631,6 +706,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(403, {"error": "wrong origin"})
site = self.site
try:
if path == "/api/editing/check":
return self.send(200, site.check_code(self.client_address[0], str(body.get("code", ""))))
if path == "/api/machines/probe":
if site.read_json("fixes-running.json", {}):
return self.send(409, {"error": "a fix is running; try again in a minute"})
@@ -641,13 +718,198 @@ class Handler(http.server.BaseHTTPRequestHandler):
site.probing = threading.Thread(target=probes.probe_all, args=(site.load(), site.data_dir, only), daemon=True)
site.probing.start()
return self.send(200, {"started": True})
if path == "/api/editing/check":
return self.send(200, site.check_code(self.client_address[0], str(body.get("code", ""))))
# ---- everything below changes something: the editing code and a name are needed ----
ok, who = self.gate()
if not ok:
return
if path == "/api/draft" and self.command == "PUT":
cfg = schema.unmask(body.get("config") or {}, site.load())
draft, conflict = site.draft.update(cfg, who, site.cfg_sha, body.get("expect"))
if conflict:
changed = applying.changed_paths(cfg, conflict["config"])
return self.send(409, {"error": "%s changed this a moment ago" % conflict.get("by", "someone"), "draft": self.masked_draft(conflict),
"changed": [applying.words_for(p, a, b, cfg, conflict["config"])["where"] for p, a, b in changed[:5]]})
return self.send(200, {"draft": self.masked_draft(draft)})
if path == "/api/draft" and self.command == "DELETE":
site.draft.clear()
self.discard_atem()
return self.send(200, {"ok": True})
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))
if path == "/api/apply":
draft = site.draft.read()
if not draft:
return self.send(400, {"error": "there is no draft to apply"})
if draft.get("base_sha") != site.cfg_sha:
return self.send(409, {"error": "the live settings changed since this draft was started - review it again",
"changed_since": True})
atem = os.path.join(site.data_dir, "draft-atem.xml")
r = site.applier.start(draft["config"], str(body.get("note", ""))[:200], who, self.client_address[0], body.get("emergency"),
atem if os.path.exists(atem) else None)
if r.get("ok"):
site.draft.clear()
return self.send(200 if r.get("ok") else 409, r)
if path.startswith("/api/restore/"):
entry = path[len("/api/restore/"):]
cfg = applying.history_config(site.data_dir, entry)
if cfg is None:
return self.send(404, {"error": "no such version"})
draft, _ = site.draft.update(cfg, who, site.cfg_sha)
return self.send(200, {"draft": self.masked_draft(draft), "from": entry})
if path == "/api/undo":
entries = [e for e in applying.history_entries(site.data_dir) if e["kind"] == "apply"]
if not entries:
return self.send(404, {"error": "nothing to undo"})
cfg = applying.history_config(site.data_dir, entries[0]["id"])
draft, _ = site.draft.update(cfg, who, site.cfg_sha)
return self.send(200, {"draft": self.masked_draft(draft), "from": entries[0]["id"], "note": entries[0]["note"]})
if path in ("/api/watchdog/restart", "/api/watchdog/stop", "/api/watchdog/start"):
live, reason = site.applier.live()
if live and path != "/api/watchdog/start":
return self.send(409, {"error": "live now (%s)" % reason})
if path.endswith("/stop") and body.get("word") != "STOP":
return self.send(400, {"error": "type STOP to confirm"})
verb = path.rsplit("/", 1)[1]
return self.send(200, self.watchdog_control(verb, who))
if path == "/api/command":
text = str(body.get("text", "")).strip()
verb = text.split(" ")[0].lower() if text else ""
if verb not in ("test", "resurvey", "service"):
return self.send(400, {"error": "not a command the page may send"})
if verb == "test":
live, reason = site.applier.live()
if live:
return self.send(409, {"error": "live now (%s): no test runs" % reason})
text = "%s %d" % (text, int(time.time()))
names = site.cfg["variables"]
cur = site.comp.get_custom(names["command"]) or ""
ok, status = site.comp.set_custom(names["command"], (cur + ";" + text) if cur.strip() else text)
log.info("%s sent command %r -> %s", who, text, ok)
return self.send(200 if ok else 502, {"ok": ok, "sent": text})
if path == "/api/atem-reference":
return self.send(200, self.take_atem(raw))
if path == "/api/atem-reference/pick-newest":
folder = os.path.expanduser((site.cfg.get("atem") or {}).get("source_folder") or "")
files = sorted(glob.glob(os.path.join(folder, "*.xml")), key=os.path.getmtime) if folder and os.path.isdir(folder) else []
if not files:
return self.send(404, {"error": "no .xml files in the folder (is Google Drive signed in?)"})
with open(files[-1], "rb") as f:
return self.send(200, dict(self.take_atem(f.read()), file=os.path.basename(files[-1])))
if path == "/api/atem-reference/discard":
self.discard_atem()
return self.send(200, {"ok": True})
if path == "/api/gear":
cfg = site.load()
gear = dict(cfg.get("gear") or {})
name = str(body.get("name", ""))[:80]
gear[name] = {"location": str(body.get("location", ""))[:120], "notes": str(body.get("notes", ""))[:300]}
draft = site.draft.read() or {"config": cfg}
draft_cfg = dict(draft["config"], gear=gear)
d, _ = site.draft.update(draft_cfg, who, site.cfg_sha)
return self.send(200, {"draft": self.masked_draft(d)})
return self.send(404, {"error": "not found"})
except Exception as e:
log.exception("POST %s", path)
log.exception("%s %s", self.command, path)
return self.send(500, {"error": str(e)})
def masked_draft(self, draft):
return dict(draft, config=schema.mask_secrets(draft["config"]))
def check(self, cfg):
"""Everything Review shows: errors, warnings, the plain-words changes, what Apply will do."""
site = self.site
live_cfg = site.load()
try:
conns = {c["label"]: c.get("moduleId") for c in site.comp.connections()}
except Exception:
conns = None
errors, warnings = schema.validate(cfg, {"connections": conns} if conns else None)
tmp = tempfile.NamedTemporaryFile("wb", suffix=".json", delete=False)
tmp.write(applying.config_text(cfg)); tmp.close()
try:
sp.load_config(tmp.name)
except (ValueError, KeyError, TypeError) as e:
errors.append("the watchdog would not accept it: %s" % e)
finally:
os.remove(tmp.name)
plan = applying.what_apply_does(live_cfg, cfg, site.applier.hashes()) if not errors else {"restart": False, "pages": [], "nav": False, "changes": applying.changed_paths(live_cfg, cfg)}
changes = [applying.words_for(p, a, b, live_cfg, cfg) for p, a, b in plan["changes"]]
live, reason = site.applier.live()
atem_pending = os.path.exists(os.path.join(site.data_dir, "draft-atem.xml"))
return {"errors": errors, "warnings": warnings, "changes": changes, "plan": {k: plan[k] for k in ("restart", "pages", "nav")},
"live": {"live": live, "reason": reason}, "atem_pending": atem_pending, "feel": self.feel(plan, atem_pending)}
def feel(self, plan, atem_pending):
if plan.get("error"):
return "The pages could not be built: " + plan["error"]
parts = []
if plan["restart"] or atem_pending:
parts.append("the deck goes purple for about 5 seconds and comes back with your changes")
if plan["pages"]:
parts.append("the %s re-imported" % " and ".join("%s page is" % p.capitalize() for p in plan["pages"] if p in ("status", "restart")))
if plan["nav"]:
parts.append("the page-number keys are recoloured")
return (", ".join(parts) + ".").capitalize() if parts else "Nothing on the deck changes."
def take_atem(self, raw):
"""An uploaded saved switcher file: capped, no DOCTYPE or entities, parsed with the watchdog's own
parser; kept as draft-atem.xml until Apply. Returns what differs from the current file."""
site = self.site
if len(raw) > 20 * 1024 * 1024:
return {"ok": False, "error": "the file is bigger than 20 MB"}
head = raw[:4096].decode("utf-8", "replace")
if "<!DOCTYPE" in head or "<!ENTITY" in head:
return {"ok": False, "error": "the file holds a DOCTYPE or entities; a saved switcher file never does"}
pending = os.path.join(site.data_dir, "draft-atem.xml")
with open(pending + ".tmp", "wb") as f:
f.write(raw)
try:
new = sp.atem_reference(pending + ".tmp", (site.cfg.get("atem") or {}).get("compare"))
except Exception as e:
os.remove(pending + ".tmp")
return {"ok": False, "error": "not a saved switcher file: %s" % e}
os.replace(pending + ".tmp", pending)
os.chmod(pending, 0o600)
current = os.path.join(site.data_dir, "atem-reference.xml")
try:
old = sp.atem_reference(current, (site.cfg.get("atem") or {}).get("compare"))
except Exception:
old = {"audio": {}, "camera": {}}
product = re.search(r'product="([^"]+)"', head)
diffs = []
for part in ("audio", "camera"):
for k in sorted(set(old[part]) | set(new[part])):
if old[part].get(k) != new[part].get(k):
diffs.append({"part": part, "key": k, "before": old[part].get(k), "after": new[part].get(k)})
return {"ok": True, "product": product.group(1) if product else None, "audio": len(new["audio"]), "camera": len(new["camera"]),
"diffs": diffs[:50], "diff_count": len(diffs)}
def discard_atem(self):
try:
os.remove(os.path.join(self.site.data_dir, "draft-atem.xml"))
except OSError:
pass
def watchdog_control(self, verb, who):
domain = "gui/%d" % os.getuid()
label = applying.WATCHDOG
plist = os.path.expanduser("~/Library/LaunchAgents/%s.plist" % label)
try:
if verb == "stop":
r = sp.subprocess.run(["/bin/launchctl", "bootout", "%s/%s" % (domain, label)], capture_output=True, text=True, timeout=30)
log.info("%s stopped the watchdog -> %s", who, r.returncode)
return {"ok": r.returncode == 0, "detail": (r.stderr or "stopped").strip()[-200:]}
if verb == "start":
r = sp.subprocess.run(["/bin/launchctl", "bootstrap", domain, plist], capture_output=True, text=True, timeout=30)
log.info("%s started the watchdog -> %s", who, r.returncode)
return {"ok": r.returncode == 0, "detail": (r.stderr or "started").strip()[-200:]}
ok, detail = self.site.applier.restart_watchdog(None)
log.info("%s restarted the watchdog -> %s %s", who, ok, detail)
return {"ok": ok, "detail": detail}
except (OSError, sp.subprocess.TimeoutExpired) as e:
return {"ok": False, "detail": str(e)}
def static(self, name, ctype):
try:
with open(os.path.join(UI, name), "rb") as f: