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: