From 3b3a1c72451e2f5200ba7ced858d057e25e8f46c Mon Sep 17 00:00:00 2001 From: christian Date: Wed, 14 Oct 2026 15:09:17 -0400 Subject: [PATCH] Names check for a copied campus; the ATEM folder says when the page may not read Google Drive Site setup > Companion lists every connection, Dante device and MIDI session the keys use that this campus does not report, with a map-to choice that renames it across checks, restart keys and fixes. The ATEM section explains the Full Disk Access step when the cloud folder looks empty to the background page. Verified on NA Ingest; Install the staged folder tested live there too. Co-Authored-By: Claude Fable 5.1 --- settings/server.py | 58 ++++++++++++++++++++++++++++++++++++++++-- settings/ui/app.js | 25 ++++++++++++++++-- tests/test_settings.py | 18 +++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/settings/server.py b/settings/server.py index 46f8e50..7873113 100644 --- a/settings/server.py +++ b/settings/server.py @@ -644,12 +644,21 @@ class Handler(http.server.BaseHTTPRequestHandler): 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)), + mounted = bool(folder and os.path.isdir(folder)) + files = sorted(glob.glob(os.path.join(folder, "*.xml")), key=os.path.getmtime, reverse=True) if mounted else [] + blocked = False + if mounted and not files: + try: + blocked = not os.listdir(folder) and "/Library/CloudStorage/" in folder # a cloud folder that looks empty to a background process + except OSError: + blocked = True + return self.send(200, {"folder": folder, "mounted": mounted, "blocked": blocked, "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/site/names": + return self.send(200, self.names_check((site.draft.read() or {}).get("config") or site.load())) if path == "/api/setup": return self.send(200, {"computers": scripts.setup_all(site.load())}) if path == "/api/program/staged": @@ -878,6 +887,51 @@ class Handler(http.server.BaseHTTPRequestHandler): log.exception("%s %s", self.command, path) return self.send(500, {"error": str(e)}) + def names_check(self, cfg): + """Every device name the keys use, against what this Companion, the Dante network and the Macs report now: + the mapping step of a copied campus.""" + site = self.site + try: + live = {c["label"]: c.get("moduleId") for c in site.comp.connections()} + companion_ok = True + except Exception: + live, companion_ok = {}, False + used = {} + for t in cfg.get("tiles", []): + for c in t.get("checks", []): + if c.get("conn") and c["type"] != "dante" or (c["type"] == "dante" and c.get("conn")): + used.setdefault(c["conn"], {"keys": set(), "module": schema.EXPECTED_MODULE.get(c["type"])})["keys"].add(t.get("label", t["id"])) + for k in (cfg.get("restart_page") or {}).get("keys", []): + for w in k.get("watch", []): + if w.startswith("conn:"): + used.setdefault(w[5:], {"keys": set(), "module": None})["keys"].add("restart key " + k.get("label", k["id"])) + conns = [] + for name, u in sorted(used.items()): + cands = [l for l, m in live.items() if l not in used and (not u["module"] or not m or m == u["module"])] + conns.append({"name": name, "keys": sorted(u["keys"]), "live": name in live, "module": u["module"], + "live_module": live.get(name), "candidates": sorted(cands)}) + snap = site.read_json("snapshot.json", {}) or {} + dmap = snap.get("dante_map") or {} + dante = [] + for t in cfg.get("tiles", []): + for c in t.get("checks", []): + if c["type"] == "dante": + for n in c.get("devices", {}): + dante.append({"name": n, "seen": n in dmap, "candidates": sorted(x for x in dmap if x not in c["devices"])}) + local, remote = snap.get("midi") or {}, snap.get("remote_midi") or {} + midi = [] + for t in cfg.get("tiles", []): + for c in t.get("checks", []): + if c["type"] == "midi_peer": + src = (remote.get(c["host"]) or {}) if c.get("host") else local + sess = src.get(c.get("session")) if isinstance(src, dict) else None + peers = [p.get("name") for p in (sess or {}).get("peers", [])] if sess else [] + midi.append({"session": c.get("session"), "peer": c.get("peer"), "host": c.get("host"), "key": t.get("label", t["id"]), + "session_seen": sess is not None, "peer_seen": c.get("peer") in peers, + "sessions_seen": sorted(src) if isinstance(src, dict) else [], "peers_seen": peers}) + return {"companion_ok": companion_ok, "connections": conns, "dante": dante, "midi": midi, + "missing": sum(1 for c in conns if not c["live"]) + sum(1 for d in dante if not d["seen"]) + sum(1 for m in midi if not m["session_seen"] or not m["peer_seen"])} + def staged(self): """The staged folder (~/status-panel-install) against the running code.""" src = os.path.expanduser("~/status-panel-install") diff --git a/settings/ui/app.js b/settings/ui/app.js index 22b542e..4e3cad3 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -827,7 +827,7 @@ const plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced); let body = own.length ? `

