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
+1 -1
View File
@@ -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:
+31
View File
@@ -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])
+29
View File
@@ -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])
+22
View File
@@ -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 {}
+548
View File
@@ -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
+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:
+455 -130
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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) => `<i class="light ${esc(l || "unknown")}"></i>`;
const wordOf = (w) => `<span class="word ${esc((w || "").split(" ")[0])}">${esc(w)}</span>`;
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(`<h3>Switch Editing on</h3><p class="help">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.</p>
if (state.editing) { state.editing = null; state.name = null; store.set("sp-code", null); store.set("sp-name", null); showEditing(); route(); return; }
dialog(`<h3>Switch Editing on</h3><p class="help">The editing code is in the campus's settings file in the repo; ask Chris.</p>
<input id="dcode" placeholder="editing code" autocomplete="off"><input id="dname" placeholder="your first name" autocomplete="off">
<div class="row"><button class="primary" id="dok">Switch on</button><button id="dcancel">Cancel</button></div><p id="dmsg" class="help"></p>`, (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 = `<div class="box">${html}</div>`; d.hidden = false; wire(d.querySelector(".box")); }
function closeDialog() { document.getElementById("dialog").hidden = true; }
function alertBox(msg) { dialog(`<p>${esc(msg)}</p><button id="dok" class="primary">OK</button>`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); }
function confirmBox(msg, label, onOk, word) {
dialog(`<p>${esc(msg)}</p>${word ? `<input id="dword" placeholder="type ${esc(word)} to confirm" autocomplete="off">` : ""}<div class="row"><button class="primary" id="dok">${esc(label)}</button><button id="dcancel">Cancel</button></div>`, (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 = `<input type="checkbox" id="${id}" data-path="${esc(path)}" ${value ? "checked" : ""} ${dis}>`;
else if (f.type === "enum" && f.options) html = `<select id="${id}" data-path="${esc(path)}" ${dis}><option value="">–</option>${f.options.map((o) => `<option ${value === o ? "selected" : ""}>${esc(o)}</option>`).join("")}</select>`;
else if (f.type === "dict") html = `<textarea id="${id}" data-path="${esc(path)}" rows="2" ${dis}>${esc(value == null ? "" : JSON.stringify(value))}</textarea>`;
else if (f.type === "list") html = `<input id="${id}" data-path="${esc(path)}" value="${esc(Array.isArray(value) ? value.join(", ") : value || "")}" ${dis}>`;
else html = `<input id="${id}" data-path="${esc(path)}" type="${f.type === "int" || f.type === "float" ? "number" : f.secret ? "password" : "text"}" value="${esc(value == null ? "" : value)}" ${f.lo != null ? `min="${f.lo}"` : ""} ${f.hi != null ? `max="${f.hi}"` : ""} ${f.type === "float" ? 'step="any"' : ""} placeholder="${esc(f.default == null || typeof f.default === "object" ? "" : f.default)}" ${dis}>`;
const dflt = f.default != null && typeof f.default !== "object" && f.default !== "" ? ` <span class="muted">(default ${esc(f.default)})</span>` : "";
return `<div class="field"><label for="${id}">${esc(path.split(".").pop().replace(/_/g, " "))}${f.required ? " *" : ""}</label>${html}<div class="help">${esc(f.help)}${dflt}${extra || ""}</div></div>`;
}
function wireInputs(root) {
for (const el of root.querySelectorAll("[data-path]")) {
if (el.onchange) continue; // a screen wired it itself
el.onchange = () => {
const path = el.dataset.path, f = fieldFor(path);
const v = el.type === "checkbox" ? el.checked : el.value;
setPath(path, f ? parse(f, v) : v);
};
}
}
function fieldFor(path) {
const F = state.schema.fields;
if (F[path]) return F[path];
const generic = path.replace(/^tiles\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^tiles\.\d+\./, "tiles[].")
.replace(/^restart_page\.keys\.\d+\./, "restart_page.keys[].").replace(/^fixes\.[^.]+\./, "fixes.*.").replace(/^hosts\.[^.]+\./, "hosts.*.")
.replace(/^gear\.[^.]+\./, "gear.*.").replace(/^(auto_fix\.windows|site\.live_services|service_windows)\.\d+\./, "$1[].");
return F[generic] || null;
}
const F = (name) => state.schema.fields[name];
// ---- search ----------------------------------------------------------------
const search = document.getElementById("search"), results = document.getElementById("searchresults");
search.oninput = () => {
const q = search.value.trim().toLowerCase();
if (!q || !state.config) { results.hidden = true; return; }
const hits = [];
const cfg = state.config;
for (const t of cfg.tiles) if ((t.label + " " + t.id).toLowerCase().includes(q)) hits.push([`Key ${t.label}`, `#keys/${t.id}`]);
for (const k of (cfg.restart_page || { keys: [] }).keys) if (k.label.toLowerCase().includes(q)) hits.push([`Restart key ${k.label}`, `#keys/restart/${k.id}`]);
for (const [a, h] of Object.entries(cfg.hosts || {})) if ((h.name || a).toLowerCase().includes(q) || (h.role || "").toLowerCase().includes(q)) hits.push([`Computer ${h.name || a}`, `#computers/${a}`]);
for (const [id, f] of Object.entries(cfg.fixes || {})) if ((id + " " + (f.label || "") + " " + (f.short || "")).toLowerCase().includes(q)) hits.push([`Fix ${f.short || id}: ${f.label || ""}`, `#fixes/${id}`]);
for (const [path, f] of Object.entries(state.schema ? state.schema.fields : {})) if (!path.includes("[]") && !path.includes("*") && (path + " " + f.help).toLowerCase().includes(q)) hits.push([`Setting ${path}`, `#site/${f.screen.toLowerCase().replace(/[^a-z]+/g, "-")}`]);
if (!q || !cfg()) { results.hidden = true; return; }
const c = cfg(), hits = [];
c.tiles.forEach((t) => { if ((t.label + " " + t.id).toLowerCase().includes(q)) hits.push([`Key ${t.label}`, `#keys/${t.id}`]); });
for (const k of (c.restart_page || { keys: [] }).keys) if (k.label.toLowerCase().includes(q)) hits.push([`Restart key ${k.label}`, `#keys/restart/${k.id}`]);
for (const [a, h] of Object.entries(c.hosts || {})) if ((h.name || a).toLowerCase().includes(q) || (h.role || "").toLowerCase().includes(q)) hits.push([`Computer ${h.name || a}`, `#computers/${a}`]);
for (const [id, f] of Object.entries(c.fixes || {})) if ((id + " " + (f.label || "") + " " + (f.short || "")).toLowerCase().includes(q)) hits.push([`Fix ${f.short || id}: ${f.label || ""}`, `#fixes/${id}`]);
for (const [path, f] of Object.entries(state.schema.fields)) if (!path.includes("[]") && !path.includes("*") && (path + " " + f.help).toLowerCase().includes(q)) hits.push([`Setting ${path}`, `#site/${f.screen.toLowerCase().replace(/[^a-z]+/g, "-")}`]);
results.innerHTML = hits.slice(0, 20).map(([t, h]) => `<a href="${h}">${esc(t)}</a>`).join("") || `<a>Nothing called "${esc(q)}"</a>`;
results.hidden = false;
};
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
// ---- routing -------------------------------------------------------------
const screens = { overview, keys, computers, logs, history, help, site, fixes };
// ---- routing ---------------------------------------------------------------
const screens = { overview, keys, computers, logs, history, help, site, fixes, review };
let timer = null;
async function route() {
const [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
for (const a of document.querySelectorAll(".menu a")) a.classList.toggle("active", a.getAttribute("href") === "#" + name);
clearInterval(timer); timer = null;
if (!state.config) {
const [c, s] = await Promise.all([api("/api/config"), api("/api/schema")]);
state.config = c.config; state.schema = s;
document.getElementById("sitename").textContent = (state.config.site || {}).name || "";
}
try {
if (!state.live) await loadAll();
const fn = screens[name] || overview;
try { await fn(rest); } catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
if (name === "overview" || name === "computers") timer = setInterval(() => fn(rest, true).catch(() => {}), 5000);
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 = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
}
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) => `<li><span class="lvl" style="background:${p.level === "err" ? "var(--problem)" : "var(--warn)"}"></span><b>${esc(p.text)}</b>${p.acked ? ' <span class="muted">(acknowledged)</span>' : ""} <span class="muted">— ${esc(p.action)}</span> <a href="#keys/${esc(p.key)}" class="small">open the key</a></li>`).join("") : `<li class="muted">Everything is fine.</li>`;
const needs = o.needs.length ? o.needs.map((n) => `<div class="notice grey">Needs Chris: ${esc(n)}</div>`).join("") : "";
const live = o.live && o.live.live ? `<div class="notice">Live now: ${esc(o.live.reason)}. Changes to the panel wait.</div>` : "";
const isLive = o.live && o.live.live;
const runnable = (p) => {
const t = cfg().tiles.find((x) => x.id === p.key); if (!t || !editing() || isLive) return "";
const fixes = (t.checks || []).map((c, j) => [typeof c.fix === "string" ? c.fix : null, j]).filter(([f]) => f && canRunNow(f));
return fixes.slice(0, 1).map(([f, j]) => ` <button class="small" data-run="${esc(t.id)}|${esc(f)}|${j}">Run the fix now</button>`).join("");
};
const prob = o.problems.length ? o.problems.map((p) => `<li><span class="lvl" style="background:${p.level === "err" ? "var(--problem)" : "var(--warn)"}"></span><b>${esc(p.text)}</b>${p.acked ? ' <span class="muted">(acknowledged)</span>' : ""} <span class="muted">— ${esc(p.action)}</span> <a href="#keys/${esc(p.key)}" class="small">open the key</a>${runnable(p)}</li>`).join("") : `<li class="muted">Everything is fine.</li>`;
const needs = (o.needs || []).map((n) => `<div class="notice grey">Needs Chris: ${esc(n)}</div>`).join("");
const failed = o.failed_last ? `<div class="notice grey">The last Apply was put back: ${esc(o.failed_last.why || "")} <a href="#history">History</a></div>` : "";
const live = isLive ? `<div class="notice">Live now: ${esc(o.live.reason)}. Changes to the panel wait.</div>` : "";
const comps = o.computers.map((c) => `<tr class="click" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}</td><td class="muted small">${esc(c.reason)}</td></tr>`).join("");
const w = o.watchdog;
const dis = editing() ? "" : "disabled title='switch Editing on'";
main.innerHTML = `
<h1>${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") + " in the room" : "Everything is fine"}</h1>
<p class="what">Is the room OK, and what do I do. Problems mirror the red keys; hold the key, or open it for the details.</p>
<ul class="problems">${prob}</ul>${needs}${live}
<ul class="problems">${prob}</ul>${needs}${failed}${live}
<div class="cols">
<div><h2>Live deck <span class="muted small">${o.deck ? "as the Stream Deck looks now · click a key" : "(Companion is not answering)"}</span></h2>${deckHtml(o.deck, state.config, "status")}
<div class="row" style="margin-top:10px"><button disabled title="step 4">Change the playlist</button><button disabled title="step 4">Add or remove a HyperDeck</button><button disabled title="step 4">Add a MIDI link</button></div></div>
<div><h2>Live deck <span class="muted small">${o.deck ? "as the Stream Deck looks now · click a key" : "(Companion is not answering)"}</span></h2>${deckHtml(o.deck, cfg(), "status")}
<div class="row" style="margin-top:10px"><button disabled title="arrives with step 4">Change the playlist</button><button disabled title="arrives with step 4">Add or remove a HyperDeck</button><button disabled title="arrives with step 4">Add a MIDI link</button></div></div>
<div><h2>Computers</h2><div class="tablewrap"><table>${comps}</table></div></div>
</div>
<div class="card">
<dl class="kv">
<dt>Watchdog</dt><dd>${w.running ? "OK, running" : "<b>not running</b>"}${w.since ? " since " + esc(when(w.since)) : ""}${w.version ? ` <span class="pill">code ${esc(w.version)}</span>` : ""}${o.heartbeat_age != null ? ` · heartbeat ${esc(ago(o.heartbeat_age))}` : ""}</dd>
<dt>Companion</dt><dd>${o.companion_ok ? "OK" : "<b>not answering</b>"}${o.pages.status ? (o.pages.status.ok ? ", pages OK" : ", page " + o.pages.status.page + " is called " + esc(o.pages.status.live || "?")) : ""}</dd>
<dt>Companion</dt><dd>${o.companion_ok ? "OK" : "<b>not answering</b>"}${o.pages && o.pages.status ? (o.pages.status.ok ? ", pages OK" : ", page " + o.pages.status.page + " is called " + esc(o.pages.status.live || "?")) : ""}</dd>
<dt>Alarms</dt><dd>${o.service_on == null ? "<span class='muted'>not reported yet</span>" : o.service_on ? "ON" : "silenced until midnight"}${o.alarms_override != null ? " (by hand)" : ""} <span class="muted small">· ${esc(o.phrases.alarms)}</span></dd>
<dt>Auto-fix</dt><dd>${esc(o.auto_fix.phrase)}${o.auto_fix.state && o.auto_fix.state.on ? " · <b>may act now</b>" : ""}${o.auto_fix.state && o.auto_fix.state.paused ? " (waiting out a network blip)" : ""}</dd>
<dt>Running now</dt><dd>${Object.keys(o.running || {}).length ? Object.values(o.running).map((r) => esc(r.what + " on " + r.key)).join(", ") : "no fix is running"}</dd>
<dt>Last change</dt><dd>${o.last_change ? esc(o.last_change.when + " · " + o.last_change.note) : "—"} <a href="#history" class="small">History</a></dd>
</dl>
<div class="row" style="margin-top:10px"><button disabled title="step 2">Restart the watchdog</button><button disabled title="step 2">Silence alarms until midnight</button></div>
<div class="row" style="margin-top:10px">
<button id="restartwd" ${dis}>${w.running ? "Restart the watchdog" : "Start the watchdog"}</button>
<button id="alarms" ${dis}>${o.service_on === false ? "Alarms back on" : "Silence alarms until midnight"}</button>
${w.running ? `<button id="stopwd" ${dis}>Stop the watchdog</button>` : ""}
</div>
</div>`;
main.querySelector("#restartwd").onclick = () => confirmBox(w.running ? "Restart the watchdog? The deck goes purple for about 5 seconds; a fix that is running finishes first." : "Start the watchdog?", w.running ? "Restart" : "Start", async () => { try { const r = await post(w.running ? "/api/watchdog/restart" : "/api/watchdog/start"); alertBox(r.ok ? "Done: " + r.detail : "Not done: " + r.detail); } catch (e) { alertBox(e.message); } });
const stop = main.querySelector("#stopwd"); if (stop) stop.onclick = () => confirmBox("Stop the watchdog? The deck goes purple and every alarm is off until it is started again.", "Stop", async () => { try { const r = await post("/api/watchdog/stop", { word: "STOP" }); alertBox(r.ok ? "Stopped." : "Not stopped: " + r.detail); } catch (e) { alertBox(e.message); } }, "STOP");
main.querySelector("#alarms").onclick = async () => { try { await post("/api/command", { text: o.service_on === false ? "service on" : "service off" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } };
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
if (!quiet) window.scrollTo(0, 0);
}
function canRunNow(fixId) {
const f = cfg().fixes[fixId]; if (!f) return false;
if (["reconnect", "hyperdeck", "midi_connect", "spotify"].includes(f.kind)) return true;
if ((f.kind || "ssh") === "ssh" || f.kind === "local") return !closes(f) && !f.never_auto;
return false;
}
function runNow(key, fixId, which) {
const f = cfg().fixes[fixId];
confirmBox(`Run "${f.label || fixId}" now? It runs through the watchdog exactly like a hold on the ${key.toUpperCase()} key.`, "Run it", async () => {
try { await post("/api/command", { text: `test ${key} ${fixId} ${which}` }); alertBox("Sent. Watch the key, or Logs › Watchdog (TEST)."); } catch (e) { alertBox(e.message); }
});
}
function deckHtml(deck, cfg, which, selected) {
function deckHtml(deck, c, which, selected) {
const cells = {};
const rp = cfg.restart_page;
const rp = c.restart_page;
if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; }
else { for (const t of cfg.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : cfg.page), "2,0": "DOWN" };
else { for (const t of c.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : c.page), "2,0": "DOWN" };
let out = `<div class="deck">`;
for (let r = 0; r < 4; r++) for (let c = 0; c < 8; c++) {
const k = `${r},${c}`, cell = cells[k];
for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) {
const k = `${r},${col}`, cell = cells[k];
if (nav[k]) out += `<div class="key nav">${esc(nav[k])}</div>`;
else if (cell && cell.nav) out += `<div class="key nav">${esc(cell.nav)}</div>`;
else if (cell) {
@@ -126,99 +282,142 @@
return out + `</div>`;
}
// ---- Keys ----------------------------------------------------------------
// ---- Keys ------------------------------------------------------------------
async function keys(rest) {
const k = state.keys || (state.keys = await api("/api/keys"));
const o = await api("/api/overview").catch(() => ({ deck: null }));
const c = cfg();
const restart = rest[0] === "restart";
const sel = restart ? rest[1] : rest[0];
const tabs = `<div class="tabs"><button class="${restart ? "" : "on"}" onclick="location.hash='#keys'">Status page (page ${k.page})</button>${k.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page (page ${k.restart_page.page})</button>` : ""}</div>`;
let panel = `<p class="muted">Click a key to see what it watches and what holding it does.</p>`;
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 = `<div class="card"><h2>${esc(r.label)}</h2><p class="muted">row ${r.pos[0]}, column ${r.pos[1]}</p><p><b>HOLDING ${esc(k.hold.replace("HOLD ", ""))} WILL:</b> ${esc(r.holding)}, even when the Status page says it is fine.</p>${r.watch ? `<p class="small muted">Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}</p>` : ""}<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd><dt>fix</dt><dd class="mono">${esc(r.fix)}</dd></dl></details><p class="help">Editing arrives with step 2.</p></div>`;
}
const tray = k.tiles.filter((t) => !t.pos && t.kind !== "master").map((t) => `<button onclick="location.hash='#keys/${esc(t.id)}'">${esc(t.label)}</button>`).join(" ");
main.innerHTML = `<h1>Keys</h1><p class="what">The deck as people hold it. In this step you can look; moving and editing keys arrive with steps 2 and 3.</p>${tabs}
<div class="cols"><div>${deckHtml(o.deck, state.config, restart ? "restart" : "status", sel)}${!restart && tray ? `<p class="small muted" style="margin-top:8px">Shown inside their group, no key of their own: ${tray}</p>` : ""}</div><div>${panel}</div></div>`;
const hold = ((c.page_hold_ms || 2000) / 1000) + " s";
const tabs = `<div class="tabs"><button class="${restart ? "" : "on"}" onclick="location.hash='#keys'">Status page (page ${c.page})</button>${c.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page (page ${c.restart_page.page})</button>` : ""}</div>`;
let panel = `<p class="muted">Click a key to see what it watches and what holding it does.${editing() ? " With Editing on you can change it here." : ""}</p>`;
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) => `<button class="small" onclick="location.hash='#keys/${esc(t.id)}'">${esc(t.label)}</button>`).join(" ");
main.innerHTML = `<h1>Keys</h1><p class="what">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).</p>${tabs}
<div class="cols"><div>${deckHtml(o.deck, c, restart ? "restart" : "status", sel)}${!restart && tray ? `<p class="small muted" style="margin-top:8px">Shown inside their group, no key of their own: ${tray}</p>` : ""}</div><div id="panel">${panel}</div></div>`;
wireKeyPanel(sel, restart);
}
function keyPanel(t, k) {
const cfg = state.config;
const checks = (t.checks || []).map((c, i) => `<li>${esc(c.sentence)} <details><summary>Advanced</summary><dl class="kv small">${Object.entries(c).filter(([n]) => !["sentence", "identity"].includes(n)).map(([n, v]) => `<dt>${esc(n)}</dt><dd class="mono">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details></li>`).join("");
const members = t.members ? `<p><b>Sums up:</b> ${t.members.map((m) => `<a href="#keys/${esc(m)}">${esc((cfg.tiles.find((x) => x.id === m) || {}).label || m)}</a>`).join(", ")}</p>` : "";
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) => `<option value="">none</option><option value="restart-connection" ${cur === "restart-connection" ? "selected" : ""}>reconnect its Companion connection</option><option value="dante-auto" ${cur === "dante-auto" ? "selected" : ""}>Dante auto (PoE port or the Dante module)</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${cur === id ? "selected" : ""}>${esc(f.short || id)} – ${esc(f.label || "")}</option>`).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 `<li>${esc(sentence)}<details><summary>Advanced</summary><dl class="kv small">${Object.entries(chk).map(([n, v]) => `<dt>${esc(n)}</dt><dd class="mono">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details></li>`;
const base = `tiles.${i}.checks.${j}.`;
const fields = (spec.fields || []).filter((f) => f !== "fix" && f !== "type").map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("");
const fixSel = typeof chk.fix === "object" && chk.fix ? `<div class="field"><label>Fix (per sub-check)</label><textarea data-path="${base}fix" rows="2" class="fixtable">${esc(JSON.stringify(chk.fix))}</textarea><div class="help">A table: sub-check → fix, "*" for everything else.</div></div>`
: `<div class="field"><label>Fix: what holding runs for this check</label><select data-path="${base}fix" class="fixsel">${fixOptions(chk.fix)}</select></div>`;
const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `<button class="small" data-run="${esc(t.id)}|${esc(chk.fix)}|${j}">Run this fix now</button>` : "";
return `<li><b>${esc(spec.question || chk.type)}</b> <span class="muted">${esc(sentence)}</span><div class="checkbox">${fields}${fixSel}
<details><summary>Advanced</summary>${["tag", "service_only", "debounce_seconds"].filter((f) => !(spec.fields || []).includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("")}<p class="mono small">type ${esc(chk.type)}</p></details>
<div class="row">${run}<button class="small danger" data-removecheck="${j}">Remove this check</button></div></div></li>`;
}).join("");
const plain = !t.members && t.kind !== "master";
const members = t.members ? (editing() ? `<div class="field"><label>Group members</label>${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => `<label class="inline"><input type="checkbox" data-member="${esc(x.id)}" ${t.members.includes(x.id) ? "checked" : ""}> ${esc(x.label)}</label>`).join(" ")}</div>` : `<p><b>Sums up:</b> ${t.members.map((m) => `<a href="#keys/${esc(m)}">${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}</a>`).join(", ")}</p>`) : "";
const addMenu = editing() && plain ? `<div class="field"><label>Add a check</label><select id="addcheck"><option value="">– pick a question –</option>${Object.entries(groupBy(state.schema.checks, (s) => s.question || "Other")).map(([q, types]) => `<optgroup label="${esc(q)}">${types.map((ty) => `<option value="${esc(ty)}">${esc(state.schema.checks[ty].words || ty)}</option>`).join("")}</optgroup>`).join("")}</select></div>` : "";
const label = editing() ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : "";
const holdAlways = editing() && plain ? `<div class="field"><label>Hold runs even when the key is green</label><select data-path="tiles.${i}.hold_always" class="holdsel"><option value="">no – only what is failing</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${t.hold_always === id ? "selected" : ""}>${esc(f.short || id)} – ${esc(f.label || "")}</option>`).join("")}</select></div>` : "";
const pos = `<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(t.id)}</dd></dl>${editing() && !t.kind ? `<div class="field"><label>Position (row, column; blank = inside its group only)</label><input id="pos" value="${esc(t.pos ? t.pos.join(", ") : "")}" placeholder="row, column"></div>` : ""}</details>`;
return `<div class="card"><h2>${esc(t.label)}</h2><p class="muted">${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)}` : ""}</p>
${t.says && t.says.length ? `<p><b>WHAT THIS KEY WILL SAY:</b> <span class="mono">${esc(t.says.join(", "))}</span></p>` : ""}
<p><b>HOLDING ${esc(k.hold.replace("HOLD ", ""))} WILL:</b> ${esc(t.holding)}${t.hold_always ? " <span class='pill'>also when green</span>" : ""}</p>
${members}${checks ? `<p><b>Checks:</b></p><ol>${checks}</ol>` : ""}
<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(t.id)}</dd></dl></details><p class="help">Editing arrives with step 2.</p></div>`;
${kt.says && kt.says.length ? `<p><b>WHAT THIS KEY WILL SAY:</b> <span class="mono">${esc(kt.says.join(", "))}</span></p>` : ""}
<p><b>HOLDING ${esc(hold)} WILL:</b> ${esc(kt.holding)}${t.hold_always ? " <span class='pill'>also when green</span>" : ""}</p>
${label}${members}${holdAlways}${checks ? `<p><b>Checks:</b></p><ol class="checks">${checks}</ol>` : ""}${addMenu}${pos}</div>`;
}
function groupBy(obj, fn) { const out = {}; for (const [k, v] of Object.entries(obj)) (out[fn(v)] = out[fn(v)] || []).push(k); return out; }
function wireKeyPanel(sel, restart) {
const c = cfg();
const i = restart ? -1 : c.tiles.findIndex((x) => x.id === sel);
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
for (const b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; state.draft.config.tiles[i].checks.splice(+b.dataset.removecheck, 1); saveDraft(); route(); });
for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); };
const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ type: add.value }); saveDraft(); route(); };
const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const m = pos.value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); if (m) t.pos = [+m[1], +m[2]]; else if (!pos.value.trim()) delete t.pos; else return alertBox("Row, column: two numbers, like 2, 5."); saveDraft(); route(); };
for (const s of main.querySelectorAll("select.fixsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
for (const ta of main.querySelectorAll("textarea.fixtable")) ta.onchange = () => { try { setPath(ta.dataset.path, JSON.parse(ta.value)); } catch (e) { alertBox("Not a valid table."); } };
for (const s of main.querySelectorAll("select.holdsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
for (const s of main.querySelectorAll("select.watchsel")) s.onchange = () => setPath(s.dataset.path, [...s.selectedOptions].map((o) => o.value));
}
function restartKeyPanel(i, hold) {
const c = cfg(), r = c.restart_page.keys[i];
const f = c.fixes[r.fix] || {};
const conns = new Set(); for (const t of c.tiles) for (const ch of t.checks || []) if (ch.conn) conns.add(ch.conn);
const edit = editing() ? `${inputHtml(`restart_page.keys.${i}.label`, F("restart_page.keys[].label"), r.label)}
<div class="field"><label>What holding runs</label><select data-path="restart_page.keys.${i}.fix">${Object.entries(c.fixes).map(([id, fx]) => `<option value="${esc(id)}" ${r.fix === id ? "selected" : ""}>${esc(fx.short || id)} – ${esc(fx.label || "")}</option>`).join("")}</select></div>
<div class="field"><label>Keep flashing HOLD TO FIX while these are down (hold cmd/ctrl for several)</label><select multiple size="5" class="watchsel" data-path="restart_page.keys.${i}.watch">${Object.keys(c.fixes).map((id) => `<option value="${esc(id)}" ${(r.watch || []).includes(id) ? "selected" : ""}>fix ${esc(id)}</option>`).join("")}${[...conns].sort().map((cn) => `<option value="conn:${esc(cn)}" ${(r.watch || []).includes("conn:" + cn) ? "selected" : ""}>connection ${esc(cn)}</option>`).join("")}</select></div>` : "";
return `<div class="card"><h2>${esc(r.label)}</h2><p class="muted">row ${r.pos[0]}, column ${r.pos[1]}</p><p><b>HOLDING ${esc(hold)} WILL:</b> ${esc(f.label || r.fix)}, even when the Status page says it is fine.</p>${r.watch && r.watch.length ? `<p class="small muted">Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}</p>` : ""}${edit}
${canRunNow(r.fix) && editing() ? `<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run this fix now</button>` : ""}<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd></dl></details></div>`;
}
// ---- Computers -----------------------------------------------------------
// ---- Computers ---------------------------------------------------------------
let compTab = "map";
async function computers(rest, quiet) {
if (quiet && document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) return;
const m = await api("/api/machines");
const selected = rest[0] && rest[0] !== "gear" && rest[0] !== "setup" ? rest[0] : null;
if (rest[0] === "gear" || rest[0] === "setup") compTab = rest[0]; else if (!quiet || !compTab) compTab = selected ? "map" : compTab || "map";
compTab = rest[0] === "gear" || rest[0] === "setup" ? rest[0] : "map";
const tabs = `<div class="tabs"><button class="${compTab === "map" ? "on" : ""}" onclick="location.hash='#computers'">Map</button><button class="${compTab === "gear" ? "on" : ""}" onclick="location.hash='#computers/gear'">Gear</button><button class="${compTab === "setup" ? "on" : ""}" onclick="location.hash='#computers/setup'">Setup</button>
<button id="probe" ${m.probing ? "disabled" : ""}>${m.probing ? "Looking…" : "Check computers now"}</button></div>`;
let body = "";
if (compTab === "gear") body = await gearHtml();
else if (compTab === "setup") body = setupHtml(m);
else {
const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `<tr class="click${selected === c.alias ? " sel" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}${c.self ? ' <span class="muted small">(this Mac)</span>' : ""}</td><td class="small">${esc(c.role)}</td><td class="small">${c.keys.map((k) => `<span class="pill">${esc((state.config.tiles.find((t) => t.id === k) || { label: k }).label)}</span>`).join(" ")}</td><td class="muted small">${esc(c.reason)}${c.looked ? `<br>looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}</td></tr>`).join("");
const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `<tr class="click${selected === c.alias ? " sel" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}${c.self ? ' <span class="muted small">(this Mac)</span>' : ""}</td><td class="small">${esc(c.role)}</td><td class="small">${(c.keys || []).map((k) => `<span class="pill">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</span>`).join(" ")}</td><td class="muted small">${esc(c.reason)}${c.looked ? `<br>looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}</td></tr>`).join("");
body = `<div class="tablewrap"><table><tr><th>status</th><th>computer</th><th>what it does for the panel</th><th>keys that turn red if it dies</th><th></th></tr>${rows}</table></div>`;
const c = m.computers.find((x) => x.alias === selected);
if (c) body += computerCard(c);
}
main.innerHTML = `<h1>Computers</h1><p class="what">What runs where. One row per computer; click it for everything that runs there, with a light each.</p>${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) => `<tr><td>${lightDot(i.light)}${esc(i.light === "ok" ? "OK" : i.light === "problem" ? "Problem" : i.light === "not_needed" ? "Not needed" : "Can't see from here")}</td><td>${esc(i.name)}${i.watched === false ? ' <span class="muted small">(not watched)</span>' : ""}</td><td class="muted small">${esc(i.reason || (typeof i.detail === "object" && i.detail ? Object.entries(i.detail).map(([k, v]) => k + " " + (v === true ? "yes" : v === false ? "no" : k === "age" ? ago(v) : v)).join(", ") : i.detail || ""))}</td></tr>`).join("");
const items = (c.items || []).map((i) => `<tr><td>${lightDot(i.light)}${esc(i.light === "ok" ? "OK" : i.light === "problem" ? "Problem" : i.light === "not_needed" ? "Not needed" : "Can't see from here")}</td><td>${esc(i.name)}${i.watched === false ? ' <span class="muted small">(not watched)</span>' : ""}</td><td class="muted small">${esc(i.reason || (typeof i.detail === "object" && i.detail ? Object.entries(i.detail).map(([k, v]) => k + " " + (v === true ? "yes" : v === false ? "no" : k === "age" ? ago(v) : v)).join(", ") : i.detail || ""))}</td></tr>`).join("");
const found = c.found || {};
const foundHtml = Object.entries(found).filter(([, v]) => Array.isArray(v) && v.length).map(([k, v]) => `<p class="small"><b>${esc(k)}:</b> <span class="mono">${esc(typeof v[0] === "object" ? v.map((x) => x.iface + " " + x.ip + (x.active ? "" : " (down)")).join(", ") : v.join(", "))}</span></p>`).join("") || `<p class="muted small">Press "Check computers now" to look.</p>`;
const done = Object.entries(c.manual_done || {}).map(([k, v]) => `<li>${lightDot("hand")}${esc(k.replace(/_/g, " "))}: done by hand (${esc(v.by)}, ${esc(v.on)})</li>`).join("");
const base = `hosts.${c.alias}.`;
const edit = editing() ? `<details open><summary>Change this computer</summary>${["name", "role", "notes", "os"].concat(c.self ? [] : ["ssh", "mac", "wake_broadcast", "pingable"]).map((f) => inputHtml(base + f, F("hosts.*." + f), getPath(base + f))).join("")}
<div class="field"><label>What runs here (one per line, as {"kind": ..., "name": ...})</label><textarea id="runs" rows="${Math.max(4, (getPath(base + "runs") || []).length + 1)}" class="mono">${esc((getPath(base + "runs") || []).map((r) => JSON.stringify(r)).join("\n"))}</textarea><div class="help">Kinds: sshd, app (process), launchagent (label), daemon (process), task (name, exe), service (name), web (url), helper_dvs, helper_solo (label), midi_session (name, peer_of), artnet (source), folder (path), disabled (label). "watched": false for things the panel only lists.</div></div></details>` : "";
return `<div class="card" id="card"><h2>${esc(c.name)} ${wordOf(c.word)}</h2><p class="muted">${esc(c.role)}${c.ssh ? ` · logs in as <span class="mono">${esc(c.ssh)}</span>` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "<b>no answer</b>"}` : ""}${c.error ? ` · <b>${esc(c.error)}</b>` : ""}</p>
${c.notes ? `<p class="notice grey">${esc(c.notes)}</p>` : ""}
<h3>What runs here</h3><div class="tablewrap"><table>${items}</table></div>
${done ? `<ul class="small" style="list-style:none;padding:0">${done}</ul>` : ""}
<details><summary>Everything found on this computer</summary>${foundHtml}</details>
${c.self ? `<details><summary>This Mac's set-up lights</summary><dl class="kv small">${Object.entries(c.setup || {}).map(([k, v]) => `<dt>${esc(k.replace(/_/g, " "))}</dt><dd>${typeof v === "boolean" ? lightDot(v ? "ok" : "problem") + (v ? "OK" : "Problem") : esc(JSON.stringify(v))}</dd>`).join("")}</dl></details>` : ""}
</div>`;
${edit}</div>`;
}
async function gearHtml() {
const g = await api("/api/gear");
return `<p class="small muted">Devices that are not computers, from the keys that watch them. Where each one lives is typed once on this list (step 2).</p><div class="tablewrap"><table><tr><th>light</th><th>device</th><th>kind</th><th>address</th><th>where / notes</th><th>watched by</th></tr>
${g.map((r) => `<tr><td>${lightDot(r.light)}${esc(r.light === "ok" ? "OK" : r.light === "problem" ? "Problem" : "–")}</td><td>${esc(r.name)}</td><td>${esc(r.kind)}</td><td class="mono small">${esc(r.address || "")}${r.detail ? `<br><span class="muted">${esc(r.detail)}</span>` : ""}</td><td class="small">${esc(r.location || "")} ${esc(r.notes || "")} <span class="muted">${esc(r.reason || "")}</span></td><td class="small">${r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc((state.config.tiles.find((t) => t.id === k) || { label: k }).label)}</a>`).join(", ")}</td></tr>`).join("")}</table></div>`;
return `<p class="small muted">Devices that are not computers, from the keys that watch them.${editing() ? " Type where each one lives and press Save." : ""}</p><div class="tablewrap"><table><tr><th>light</th><th>device</th><th>kind</th><th>address</th><th>where / notes</th><th>watched by</th></tr>
${g.map((r) => `<tr><td>${lightDot(r.light)}${esc(r.light === "ok" ? "OK" : r.light === "problem" ? "Problem" : "–")}</td><td>${esc(r.name)}</td><td>${esc(r.kind)}</td><td class="mono small">${esc(r.address || "")}${r.detail ? `<br><span class="muted">${esc(r.detail)}</span>` : ""}</td><td class="small">${editing() ? `<input data-loc value="${esc(r.location || "")}" placeholder="where it is"> <input data-notes value="${esc(r.notes || "")}" placeholder="notes"> <button class="small" data-gear="${esc(r.name)}">Save</button>` : `${esc(r.location || "")} ${esc(r.notes || "")}`} <span class="muted">${esc(r.reason || "")}</span></td><td class="small">${(r.watched_by || []).map((k) => `<a href="#keys/${esc(k)}">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</a>`).join(", ")}</td></tr>`).join("")}</table></div>`;
}
function setupHtml(m) {
return m.computers.map((c) => {
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
const steps = m.setup_steps[kind];
const steps = (m.setup_steps || {})[kind] || [];
const lightFor = (step) => {
const s = c.setup || {};
const item = (k) => (c.items || []).find((i) => i.kind === k);
if (/login key/.test(step)) return s.key_ok === true || s.login_key === true ? "ok" : s.key_ok === false || s.login_key === false ? "problem" : "unknown";
if (/python/.test(step)) return s.python_ok === true ? "ok" : s.python_ok === false ? "problem" : "unknown";
if (/SSH service/.test(step)) return c.reachable ? "ok" : "problem";
if (/firewall/.test(step)) return s.firewall_allow ? "ok" : "unknown";
if (/wake-on-LAN/.test(step)) return s.wake_on_lan === "Enabled" ? "ok" : s.wake_on_lan ? "problem" : "unknown";
if (/Public/.test(step)) return s.profile === "Public" ? "ok" : s.profile ? "problem" : "unknown";
if (/DVS helper/.test(step)) { const it = (c.items || []).find((i) => i.kind === "helper_dvs"); return it ? it.light : "not_needed"; }
if (/Pro Tools helper/.test(step)) { const it = (c.items || []).find((i) => i.kind === "helper_solo"); return it ? it.light : "not_needed"; }
if (/DVS helper/.test(step)) { const it = item("helper_dvs"); return it ? it.light : "not_needed"; }
if (/Pro Tools helper/.test(step)) { const it = item("helper_solo"); return it ? it.light : "not_needed"; }
if (/StatusPanel/.test(step)) { const ts = (c.items || []).filter((i) => i.kind === "task"); return ts.length ? (ts.every((i) => i.light === "ok") ? "ok" : "problem") : "unknown"; }
if (/rtpMIDI/.test(step)) { const it = (c.items || []).find((i) => i.kind === "service"); return it ? it.light : "not_needed"; }
if (/custom variables/.test(step)) return "unknown";
if (/rtpMIDI/.test(step)) { const it = item("service"); return it ? it.light : "not_needed"; }
if (/Netgear/.test(step)) return s.netgear_keychain === true ? "ok" : s.netgear_keychain === false ? "problem" : "not_needed";
if (/Google Drive/.test(step)) { const it = (c.items || []).find((i) => i.kind === "folder"); return it ? it.light : "not_needed"; }
if (/watchdog and the settings/.test(step)) { const ok = (c.items || []).filter((i) => i.kind === "launchagent" && /watchdog|settings/.test(i.name)); return ok.length && ok.every((i) => i.light === "ok") ? "ok" : "unknown"; }
if (/Google Drive/.test(step)) { const it = item("folder"); return it ? it.light : "not_needed"; }
if (/watchdog and the settings/.test(step)) { const ok = (c.items || []).filter((i) => i.kind === "launchagent" && /watchdog|settings/i.test(i.name)); return ok.length && ok.every((i) => i.light === "ok") ? "ok" : "unknown"; }
if (/Wi-Fi/.test(step)) return c.manual_done && c.manual_done.wifi_power ? "hand" : "unknown";
if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown";
return "unknown";
@@ -227,14 +426,14 @@
}).join("");
}
// ---- Logs ----------------------------------------------------------------
// ---- Logs ------------------------------------------------------------------
let logTab = "watchdog", logFilter = "";
async function logs(rest) {
logTab = rest[0] || logTab;
const tabs = ["watchdog", "errors", "autofix", "settings"].map((n) => `<button class="${logTab === n ? "on" : ""}" onclick="location.hash='#logs/${n}'">${{ watchdog: "Watchdog", errors: "Errors", autofix: "Auto-fix days", settings: "Settings page" }[n]}</button>`).join("");
const filters = logTab === "watchdog" ? `<div class="tabs">${["", "faults", "holds", "fixes", "auto", "errors"].map((f) => `<button class="${logFilter === f ? "on" : ""}" data-f="${f}">${f || "everything"}</button>`).join("")}</div>` : "";
let data;
if (logTab === "autofix") { data = await api("/api/logs/autofix" + (rest[1] ? "?file=" + encodeURIComponent(rest[1]) : "")); }
if (logTab === "autofix") data = await api("/api/logs/autofix" + (rest[1] ? "?file=" + encodeURIComponent(rest[1]) : ""));
else data = await api(`/api/logs/${logTab}?filter=${encodeURIComponent(logFilter)}`);
const files = logTab === "autofix" ? `<p class="small">${(data.files || []).slice().reverse().map((f) => `<a href="#logs/autofix/${encodeURIComponent(f)}" class="${data.file === f ? "pill" : ""}">${esc(f.replace(" Auto-Fix.log", ""))}</a>`).join(" · ") || "no auto-fix days yet"}</p>` : "";
main.innerHTML = `<h1>Logs</h1><p class="what">What the watchdog did and saw. Newest lines at the bottom.</p><div class="tabs">${tabs}</div>${filters}${files}<pre class="log">${esc((data.lines || []).join("\n")) || "(empty)"}</pre>`;
@@ -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 = `<h1>History</h1><p class="what">Every version of the settings that was ever applied. ${esc(h.note)}</p><div class="tablewrap"><table><tr><th>when</th><th>file</th><th>size</th></tr>${h.entries.map((e) => `<tr><td>${esc(e.when)}</td><td class="mono small">${esc(e.file)}</td><td>${esc(e.size)}</td></tr>`).join("") || "<tr><td colspan=3 class='muted'>nothing yet</td></tr>"}</table></div>`;
let detail = "";
if (rest[0]) {
const d = await api("/api/history/" + encodeURIComponent(rest[0]));
detail = `<div class="card"><h2>${esc(rest[0])}</h2><p class="muted">What is different between that version and now:</p>${d.changes_to_now.length ? `<ul>${d.changes_to_now.map((c) => `<li>${esc(c.text)}</li>`).join("")}</ul>` : "<p>Nothing: it is the same as now.</p>"}
${editing() ? `<button class="primary" id="goback">Go back to this version</button> <span class="help">It becomes your draft; Review & Apply puts it live.</span>` : `<p class="help">Switch Editing on to go back to it.</p>`}</div>`;
}
const rows = h.entries.map((e) => `<tr class="click" onclick="location.hash='#history/${encodeURIComponent(e.id)}'"><td>${esc(when(e.when))}</td><td>${esc(e.by)}</td><td>${esc(e.note)}</td><td class="small muted">${esc((e.actions || []).join(", "))}${e.outcome && e.outcome !== "applied" ? " · " + esc(e.outcome) : ""}</td></tr>`).join("");
main.innerHTML = `<h1>History</h1><p class="what">Every version of the settings that was ever applied: who, when, the note, what was different.</p>
<div class="row">${editing() && h.entries.length ? `<button id="undo">Undo the last change</button>` : ""}</div>
<div class="tablewrap"><table><tr><th>when</th><th>who</th><th>note</th><th></th></tr>${rows || "<tr><td colspan=4 class='muted'>nothing yet</td></tr>"}</table></div>${detail}`;
const gb = main.querySelector("#goback"); if (gb) gb.onclick = async () => { try { const r = await post("/api/restore/" + encodeURIComponent(rest[0])); state.draft = r.draft; badge(); location.hash = "#review"; } catch (e) { alertBox(e.message); } };
const un = main.querySelector("#undo"); if (un) un.onclick = () => confirmBox("Undo the last change? The version before it becomes your draft; Review & Apply puts it live.", "Undo", async () => { try { const r = await post("/api/undo"); state.draft = r.draft; badge(); location.hash = "#review"; } catch (e) { alertBox(e.message); } });
}
// ---- Help ----------------------------------------------------------------
async function help(rest) {
// ---- Review & Apply -------------------------------------------------------------
async function review() {
const st = await api("/api/apply/status");
if (st.state === "running") return applyProgress(st);
const d = await api("/api/draft"); state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; badge();
if (!state.draft) {
main.innerHTML = `<h1>Review & Apply</h1><p class="what">Nothing to apply: there is no draft. Change something on any screen with Editing on.</p>${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`;
return;
}
const r = await post("/api/validate", {});
const changes = r.changes.map((c) => `<li class="${c.command ? "command" : ""}">${esc(c.text)}${c.command ? ' <span class="pill">command</span>' : ""}</li>`).join("") + (r.atem_pending ? "<li>A new saved switcher file replaces the current one.</li>" : "");
const errors = r.errors.map((e) => `<li>${esc(e)}</li>`).join("");
const warnings = r.warnings.map((w) => `<li>${esc(w)}</li>`).join("");
const live = r.live && r.live.live ? `<div class="notice">Live now: ${esc(r.live.reason)}. Apply waits until it is over; your draft is saved. ${editing() ? '<button id="emergency" class="danger">Apply anyway (emergency)</button>' : ""}</div>` : "";
main.innerHTML = `<h1>Review & Apply</h1><p class="what">Started by ${esc(state.draft.started_by || state.draft.by || "?")}${state.draft.started ? " at " + esc(when(state.draft.started)) : ""}. ${state.draftStale ? "<b>The live settings changed since this draft was started: look again before applying.</b>" : ""}</p>
<div class="card"><h3>Your changes (${r.changes.length + (r.atem_pending ? 1 : 0)})</h3>${changes ? `<ul class="changes">${changes}</ul>` : "<p class='muted'>No change yet.</p>"}
<p><b>What will happen:</b> ${esc(r.feel)}</p>
${errors ? `<div class="notice problem"><b>Not possible yet:</b><ul>${errors}</ul></div>` : ""}${warnings ? `<div class="notice grey"><b>Worth knowing:</b><ul>${warnings}</ul></div>` : ""}${live}
<div class="field"><label for="note">Note for history</label><input id="note" placeholder="what you changed and why" maxlength="200"></div>
<div class="row"><button class="primary" id="apply" ${errors || (r.live && r.live.live) || !editing() || !changes ? "disabled" : ""}>APPLY</button><button id="discard" ${editing() ? "" : "disabled"}>Throw the draft away</button></div></div>
${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`;
const go = (emergency) => async () => {
const note = main.querySelector("#note").value.trim();
try { const res = await post("/api/apply", { note, emergency }); if (res.ok) { state.draft = null; state.atemPending = false; badge(); review(); } else alertBox(res.error); }
catch (e) { alertBox(e.message); if (e.body && e.body.changed_since) { state.draftStale = true; badge(); } }
};
main.querySelector("#apply").onclick = go(null);
const em = main.querySelector("#emergency"); if (em) em.onclick = () => dialog(`<h3>Apply during a live service</h3><p>${esc(r.feel)} Say why, and type LIVE.</p><input id="why" placeholder="why it cannot wait"><input id="word" placeholder="type LIVE"><div class="row"><button class="primary danger" id="dok">Apply now</button><button id="dcancel">Cancel</button></div>`, (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 = `<h1>Applying…</h1><p class="what">${esc(st.note || "")}</p>${stepsHtml(st)}<p class="muted">This page updates by itself.</p>`;
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 `<ol class="steps">${(st.steps || []).map((s) => `<li class="${esc(s.state)}">${lightDot(s.state === "ok" ? "ok" : s.state === "failed" ? "problem" : s.state === "running" ? "warn" : s.state === "skipped" ? "not_needed" : "unknown")}<b>${esc(s.name)}</b> ${esc(s.state === "skipped" ? "skipped: " + s.detail : s.detail || s.state)}</li>`).join("")}</ol>`; }
function applyResult(st) { return `<div class="card"><h3>${st.state === "done" ? "Applied" : "Not applied"} <span class="muted small">${esc(when(st.finished))}${st.by ? " · " + esc(st.by) : ""}</span></h3><p>${esc(st.outcome)}</p>${stepsHtml(st)}</div>`; }
// ---- Help ------------------------------------------------------------------
async function help() {
const facts = await api("/api/facts");
const gl = state.schema.glossary;
const gl = state.schema.glossary || {};
const cards = [
["Read a red key", "Green is fine. A flashing red key names the problem. Press it once to acknowledge (it stops flashing, stays dark red). Hold it " + esc(state.schema.fields.page_hold_ms ? "2 s" : "2 s") + " and it runs the fix for what is failing."],
["Purple means the watchdog is down", "Every key turns purple when the watchdog on the Ingest Mac has been silent for 15 s. Open this page: if the Overview says Companion is not answering, the problem is Companion; otherwise restart the watchdog (step 2 adds the button; until then: ask Chris)."],
["Read a red key", "Green is fine. A flashing red key names the problem. Press it once to acknowledge (it stops flashing, stays dark red). Hold it 2 s and it runs the fix for what is failing."],
["Purple means the watchdog is down", "Every key turns purple when the watchdog on the Ingest Mac has been silent for 15 s. Open this page: if the Overview says Companion is not answering, the problem is Companion; otherwise press Restart the watchdog on the Overview."],
["Which computer or device is that?", "Computers › Map shows every computer and the keys that turn red if it dies; Computers › Gear lists the switcher, decks, recorders, wireless and LED gear with their addresses."],
["What is a connection?", "Companion's link to one device (a HyperDeck, the Tascam, the switcher). The keys watch those connections; Site setup › Companion lists them all and which key watches each."],
].map(([t, b]) => `<div class="card"><h3>${t}</h3><p>${b}</p></div>`).join("");
const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `<details open><summary><b>${esc(g)}</b></summary><p class="mono small">${esc(words.join(" · "))}</p></details>`).join("");
main.innerHTML = `<h1>Help</h1><p class="what">Start with the four cards. The pages below are made from the settings, so they are never out of date.</p><div class="cards">${cards}</div>
<h2>Pages</h2><p><a href="/key-guide.html" target="_blank">What each key does</a> · <a href="/cheat.html" target="_blank">Cheat sheet (one page)</a> · <a href="/where.html" target="_blank">Where things are</a></p>
<h2>Changing something</h2><ol><li>Switch Editing on (top right): the editing code and your first name.</li><li>Change it where it lives: a key on Keys, a computer on Computers, a fix on Fixes, the rest on Site setup.</li><li>The badge at the top counts your changes; it opens Review & Apply.</li><li>Review reads your changes back in plain words and says what will happen (the deck goes purple for a few seconds; pages re-import only when the layout changed). Press APPLY.</li><li>If the watchdog does not come back, the previous version is put back by itself. History keeps every version; Go back to any of them.</li></ol>
<h2>First-day walkthrough (10 minutes, no code needed)</h2><ol><li>Overview: the live deck. Green, red, purple.</li><li>Click a red key (or any key): read what it checks and what holding it will do.</li><li>Logs › Watchdog, filter "holds": find a hold and what it did.</li><li>Keys › Restart page: each key restarts one thing, even when green.</li><li>Overview: when alarms are on and when auto-fix may act by itself.</li><li>Computers › Map: which computer a key talks to, and what runs there.</li><li>Help › What each key does: the whole deck in words.</li></ol>
<h2>The words a key can show</h2>${glossary}
<h2>Things only a person can do</h2><ul><li>Run the set-up script on a new computer (ARD on a Mac, PowerShell as administrator on a PC), allow Accessibility on the Studio B Mac, click Allow on the Spotify prompt once.</li><li>Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.</li><li>Type the Netgear login into the keychain; keep Google Drive signed in for the saved switcher files.</li><li>Save a new switcher file from ATEM Software Control when the show changes.</li></ul>
<h2>Companion facts (found by trying)</h2><dl class="kv">${facts.facts.map(([q, a]) => `<dt>${esc(q)}</dt><dd>${esc(a)}</dd>`).join("")}</dl>`;
<h2>Things only a person can do</h2><ul><li>Run the set-up script on a new computer (ARD on a Mac, PowerShell as administrator on a PC), allow Accessibility on the Studio B Mac, click Allow on the Spotify prompt once.</li><li>Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.</li><li>Type the Netgear login into the keychain; keep Google Drive signed in for the saved switcher files.</li><li>Save a new switcher file from ATEM Software Control when the show changes, then pick it on Site setup › ATEM.</li></ul>
<h2>Companion facts (found by trying)</h2><dl class="kv">${(facts.facts || []).map(([q, a]) => `<dt>${esc(q)}</dt><dd>${esc(a)}</dd>`).join("")}</dl>`;
}
// ---- Site setup (read-only) ----------------------------------------------
// ---- Site setup -----------------------------------------------------------------
const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys"];
async function site(rest) {
const cfg = state.config, F = state.schema.fields;
const c = cfg();
const sections = { site: "Site", schedules: "Schedules", companion: "Companion", "network-dante": "Network & Dante", spotify: "Spotify", atem: "ATEM" };
const which = sections[rest[0]] ? rest[0] : "site";
const tabs = Object.entries(sections).map(([k, n]) => `<button class="${which === k ? "on" : ""}" onclick="location.hash='#site/${k}'">${n}</button>`).join("");
const screen = sections[which];
const get = (path) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), cfg);
const fieldHtml = (path, f) => { const v = get(path); return `<div class="field"><label>${esc(path)} <span class="help">${esc(f.help)}</span></label><div class="val">${v === undefined ? `<span class="muted">(default: ${esc(JSON.stringify(f.default))})</span>` : esc(typeof v === "object" ? JSON.stringify(v) : String(v))}</div></div>`; };
const own = Object.entries(F).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !["tiles", "fixes", "hosts", "gear"].includes(p));
let body = `<div class="card"><h3>Settings</h3>${own.filter(([, f]) => !f.advanced).map(([p, f]) => fieldHtml(p, f)).join("")}<details><summary>Advanced</summary>${own.filter(([, f]) => f.advanced).map(([p, f]) => fieldHtml(p, f)).join("")}</details></div>`;
if (which === "companion") {
const c = await api("/api/companion");
const lights = Object.entries(c.lights).map(([k, v]) => `<li>${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</li>`).join("");
const conns = c.connections.map((r) => `<tr><td>${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed")}${esc(r.label)}</td><td class="mono small">${esc(r.module || "")}</td><td class="small">${esc(r.enabled ? r.category || "" : "off")}${r.message ? ` <span class="muted">${esc(r.message)}</span>` : ""}</td><td class="small">${r.watched_by.length ? r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc(k.toUpperCase())}</a>`).join(", ") : '<span class="muted">not watched</span>'}</td></tr>`).join("");
body = `<div class="card"><h3>Companion ${c.ok ? "OK" : "<b>not answering</b>"}${c.build ? ` <span class="pill">${esc(c.build)}</span>` : ""}</h3><p class="small muted">Set up once by the person who installed Companion. You normally only come here to see which connection a key watches.</p><ul style="list-style:none;padding:0">${lights}</ul>
${c.missing.length ? `<p class="notice">Keys watch connections Companion does not have: ${esc(c.missing.join(", "))}</p>` : ""}
<p class="small">Page-number buttons coloured on ${c.page_number_buttons.pages_coloured} pages (${esc(c.page_number_buttons.recorded_in)}).</p>
<h3>Connections (live)</h3><div class="tablewrap"><table><tr><th>connection</th><th>module</th><th>status</th><th>watched by</th></tr>${conns}</table></div></div>` + body;
const own = Object.entries(state.schema.fields).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !HAND_MADE.includes(p) && !HAND_MADE.some((h) => h.startsWith(p + ".")) && !Object.keys(state.schema.fields).some((k) => k.startsWith(p + ".") || k.startsWith(p + "[]")));
const fieldHtml = ([path, f]) => inputHtml(path, f, getPath(path));
const plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced);
let body = own.length ? `<div class="card"><h3>Settings</h3>${plain.map(fieldHtml).join("") || "<p class='muted small'>Only numbers here; they sit under Advanced.</p>"}${adv.length ? `<details><summary>Advanced</summary>${adv.map(fieldHtml).join("")}</details>` : ""}</div>` : "";
if (which === "companion") body = (await companionHtml()) + body;
if (which === "schedules") body = schedulesHtml(c) + body;
if (which === "network-dante") body += await danteHtml(c);
if (which === "spotify") body = spotifyHtml(c) + body;
if (which === "atem") body = (await atemHtml(c)) + body;
main.innerHTML = `<h1>Site setup</h1><p class="what">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."}</p><div class="tabs">${tabs}</div>${body}`;
wireSite();
}
if (which === "network-dante") {
const d = (cfg.tiles.flatMap((t) => t.checks || []).find((c) => c.type === "dante") || {}).devices || {};
const snap = await api("/api/snapshot");
function windowsHtml(path, list, title, help) {
const days = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
const dis = editing() ? "" : "disabled";
const rows = (list || []).map((w, i) => `<div class="window" data-win="${path}.${i}">${days.map((d) => `<label class="inline"><input type="checkbox" data-day="${d}" ${(w.days || []).includes(d) ? "checked" : ""} ${dis}> ${d}</label>`).join("")} from <input class="hhmm" data-start value="${esc(w.start)}" ${dis}> to <input class="hhmm" data-end value="${esc(w.end)}" ${dis}> ${editing() ? `<button class="small" data-delwin="${path}.${i}">remove</button>` : ""}</div>`).join("");
return `<div class="field"><label>${esc(title)}</label>${rows || "<p class='muted small'>none</p>"}${editing() ? `<button class="small" data-addwin="${path}">+ window</button>` : ""}<div class="help">${esc(help)}</div></div>`;
}
function schedulesHtml(c) {
const fixes = Object.entries(c.fixes).map(([id, f]) => { const cl = closes(f); const on = (c.auto_fix.fixes || []).includes(id); return `<label class="inline"><input type="checkbox" data-autofix="${esc(id)}" ${on ? "checked" : ""} ${editing() && !cl && !f.never_auto ? "" : "disabled"}> ${esc(f.short || id)}${cl ? ' <span class="muted small">(closes a program: hold only)</span>' : f.never_auto ? ' <span class="muted small">(never by itself)</span>' : ""}</label>`; }).join(" ");
return `<div class="notice">Alarms are on every day. The ALARMS key silences them until midnight, then they come back by themselves. Changes to the panel wait while a HyperDeck is recording.</div>
<div class="card"><h3>Auto-fix</h3>${windowsHtml("auto_fix.windows", c.auto_fix.windows, "When auto-fix may act by itself", "An end earlier than the start runs past midnight.")}<div class="field"><label>Fixes it may run (gentle ones only)</label>${fixes}</div></div>
<div class="card"><h3>Changes wait</h3>${windowsHtml("site.live_services", (c.site || {}).live_services, "Clock windows during which changes to the panel wait", "A recording HyperDeck always counts, whatever the clock says. NA has no clock windows.")}</div>`;
}
async function companionHtml() {
const cc = await api("/api/companion");
const lights = Object.entries(cc.lights || {}).map(([k, v]) => `<li>${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</li>`).join("");
const conns = (cc.connections || []).map((r) => `<tr><td>${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed")}${esc(r.label)}</td><td class="mono small">${esc(r.module || "")}</td><td class="small">${esc(r.enabled ? r.category || "" : "off")}${r.message ? ` <span class="muted">${esc(r.message)}</span>` : ""}</td><td class="small">${(r.watched_by || []).length ? r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc(k.toUpperCase())}</a>`).join(", ") : '<span class="muted">not watched</span>'}</td></tr>`).join("");
return `<div class="card"><h3>Companion ${cc.ok ? "OK" : "<b>not answering</b>"}${cc.build ? ` <span class="pill">${esc(cc.build)}</span>` : ""}</h3><p class="small muted">Set up once by the person who installed Companion. You normally only come here to see which connection a key watches.</p><ul style="list-style:none;padding:0">${lights}</ul>
${(cc.missing || []).length ? `<p class="notice">Keys watch connections Companion does not have: ${esc(cc.missing.join(", "))}</p>` : ""}
${cc.page_number_buttons ? `<p class="small">Page-number buttons coloured on ${cc.page_number_buttons.pages_coloured} pages (${esc(cc.page_number_buttons.recorded_in)}).</p>` : ""}
<h3>Connections (live)</h3><div class="tablewrap"><table><tr><th>connection</th><th>module</th><th>status</th><th>watched by</th></tr>${conns}</table></div></div>`;
}
function danteCheck(c) { const t = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "dante")); return t ? t.checks.find((ch) => ch.type === "dante") : null; }
async function danteHtml(c) {
const chk = danteCheck(c);
if (!chk) return "";
const snap = await api("/api/snapshot").catch(() => ({}));
const map = snap.dante_map || {};
body += `<div class="card"><h3>Dante device list (the DANTE key's devices)</h3><div class="tablewrap"><table><tr><th>Dante name</th><th>key text</th><th>address</th><th>switch / port</th><th>PoE</th></tr>${Object.entries(d).map(([n, a]) => { const e = map[n] || {}; return `<tr><td class="mono small">${esc(n)}</td><td>${esc(a)}</td><td class="mono small">${esc(e.ip || "")}</td><td class="small">${e.switch ? esc(e.switch + " port " + e.port) : ""}</td><td>${e.poe ? "yes" : ""}</td></tr>`; }).join("")}</table></div><p class="small muted">Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.</p></div>`;
const fixOf = (n) => typeof chk.fix === "object" && chk.fix ? (chk.fix[n] || "") : "";
const fixSel = (n) => `<select data-dantefix="${esc(n)}" ${editing() ? "" : "disabled"}><option value="">(everything else)</option><option value="dante-auto" ${fixOf(n) === "dante-auto" ? "selected" : ""}>Dante auto</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${fixOf(n) === id ? "selected" : ""}>${esc(f.short || id)}</option>`).join("")}</select>`;
const rows = Object.entries(chk.devices || {}).map(([n, a]) => { const e = map[n] || {}; return `<tr><td class="mono small">${esc(n)}</td><td>${editing() ? `<input data-dantekey="${esc(n)}" value="${esc(a)}" size="10">` : esc(a)}</td><td class="mono small">${esc(e.ip || "")}</td><td class="small">${e.switch ? esc(e.switch + " port " + e.port) : ""}</td><td>${e.poe ? "yes" : ""}</td><td>${fixSel(n)}</td><td>${editing() ? `<button class="small danger" data-dantedel="${esc(n)}">remove</button>` : ""}</td></tr>`; }).join("");
const extra = Object.keys(map).filter((n) => !((chk.devices || {})[n]));
return `<div class="card"><h3>Dante device list (the DANTE key's devices)</h3><div class="tablewrap"><table><tr><th>Dante name</th><th>key text</th><th>address</th><th>switch / port</th><th>PoE</th><th>when it is gone, hold runs</th><th></th></tr>${rows}</table></div>
<p class="small muted">Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.${editing() ? ` <button class="small" id="resurvey">Check the Dante network now</button>` : ""}</p>
${editing() ? `<div class="field"><label>Add a device</label><input id="dantenew" placeholder="Dante name (as Dante Controller shows it)" list="dantefound"><datalist id="dantefound">${extra.map((n) => `<option value="${esc(n)}">`).join("")}</datalist> <input id="dantenewkey" placeholder="key text" size="10"> <button class="small" id="danteadd">Add</button>${extra.length ? `<div class="help">Seen on the network but not listed: ${esc(extra.join(", "))}</div>` : ""}</div>` : ""}</div>`;
}
if (which === "schedules") {
const ph = await api("/api/overview").then((o) => o.phrases).catch(() => ({}));
body = `<div class="notice">${esc(ph.alarms || "")} Auto-fix ${esc(ph.auto_fix || "")}. ${esc(((ph.live || "")[0] || "").toUpperCase() + (ph.live || "").slice(1))}.</div><div class="card"><h3>Fixes auto-fix may run</h3><p>${(cfg.auto_fix.fixes || []).map((f) => `<span class="pill">${esc((cfg.fixes[f] || {}).short || f)}</span>`).join(" ")}</p></div>` + body;
function spotifyHtml(c) {
const s = c.spotify || {};
return `<div class="card"><h3>The service playlist</h3><div class="field"><label>Playlist link</label><input id="splink" value="${esc(s.playlist || "")}" placeholder="paste the open.spotify.com/playlist/... link" ${editing() ? "" : "disabled"}> ${editing() ? `<button class="small" id="splook">Look it up</button>` : ""}<div class="help">The SPOTIFY key, the play fix and the key guide all use it.</div></div>
<div class="field"><label>Name</label><input id="spname" value="${esc(s.name || "")}" ${editing() ? "" : "disabled"}><div class="help">As the keys say it.</div></div><p id="spinfo" class="help"></p></div>`;
}
async function atemHtml(c) {
const folder = await api("/api/atem-reference/folder").catch(() => ({ files: [], mounted: false }));
const pending = state.atemPending ? `<div class="notice">A new saved switcher file is waiting for Apply. ${editing() ? `<button class="small" id="atemdiscard">Discard it</button>` : ""}</div>` : "";
return `<div class="card"><h3>The saved switcher file</h3><p class="small muted">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.</p>${pending}
${editing() ? `<div class="row"><label class="btn">Upload a saved file <input type="file" id="atemfile" accept=".xml" hidden></label><button id="atemnewest" ${folder.mounted && (folder.files || []).length ? "" : "disabled"}>Pick the newest from the folder</button></div>` : ""}
<p class="small">Folder: <span class="mono">${esc(folder.folder || "(not set)")}</span> ${folder.mounted ? `· newest: ${(folder.files || []).length ? esc(folder.files[0].name) + " (" + esc(when(folder.files[0].when)) + ")" : "none"}` : "· <b>not mounted</b>"}</p><p id="ateminfo" class="help"></p></div>`;
}
function wireSite() {
const q = (s) => main.querySelector(s);
for (const b of main.querySelectorAll("[data-addwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.addwin; const list = (getPath(path, state.draft.config) || []).slice(); list.push({ days: ["sun"], start: "09:00", end: "12:00" }); setPath(path, list); route(); };
for (const b of main.querySelectorAll("[data-delwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.delwin.replace(/\.\d+$/, ""), i = +b.dataset.delwin.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list.splice(i, 1); setPath(path, list); route(); };
for (const w of main.querySelectorAll("[data-win]")) for (const el of w.querySelectorAll("input")) el.onchange = () => { if (!ensureDraft()) return; const path = w.dataset.win.replace(/\.\d+$/, ""), i = +w.dataset.win.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list[i] = { days: [...w.querySelectorAll("[data-day]")].filter((x) => x.checked).map((x) => x.dataset.day), start: w.querySelector("[data-start]").value.trim(), end: w.querySelector("[data-end]").value.trim() }; setPath(path, list); };
for (const cb of main.querySelectorAll("[data-autofix]")) cb.onchange = () => { if (!ensureDraft()) return; const set = new Set(state.draft.config.auto_fix.fixes || []); cb.checked ? set.add(cb.dataset.autofix) : set.delete(cb.dataset.autofix); setPath("auto_fix.fixes", [...set]); };
const dc = () => danteCheck(state.draft.config);
for (const el of main.querySelectorAll("[data-dantekey]")) el.onchange = () => { if (!ensureDraft()) return; dc().devices[el.dataset.dantekey] = el.value.trim(); saveDraft(); };
for (const el of main.querySelectorAll("[data-dantefix]")) el.onchange = () => { if (!ensureDraft()) return; const ch = dc(); if (typeof ch.fix !== "object" || !ch.fix) ch.fix = { "*": ch.fix || "dante-auto" }; if (el.value) ch.fix[el.dataset.dantefix] = el.value; else delete ch.fix[el.dataset.dantefix]; saveDraft(); };
for (const b of main.querySelectorAll("[data-dantedel]")) b.onclick = () => { if (!ensureDraft()) return; const ch = dc(); delete ch.devices[b.dataset.dantedel]; if (typeof ch.fix === "object" && ch.fix) delete ch.fix[b.dataset.dantedel]; saveDraft(); route(); };
const da = q("#danteadd"); if (da) da.onclick = () => { if (!ensureDraft()) return; const n = q("#dantenew").value.trim(), k = q("#dantenewkey").value.trim(); if (!n) return; const ch = dc(); ch.devices = ch.devices || {}; ch.devices[n] = k || n.slice(0, 10).toUpperCase(); saveDraft(); route(); };
const rs = q("#resurvey"); if (rs) rs.onclick = async () => { try { await post("/api/command", { text: "resurvey" }); alertBox("Asked the watchdog to look at the switches; the map updates within a minute."); } catch (e) { alertBox(e.message); } };
const look = q("#splook"); if (look) look.onclick = async () => { const info = q("#spinfo"); info.textContent = "Looking…"; try { const r = await api("/api/live/spotify?url=" + encodeURIComponent(q("#splink").value)); if (!r.ok) { info.textContent = r.error; return; } if (!ensureDraft()) return; setPath("spotify.playlist", r.uri); if (r.name) { setPath("spotify.name", r.name); q("#spname").value = r.name; } q("#splink").value = r.uri; info.textContent = r.name ? `${r.name}: ${r.songs} songs. The play fix's words follow the name.` : (r.warning || ""); } catch (e) { info.textContent = e.message; } };
const spname = q("#spname"); if (spname) spname.onchange = () => setPath("spotify.name", spname.value.trim());
const splink = q("#splink"); if (splink) splink.onchange = () => { const m = splink.value.match(/(?:playlist[/:])([A-Za-z0-9]{22})/); if (m) setPath("spotify.playlist", "spotify:playlist:" + m[1]); else if (splink.value.trim()) alertBox("That is not a playlist link or URI."); };
const af = q("#atemfile"); if (af) af.onchange = async () => { const f = af.files[0]; if (!f) return; try { const r = await fetch("/api/atem-reference", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/xml" }, body: await f.arrayBuffer() }).then((x) => x.json()); showAtem(r); } catch (e) { alertBox(e.message); } };
const an = q("#atemnewest"); if (an) an.onclick = async () => { try { showAtem(await post("/api/atem-reference/pick-newest")); } catch (e) { alertBox(e.message); } };
const ad = q("#atemdiscard"); if (ad) ad.onclick = async () => { try { await post("/api/atem-reference/discard"); state.atemPending = false; badge(); route(); } catch (e) { alertBox(e.message); } };
function showAtem(r) {
if (!r.ok) { alertBox(r.error || "Not taken."); return; }
state.atemPending = true; if (!state.draft && ensureDraft()) saveDraft(); badge();
const diffs = (r.diffs || []).slice(0, 12).map((d) => d.part + " " + d.key + ": " + JSON.stringify(d.before) + " → " + JSON.stringify(d.after)).join("\n");
alertBox(`${r.file ? r.file + ": " : ""}${r.product || ""}, ${r.audio} audio and ${r.camera} camera values; ${r.diff_count} differ from the current file${diffs ? ":\n" + diffs : "."}\nApply replaces the file.`);
route();
}
main.innerHTML = `<h1>Site setup</h1><p class="what">Set up once. Each section opens on the few things a person changes; the numbers sit under Advanced. Editing arrives with step 2.</p><div class="tabs">${tabs}</div>${body}`;
}
// ---- Fixes ---------------------------------------------------------------
// ---- Fixes -------------------------------------------------------------------------
async function fixes(rest) {
const cfg = state.config, auto = new Set(cfg.auto_fix.fixes || []);
const c = cfg(), auto = new Set(c.auto_fix.fixes || []);
const used = {};
for (const t of cfg.tiles) for (const c of t.checks || []) { const fx = typeof c.fix === "object" && c.fix ? Object.values(c.fix) : [c.fix]; for (const f of fx) if (f) (used[f] = used[f] || new Set()).add(t.label); if (t.hold_always) (used[t.hold_always] = used[t.hold_always] || new Set()).add(t.label); }
for (const k of (cfg.restart_page || { keys: [] }).keys) (used[k.fix] = used[k.fix] || new Set()).add("restart key " + k.label);
const rows = Object.entries(cfg.fixes).map(([id, f]) => `<tr class="click${rest[0] === id ? " sel" : ""}" onclick="location.hash='#fixes/${esc(id)}'"><td><b>${esc(f.short || id)}</b></td><td>${esc(f.label || "")}</td><td class="small">${esc(f.kind || "ssh")}${f.host ? " on " + esc((cfg.hosts[f.host] || {}).name || f.host) : f.kind === "local" || f.kind === "spotify" ? " on this Mac" : ""}</td><td class="small">${[...(used[id] || [])].join(", ")}</td><td class="small">${auto.has(id) ? "may run by itself" : f.never_auto ? "never by itself" : "hold only"}</td></tr>`).join("");
const f = cfg.fixes[rest[0]];
const detail = f ? `<div class="card"><h2>${esc(f.short || rest[0])}</h2><p>${esc(f.label || "")}</p><details open><summary>Advanced</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details><p class="help">Builders (fixes made from fields instead of commands) arrive with step 2.</p></div>` : "";
main.innerHTML = `<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(cfg.fixes).length} today.</p><div class="tablewrap"><table><tr><th>key text</th><th>plain words</th><th>built as</th><th>used by</th><th>by itself</th></tr>${rows}</table></div>${detail}`;
const use = (f, who) => { if (f) (used[f] = used[f] || new Set()).add(who); };
for (const t of c.tiles) { for (const ch of t.checks || []) for (const f of (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix])) use(f, t.label); use(t.hold_always, t.label); }
for (const k of (c.restart_page || { keys: [] }).keys) use(k.fix, "restart key " + k.label);
const rows = Object.entries(c.fixes).map(([id, f]) => `<tr class="click${rest[0] === id ? " sel" : ""}" onclick="location.hash='#fixes/${esc(id)}'"><td><b>${esc(f.short || id)}</b></td><td>${esc(f.label || "")}</td><td class="small">${esc(f.kind || "ssh")}${f.host ? " on " + esc((c.hosts[f.host] || {}).name || f.host) : f.kind === "local" || f.kind === "spotify" ? " on this Mac" : ""}</td><td class="small">${[...(used[id] || [])].join(", ")}</td><td class="small">${auto.has(id) ? "may run by itself" : f.never_auto ? "never by itself" : "hold only"}</td></tr>`).join("");
const id = rest[0], f = c.fixes[id];
let detail = "";
if (f) {
const kind = f.kind || "ssh";
const fields = ["label", "short"].concat((state.schema.fix_kind_fields || {})[kind] || []).concat(["restart_ok", "never_auto", "timeout"]);
const inputs = editing() ? [...new Set(fields)].map((fl) => {
const Fd = F("fixes.*." + fl); if (!Fd) return "";
if (fl === "host") return `<div class="field"><label>Computer</label><select data-path="fixes.${esc(id)}.host"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${f.host === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select></div>`;
if (fl === "run") return `<div class="field"><label>Command (shown as a command at Review; never run before Apply)</label><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run || "")}</textarea><div class="help">zsh on a Mac, cmd.exe on a PC. ${esc(Fd.help)}</div></div>`;
return inputHtml(`fixes.${id}.${fl}`, Fd, f[fl]);
}).join("") : "";
const kindSel = editing() ? `<div class="field"><label>Built as</label><select data-path="fixes.${esc(id)}.kind" class="kindsel">${(state.schema.fix_kinds || ["ssh", "local"]).map((k) => `<option ${kind === k ? "selected" : ""}>${k}</option>`).join("")}</select></div>` : "";
const keys = [...(used[id] || [])];
detail = `<div class="card"><h2>${esc(f.short || id)}</h2><p>${esc(f.label || "")}</p>${kindSel}${inputs}${canRunNow(id) && editing() ? `<p class="help">Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).</p>` : ""}<details ${editing() ? "" : "open"}><summary>Advanced</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}<dt>id</dt><dd class="mono">${esc(id)}</dd></dl></details>${editing() && !keys.length ? `<button class="danger small" id="delfix">Delete this fix</button>` : ""}</div>`;
}
const add = editing() ? `<div class="row"><input id="newfix" placeholder="new fix id (letters, digits, _)"><button id="addfix" class="small">+ New fix</button></div>` : "";
main.innerHTML = `<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(c.fixes).length} today.</p>${add}<div class="tablewrap"><table><tr><th>key text</th><th>plain words</th><th>built as</th><th>used by</th><th>by itself</th></tr>${rows}</table></div>${detail}`;
const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value); route(); };
const del = main.querySelector("#delfix"); if (del) del.onclick = () => confirmBox(`Delete the fix ${id}? No key uses it.`, "Delete", () => { if (!ensureDraft()) return; delete state.draft.config.fixes[id]; const af2 = state.draft.config.auto_fix.fixes || []; state.draft.config.auto_fix.fixes = af2.filter((x) => x !== id); saveDraft(); location.hash = "#fixes"; });
const af = main.querySelector("#addfix"); if (af) af.onclick = () => { const nid = main.querySelector("#newfix").value.trim(); if (!/^[A-Za-z0-9_]+$/.test(nid) || c.fixes[nid]) return alertBox("A new id: letters, digits and _ only, not in use."); if (!ensureDraft()) return; state.draft.config.fixes[nid] = { kind: "local", short: nid.toUpperCase().slice(0, 12), label: "", run: "" }; saveDraft(); location.hash = "#fixes/" + nid; };
}
function alertBox(msg) { dialog(`<p>${esc(msg)}</p><button id="dok" class="primary">OK</button>`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); }
})();
+18
View File
@@ -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); }
+228
View File
@@ -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("<x/>")
self.site = server.Site(self.data, companion=mock.Mock())
self.launchctl = FakeLaunchctl(self.data)
self.applier = ap.Applier(self.site, run=self.launchctl, sleep=lambda s: None)
def run_apply(self, new_cfg, note="t", emergency=None):
r = self.applier.start(new_cfg, note, "sam", "10.1.1.50", emergency)
if r.get("ok"):
self.applier.thread.join(30)
return r
def test_a_timing_change_restarts_and_is_kept_in_history(self):
new = json.loads(json.dumps(self.cfg))
new["debounce_seconds"] = 4
r = self.run_apply(new, "slower")
self.assertTrue(r["ok"])
st = self.applier.status
self.assertEqual(st["state"], "done", st)
names = {s["name"]: s["state"] for s in st["steps"]}
self.assertEqual((names["check"], names["save"], names["restart"], names["pages"]), ("ok", "ok", "ok", "skipped"))
self.assertEqual(json.load(open(os.path.join(self.data, "config.json")))["debounce_seconds"], 4)
self.assertTrue(any(a[1] == "kickstart" for a in self.launchctl.calls))
entries = ap.history_entries(self.data)
self.assertEqual((entries[0]["by"], entries[0]["note"], entries[0]["actions"]), ("sam", "slower", ["restart"]))
self.assertEqual(ap.history_config(self.data, entries[0]["id"])["debounce_seconds"], 3) # the version before
self.assertEqual(oct(os.stat(os.path.join(self.data, "history", entries[0]["id"] + ".json")).st_mode)[-3:], "600")
def test_page_only_data_needs_no_restart(self):
new = json.loads(json.dumps(self.cfg))
new["gear"] = {"UHDJr": {"location": "lobby"}}
self.run_apply(new)
names = {s["name"]: s["state"] for s in self.applier.status["steps"]}
self.assertEqual(names["restart"], "skipped")
self.assertFalse(any(a[1] == "kickstart" for a in self.launchctl.calls))
def test_a_bad_config_is_refused_before_anything_happens(self):
new = json.loads(json.dumps(self.cfg))
new["fixes"]["vista"]["host"] = "nowhere"
self.run_apply(new)
self.assertEqual(self.applier.status["state"], "failed")
self.assertIn("unknown computer nowhere", self.applier.status["outcome"])
self.assertEqual(json.load(open(os.path.join(self.data, "config.json")))["fixes"]["vista"]["host"], "lighting")
self.assertEqual(ap.history_entries(self.data), [])
def test_rollback_when_the_watchdog_does_not_come_back(self):
self.launchctl.fail = True
new = json.loads(json.dumps(self.cfg))
new["debounce_seconds"] = 5
self.run_apply(new, "will fail")
st = self.applier.status
self.assertEqual(st["state"], "failed")
self.assertIn("previous settings", st["outcome"].lower())
self.assertEqual(json.load(open(os.path.join(self.data, "config.json")))["debounce_seconds"], 3) # put back
self.assertTrue(os.path.exists(os.path.join(self.data, "last-apply-failed.json")))
meta = ap.history_entries(self.data)[0]
self.assertIn("rolled back", meta["outcome"])
def test_layout_change_imports_the_page_and_recolours_the_nav_keys(self):
new = json.loads(json.dumps(self.cfg))
next(t for t in new["tiles"] if t["id"] == "led")["pos"] = [2, 8]
pushed, coloured, calls = [], [], {"n": 0}
def fetch(h, p, page): # before the push: only page keys; after: as built
calls["n"] += 1
return {"page": {"name": "19\\nStatus", "controls": {}}} if calls["n"] % 2 == 1 else ap.make_page.build(new)
with mock.patch.object(ap.push_page, "fetch_page", fetch), \
mock.patch.object(ap.push_page, "push", lambda *a: pushed.append(a[2])), \
mock.patch.object(ap.nav_keys, "colour", lambda *a: coloured.append(a) or 96):
self.run_apply(new, "move LED")
st = self.applier.status
self.assertEqual(st["state"], "done", st)
self.assertEqual(pushed, [19])
self.assertEqual(len(coloured), 1)
self.assertEqual(json.load(open(os.path.join(self.data, "build-hashes.json")))["status"], ap.build_hashes(new)["status"])
def test_a_foreign_key_on_the_page_stops_the_import_and_rolls_back(self):
new = json.loads(json.dumps(self.cfg))
next(t for t in new["tiles"] if t["id"] == "led")["pos"] = [2, 8]
busy = {"page": {"name": "19\\nStatus", "controls": {"2": {"3": {"type": "button-layered", "options": {"notes": "someone's key"}}}}}}
with mock.patch.object(ap.push_page, "fetch_page", lambda h, p, page: busy), mock.patch.object(ap.push_page, "push", lambda *a: self.fail("must not push")):
self.run_apply(new, "move LED")
st = self.applier.status
self.assertEqual(st["state"], "failed")
self.assertIn("row 2 col 3", st["outcome"])
self.assertEqual(next(t for t in json.load(open(os.path.join(self.data, "config.json")))["tiles"] if t["id"] == "led")["pos"], [3, 6])
self.assertTrue(os.path.exists(os.path.join(self.data, "pages-out-of-sync.json")))
def test_live_refuses_unless_emergency(self):
sp.write_json(os.path.join(self.data, "live-now.json"), {"live": True, "reason": "a HyperDeck is recording", "time": time.time()})
new = json.loads(json.dumps(self.cfg))
new["debounce_seconds"] = 4
r = self.run_apply(new)
self.assertFalse(r["ok"])
self.assertTrue(r.get("live"))
r = self.run_apply(new, "urgent", {"word": "LIVE", "reason": "the deck is wrong"})
self.assertTrue(r["ok"])
self.assertEqual(self.applier.status["state"], "done")
self.assertIn("EMERGENCY apply by sam", open(os.path.join(self.data, "apply.log")).read())
sp.write_json(os.path.join(self.data, "live-now.json"), {"live": True, "reason": "stale", "time": time.time() - 120})
self.assertEqual(self.applier.live(), (False, "")) # stale: the schedule (none) decides
def test_a_new_switcher_file_goes_with_the_config_and_is_kept(self):
pending = os.path.join(self.data, "draft-atem.xml")
open(pending, "w").write("<y/>")
new = json.loads(json.dumps(self.cfg))
r = self.applier.start(new, "new switcher file", "sam", "10.1.1.50", None, pending)
self.applier.thread.join(30)
self.assertEqual(open(os.path.join(self.data, "atem-reference.xml")).read(), "<y/>")
self.assertTrue(os.listdir(os.path.join(self.data, "history", "atem"))) # the old one, dated
self.assertFalse(os.path.exists(pending))
self.assertIn("switcher file", ap.history_entries(self.data)[0]["actions"])
def test_one_apply_at_a_time(self):
gate = threading.Event()
slow = ap.Applier(self.site, run=self.launchctl, sleep=lambda s: gate.wait(2))
new = json.loads(json.dumps(self.cfg))
new["debounce_seconds"] = 4
self.assertTrue(slow.start(new, "a", "sam", "x")["ok"])
self.assertIn("already running", slow.start(new, "b", "sam", "x")["error"])
gate.set()
slow.thread.join(30)
if __name__ == "__main__":
unittest.main()
+81 -3
View File
@@ -20,10 +20,11 @@ from settings import keyguide, probes, server # noqa: E402
from test_status_panel import CONFIG, FakeCompanion, healthy_snapshot # noqa: E402
def get(port, path, json_body=None, origin=None):
def get(port, path, json_body=None, origin=None, method=None, code=None, who=None):
data = json.dumps(json_body).encode() if json_body is not None else None
req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, path), data=data,
headers={"Content-Type": "application/json", **({"Origin": origin} if origin else {})})
headers = {"Content-Type": "application/json", **({"Origin": origin} if origin else {}),
**({"X-Edit-Key": code} if code else {}), **({"X-Editor": who} if who else {})}
req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, path), data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=10) as r:
body = r.read()
@@ -134,6 +135,83 @@ class TestServer(unittest.TestCase):
self.site.attempts.clear()
self.assertEqual(get(self.port, "/api/editing/check", {"code": "x"}, origin="http://evil.example")[0], 403)
# ---- step 2: the write routes ----
CODE = "apple river stone"
def put(self, path, body, method="PUT", who="sam", code=None):
st, raw = get(self.port, path, body, method=method, code=code or self.CODE, who=who)
return st, json.loads(raw)
def test_writes_need_the_code_and_a_name(self):
self.site.attempts.clear()
self.assertEqual(get(self.port, "/api/draft", {"config": {}}, method="PUT")[0], 401)
self.assertEqual(get(self.port, "/api/draft", {"config": {}}, method="PUT", code="wrong", who="sam")[0], 401)
self.assertEqual(get(self.port, "/api/draft", {"config": {}}, method="PUT", code=self.CODE)[0], 400) # no name
self.site.attempts.clear()
def test_draft_round_trip_conflict_and_discard(self):
self.site.attempts.clear()
live = self.site.load()
mine = json.loads(json.dumps(live)); mine["debounce_seconds"] = 4
st, r = self.put("/api/draft", {"config": mine})
self.assertEqual(st, 200)
v1 = r["draft"]["version"]
self.assertEqual(r["draft"]["config"]["debounce_seconds"], 4)
self.assertTrue(r["draft"]["config"]["site"]["editing_code"].startswith("set (")) # masked on the way out
self.assertEqual(json.loads(get(self.port, "/api/draft")[1])["draft"]["by"], "sam")
theirs = json.loads(json.dumps(live)); theirs["debounce_seconds"] = 5
st, r = self.put("/api/draft", {"config": theirs, "expect": v1}, who="chris")
self.assertEqual(st, 200)
st, r = self.put("/api/draft", {"config": mine, "expect": v1}) # sam's tab is behind
self.assertEqual(st, 409)
self.assertIn("chris", r["error"])
self.assertEqual(r["draft"]["config"]["debounce_seconds"], 5)
self.assertEqual(self.site.draft.read()["config"]["site"]["editing_code"], self.CODE) # the secret survived the masked round trip
st, r = self.put("/api/validate", {})
self.assertEqual(st, 200)
self.assertTrue(any("debounce seconds: %d → 5" % live["debounce_seconds"] in c["text"] for c in r["changes"]), r["changes"])
self.assertIn("purple", r["feel"].lower())
self.assertTrue(r["live"]["live"])
self.assertEqual(self.put("/api/draft", {}, method="DELETE")[0], 200)
self.assertIsNone(json.loads(get(self.port, "/api/draft")[1])["draft"])
def test_apply_waits_for_live_then_runs_restores_and_undoes(self):
from test_apply import FakeLaunchctl
self.site.attempts.clear()
fake = FakeLaunchctl(self.data)
self.site.applier.run, self.site.applier.sleep = fake, lambda s: None
live = self.site.load()
mine = json.loads(json.dumps(live)); mine["debounce_seconds"] = 6
self.put("/api/draft", {"config": mine})
st, r = self.put("/api/apply", {"note": "slower"}, method="POST")
self.assertEqual(st, 409)
self.assertTrue(r.get("live"))
self.assertIsNotNone(self.site.draft.read()) # the draft is kept
st, r = self.put("/api/apply", {"note": "slower", "emergency": {"word": "LIVE", "reason": "deck wrong"}}, method="POST")
self.assertEqual(st, 200, r)
self.site.applier.thread.join(30)
self.assertIsNone(self.site.draft.read())
status = json.loads(get(self.port, "/api/apply/status")[1])
self.assertEqual(status["state"], "done", status)
self.assertEqual(self.site.load()["debounce_seconds"], 6)
hist = json.loads(get(self.port, "/api/history")[1])["entries"]
self.assertEqual((hist[0]["by"], hist[0]["note"]), ("sam", "slower"))
detail = json.loads(get(self.port, "/api/history/" + hist[0]["id"])[1])
self.assertTrue(any("debounce seconds: %d → 6" % live["debounce_seconds"] in c["text"] for c in detail["changes_to_now"]), detail)
st, r = self.put("/api/restore/" + hist[0]["id"], {}, method="POST")
self.assertEqual(r["draft"]["config"]["debounce_seconds"], live["debounce_seconds"])
self.put("/api/draft", {}, method="DELETE")
st, r = self.put("/api/undo", {}, method="POST")
self.assertEqual((st, r["draft"]["config"]["debounce_seconds"], r["note"]), (200, live["debounce_seconds"], "slower"))
self.put("/api/draft", {}, method="DELETE")
# watchdog control waits for live too; a command goes to the watchdog through Companion
self.assertEqual(self.put("/api/watchdog/restart", {}, method="POST")[0], 409)
st, r = self.put("/api/command", {"text": "service off"}, method="POST")
self.assertEqual((st, r["sent"]), (200, "service off"))
self.assertIn("service off", FakeCompanion.store["custom"]["sp_cmd"])
self.assertEqual(self.put("/api/command", {"text": "rm -rf"}, method="POST")[0], 400)
self.assertEqual(self.put("/api/command", {"text": "test hd2 vista 0"}, method="POST")[0], 409) # no test while live
def test_probe_is_refused_while_a_fix_runs(self):
json.dump({"fix-x": {"key": "hd2"}}, open(os.path.join(self.data, "fixes-running.json"), "w"))
try: