diff --git a/README.md b/README.md index 471f82a..bc72371 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ The page reads what the watchdog writes next to its config (`snapshot.json` ever This folder is the source of truth. Edit `config.json` here, then copy the folder to NA Ingest: ```bash -scp status_panel.py coremidi.py netgear.py make_page.py push_page.py nav_keys.py nav_templates.json config.json install.sh atem_watch.js atem_state.js atem-reference.xml checklist-ingest:status-panel-install/ +scp -r status_panel.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py nav_templates.json config.json install.sh atem_watch.js atem_state.js atem-reference.xml settings checklist-ingest:status-panel-install/ ``` Then run the installer there. It checks the config with the new code first (a bad config never gets as far as the watchdog), keeps the code that was running in `prev/`, backs up the old config, writes the LaunchAgent with the exit timeout this config needs (the longest fix plus its verify time, so a restart never cuts a fix in half; launchd honours at most 60 s of it) and restarts the watchdog: diff --git a/nav_keys.py b/nav_keys.py index 24c6057..b783887 100644 --- a/nav_keys.py +++ b/nav_keys.py @@ -75,6 +75,37 @@ def find_nav_keys(host, port, status_page): return found +def colour(host, port, status_page, coloured, record_path, revert=False): + """Colour (or restore) the nav keys on every page; returns how many keys changed.""" + record = json.load(open(record_path)) if os.path.exists(record_path) else {} + found = find_nav_keys(host, port, status_page) + if revert: + work = [(p, r, c, k, "restore") for p, r, c, k, ours in found if ours] + else: + work = [(p, r, c, k, "colour") for p, r, c, k, ours in found if k in coloured] + work += [(p, r, c, k, "restore") for p, r, c, k, ours in found if ours and k not in coloured] + ws = WebSocket(host, port, "/trpc") + done = 0 + try: + api = Trpc(ws) + for page, row, col, kind, what in work: + location = {"pageNumber": page, "row": row, "column": col} + if what == "restore": + api.mutation("controls.resetControl", {"location": location, "newType": kind}) + record.pop("%d/%d/%d" % (page, row, col), None) + else: + r, c = NAV_LOCATIONS[kind] + source = {"pageNumber": status_page, "row": int(r), "column": int(c)} + if not api.mutation("controls.copyControl", {"fromLocation": source, "toLocation": location}): + raise RuntimeError("copy to page %d row %d col %d failed" % (page, row, col)) + record["%d/%d/%d" % (page, row, col)] = kind + done += 1 + finally: + ws.close() + json.dump(record, open(record_path, "w"), indent=1, sort_keys=True) + return done + + def main(): cfg = json.load(open(os.path.join(HERE, "config.json"))) ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) diff --git a/push_page.py b/push_page.py index d5d8463..fa98b74 100644 --- a/push_page.py +++ b/push_page.py @@ -178,6 +178,35 @@ def page_name(host, port, page): return r.read().decode() +def push(host, port, page, file_path, expected_name): + """Upload a built page file and import it into `page` (the caller has checked safe_target).""" + data = open(file_path, "rb").read() + built = json.loads(gzip.decompress(data)) + if (built.get("page") or {}).get("name") != expected_name: + raise ValueError("%s builds page %r, not %r" % (os.path.basename(file_path), (built.get("page") or {}).get("name"), expected_name)) + ws = WebSocket(host, port, "/trpc") + try: + api = Trpc(ws) + for attempt in range(40): + try: + session = api.mutation("importExport.prepareImport.start", {"name": os.path.basename(file_path), "size": len(data)}) + break + except RuntimeError as e: + if "already exists" not in str(e) or attempt == 39: + raise + time.sleep(3) + for offset in range(0, len(data), CHUNK): + api.mutation("importExport.prepareImport.uploadChunk", + {"sessionId": session, "offset": offset, "data": base64.b64encode(data[offset:offset + CHUNK]).decode()}) + summary = api.mutation("importExport.prepareImport.complete", + {"sessionId": session, "expectedChecksum": hashlib.sha1(data).hexdigest(), "userData": None}) + if isinstance(summary, list) and summary and summary[0]: + raise RuntimeError("Companion could not read the file: %s" % summary[0]) + api.mutation("importExport.importSinglePage", {"targetPage": page, "sourcePage": 1, "connectionIdRemapping": {}}) + finally: + ws.close() + + def main(): cfg = json.load(open(os.path.join(HERE, "config.json"))) ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) diff --git a/schema.py b/schema.py index b0e2826..aacb266 100644 --- a/schema.py +++ b/schema.py @@ -644,6 +644,28 @@ def mask_secrets(obj): return walk(copy.deepcopy(obj), "") +def unmask(draft, live): + """A draft that came back from the page carries masked secrets ("set (N characters)"): put the + live values back so a masked value never overwrites a real one.""" + secret_paths = {p for p, f in F.items() if f["secret"]} + + def walk(node, ref, path): + if isinstance(node, dict): + out = {} + for k, v in node.items(): + p = "%s.%s" % (path, k) if path else k + if (p in secret_paths or k in secret_paths) and isinstance(v, str) and (v.startswith("set (") or v == "") \ + and isinstance(ref, dict) and isinstance(ref.get(k), str) and ref.get(k): + out[k] = ref[k] if v.startswith("set (") else "" + else: + out[k] = walk(v, ref.get(k) if isinstance(ref, dict) else None, p) + return out + if isinstance(node, list): + return [walk(v, ref[i] if isinstance(ref, list) and i < len(ref) else None, path + "[]") for i, v in enumerate(node)] + return node + return walk(copy.deepcopy(draft), live, "") + + def phrases(cfg): """The sentences the page and the logs build from the config.""" a = cfg.get("auto_fix") or {} diff --git a/settings/apply.py b/settings/apply.py new file mode 100644 index 0000000..dfb8eaa --- /dev/null +++ b/settings/apply.py @@ -0,0 +1,548 @@ +"""Saving a change: the draft, the checks, the history, and Apply itself. + +Apply, in order, each step only after the previous one worked: + keep a copy of the live config in history/ -> write the new file in one go (and the new switcher + file with it) -> wait for any running fix to finish -> restart the watchdog -> prove it came back + (started.json carries the new file's fingerprint) -> only then re-import the Companion pages when + the built page differs from the last verified one -> recolour the page-number keys when a page + changed. If the watchdog does not come back, the previous version is put back, once. +Nothing here runs while a HyperDeck is recording unless the Emergency override says so. +""" +import gzip +import hashlib +import json +import os +import re +import shutil +import subprocess +import tempfile +import threading +import time + +from settings import PARENT # noqa: F401 +import make_page +import nav_keys +import push_page +import schema +import status_panel as sp + +WATCHDOG = "org.waterschurch.status-panel" +# Fields whose change needs no restart and no page: the watchdog never reads them. +PAGE_ONLY = {"none"} + + +def sha(data): + return hashlib.sha256(data if isinstance(data, bytes) else json.dumps(data, sort_keys=True).encode()).hexdigest() + + +def strip_notes(export): + export = json.loads(json.dumps(export)) + for row in export["page"]["controls"].values(): + for ctl in (row or {}).values(): + if ctl and "options" in ctl: + ctl["options"].pop("notes", None) + return export + + +def build_hashes(cfg): + """Fingerprints of both pages as built from cfg, notes stripped (a note change never re-imports).""" + out = {"status": sha(strip_notes(make_page.build(cfg)))} + if cfg.get("restart_page"): + out["restart"] = sha(strip_notes(make_page.build_restart(cfg))) + return out + + +def config_text(cfg): + return (json.dumps(cfg, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + + +class Draft: + """One shared draft per site, in draft.json (0600): a closed tab loses nothing.""" + + def __init__(self, data_dir): + self.path = os.path.join(data_dir, "draft.json") + self.lock = threading.Lock() + + def read(self): + try: + with open(self.path) as f: + return json.load(f) + except (OSError, ValueError): + return None + + def write(self, draft): + with self.lock: + sp.write_json(self.path, draft) + os.chmod(self.path, 0o600) + + def clear(self): + try: + os.remove(self.path) + except OSError: + pass + + def update(self, config, by, base_sha, expect=None): + """Replace the draft's config. `expect` is the version the tab last saw; another tab's + newer write is reported with the fields that differ (409), never overwritten silently.""" + with self.lock: + cur = self.read() + if cur and expect is not None and cur.get("version") != expect: + return None, cur + now = time.time() + draft = {"config": config, "by": by, "base_sha": base_sha, "started": (cur or {}).get("started", now), + "started_by": (cur or {}).get("started_by", by), "updated": now, "version": "%d-%s" % (int(now * 1000), by)} + sp.write_json(self.path, draft) + os.chmod(self.path, 0o600) + return draft, None + + +def changed_paths(a, b, prefix=""): + """Every leaf path that differs between two configs, with (before, after).""" + out = [] + if isinstance(a, dict) and isinstance(b, dict): + for k in sorted(set(a) | set(b)): + p = "%s.%s" % (prefix, k) if prefix else k + if k not in a: + out.append((p, None, b[k])) + elif k not in b: + out.append((p, a[k], None)) + else: + out += changed_paths(a[k], b[k], p) + elif isinstance(a, list) and isinstance(b, list) and all(isinstance(x, dict) for x in a + b) and any("id" in x for x in a + b): + # lists of records with ids (tiles, restart keys): match by id so a move is not a delete + add + ida = {x.get("id"): x for x in a} + idb = {x.get("id"): x for x in b} + for k in list(dict.fromkeys(list(ida) + list(idb))): + p = "%s[%s]" % (prefix, k) + if k not in ida: + out.append((p, None, idb[k])) + elif k not in idb: + out.append((p, ida[k], None)) + else: + out += changed_paths(ida[k], idb[k], p) + elif isinstance(a, list) and isinstance(b, list) and a and len(a) == len(b) and all(isinstance(x, dict) for x in a + b): + # lists of plain records of the same length (checks, windows): by position + for i, (x, y) in enumerate(zip(a, b)): + out += changed_paths(x, y, "%s.%d" % (prefix, i)) + elif a != b: + out.append((prefix, a, b)) + return out + + +def generic_path(path): + """A concrete path -> the registry's spelling: tiles[hd2].checks.0.optional -> tiles[].checks[].optional, + hosts.lighting.notes -> hosts.*.notes, auto_fix.windows.0.start -> auto_fix.windows[].start.""" + g = re.sub(r"\[[^\]]*\]", "[]", path) + g = re.sub(r"^(tiles\[\]|restart_page\.keys\[\])\.checks\.\d+\.", r"\1.checks[].", g) + g = re.sub(r"^(hosts|fixes|gear)\.[^.]+", r"\1.*", g) + g = re.sub(r"^(auto_fix\.windows|service_windows|site\.live_services)\.\d+", r"\1[]", g) + return g + + +def leaves(path, value): + """Every leaf under an added or removed subtree, so each can be looked up in the registry.""" + if isinstance(value, dict) and value: + for k, v in value.items(): + yield from leaves("%s.%s" % (path, k), v) + elif isinstance(value, list) and value and all(isinstance(x, dict) for x in value): + for i, v in enumerate(value): + yield from leaves("%s[%s]" % (path, v.get("id", i)) if "id" in v else "%s.%d" % (path, i), v) + else: + yield path + + +def words_for(path, before, after, cfg_before, cfg_after): + """One plain-words line for a changed path, using the registry's names.""" + tiles = {t["id"]: t.get("label", t["id"]) for t in (cfg_after.get("tiles") or cfg_before.get("tiles") or [])} + keys = {k["id"]: k.get("label", k["id"]) for k in ((cfg_after.get("restart_page") or cfg_before.get("restart_page") or {}).get("keys") or [])} + hosts = {a: h.get("name", a) for a, h in {**(cfg_before.get("hosts") or {}), **(cfg_after.get("hosts") or {})}.items()} + fixes = {a: f.get("short", a) for a, f in {**(cfg_before.get("fixes") or {}), **(cfg_after.get("fixes") or {})}.items()} + where = path + where = re.sub(r"^tiles\[([^\]]+)\]", lambda m: "key %s" % tiles.get(m.group(1), m.group(1)), where) + where = re.sub(r"^restart_page\.keys\[([^\]]+)\]", lambda m: "restart key %s" % keys.get(m.group(1), m.group(1)), where) + where = re.sub(r"^hosts\.([a-z0-9_]+)", lambda m: "computer %s" % hosts.get(m.group(1), m.group(1)), where) + where = re.sub(r"^fixes\.([A-Za-z0-9_]+)", lambda m: "fix %s" % fixes.get(m.group(1), m.group(1)), where) + where = re.sub(r"\.checks\.(\d+)", lambda m: " › check %d" % (int(m.group(1)) + 1), where) + where = where.replace("restart_page", "Restart page").replace("auto_fix", "auto-fix").replace("site.", "site › ") + field = path.split(".")[-1].split("[")[0] + generic = {f["path"].split(".")[-1]: f for f in schema.F.values()} + help_ = (generic.get(field) or {}).get("help", "") + if where.endswith("." + field): + where = where[:-len(field) - 1] + " › " + field.replace("_", " ") + elif where.endswith(field): + where = where[:-len(field)] + field.replace("_", " ") + + def val(v): + if v is None: + return "—" + if isinstance(v, bool): + return "yes" if v else "no" + if isinstance(v, (dict, list)): + s = json.dumps(v, ensure_ascii=False) + return s if len(s) < 80 else s[:77] + "..." + return str(v) + secret = any(f["secret"] for f in schema.F.values() if f["path"].endswith(field)) + if secret: + return {"path": path, "where": where, "text": "%s changed" % where, "command": False} + text = "%s: %s → %s" % (where, val(before), val(after)) if before is not None and after is not None else \ + ("%s added: %s" % (where, val(after)) if before is None else "%s removed (was %s)" % (where, val(before))) + command = field in ("run", "cmd", "match") or path.endswith("pref.want") + return {"path": path, "where": where, "text": text, "help": help_, "command": command} + + +def what_apply_does(cfg_before, cfg_after, hashes_before): + """Which steps a change needs: the watchdog restarts unless every changed field is page-only + data; the pages re-import when the built page differs; page-number keys follow a page change.""" + changes = changed_paths(cfg_before, cfg_after) + restart = False + for path, before, after in changes: + for leaf in leaves(path, after if before is None else before): + f = schema.F.get(generic_path(leaf)) + if f is None or f["apply"] != "none": + restart = True + pages = [] + try: + after = build_hashes(cfg_after) + except Exception as e: # a config that cannot build is caught by validate; be safe here + return {"restart": restart, "pages": [], "nav": False, "error": str(e), "changes": changes} + for which, h in after.items(): + if hashes_before.get(which) != h: + pages.append(which) + if cfg_before.get("restart_page") and not cfg_after.get("restart_page"): + pages.append("restart-removed") + return {"restart": restart, "pages": pages, "nav": bool(pages) or cfg_before.get("page") != cfg_after.get("page") + or (cfg_before.get("nav_keys") != cfg_after.get("nav_keys")), "changes": changes, "hashes_after": after} + + +class Applier: + """Runs one Apply in a thread and reports its steps. One at a time (apply.lock).""" + + def __init__(self, site, run=None, sleep=None): + self.site = site + self.run = run or subprocess.run + self.sleep = sleep or time.sleep + self.status = {"state": "idle", "steps": [], "started": None, "finished": None, "outcome": "", "note": "", "by": ""} + self.thread = None + self.lock_path = os.path.join(site.data_dir, "apply.lock") + self.log_path = os.path.join(site.data_dir, "apply.log") + + # --- helpers --- + def log(self, text): + line = "%s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), text) + try: + with open(self.log_path, "a") as f: + f.write(line) + except OSError: + pass + + def step(self, name, state, detail=""): + for s in self.status["steps"]: + if s["name"] == name: + s.update(state=state, detail=detail) + break + else: + self.status["steps"].append({"name": name, "state": state, "detail": detail}) + self.log("%s: %s %s" % (name, state, detail)) + self.write_status() + + def write_status(self): + try: + sp.write_json(os.path.join(self.site.data_dir, "apply-status.json"), self.status) + except OSError: + pass + + def busy(self): + return bool(self.thread and self.thread.is_alive()) + + def live(self): + """(live?, reason) from the watchdog's live-now.json; stale = not live (the schedule has none at NA).""" + d = self.site.read_json("live-now.json", {}) or {} + if d and time.time() - d.get("time", 0) < 30: + return bool(d.get("live")), d.get("reason", "") + return False, "" + + def hashes(self): + h = self.site.read_json("build-hashes.json", None) + if h is None: # first use: the pages were imported from the live config + h = build_hashes(self.site.load()) + sp.write_json(os.path.join(self.site.data_dir, "build-hashes.json"), h) + return h + + def history_dir(self): + d = os.path.join(self.site.data_dir, "history") + os.makedirs(d, exist_ok=True) + return d + + def keep_history(self, note, by, address, actions, sha_before, sha_after, outcome="applied"): + """Copy the live config (and name the switcher file) into history/, 0600.""" + ts = time.strftime("%Y%m%d-%H%M%S") + slug = re.sub(r"[^a-z0-9]+", "-", (note or "change").lower())[:30].strip("-") or "change" + base = os.path.join(self.history_dir(), "%s-%s" % (ts, slug)) + shutil.copyfile(self.site.config_path, base + ".json") + os.chmod(base + ".json", 0o600) + xml = os.path.join(self.site.data_dir, "atem-reference.xml") + meta = {"when": time.time(), "by": by, "address": address, "note": note, "sha_before": sha_before, "sha_after": sha_after, + "actions": actions, "outcome": outcome, + "atem_sha": sha(open(xml, "rb").read()) if os.path.exists(xml) else None} + sp.write_json(base + ".meta.json", meta) + os.chmod(base + ".meta.json", 0o600) + return os.path.basename(base) + + def watchdog_pid(self): + try: + out = self.run(["/bin/launchctl", "print", "gui/%d/%s" % (os.getuid(), WATCHDOG)], capture_output=True, text=True, timeout=5).stdout + m = re.search(r"pid = (\d+)", out) + return int(m.group(1)) if m else None, "state = running" in out + except (OSError, subprocess.TimeoutExpired): + return None, False + + def restart_watchdog(self, want_sha, timeout=25): + """kickstart and wait for started.json with our fingerprint. (ok, detail).""" + waited = 0 + while self.site.read_json("fixes-running.json", {}) and waited < 60: + self.sleep(2) + waited += 2 + if self.site.read_json("fixes-running.json", {}): + return False, "a fix is still running on %s" % ", ".join(v.get("key", "?") for v in self.site.read_json("fixes-running.json", {}).values()) + old_pid, loaded = self.watchdog_pid() + t0 = time.time() + domain = "gui/%d" % os.getuid() + if loaded or old_pid: + r = self.run(["/bin/launchctl", "kickstart", "-k", "%s/%s" % (domain, WATCHDOG)], capture_output=True, text=True, timeout=30) + else: + plist = os.path.expanduser("~/Library/LaunchAgents/%s.plist" % WATCHDOG) + r = self.run(["/bin/launchctl", "bootstrap", domain, plist], capture_output=True, text=True, timeout=30) + if r.returncode != 0: + return False, "launchctl: %s" % (r.stderr or r.stdout).strip()[-200:] + waited = 0 + while waited < timeout: + self.sleep(1) + waited += 1 + st = self.site.read_json("started.json", {}) or {} + if st.get("started_at", 0) >= t0 - 1 and st.get("pid") != old_pid: + if want_sha and st.get("config_sha") != want_sha: + return False, "the watchdog started on a different settings file (%s)" % (st.get("config_sha") or "?")[:12] + self.sleep(3) + pid, running = self.watchdog_pid() + if not running: + return False, "the watchdog started and stopped again - see the log" + return True, "running (code %s)" % st.get("version") + return False, "no start stamp after %d s - see ~/Library/Logs/status-panel.err.log" % timeout + + def import_pages(self, cfg, which): + """make_page -> push_page with the marker-only rule -> verify, for the pages in `which`.""" + comp = cfg["companion"] + host, port = comp.get("host", "127.0.0.1"), comp.get("port", 8000) + out_dir = self.site.data_dir + done = [] + for name in which: + if name == "status": + page, expected, fname = cfg["page"], cfg["page_name"], "status-page.companionconfig" + export = make_page.build(cfg) + elif name == "restart" and cfg.get("restart_page"): + page, expected, fname = cfg["restart_page"]["page"], cfg["restart_page"]["page_name"], "restart-page.companionconfig" + export = make_page.build_restart(cfg) + else: + continue + path = os.path.join(out_dir, fname) + make_page.write(path, export) + live = push_page.fetch_page(host, port, page) + ok, why = push_page.safe_target(live, expected) + if not ok: + return False, "page %d: %s" % (page, why) + push_page.push(host, port, page, path, expected) + after = push_page.fetch_page(host, port, page) + want = {(r, c) for r, row in export["page"]["controls"].items() for c, ctl in row.items() if ctl and ctl.get("type") == "button-layered"} + have = {(r, c) for r, row in (after.get("page") or {}).get("controls", {}).items() for c, ctl in (row or {}).items() if ctl and ctl.get("type") == "button-layered"} + if want != have: + return False, "page %d: the import did not land as built (%d keys built, %d on the page)" % (page, len(want), len(have)) + done.append(name) + return True, ", ".join("page %d" % (cfg["page"] if n == "status" else cfg["restart_page"]["page"]) for n in done) or "nothing to import" + + def colour_nav_keys(self, cfg): + comp = cfg["companion"] + try: + n = nav_keys.colour(comp.get("host", "127.0.0.1"), comp.get("port", 8000), cfg["page"], cfg.get("nav_keys", list(make_page.NAV_LOCATIONS)), + os.path.join(self.site.data_dir, "nav_locations.json")) + return True, "%d page keys recoloured" % n + except Exception as e: + return False, str(e) + + # --- the pipeline --- + def start(self, new_cfg, note, by, address, emergency=None, atem_file=None): + """Begin an Apply in the background; the caller shows /api/apply/status. Refusals are immediate.""" + if self.busy(): + return {"ok": False, "error": "an Apply is already running"} + live, reason = self.live() + if live and not (emergency and emergency.get("word") == "LIVE" and emergency.get("reason")): + return {"ok": False, "error": "live now (%s): changes wait until it is over" % reason, "live": True} + if os.path.exists(self.lock_path) and time.time() - os.path.getmtime(self.lock_path) < 600: + return {"ok": False, "error": "an Apply is already running"} + self.status = {"state": "running", "steps": [], "started": time.time(), "finished": None, "outcome": "", "note": note, "by": by, + "emergency": emergency} + self.thread = threading.Thread(target=self._run, args=(new_cfg, note, by, address, atem_file), daemon=True) + self.thread.start() + return {"ok": True} + + def _run(self, new_cfg, note, by, address, atem_file): + open(self.lock_path, "w").close() + try: + self._pipeline(new_cfg, note, by, address, atem_file) + except Exception as e: + self.log("apply crashed: %r" % e) + self.status["state"], self.status["outcome"] = "failed", "Apply stopped with an error: %s" % e + finally: + self.status["finished"] = time.time() + try: + os.remove(self.lock_path) + except OSError: + pass + self.write_status() + + def _pipeline(self, new_cfg, note, by, address, atem_file): + site = self.site + old_cfg = site.load() + sha_before = site.cfg_sha + if self.status.get("emergency"): + self.log("EMERGENCY apply by %s: %s" % (by, self.status["emergency"].get("reason"))) + # 1. check + self.step("check", "running") + errors, warnings = schema.validate(new_cfg) + tmp = tempfile.NamedTemporaryFile("wb", suffix=".json", delete=False) + tmp.write(config_text(new_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) + if errors: + self.step("check", "failed", "; ".join(errors)[:500]) + self.status["state"], self.status["outcome"] = "failed", "Not applied: " + errors[0] + return + plan = what_apply_does(old_cfg, new_cfg, self.hashes()) + if plan.get("error"): + self.step("check", "failed", plan["error"]) + self.status["state"], self.status["outcome"] = "failed", "Not applied: the pages could not be built: " + plan["error"] + return + self.step("check", "ok", "%d change%s" % (len(plan["changes"]), "" if len(plan["changes"]) == 1 else "s")) + # 2. history + write + self.step("save", "running") + actions = [a for a, on in (("restart", plan["restart"] or bool(atem_file)), ("pages", bool(plan["pages"])), ("nav", plan["nav"])) if on] + if atem_file and os.path.exists(atem_file): + actions.append("switcher file") + text = config_text(new_cfg) + sha_after = sha(text) + entry = self.keep_history(note, by, address, actions, sha_before, sha_after) + tmp_path = site.config_path + ".tmp" + with open(tmp_path, "wb") as f: + f.write(text) + os.replace(tmp_path, site.config_path) + xml_backup = None + if atem_file and os.path.exists(atem_file): + xml = os.path.join(site.data_dir, "atem-reference.xml") + if os.path.exists(xml): + hist = os.path.join(self.history_dir(), "atem") + os.makedirs(hist, exist_ok=True) + xml_backup = os.path.join(hist, time.strftime("%Y%m%d-%H%M%S") + ".xml") + shutil.copyfile(xml, xml_backup) + os.replace(atem_file, xml) + site.load() + self.step("save", "ok", "kept as %s" % entry) + # 3. restart the watchdog + if plan["restart"] or atem_file: + self.step("restart", "running") + ok, detail = self.restart_watchdog(sha_after) + if not ok: + self.step("restart", "failed", detail) + self._rollback(entry, xml_backup, plan, old_cfg, detail) + return + self.step("restart", "ok", detail) + else: + self.step("restart", "skipped", "nothing the watchdog reads changed") + # 4. pages + if plan["pages"]: + self.step("pages", "running") + ok, detail = self.import_pages(new_cfg, [p for p in plan["pages"] if p in ("status", "restart")]) + if not ok: + self.step("pages", "failed", detail) + sp.write_json(os.path.join(site.data_dir, "pages-out-of-sync.json"), {"when": time.time(), "detail": detail}) + self._rollback(entry, xml_backup, plan, old_cfg, "the pages could not be imported: " + detail) + return + sp.write_json(os.path.join(site.data_dir, "build-hashes.json"), plan["hashes_after"]) + try: + os.remove(os.path.join(site.data_dir, "pages-out-of-sync.json")) + except OSError: + pass + self.step("pages", "ok", detail) + else: + self.step("pages", "skipped", "the deck's layout did not change") + if plan["nav"]: + self.step("page keys", "running") + ok, detail = self.colour_nav_keys(new_cfg) + self.step("page keys", "ok" if ok else "failed", detail) + self.status["state"], self.status["outcome"] = "done", "Applied. " + ("The watchdog restarted. " if plan["restart"] else "") + \ + ("Pages re-imported. " if plan["pages"] else "") + self.log("applied by %s (%s): %s" % (by, address, note)) + + def _rollback(self, entry, xml_backup, plan, old_cfg, why): + """Put the previous version back, once.""" + site = self.site + self.step("rollback", "running", why) + src = os.path.join(self.history_dir(), entry + ".json") + shutil.copyfile(src, site.config_path + ".tmp") + os.replace(site.config_path + ".tmp", site.config_path) + if xml_backup: + shutil.copyfile(xml_backup, os.path.join(site.data_dir, "atem-reference.xml")) + site.load() + ok, detail = self.restart_watchdog(site.cfg_sha) + pages_note = "" + if ok and plan.get("pages"): + pok, pdetail = self.import_pages(old_cfg, [p for p in plan["pages"] if p in ("status", "restart")]) + pages_note = "; pages put back" if pok else "; the pages could not be put back: " + pdetail + self.step("rollback", "ok" if ok else "failed", ("previous settings restored, " + detail + pages_note) if ok else detail) + self.status["state"] = "failed" + self.status["outcome"] = "Not applied - %s. %s" % (why, "The previous settings are back." if ok else "The previous settings could not be restarted either: " + detail) + sp.write_json(os.path.join(site.data_dir, "last-apply-failed.json"), {"when": time.time(), "why": why, "restored": ok}) + for e in (entry,): + meta_path = os.path.join(self.history_dir(), e + ".meta.json") + try: + meta = json.load(open(meta_path)) + meta["outcome"] = "failed, rolled back: " + why + sp.write_json(meta_path, meta) + except (OSError, ValueError): + pass + + +def history_entries(data_dir): + """Newest first: the Apply copies plus the installer's config.json.bak-* files.""" + out = [] + hist = os.path.join(data_dir, "history") + if os.path.isdir(hist): + for name in sorted(os.listdir(hist), reverse=True): + if not name.endswith(".json") or name.endswith(".meta.json"): + continue + base = name[:-5] + try: + meta = json.load(open(os.path.join(hist, base + ".meta.json"))) + except (OSError, ValueError): + meta = {} + out.append({"id": base, "when": meta.get("when"), "by": meta.get("by", "?"), "note": meta.get("note", ""), + "outcome": meta.get("outcome", ""), "actions": meta.get("actions", []), "kind": "apply"}) + for name in sorted(os.listdir(data_dir), reverse=True): + m = re.match(r"config\.json\.bak-(\d{8})-(\d{6})$", name) + if m: + t = time.mktime(time.strptime(m.group(1) + m.group(2), "%Y%m%d%H%M%S")) + out.append({"id": name, "when": t, "by": "the installer", "note": "config.json replaced by install.sh", "outcome": "", "actions": [], "kind": "install"}) + out.sort(key=lambda e: e["when"] or 0, reverse=True) + return out + + +def history_config(data_dir, entry_id): + path = os.path.join(data_dir, "history", entry_id + ".json") if not entry_id.startswith("config.json.bak-") else os.path.join(data_dir, entry_id) + if not os.path.realpath(path).startswith(os.path.realpath(data_dir)): + return None + try: + return json.load(open(path)) + except (OSError, ValueError): + return None diff --git a/settings/server.py b/settings/server.py index fa6a719..822de1d 100644 --- a/settings/server.py +++ b/settings/server.py @@ -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'', 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 " %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: diff --git a/settings/ui/app.js b/settings/ui/app.js index bc0424a..286c47b 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -1,119 +1,275 @@ -/* The settings page, step 1: every screen, read-only. Plain JS, no framework. */ +/* The settings page. Plain JS, no framework. Everything is readable; with Editing on (the editing + code + your first name) the same screens grow inputs; changes live in one shared draft on the server + until Review & Apply. */ (function () { "use strict"; const main = document.getElementById("main"); - const state = { config: null, schema: null, keys: null, editing: null, name: null }; + const state = { live: null, liveSha: null, schema: null, draft: null, draftStale: false, atemPending: false, editing: null, name: null, saveTimer: null }; const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); - const api = async (path, opts) => { - const r = await fetch(path, opts); - if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText); - return r.json(); - }; - const post = (path, body) => api(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) }); const ago = (s) => s == null ? "" : s < 90 ? Math.round(s) + " s ago" : s < 5400 ? Math.round(s / 60) + " min ago" : Math.round(s / 3600) + " h ago"; const when = (t) => t ? new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) : ""; const hex = (n) => "#" + (n >>> 0).toString(16).padStart(6, "0").slice(-6); const lightDot = (l) => ``; const wordOf = (w) => `${esc(w)}`; + const deep = (o) => JSON.parse(JSON.stringify(o)); + const canon = (v) => Array.isArray(v) ? "[" + v.map(canon).join(",") + "]" : v && typeof v === "object" ? "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canon(v[k])).join(",") + "}" : JSON.stringify(v); + const cfg = () => (state.draft ? state.draft.config : state.live); + const editing = () => !!state.editing; + const store = { get: (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch (e) {} } }; + const closes = (f) => /pkill|taskkill|killall/.test(f.run || "") && !f.restart_ok; - // ---- top bar ------------------------------------------------------------- + async function api(path, opts) { + opts = opts || {}; + const headers = Object.assign({ "Content-Type": "application/json" }, opts.headers || {}); + if (state.editing) { headers["X-Edit-Key"] = state.editing; headers["X-Editor"] = state.name || ""; } + const r = await fetch(path, Object.assign({}, opts, { headers })); + let body = null; + try { body = await r.json(); } catch (e) { body = {}; } + if (!r.ok) { const err = new Error(body.error || r.statusText); err.status = r.status; err.body = body; throw err; } + return body; + } + const post = (path, body, method) => api(path, { method: method || "POST", body: JSON.stringify(body || {}) }); + + // ---- top bar: legend, editing code ---------------------------------------- document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; }; const editbtn = document.getElementById("editbtn"); + function showEditing() { + editbtn.textContent = state.editing ? "Editing as " + state.name : "Editing: off"; + editbtn.classList.toggle("on", !!state.editing); + document.body.classList.toggle("editing", !!state.editing); + } + state.editing = store.get("sp-code"); state.name = store.get("sp-name"); + showEditing(); editbtn.onclick = () => { - if (state.editing) { state.editing = null; editbtn.textContent = "Editing: off"; editbtn.classList.remove("on"); return; } - dialog(`
The editing code is in the campus's settings file in the repo; ask Chris. In this step nothing can be changed yet: the switch only proves the code works from here.
+ if (state.editing) { state.editing = null; state.name = null; store.set("sp-code", null); store.set("sp-name", null); showEditing(); route(); return; } + dialog(`The editing code is in the campus's settings file in the repo; ask Chris.
`, (box) => { box.querySelector("#dcancel").onclick = closeDialog; - box.querySelector("#dok").onclick = async () => { + const go = async () => { const code = box.querySelector("#dcode").value, name = box.querySelector("#dname").value.trim(); const r = await post("/api/editing/check", { code }); - if (r.ok && name) { state.editing = code; state.name = name; editbtn.textContent = "Editing as " + name; editbtn.classList.add("on"); closeDialog(); } + if (r.ok && name) { state.editing = code; state.name = name; store.set("sp-code", code); store.set("sp-name", name); showEditing(); closeDialog(); route(); } else box.querySelector("#dmsg").textContent = r.locked_for ? `Locked for ${Math.ceil(r.locked_for / 60)} minutes after too many tries.` : r.no_code ? "No editing code is set in the config yet." : !name ? "Your first name, please." : `Not the code (${r.tries_left} tries left).`; }; + box.querySelector("#dok").onclick = go; + box.querySelector("#dname").onkeydown = (e) => { if (e.key === "Enter") go(); }; + box.querySelector("#dcode").focus(); }); }; function dialog(html, wire) { const d = document.getElementById("dialog"); d.innerHTML = `${esc(msg)}
`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); } + function confirmBox(msg, label, onOk, word) { + dialog(`${esc(msg)}
${word ? `` : ""}`, (b) => { + b.querySelector("#dcancel").onclick = closeDialog; + b.querySelector("#dok").onclick = () => { if (word && b.querySelector("#dword").value.trim() !== word) return; closeDialog(); onOk(); }; + }); + } - // ---- search -------------------------------------------------------------- + // ---- 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; + document.getElementById("sitename").textContent = (state.live.site || {}).name || ""; + badge(); + } + function ensureDraft() { + if (!editing()) { alertBox("Switch Editing on first (top right)."); return false; } + if (!state.draft) state.draft = { config: deep(state.live), version: null, by: state.name }; + return true; + } + function changed() { + if (!state.draft) return 0; + let n = 0; + const walk = (a, b) => { + if (Array.isArray(a) && Array.isArray(b)) { if (canon(a) !== canon(b)) n++; return; } + if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) { for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) walk(a[k], b[k]); return; } + if (canon(a) !== canon(b)) n++; + }; + walk(state.live, state.draft.config); + return n + (state.atemPending ? 1 : 0); + } + function badge() { + let b = document.getElementById("badge"); + if (!b) { b = document.createElement("a"); b.id = "badge"; b.className = "badge"; b.href = "#review"; document.querySelector(".topright").prepend(b); } + const n = changed(); + b.hidden = !n && !state.draft; + b.textContent = n ? `${n} change${n > 1 ? "s" : ""} – Review & Apply` : "Draft – Review"; + b.classList.toggle("warn", !!state.draftStale); + } + function saveDraft() { + badge(); + clearTimeout(state.saveTimer); + state.saveTimer = setTimeout(async () => { + try { + const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT"); + state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by; + } catch (e) { + if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); } + else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); } + else alertBox("Could not save the draft: " + e.message); + } + }, 400); + } + // set a value at a dotted path in the draft (creating objects on the way) + function setPath(path, value) { + if (!ensureDraft()) return; + const parts = path.split("."); let o = state.draft.config; + for (let i = 0; i < parts.length - 1; i++) { if (o[parts[i]] == null || typeof o[parts[i]] !== "object") o[parts[i]] = {}; o = o[parts[i]]; } + if (value === undefined) delete o[parts[parts.length - 1]]; else o[parts[parts.length - 1]] = value; + saveDraft(); + } + const getPath = (path, from) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), from || cfg()); + + // ---- generic inputs from the registry ----------------------------------- + function parse(f, raw) { + if (f.type === "int") { const n = parseInt(raw, 10); return isNaN(n) ? undefined : n; } + if (f.type === "float") { const n = parseFloat(raw); return isNaN(n) ? undefined : n; } + if (f.type === "bool") return !!raw; + if (f.type === "list") return raw.split(",").map((s) => s.trim()).filter(Boolean); + if (f.type === "dict") { try { return raw.trim() ? JSON.parse(raw) : undefined; } catch (e) { return undefined; } } + return raw === "" ? undefined : raw; + } + function inputHtml(path, f, value, extra) { + if (!f) return ""; + const id = "f-" + path.replace(/[^A-Za-z0-9]/g, "-"); + const dis = editing() ? "" : "disabled"; + let html; + if (f.type === "bool") html = ``; + else if (f.type === "enum" && f.options) html = ``; + else if (f.type === "dict") html = ``; + else if (f.type === "list") html = ``; + else html = ``; + const dflt = f.default != null && typeof f.default !== "object" && f.default !== "" ? ` (default ${esc(f.default)})` : ""; + return `Could not load this screen: ${esc(e.message)}
`; } - if (name === "overview" || name === "computers") timer = setInterval(() => fn(rest, true).catch(() => {}), 5000); + try { + if (!state.live) await loadAll(); + const fn = screens[name] || overview; + await fn(rest); + wireInputs(main); + const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0])); // never under a typist's hands + if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000); + } catch (e) { main.innerHTML = `Could not load this screen: ${esc(e.message)}
`; } } window.addEventListener("hashchange", route); route(); - // ---- Overview ------------------------------------------------------------ + // ---- Overview -------------------------------------------------------------- async function overview(_, quiet) { + if (quiet && document.activeElement && document.activeElement.tagName === "INPUT") return; // never pull the field out from under a typist const o = await api("/api/overview"); const dot = document.getElementById("hbdot"); dot.className = "dot " + (o.heartbeat_age != null && o.heartbeat_age < 15 ? "ok" : "bad"); - const prob = o.problems.length ? o.problems.map((p) => `Is the room OK, and what do I do. Problems mirror the red keys; hold the key, or open it for the details.
-Click a key to see what it watches and what holding it does.
`; - if (sel && !restart) { - const t = k.tiles.find((x) => x.id === sel); - if (t) panel = keyPanel(t, k); - } else if (sel && restart) { - const r = k.restart_page.keys.find((x) => x.id === sel); - if (r) panel = `row ${r.pos[0]}, column ${r.pos[1]}
HOLDING ${esc(k.hold.replace("HOLD ", ""))} WILL: ${esc(r.holding)}, even when the Status page says it is fine.
${r.watch ? `Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}
` : ""}Editing arrives with step 2.
The deck as people hold it. In this step you can look; moving and editing keys arrive with steps 2 and 3.
${tabs} -Shown inside their group, no key of their own: ${tray}
` : ""}Click a key to see what it watches and what holding it does.${editing() ? " With Editing on you can change it here." : ""}
`; + if (sel && !restart) { const i = c.tiles.findIndex((x) => x.id === sel); if (i >= 0) panel = await keyPanel(i, hold); } + else if (sel && restart && c.restart_page) { const i = c.restart_page.keys.findIndex((x) => x.id === sel); if (i >= 0) panel = restartKeyPanel(i, hold); } + const tray = c.tiles.filter((t) => !t.pos && t.kind !== "master").map((t) => ``).join(" "); + main.innerHTML = `The deck as people hold it. Click a key; with Editing on, change what it watches and what holding it does (moving keys arrives with step 3).
${tabs} +Shown inside their group, no key of their own: ${tray}
` : ""}Sums up: ${t.members.map((m) => `${esc((cfg.tiles.find((x) => x.id === m) || {}).label || m)}`).join(", ")}
` : ""; + async function keyPanel(i, hold) { + const c = cfg(), t = c.tiles[i]; + const k = await api("/api/keys"); + const kt = k.tiles.find((x) => x.id === t.id) || { says: [], holding: "", checks: [] }; + const fixOptions = (cur) => `${Object.entries(c.fixes).map(([id, f]) => ``).join("")}`; + const checks = (t.checks || []).map((chk, j) => { + const spec = state.schema.checks[chk.type] || { fields: [], question: "" }; + const sentence = (kt.checks[j] || {}).sentence || chk.type; + if (!editing()) return `type ${esc(chk.type)}
Sums up: ${t.members.map((m) => `${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}`).join(", ")}
`) : ""; + const addMenu = editing() && plain ? `` : ""; + const label = editing() ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : ""; + const holdAlways = editing() && plain ? `` : ""; + const pos = `${t.pos ? `row ${t.pos[0]}, column ${t.pos[1]}` : "inside its group"}${t.kind ? ` · ${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}` : ""}
- ${t.says && t.says.length ? `WHAT THIS KEY WILL SAY: ${esc(t.says.join(", "))}
` : ""} -HOLDING ${esc(k.hold.replace("HOLD ", ""))} WILL: ${esc(t.holding)}${t.hold_always ? " also when green" : ""}
- ${members}${checks ? `Checks:
Editing arrives with step 2.
WHAT THIS KEY WILL SAY: ${esc(kt.says.join(", "))}
` : ""} +HOLDING ${esc(hold)} WILL: ${esc(kt.holding)}${t.hold_always ? " also when green" : ""}
+ ${label}${members}${holdAlways}${checks ? `Checks:
row ${r.pos[0]}, column ${r.pos[1]}
HOLDING ${esc(hold)} WILL: ${esc(f.label || r.fix)}, even when the Status page says it is fine.
${r.watch && r.watch.length ? `Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}
` : ""}${edit} + ${canRunNow(r.fix) && editing() ? `` : ""}| status | computer | what it does for the panel | keys that turn red if it dies |
|---|
What runs where. One row per computer; click it for everything that runs there, with a light each.
${tabs}${body}`; const pb = document.getElementById("probe"); - if (pb) pb.onclick = async () => { pb.disabled = true; pb.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => computers(rest, true), 4000); }; + if (pb) pb.onclick = async () => { pb.disabled = true; pb.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => computers(rest, true).then(() => wireInputs(main)), 4000); }; + for (const b of main.querySelectorAll("[data-gear]")) b.onclick = async () => { const row = b.closest("tr"); try { await post("/api/gear", { name: b.dataset.gear, location: row.querySelector("[data-loc]").value, notes: row.querySelector("[data-notes]").value }); const d = await api("/api/draft"); state.draft = d.draft; badge(); b.textContent = "Saved"; } catch (e) { alertBox(e.message); } }; + const runs = main.querySelector("#runs"); if (runs) runs.onchange = () => { if (!ensureDraft()) return; try { state.draft.config.hosts[selected].runs = runs.value.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l)); saveDraft(); } catch (err) { alertBox("Each line must be a valid {...} entry."); } }; } - function computerCard(c) { - const items = c.items.map((i) => `${esc(k)}: ${esc(typeof v[0] === "object" ? v.map((x) => x.iface + " " + x.ip + (x.active ? "" : " (down)")).join(", ") : v.join(", "))}
`).join("") || `Press "Check computers now" to look.
`; const done = Object.entries(c.manual_done || {}).map(([k, v]) => `${esc(c.role)}${c.ssh ? ` · logs in as ${esc(c.ssh)}` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "no answer"}` : ""}${c.error ? ` · ${esc(c.error)}` : ""}
${c.notes ? `${esc(c.notes)}
` : ""}Devices that are not computers, from the keys that watch them. Where each one lives is typed once on this list (step 2).
| light | device | kind | address | where / notes | watched by |
|---|---|---|---|---|---|
| ${lightDot(r.light)}${esc(r.light === "ok" ? "OK" : r.light === "problem" ? "Problem" : "–")} | ${esc(r.name)} | ${esc(r.kind)} | ${esc(r.address || "")}${r.detail ? ` ${esc(r.detail)}` : ""} | ${esc(r.location || "")} ${esc(r.notes || "")} ${esc(r.reason || "")} | ${r.watched_by.map((k) => `${esc((state.config.tiles.find((t) => t.id === k) || { label: k }).label)}`).join(", ")} |
Devices that are not computers, from the keys that watch them.${editing() ? " Type where each one lives and press Save." : ""}
| light | device | kind | address | where / notes | watched by |
|---|---|---|---|---|---|
| ${lightDot(r.light)}${esc(r.light === "ok" ? "OK" : r.light === "problem" ? "Problem" : "–")} | ${esc(r.name)} | ${esc(r.kind)} | ${esc(r.address || "")}${r.detail ? ` ${esc(r.detail)}` : ""} | ${editing() ? ` ` : `${esc(r.location || "")} ${esc(r.notes || "")}`} ${esc(r.reason || "")} | ${(r.watched_by || []).map((k) => `${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}`).join(", ")} |
${(data.files || []).slice().reverse().map((f) => `${esc(f.replace(" Auto-Fix.log", ""))}`).join(" · ") || "no auto-fix days yet"}
` : ""; main.innerHTML = `What the watchdog did and saw. Newest lines at the bottom.
${esc((data.lines || []).join("\n")) || "(empty)"}`;
@@ -242,75 +441,201 @@
const pre = main.querySelector("pre.log"); pre.scrollTop = pre.scrollHeight;
}
- // ---- History -------------------------------------------------------------
- async function history() {
+ // ---- History ----------------------------------------------------------------
+ async function history(rest) {
const h = await api("/api/history");
- main.innerHTML = `Every version of the settings that was ever applied. ${esc(h.note)}
| when | file | size |
|---|---|---|
| ${esc(e.when)} | ${esc(e.file)} | ${esc(e.size)} |
| nothing yet | ||
What is different between that version and now:
${d.changes_to_now.length ? `Nothing: it is the same as now.
"} + ${editing() ? ` It becomes your draft; Review & Apply puts it live.` : `Switch Editing on to go back to it.
`}Every version of the settings that was ever applied: who, when, the note, what was different.
+| when | who | note | |
|---|---|---|---|
| nothing yet | |||
Nothing to apply: there is no draft. Change something on any screen with Editing on.
${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`; + return; + } + const r = await post("/api/validate", {}); + const changes = r.changes.map((c) => `Started by ${esc(state.draft.started_by || state.draft.by || "?")}${state.draft.started ? " at " + esc(when(state.draft.started)) : ""}. ${state.draftStale ? "The live settings changed since this draft was started: look again before applying." : ""}
+No change yet.
"} +What will happen: ${esc(r.feel)}
+ ${errors ? `${esc(r.feel)} Say why, and type LIVE.
`, (b) => { b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = () => { const why = b.querySelector("#why").value.trim(), word = b.querySelector("#word").value.trim(); if (word !== "LIVE" || !why) return; closeDialog(); go({ word, reason: why })(); }; }); + main.querySelector("#discard").onclick = () => confirmBox("Throw the draft away? Every change since the last Apply is lost.", "Throw it away", async () => { try { await post("/api/draft", {}, "DELETE"); state.draft = null; state.atemPending = false; badge(); review(); } catch (e) { alertBox(e.message); } }); + } + function applyProgress(st) { + main.innerHTML = `${esc(st.note || "")}
${stepsHtml(st)}This page updates by itself.
`; + setTimeout(async () => { try { const s2 = await api("/api/apply/status"); if (s2.state === "running") applyProgress(s2); else { await loadAll(); route(); } } catch (e) { setTimeout(() => applyProgress(st), 2000); } }, 1500); + } + function stepsHtml(st) { return `${esc(st.outcome)}
${stepsHtml(st)}${b}
${esc(words.join(" · "))}
Start with the four cards. The pages below are made from the settings, so they are never out of date.
What each key does · Cheat sheet (one page) · Where things are
+Set up once by the person who installed Companion. You normally only come here to see which connection a key watches.
Keys watch connections Companion does not have: ${esc(c.missing.join(", "))}
` : ""} -Page-number buttons coloured on ${c.page_number_buttons.pages_coloured} pages (${esc(c.page_number_buttons.recorded_in)}).
-| connection | module | status | watched by |
|---|
Only numbers here; they sit under Advanced.
"}${adv.length ? `Set up once. Each section opens on the few things a person changes; the numbers sit under Advanced.${editing() ? "" : " Switch Editing on to change anything."}
none
"}${editing() ? `` : ""}Set up once by the person who installed Companion. You normally only come here to see which connection a key watches.
Keys watch connections Companion does not have: ${esc(cc.missing.join(", "))}
` : ""} + ${cc.page_number_buttons ? `Page-number buttons coloured on ${cc.page_number_buttons.pages_coloured} pages (${esc(cc.page_number_buttons.recorded_in)}).
` : ""} +| connection | module | status | watched by |
|---|
| Dante name | key text | address | switch / port | PoE | when it is gone, hold runs |
|---|
Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.${editing() ? ` ` : ""}
+ ${editing() ? `The panel keeps the switcher's audio and camera settings in line with this file (atem-reference.xml). Save a new one from ATEM Software Control when the show changes.
${pending} + ${editing() ? `` : ""} +Folder: ${esc(folder.folder || "(not set)")} ${folder.mounted ? `· newest: ${(folder.files || []).length ? esc(folder.files[0].name) + " (" + esc(when(folder.files[0].when)) + ")" : "none"}` : "· not mounted"}
| Dante name | key text | address | switch / port | PoE |
|---|---|---|---|---|
| ${esc(n)} | ${esc(a)} | ${esc(e.ip || "")} | ${e.switch ? esc(e.switch + " port " + e.port) : ""} | ${e.poe ? "yes" : ""} |
Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.
${(cfg.auto_fix.fixes || []).map((f) => `${esc((cfg.fixes[f] || {}).short || f)}`).join(" ")}
Set up once. Each section opens on the few things a person changes; the numbers sit under Advanced. Editing arrives with step 2.
${esc(f.label || "")}
Builders (fixes made from fields instead of commands) arrive with step 2.
Everything a hold can run. ${Object.keys(cfg.fixes).length} today.
| key text | plain words | built as | used by | by itself |
|---|
${esc(f.label || "")}
${kindSel}${inputs}${canRunNow(id) && editing() ? `Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).
` : ""}Everything a hold can run. ${Object.keys(c.fixes).length} today.
${add}| key text | plain words | built as | used by | by itself |
|---|
${esc(msg)}
`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); } })(); diff --git a/settings/ui/style.css b/settings/ui/style.css index 37fc2ca..4cfbe50 100644 --- a/settings/ui/style.css +++ b/settings/ui/style.css @@ -73,3 +73,21 @@ pre.log { background: var(--deck); color: #d6dbe4; padding: 10px 12px; border-ra .menu .divider { border: 0; margin: 0 6px; padding: 8px 0; } main { padding: 14px 16px 40px; } .searchresults { left: 16px; } .top { flex-wrap: wrap; } #search { order: 3; flex-basis: 100%; } } +/* step 2: editing */ +.badge { display: inline-block; padding: 4px 10px; border-radius: 999px; background: var(--accent); color: #fff; text-decoration: none; font-weight: 600; font-size: .88rem; white-space: nowrap; } +.badge.warn { background: var(--warn); color: #1d2420; } +button.toggle.on { background: var(--accent); color: #fff; } +button.danger { border-color: var(--problem); color: var(--problem); } button.primary.danger { background: var(--problem); color: #fff; } +button.small { padding: 3px 9px; font-size: .85rem; } +label.inline { display: inline-flex; align-items: center; gap: 4px; margin: 2px 10px 2px 0; color: var(--fg); font-size: .92rem; } +label.btn { display: inline-block; cursor: pointer; border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 6px 12px; } +.field input:not([type=checkbox]), .field select, .field textarea { width: 100%; max-width: 520px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--fg); font: inherit; } +.field textarea.mono, textarea.mono { font-family: var(--mono); font-size: .85rem; } +.field input[disabled], .field select[disabled], .field textarea[disabled] { opacity: .75; border-style: dashed; } +.field input.hhmm { width: 5.5em; display: inline-block; } +.window { margin: 4px 0; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; } +ol.checks li { margin: 8px 0; } .checkbox { padding: 6px 10px; margin: 4px 0; border-left: 3px solid var(--line); } +ul.changes li.command { font-family: var(--mono); font-size: .9em; color: var(--problem); } +.notice.problem { background: #fdecea; color: #7a1d16; border: 1px solid var(--problem); } +ol.steps { padding-left: 18px; } ol.steps li { margin: 3px 0; } +tr.sel { background: var(--accent-soft); } diff --git a/tests/test_apply.py b/tests/test_apply.py new file mode 100644 index 0000000..a3b7950 --- /dev/null +++ b/tests/test_apply.py @@ -0,0 +1,228 @@ +"""Saving a change, offline: the shared draft, what Apply will do, the pipeline against a temp data +dir with a fake launchctl and a fake Companion, rollback when the watchdog does not come back, the +recording guard with its Emergency override, history and Go back.""" +import json +import os +import sys +import tempfile +import threading +import time +import unittest +from unittest import mock + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import status_panel as sp # noqa: E402 +from settings import apply as ap # noqa: E402 +from settings import server # noqa: E402 +from test_status_panel import CONFIG # noqa: E402 + + +class FakeLaunchctl: + """Records calls; a kickstart or bootstrap "starts" the watchdog by writing started.json + (with the sha of the config on disk) unless told to fail.""" + + def __init__(self, data_dir, fail=False): + self.data_dir, self.fail, self.calls, self.pid = data_dir, fail, [], 100 + + def __call__(self, argv, **kw): + self.calls.append(argv) + + class R: + returncode, stdout, stderr = 0, "", "" + r = R() + if argv[1] == "print": + r.stdout = "state = running\n\tpid = %d\n" % self.pid + elif argv[1] in ("kickstart", "bootstrap"): + if not self.fail: + self.pid += 1 + cfg_sha = ap.sha(open(os.path.join(self.data_dir, "config.json"), "rb").read()) + sp.write_json(os.path.join(self.data_dir, "started.json"), {"pid": self.pid, "started_at": time.time(), "config_sha": cfg_sha, "version": "test"}) + return r + + +class TestDraftAndPlan(unittest.TestCase): + def setUp(self): + self.data = tempfile.mkdtemp(prefix="sp-apply-") + self.cfg = json.loads(json.dumps(CONFIG)) + self.cfg["companion"] = {"host": "127.0.0.1", "port": 1} + with open(os.path.join(self.data, "config.json"), "wb") as f: + f.write(ap.config_text(self.cfg)) + + def test_draft_is_shared_and_never_overwritten_silently(self): + d = ap.Draft(self.data) + a, _ = d.update({"x": 1}, "sam", "sha1") + self.assertEqual(oct(os.stat(d.path).st_mode)[-3:], "600") + b, conflict = d.update({"x": 2}, "chris", "sha1", expect=a["version"]) + self.assertIsNone(conflict) + _, conflict = d.update({"x": 3}, "sam", "sha1", expect=a["version"]) # sam's tab is behind + self.assertEqual(conflict["by"], "chris") + self.assertEqual(d.read()["config"], {"x": 2}) + + def test_what_apply_does(self): + hashes = ap.build_hashes(self.cfg) + same = json.loads(json.dumps(self.cfg)) + self.assertEqual(ap.what_apply_does(self.cfg, same, hashes)["changes"], []) + notes = json.loads(json.dumps(self.cfg)) + notes["gear"] = {"UHDJr": {"location": "lobby rack"}} + plan = ap.what_apply_does(self.cfg, notes, hashes) + self.assertFalse(plan["restart"]) # page-only data + self.assertEqual(plan["pages"], []) + timing = json.loads(json.dumps(self.cfg)) + timing["debounce_seconds"] = 4 + self.assertTrue(ap.what_apply_does(self.cfg, timing, hashes)["restart"]) + self.assertEqual(ap.what_apply_does(self.cfg, timing, hashes)["pages"], []) + moved = json.loads(json.dumps(self.cfg)) + next(t for t in moved["tiles"] if t["id"] == "led")["pos"] = [2, 8] + plan = ap.what_apply_does(self.cfg, moved, hashes) + self.assertEqual(plan["pages"], ["status"]) + self.assertTrue(plan["nav"]) + label = json.loads(json.dumps(self.cfg)) + label["fixes"]["vista"]["label"] = "start Vista please" # only a key note changes + self.assertEqual(ap.what_apply_does(self.cfg, label, hashes)["pages"], []) + + def test_words(self): + after = json.loads(json.dumps(self.cfg)) + hd2 = next(t for t in after["tiles"] if t["id"] == "hd2") + hd2["checks"][0]["optional"] = False + after["site"]["editing_code"] = "new words" + after["hosts"]["lighting"]["notes"] = "keep it Public" + texts = [ap.words_for(p, a, b, self.cfg, after)["text"] for p, a, b in ap.changed_paths(self.cfg, after)] + self.assertIn("key HD2 › check 1 › optional: yes → no", texts) + self.assertIn("computer Lighting PC › notes: Keep the network profile on Public: rtpMIDI's Bonjour rules only allow Public. → keep it Public", texts) + self.assertTrue(any(t == "site › editing code changed" for t in texts), texts) # a secret: never its value + + +class TestPipeline(unittest.TestCase): + def setUp(self): + self.data = tempfile.mkdtemp(prefix="sp-pipe-") + self.cfg = json.loads(json.dumps(CONFIG)) + self.cfg["companion"] = {"host": "127.0.0.1", "port": 1} + with open(os.path.join(self.data, "config.json"), "wb") as f: + f.write(ap.config_text(self.cfg)) + open(os.path.join(self.data, "atem-reference.xml"), "w").write("