Settings

${plain.map(fieldHtml).join("") || "

Only numbers here; they sit under Advanced.

"}${adv.length ? `
Advanced${adv.map(fieldHtml).join("")}
` : ""}
` : ""; if (which === "site") body += await programHtml(); - if (which === "companion") body = (await companionHtml()) + body; + if (which === "companion") body = (await namesHtml()) + (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; @@ -860,6 +860,25 @@
Export site bundle${editing() ? `` : ""}

New campus from a bundle: this campus's name, addresses and editing code stay; every computer from the bundle is flagged "copied from … - check it" until you touch it; keys, checks and fixes come along. Then Review & Apply.

`; } + async function namesHtml() { + const n = await api("/api/site/names").catch(() => null); + if (!n || !n.missing) return ""; + const sel = (kind, name, cands) => editing() ? `` : ""; + const conns = n.connections.filter((c) => !c.live).map((c) => `${lightDot("problem")}${esc(c.name)}${esc(c.keys.join(", "))}${n.companion_ok ? "not in this Companion" : "Companion not answering"}${c.module ? ` (needs module ${esc(c.module)})` : ""}${sel("conn", c.name, c.candidates)}`).join(""); + const dante = n.dante.filter((d) => !d.seen).map((d) => `${lightDot("warn")}${esc(d.name)}DANTEnot seen on the Dante network (last survey)${sel("dante", d.name, d.candidates)}`).join(""); + const midi = n.midi.filter((m) => !m.session_seen || !m.peer_seen).map((m) => `${lightDot("warn")}${esc(m.session)} › ${esc(m.peer)}${esc(m.key)}${!m.session_seen ? `session not seen${m.host ? " on " + esc((cfg().hosts[m.host] || {}).name || m.host) : ""}${m.sessions_seen.length ? " (seen: " + esc(m.sessions_seen.join(", ")) + ")" : ""}` : `peer not in the session now${m.peers_seen.length ? " (in it: " + esc(m.peers_seen.join(", ")) + ")" : ""}`}change it on the key`).join(""); + return `

Names the keys use that are not here (${n.missing})

A copied campus, or something renamed. Map each name to what this campus has; the keys, checks and restart keys that use it follow.

+
${conns}${dante}${midi}
nameused by
`; + } + function renameConn(from, to) { + const d = state.draft.config; + for (const t of d.tiles) for (const c of t.checks || []) if (c.conn === from) c.conn = to; + if (d.restart_page) for (const k of d.restart_page.keys) k.watch = (k.watch || []).map((w) => (w === "conn:" + from ? "conn:" + to : w)); + for (const f of Object.values(d.fixes)) if (f.conn === from) f.conn = to; + } + function renameDante(from, to) { + for (const t of state.draft.config.tiles) for (const c of t.checks || []) if (c.type === "dante" && c.devices && from in c.devices) { c.devices[to] = c.devices[from]; delete c.devices[from]; if (c.fix && typeof c.fix === "object" && from in c.fix) { c.fix[to] = c.fix[from]; delete c.fix[from]; } } + } async function companionHtml() { const cc = await api("/api/companion"); const lights = Object.entries(cc.lights || {}).map(([k, v]) => `
  • ${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}
  • `).join(""); @@ -893,10 +912,12 @@ const pending = state.atemPending ? `
    A new saved switcher file is waiting for Apply. ${editing() ? `` : ""}
    ` : ""; return `

    The saved switcher file

    The panel keeps the switcher's audio and camera settings in line with this file (atem-reference.xml). Save a new one from ATEM Software Control when the show changes.

    ${pending} ${editing() ? `
    ` : ""} -

    Folder: ${esc(folder.folder || "(not set)")} ${folder.mounted ? `· newest: ${(folder.files || []).length ? esc(folder.files[0].name) + " (" + esc(when(folder.files[0].when)) + ")" : "none"}` : "· not mounted"}

    `; +

    Folder: ${esc(folder.folder || "(not set)")} ${folder.mounted ? `· newest: ${(folder.files || []).length ? esc(folder.files[0].name) + " (" + esc(when(folder.files[0].when)) + ")" : "none"}` : "· not mounted"}

    + ${folder.blocked ? `
    The folder is there but this page is not allowed to read Google Drive (macOS keeps background programs out of cloud folders). Once, on the Ingest Mac: System Settings › Privacy & Security › Full Disk Access › add /usr/bin/python3, then restart the settings page from the Overview. Until then, use Upload a saved file.
    ` : ""}

    `; } function wireSite() { const q = (s) => main.querySelector(s); + for (const m of main.querySelectorAll("[data-map]")) m.onchange = () => { if (!m.value || !ensureDraft()) return; const [kind, name] = m.dataset.map.split("|"); (kind === "conn" ? renameConn : renameDante)(name, m.value); saveDraft(); route(); }; for (const b of main.querySelectorAll("[data-addconn]")) b.onclick = () => { const conn = b.dataset.addconn; if (!ensureDraft()) return; const d = state.draft.config; let id = slug(conn) || "key", n = 2; while (d.tiles.some((t) => t.id === id)) id = slug(conn) + "_" + n++; const t = { id, label: conn.toUpperCase().replace(/[_-]+/g, " ").slice(0, 10).trim(), checks: [{ type: "conn", conn, fix: "restart-connection" }] }; const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; d.tiles.push(t); saveDraft(); location.hash = "#keys/" + id; }; 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(); }; diff --git a/tests/test_settings.py b/tests/test_settings.py index 24a5cd9..3643452 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -250,6 +250,24 @@ class TestServer(unittest.TestCase): self.assertIn("running_version", json.loads(raw)) self.assertEqual(self.put("/api/program/install", {}, method="POST")[0], 409) + def test_names_check_lists_what_this_companion_lacks(self): + self.site.attempts.clear() + n = json.loads(get(self.port, "/api/site/names")[1]) + self.assertTrue(n["companion_ok"]) + live = self.site.load() + mine = json.loads(json.dumps(live)) + for t in mine["tiles"]: + for c in t.get("checks", []): + if c.get("conn") == "HyperDeck_2": + c["conn"] = "HyperDeck_Z" + self.put("/api/draft", {"config": mine}) + n = json.loads(get(self.port, "/api/site/names")[1]) + gone = [c for c in n["connections"] if not c["live"]] + self.assertEqual([(c["name"], c["keys"]) for c in gone], [("HyperDeck_Z", ["HD2"])]) + self.assertIn("HyperDeck_2", gone[0]["candidates"]) # same module, unused + self.assertNotIn("HyperDeck_1", gone[0]["candidates"]) # used by HD1 + self.put("/api/draft", {}, method="DELETE") + 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: