Steps 4-6: the jobs, the set-up tab with generated scripts, Install the staged folder, site bundles and the campus template

- Overview jobs: Change the playlist, Add or remove a HyperDeck, Add a MIDI link, Add a computer,
  Make a PC program startable; 'Add a key for it' on unwatched Companion connections.
- Computers > Setup: steps with a [how] each and the set-up scripts generated per computer by
  settings/scripts.py from the templates in remote-access/ (no site-shaped value left in them);
  Remove this computer drops the fixes and checks that used it.
- Site setup > Site: the running and staged code, Install the staged folder (a one-shot launchd job
  running install.sh --keep-config, refused while live), Export site bundle (zip), New campus from a
  bundle (this campus's site block stays; every copied computer is flagged until touched).
- sites/: na -> the repo config, _template = the empty campus; install.sh --site <id>.
- Review reads appended checks as 'added'; tests for all of it.
- Deployed page-only to NA Ingest. (config.json, status_panel.py and readiness.py are left to the
  READY-page work in progress in the same tree.)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 14:53:08 -04:00
co-authored by Claude Fable 5.1
parent dafa14a512
commit a33e344cae
19 changed files with 804 additions and 59 deletions
+120
View File
@@ -20,18 +20,23 @@ import logging.handlers
import os
import re
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
import io
import plistlib
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 scripts
from settings import keyguide, probes
log = logging.getLogger("status-settings")
@@ -645,6 +650,28 @@ 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/setup":
return self.send(200, {"computers": scripts.setup_all(site.load())})
if path == "/api/program/staged":
return self.send(200, self.staged())
if path == "/api/site/bundle":
cfg = site.load()
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("config.json", applying.config_text(cfg))
for name in ("atem-reference.xml", "nav_locations.json"):
fp = os.path.join(site.data_dir, name)
if os.path.exists(fp):
z.write(fp, name)
z.writestr("README.txt", "Site bundle of %s, exported %s from the settings page.\nDrop it in the repo as sites/%s/ or feed it to "
"'New campus from a bundle' on another campus's page.\n" % ((cfg.get("site") or {}).get("name", "?"), time.strftime("%Y-%m-%d %H:%M"), (cfg.get("site") or {}).get("id", "site")))
data = buf.getvalue()
self.send_response(200)
self.send_header("Content-Type", "application/zip")
self.send_header("Content-Disposition", "attachment; filename=\"site-%s-%s.zip\"" % ((cfg.get("site") or {}).get("id", "site"), time.strftime("%Y%m%d")))
self.send_header("Content-Length", str(len(data)))
self.end_headers()
return self.wfile.write(data)
if path == "/api/builders":
return self.send(200, {"fixes": builders.template_list(site.cfg), "checks": builders.check_template_list()})
if path == "/api/facts":
@@ -801,6 +828,32 @@ class Handler(http.server.BaseHTTPRequestHandler):
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/program/install":
live, reason = site.applier.live()
if live:
return self.send(409, {"error": "live now (%s): the program is not updated during a service" % reason})
return self.send(200, self.install_staged(who, keep_config=body.get("keep_config", True)))
if path == "/api/site/import":
try:
new_cfg = self.bundle_config(raw)
except (ValueError, KeyError, zipfile.BadZipFile) as e:
return self.send(400, {"error": "not a site bundle or settings file: %s" % e})
cfg = site.load()
mine = cfg.get("site") or {}
src = (new_cfg.get("site") or {}).get("name") or "the other campus"
new_cfg["site"] = dict(new_cfg.get("site") or {}, **{k: mine[k] for k in ("id", "name", "ingest_name", "ingest_hostname", "ingest_ips", "editing_code", "settings_port") if k in mine})
flag = "copied from %s - check it" % src
for alias, h in (new_cfg.get("hosts") or {}).items():
if h.get("self"):
for k in ("ssh", "mac", "wake_broadcast"):
h.pop(k, None)
h["notes"] = (flag + (". " + h["notes"] if h.get("notes") else ""))
for f in (new_cfg.get("fixes") or {}).values():
f["label"] = f.get("label", "")
errors, warnings = schema.validate(new_cfg)
draft, _ = site.draft.update(new_cfg, who, site.cfg_sha)
log.info("%s started a new campus from a bundle of %s", who, src)
return self.send(200, {"draft": self.masked_draft(draft), "from": src, "errors": errors, "warnings": warnings})
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 []
@@ -825,6 +878,73 @@ class Handler(http.server.BaseHTTPRequestHandler):
log.exception("%s %s", self.command, path)
return self.send(500, {"error": str(e)})
def staged(self):
"""The staged folder (~/status-panel-install) against the running code."""
src = os.path.expanduser("~/status-panel-install")
out = {"folder": src, "present": os.path.isfile(os.path.join(src, "install.sh")), "staged_version": None, "staged_when": None,
"running_version": (self.site.read_json("started.json", {}) or {}).get("version"), "job": None, "log": []}
sp_path = os.path.join(src, "status_panel.py")
if os.path.isfile(sp_path):
m = re.search(r'^VERSION\s*=\s*"([^"]+)"', open(sp_path, encoding="utf-8").read(), re.M)
out["staged_version"] = m.group(1) if m else "?"
out["staged_when"] = os.path.getmtime(sp_path)
logp = os.path.expanduser("~/Library/Logs/status-install.log")
if os.path.exists(logp):
try:
out["log"] = open(logp, encoding="utf-8", errors="replace").read().splitlines()[-25:]
out["log_when"] = os.path.getmtime(logp)
except OSError:
pass
try:
r = subprocess.run(["/bin/launchctl", "print", "gui/%d/org.waterschurch.status-install" % os.getuid()], capture_output=True, text=True, timeout=5)
out["job"] = "running" if "state = running" in r.stdout else ("loaded" if r.returncode == 0 else None)
except (OSError, subprocess.TimeoutExpired):
pass
return out
def install_staged(self, who, keep_config=True):
"""Run install.sh from the staged folder as a one-shot launchd job (it survives this agent's own restart)."""
src = os.path.expanduser("~/status-panel-install")
if not os.path.isfile(os.path.join(src, "install.sh")):
return {"ok": False, "error": "nothing is staged in %s (copy the folder there with scp first)" % src}
label = "org.waterschurch.status-install"
plist = os.path.expanduser("~/Library/LaunchAgents/%s.plist" % label)
args = ["/bin/zsh", os.path.join(src, "install.sh")] + (["--keep-config"] if keep_config else [])
with open(plist, "wb") as f:
plistlib.dump({"Label": label, "ProgramArguments": args, "RunAtLoad": True, "AbandonProcessGroup": True,
"StandardOutPath": os.path.expanduser("~/Library/Logs/status-install.log"),
"StandardErrorPath": os.path.expanduser("~/Library/Logs/status-install.log"),
"EnvironmentVariables": {"HOME": os.path.expanduser("~"), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}}, f)
domain = "gui/%d" % os.getuid()
subprocess.run(["/bin/launchctl", "bootout", "%s/%s" % (domain, label)], capture_output=True, text=True, timeout=10)
try:
open(os.path.expanduser("~/Library/Logs/status-install.log"), "a").write("\n==== %s install started by %s from the settings page ====\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), who))
except OSError:
pass
r = subprocess.run(["/bin/launchctl", "bootstrap", domain, plist], capture_output=True, text=True, timeout=10)
log.info("%s started the install job (%s): rc=%s %s", who, " ".join(args), r.returncode, (r.stderr or r.stdout).strip())
if r.returncode != 0:
return {"ok": False, "error": "launchctl: %s" % (r.stderr or r.stdout).strip()[-200:]}
return {"ok": True, "detail": "install.sh is running; this page comes back in about 20 seconds"}
def bundle_config(self, raw):
"""config.json out of a site bundle (zip) or a bare settings file."""
if raw[:2] == b"PK":
with zipfile.ZipFile(io.BytesIO(raw)) as z:
names = [n for n in z.namelist() if n.endswith("config.json")]
if not names:
raise ValueError("no config.json in the bundle")
cfg = json.loads(z.read(names[0]).decode("utf-8"))
xml = [n for n in z.namelist() if n.endswith("atem-reference.xml")]
if xml:
with open(os.path.join(self.site.data_dir, "draft-atem.xml"), "wb") as f:
f.write(z.read(xml[0]))
else:
cfg = json.loads(raw.decode("utf-8"))
if not isinstance(cfg, dict) or "tiles" not in cfg:
raise ValueError("no keys in it")
return cfg
def masked_draft(self, draft):
return dict(draft, config=schema.mask_secrets(draft["config"]))