From 69bb09ad2752fd7ec4839c1fc6a5859d5c95a8e5 Mon Sep 17 00:00:00 2001 From: christian Date: Wed, 14 Oct 2026 17:42:03 -0400 Subject: [PATCH] The page revamped: icon navigation, hints instead of prose, one Settings page, a weekly calendar, a PIN - Top bar with icon navigation (Overview, Keys, Computers, Settings, Logs, History, Help); no side menu; every explanation moved into a hover hint; the [how] lines no longer shift the layout. - One Settings page with sections: Playlist, Auto-fix (a paintable weekly calendar + which fixes), Changes wait (calendar), HyperDecks, MIDI links, Computers, Dante, Switcher, Companion (with the names check), Network, Site & PIN, Program & campus, Advanced (every field by screen). - Fixes live under Keys as a tab; the jobs became sections or + buttons where they belong. - Editing is switched on with a PIN (digits) plus a first name for History; the overview's status line became chips; 'Needs attention' instead of a person's name, and it no longer fires after a page-only Apply (page-only.json marker); last change comes from History. - Deployed to NA Ingest; the campus PIN set there and in config.json. Co-Authored-By: Claude Fable 5.1 --- settings/apply.py | 8 + settings/server.py | 14 +- settings/ui/app.js | 811 ++++++++++++++++++++++------------------- settings/ui/index.html | 40 +- settings/ui/style.css | 155 ++++---- 5 files changed, 561 insertions(+), 467 deletions(-) diff --git a/settings/apply.py b/settings/apply.py index 934ba9b..16d354c 100644 --- a/settings/apply.py +++ b/settings/apply.py @@ -462,8 +462,16 @@ class Applier: self._rollback(entry, xml_backup, plan, old_cfg, detail) return self.step("restart", "ok", detail) + try: + os.remove(os.path.join(site.data_dir, "page-only.json")) + except OSError: + pass else: self.step("restart", "skipped", "nothing the watchdog reads changed") + started = site.read_json("started.json", {}) or {} + prev = site.read_json("page-only.json", {}) or {} + sp.write_json(os.path.join(site.data_dir, "page-only.json"), + {"sha": sha_after, "watchdog_sha": prev.get("watchdog_sha") if prev.get("watchdog_sha") == started.get("config_sha") else started.get("config_sha")}) # 4. pages if plan["pages"]: self.step("pages", "running") diff --git a/settings/server.py b/settings/server.py index 008f1b3..9fc6ce8 100644 --- a/settings/server.py +++ b/settings/server.py @@ -196,7 +196,9 @@ class Site: "level": "warn" if s.get("b") == 0xFFA000 else "err", "acked": s.get("b") == 0x8B0000, "since": None, "action": "hold the %s key (%s)" % (tile["label"], hold) if hold and not hold.startswith("nothing") else "no automatic fix: check the device"}) - if started and started.get("config_sha") and started["config_sha"] != self.cfg_sha: + page_only = self.read_json("page-only.json", {}) or {} # the last Apply changed nothing the watchdog reads + if started and started.get("config_sha") and started["config_sha"] != self.cfg_sha \ + and not (page_only.get("sha") == self.cfg_sha and page_only.get("watchdog_sha") == started["config_sha"]): needs.append("The watchdog is running an older settings file than the one on disk (restart it to pick up the change).") pages = self.pages_state() if pages.get("status") and not pages["status"]["ok"]: @@ -314,12 +316,12 @@ class Site: return self.cached("pages", 60, look) def last_change(self): - baks = sorted(glob.glob(os.path.join(self.data_dir, "config.json.bak-*"))) - if not baks: + entries = applying.history_entries(self.data_dir) + if not entries: return None - m = re.search(r"bak-(\d{8})-(\d{6})$", baks[-1]) - return {"when": "%s-%s-%s %s:%s" % (m.group(1)[:4], m.group(1)[4:6], m.group(1)[6:], m.group(2)[:2], m.group(2)[2:4]) if m else None, - "by": "the installer", "note": "config.json replaced"} + e = entries[0] + return {"when": time.strftime("%Y-%m-%d %H:%M", time.localtime(e["when"])) if e.get("when") else None, "by": e.get("by"), "note": e.get("note"), + "outcome": e.get("outcome")} def companion_view(self): cfg = self.load() diff --git a/settings/ui/app.js b/settings/ui/app.js index cafb4f3..25c77d4 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -1,6 +1,6 @@ -/* 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. */ +/* The settings page. Plain JS, no framework. Everything is readable; with Editing on (the PIN + your + first name) the same screens grow inputs; changes live in one shared draft on the server until + Review & Apply. Short labels everywhere; the explanation sits in a hint you hover. */ (function () { "use strict"; const main = document.getElementById("main"); @@ -9,14 +9,18 @@ const ago = (s) => s == null ? "" : s < 90 ? Math.round(s) + " s ago" : s < 5400 ? Math.round(s / 60) + " min ago" : Math.round(s / 3600) + " h ago"; const when = (t) => t ? new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) : ""; const hex = (n) => "#" + (n >>> 0).toString(16).padStart(6, "0").slice(-6); - const lightDot = (l) => ``; + const lightDot = (l, hintText) => ``; const wordOf = (w) => `${esc(w)}`; + const hint = (text) => text ? `i` : ""; 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; + const slug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""); + const tagOf = (t) => t.toUpperCase().replace(/[^A-Z0-9 ]+/g, " ").trim().slice(0, 10).trim(); + const lightWord = { ok: "OK", problem: "Problem", not_needed: "Not needed", hand: "Done by hand", unknown: "Can't see from here", warn: "Warning" }; async function api(path, opts) { opts = opts || {}; @@ -30,11 +34,12 @@ } const post = (path, body, method) => api(path, { method: method || "POST", body: JSON.stringify(body || {}) }); - // ---- top bar: legend, editing code ---------------------------------------- + // ---- top bar: legend, the PIN ---------------------------------------------- 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.querySelector("span").textContent = state.editing ? state.name : "Edit"; + editbtn.dataset.hint = state.editing ? "Editing as " + state.name + " · click to switch off" : "Switch Editing on with the PIN"; editbtn.classList.toggle("on", !!state.editing); document.body.classList.toggle("editing", !!state.editing); } @@ -42,18 +47,18 @@ showEditing(); editbtn.onclick = () => { if (state.editing) { state.editing = null; state.name = null; store.set("sp-code", null); store.set("sp-name", null); showEditing(); route(); return; } - dialog(`

Switch Editing on

The editing code is the "editing code" line in the campus's settings file in the repo (config.json); whoever has the repo has it.

- + dialog(`

Editing

`, (box) => { box.querySelector("#dcancel").onclick = closeDialog; 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; 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).`; + 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 PIN is set yet (Settings › Site)." : !name ? "Your first name, please." : `Not the PIN (${r.tries_left} tries left).`; }; box.querySelector("#dok").onclick = go; box.querySelector("#dname").onkeydown = (e) => { if (e.key === "Enter") go(); }; + box.querySelector("#dcode").onkeydown = (e) => { if (e.key === "Enter") box.querySelector("#dname").focus(); }; box.querySelector("#dcode").focus(); }); }; @@ -66,6 +71,7 @@ b.querySelector("#dok").onclick = () => { if (word && b.querySelector("#dword").value.trim() !== word) return; closeDialog(); onOk(); }; }); } + const needEditing = () => { if (!editing()) { alertBox("Switch Editing on first (Edit, top right)."); return false; } return true; }; // ---- the draft ------------------------------------------------------------ async function loadAll() { @@ -75,7 +81,7 @@ badge(); } function ensureDraft() { - if (!editing()) { alertBox("Switch Editing on first (top right)."); return false; } + if (!needEditing()) return false; if (!state.draft) state.draft = { config: deep(state.live), version: null, by: state.name }; return true; } @@ -92,10 +98,10 @@ } 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); } + if (!b) { b = document.createElement("a"); b.id = "badge"; b.className = "badge"; b.href = "#review"; b.dataset.hint = "Your unsaved changes: review them, then APPLY"; 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.textContent = n ? `${n} change${n > 1 ? "s" : ""} · Apply` : "Draft"; b.classList.toggle("warn", !!state.draftStale); } function saveDraft() { @@ -117,7 +123,6 @@ return state.saving; } const flush = () => state.saving || Promise.resolve(); - // 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; @@ -127,7 +132,7 @@ } const getPath = (path, from) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), from || cfg()); - // ---- generic inputs from the registry ----------------------------------- + // ---- inputs from the registry: a short label, a hint, the input ------------ 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; } @@ -136,22 +141,24 @@ if (f.type === "dict") { try { return raw.trim() ? JSON.parse(raw) : undefined; } catch (e) { return undefined; } } return raw === "" ? undefined : raw; } + const LABELS = { editing_code: "PIN", ssh: "log-in address", mac: "hardware address", ingest_ips: "this Mac's addresses", ingest_name: "this Mac's name", ingest_hostname: "network name", hold_always: "hold also when green", page_hold_ms: "hold time (ms)", optional: "may have no disk" }; function inputHtml(path, f, value, extra) { if (!f) return ""; const id = "f-" + path.replace(/[^A-Za-z0-9]/g, "-"); const dis = editing() ? "" : "disabled"; + const last = path.split(".").pop(); let html; if (f.type === "bool") html = ``; else if (f.type === "enum" && f.options) html = ``; - else if (f.type === "dict") html = ``; - else if (f.type === "list") html = ``; - else html = ``; - const dflt = f.default != null && typeof f.default !== "object" && f.default !== "" ? ` (default ${esc(f.default)})` : ""; - return `
${html}
${esc(f.help)}${dflt}${extra || ""}
`; + else if (f.type === "dict") html = ``; + else if (f.type === "list") html = ``; + else html = ``; + const dflt = f.default != null && typeof f.default !== "object" && f.default !== "" ? ` Default ${f.default}.` : ""; + return `
${esc(LABELS[last] || last.replace(/_/g, " "))}${f.required ? " *" : ""}${hint((f.help || "") + dflt)}${html}${extra || ""}
`; } function wireInputs(root) { for (const el of root.querySelectorAll("[data-path]")) { - if (el.onchange) continue; // a screen wired it itself + if (el.onchange) continue; el.onchange = () => { const path = el.dataset.path, f = fieldFor(path); const v = el.type === "checkbox" ? el.checked : el.value; @@ -178,26 +185,28 @@ 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, "-")}`]); + 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 || ""}`, `#keys/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.replace(/_/g, " ")}`, `#settings/advanced`]); results.innerHTML = hits.slice(0, 20).map(([t, h]) => `${esc(t)}`).join("") || `Nothing called "${esc(q)}"`; results.hidden = false; }; search.onblur = () => setTimeout(() => { results.hidden = true; }, 200); // ---- routing --------------------------------------------------------------- - const screens = { overview, keys, computers, logs, history, help, site, fixes, review }; + const screens = { overview, keys, computers, settings, logs, history, help, 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); + let [name, ...rest] = (location.hash || "#overview").slice(1).split("/"); + if (name === "site") { name = "settings"; rest = [{ site: "site", schedules: "autofix", companion: "companion", "network-dante": "dante", spotify: "playlist", atem: "switcher" }[rest[0]] || "playlist"]; } + if (name === "fixes") { name = "keys"; rest = ["fixes"].concat(rest); } + for (const a of document.querySelectorAll(".nav a")) a.classList.toggle("active", a.getAttribute("href") === "#" + name); clearInterval(timer); timer = null; try { if (!state.live) await loadAll(); const fn = screens[name] || overview; await fn(rest); wireInputs(main); - const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0])); // never under a typist's hands + const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0])); if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000); } catch (e) { main.innerHTML = `

Could not load this screen: ${esc(e.message)}

`; } } @@ -206,7 +215,7 @@ // ---- Overview -------------------------------------------------------------- async function overview(_, quiet) { - if (quiet && document.activeElement && document.activeElement.tagName === "INPUT") return; // never pull the field out from under a typist + if (quiet && document.activeElement && document.activeElement.tagName === "INPUT") return; const o = await api("/api/overview"); const dot = document.getElementById("hbdot"); dot.className = "dot " + (o.heartbeat_age != null && o.heartbeat_age < 15 ? "ok" : "bad"); @@ -214,192 +223,42 @@ 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]) => ` `).join(""); + return fixes.slice(0, 1).map(([f, j]) => ``).join(""); }; - const prob = o.problems.length ? o.problems.map((p) => `
  • ${esc(p.text)}${p.acked ? ' (acknowledged)' : ""} — ${esc(p.action)} open the key${runnable(p)}
  • `).join("") : `
  • Everything is fine.
  • `; - const needs = (o.needs || []).map((n) => `
    Needs Chris: ${esc(n)}
    `).join(""); + const prob = o.problems.length ? o.problems.map((p) => `
  • ${esc(p.text)}${p.acked ? 'acknowledged' : ""}hold ${esc(p.label)} ↗${runnable(p)}
  • `).join("") : `
  • Everything is fine.
  • `; + const needs = (o.needs || []).map((n) => `
    Needs attention: ${esc(n)}
    `).join(""); const failed = o.failed_last ? `
    The last Apply was put back: ${esc(o.failed_last.why || "")} History
    ` : ""; - const live = isLive ? `
    Live now: ${esc(o.live.reason)}. Changes to the panel wait.
    ` : ""; + const live = isLive ? `
    Live now: ${esc(o.live.reason)}. Changes wait.
    ` : ""; const comps = o.computers.map((c) => `${wordOf(c.word)}${esc(c.name)}${esc(c.reason)}`).join(""); const w = o.watchdog; - const dis = editing() ? "" : "disabled title='switch Editing on'"; + const dis = editing() ? "" : "disabled"; + const chip = (ok, text, hintText) => `${text}`; main.innerHTML = ` -

    ${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") + " in the room" : "Everything is fine"}

    -

    Is the room OK, and what do I do. Problems mirror the red keys; hold the key, or open it for the details.

    +

    ${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") : "Everything is fine"}${hint("The red keys, in words. Hold the key on the deck, or open it.")}

    ${needs}${failed}${live} -
    -

    Live deck ${o.deck ? "as the Stream Deck looks now · click a key" : "(Companion is not answering)"}

    ${deckHtml(o.deck, cfg(), "status")} -
    -
    -

    Computers

    ${comps}
    +
    + ${chip(w.running, w.running ? "Watchdog ✓" : "Watchdog down", (w.running ? "Running since " + when(w.since) : "Not running") + (w.version ? " · code " + w.version : "") + (o.heartbeat_age != null ? " · heartbeat " + ago(o.heartbeat_age) : ""))} + ${chip(o.companion_ok, o.companion_ok ? "Companion ✓" : "Companion down", o.pages && o.pages.status ? (o.pages.status.ok ? "Pages OK" : "Page " + o.pages.status.page + " is called " + (o.pages.status.live || "?")) : "")} + ${chip(o.service_on == null ? null : o.service_on, o.service_on == null ? "Alarms ?" : o.service_on ? "Alarms on" : "Alarms silenced", o.phrases.alarms)} + ${chip(null, "Auto-fix " + (o.auto_fix.state && o.auto_fix.state.on ? "active" : "waiting"), o.auto_fix.phrase + (o.auto_fix.state && o.auto_fix.state.paused ? " (waiting out a network blip)" : ""))} + ${Object.keys(o.running || {}).length ? chip(null, "Fix running", Object.values(o.running).map((r) => r.what + " on " + r.key).join(", ")) : ""} + ${o.last_change ? `Last change ${esc(o.last_change.when || "")}` : ""}
    -
    -
    -
    Watchdog
    ${w.running ? "OK, running" : "not running"}${w.since ? " since " + esc(when(w.since)) : ""}${w.version ? ` code ${esc(w.version)}` : ""}${o.heartbeat_age != null ? ` · heartbeat ${esc(ago(o.heartbeat_age))}` : ""}
    -
    Companion
    ${o.companion_ok ? "OK" : "not answering"}${o.pages && o.pages.status ? (o.pages.status.ok ? ", pages OK" : ", page " + o.pages.status.page + " is called " + esc(o.pages.status.live || "?")) : ""}
    -
    Alarms
    ${o.service_on == null ? "not reported yet" : o.service_on ? "ON" : "silenced until midnight"}${o.alarms_override != null ? " (by hand)" : ""} · ${esc(o.phrases.alarms)}
    -
    Auto-fix
    ${esc(o.auto_fix.phrase)}${o.auto_fix.state && o.auto_fix.state.on ? " · may act now" : ""}${o.auto_fix.state && o.auto_fix.state.paused ? " (waiting out a network blip)" : ""}
    -
    Running now
    ${Object.keys(o.running || {}).length ? Object.values(o.running).map((r) => esc(r.what + " on " + r.key)).join(", ") : "no fix is running"}
    -
    Last change
    ${o.last_change ? esc(o.last_change.when + " · " + o.last_change.note) : "—"} History
    -
    -
    - - - ${w.running ? `` : ""} -
    +
    +
    ${deckHtml(o.deck, cfg(), "status")} +
    + + + ${w.running ? `` : ""} +
    +
    ${comps}
    `; 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("|")); - for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job); - const mj = main.querySelector("#morejobs"); if (mj) mj.onchange = () => { const j = mj.value; mj.value = ""; if (j) job(j); }; if (!quiet) window.scrollTo(0, 0); } - - // ---- Jobs: the changes people actually make, one dialog each -------------------- - const slug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""); - const tagOf = (t) => t.toUpperCase().replace(/[^A-Z0-9 ]+/g, " ").trim().slice(0, 10).trim(); - async function job(which) { - if (!editing()) return alertBox("Switch Editing on first (top right)."); - clearInterval(timer); timer = null; - if (which === "playlist") return jobPlaylist(); - if (which === "hyperdecks") return jobHyperdecks(); - if (which === "midi") return jobMidi(); - if (which === "computer") return jobComputer(); - if (which === "pcprogram") return jobPcProgram(); - } - function jobPlaylist() { - const sp = cfg().spotify || {}; - dialog(`

    Change the playlist

    Paste the playlist link from Spotify (Share › Copy link). The SPOTIFY key, the play fix and the key guide follow it.

    -

    Now: ${esc(sp.name || "?")}

    -
    `, (b) => { - b.querySelector("#dcancel").onclick = closeDialog; - b.querySelector("#dok").onclick = async () => { - const info = b.querySelector("#jinfo"); info.textContent = "Looking…"; - try { - const r = await api("/api/live/spotify?url=" + encodeURIComponent(b.querySelector("#jlink").value)); - if (!r.ok) { info.textContent = r.error; return; } - if (!ensureDraft()) return; - const c = state.draft.config; c.spotify = Object.assign({}, c.spotify, { playlist: r.uri }); if (r.name) c.spotify.name = r.name; - await saveDraft(); - for (const [id, f] of Object.entries(c.fixes)) if (f.kind === "spotify") { const bb = await post("/api/build", { fix: f }); if (bb.ok && bb.label) f.label = bb.label; } - await saveDraft(); closeDialog(); location.hash = "#review"; - } catch (e) { info.textContent = e.message; } - }; - }); - } - async function jobHyperdecks() { - let cc; - try { cc = await api("/api/companion"); } catch (e) { return alertBox("Companion is not answering right now (" + e.message + "); try again in a moment."); } - const c = cfg(); - const decks = (cc.connections || []).filter((r) => r.module === "bmd-hyperdeck"); - const keyFor = (conn) => c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn)); - const rows = decks.map((d) => { const k = keyFor(d.label); const chk = k && k.checks.find((ch) => ch.conn === d.label); return `${k ? esc(k.label) + (k.pos ? ` (row ${k.pos[0]}, col ${k.pos[1]})` : "") : "no key"}`; }).join(""); - if (!decks.length) return alertBox("Companion has no HyperDeck connections (module bmd-hyperdeck). Add the connection in Companion first."); - dialog(`

    Add or remove a HyperDeck

    Tick the decks in use. A ticked deck gets an HD key in the REC group; an unticked one loses its key (its cell stays empty; drag another key there if you like). "May have no disk" means NO DISK is a warning, not an alarm.

    -
    ${rows}
    Companion connectionkey
    -
    `, (b) => { - b.querySelector("#dcancel").onclick = closeDialog; - b.querySelector("#dok").onclick = () => { - if (!ensureDraft()) return; - const d = state.draft.config; - const rec = d.tiles.find((t) => (t.members || []).some((m) => d.tiles.some((x) => x.id === m && (x.checks || []).some((ch) => ch.type === "hyperdeck")))) || d.tiles.find((t) => t.id === "rec"); - const fixId = Object.keys(d.fixes).find((id) => d.fixes[id].kind === "hyperdeck"); - for (const cb of b.querySelectorAll("[data-deck]")) { - const conn = cb.dataset.deck, nodisk = b.querySelector(`[data-nodisk="${CSS.escape(conn)}"]`).checked; - const existing = d.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn)); - if (cb.checked && existing) { const ch = existing.checks.find((x) => x.conn === conn); if (nodisk) ch.optional = true; else delete ch.optional; } - else if (cb.checked) { - const n = (conn.match(/(\d+)\s*$/) || [])[1]; let id = n ? "hd" + n : slug(conn), k = 2; while (d.tiles.some((t) => t.id === id)) id = (n ? "hd" + n : slug(conn)) + "_" + k++; - const t = { id, label: n ? "HD" + n : conn.toUpperCase().slice(0, 8), hold_always: fixId || undefined, checks: [Object.assign({ type: "hyperdeck", conn }, nodisk ? { optional: true } : {}, fixId ? { fix: { conn: fixId } } : {})] }; - const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; - d.tiles.push(t); if (rec) rec.members.push(id); - } else if (existing) { d.tiles.splice(d.tiles.indexOf(existing), 1); for (const t of d.tiles) if (t.members) t.members = t.members.filter((m) => m !== existing.id); } - } - saveDraft(); closeDialog(); location.hash = "#keys"; - }; - }); - } - async function jobMidi() { - const live = await api("/api/live/midi").catch(() => ({ local: {}, remote: {} })); - const c = cfg(); - const macs = Object.entries(c.hosts).filter(([, h]) => h.self || (h.os || "mac") === "mac"); - const midiKey = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "midi_peer")); - const fixId = Object.keys(c.fixes).find((id) => c.fixes[id].kind === "midi_connect"); - const sessionsOf = (alias) => { const h = c.hosts[alias]; const src = h.self ? (live.local || {}) : ((live.remote || {})[alias] || {}); return Object.entries(src).map(([name, v]) => [name, (v.peers || []).map((p) => p.name)]); }; - dialog(`

    Add a MIDI link

    A network MIDI session on one of the Macs, and the peer that must stay in it. Pick from what the Macs report now, or type the names if that Mac is off.

    -
    -
    -
    -
    -
    -
    -
    `, (b) => { - b.querySelector("#dcancel").onclick = closeDialog; - const fill = () => { const ss = sessionsOf(b.querySelector("#jhost").value); b.querySelector("#jsessions").innerHTML = ss.map(([n]) => `
    `; } - // ---- Keys ------------------------------------------------------------------ + // ---- Keys: the two pages and the fixes ------------------------------------- async function keys(rest) { await flush(); + if (rest[0] === "fixes") return fixes(rest.slice(1)); 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 hold = ((c.page_hold_ms || 2000) / 1000) + " s"; - const tabs = `
    ${c.restart_page ? `` : ""}
    - ${editing() ? `

    In Companion the Status page is page ${c.restart_page ? ` and the Restart page is page ` : ""}

    ` : ""}`; - let panel = `

    Click a key to see what it watches and what holding it does.${editing() ? " With Editing on you can change it here, drag a key to move it, drag it onto the tray to hide it." : ""}

    `; + const tabs = `
    ${c.restart_page ? `` : ""} + ${editing() ? `page ${c.restart_page ? ` · restart page ` : ""} ` : ""}
    `; + let panel = `

    Click a key.${editing() ? " Drag to move; drop on the tray to hide." : ""}

    `; 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 trayKeys = c.tiles.filter((t) => !t.pos && t.kind !== "master"); const tray = trayKeys.map((t) => ``).join(" "); let note = ""; - if (state.draft) { try { const v = await post("/api/validate", {}); if (v.plan && v.plan.pages.length) note = `
    Layout changed: Apply will re-import the ${v.plan.pages.filter((p) => p === "status" || p === "restart").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.
    `; } catch (e) {} } - const adders = editing() ? (restart ? `` : ``) : ""; - main.innerHTML = `

    Keys

    The deck as people hold it. Click a key; with Editing on, change what it watches and what holding it does, drag it to move it.

    ${tabs}${note} -
    ${restart && !c.restart_page ? "

    This campus has no Restart page.

    " : deckHtml(o.deck, c, restart ? "restart" : "status", sel, true)} - ${!restart ? `

    Shown inside their group, no key of their own: ${tray || "(none)"}${editing() ? " · drop a key here to hide it" : ""}

    ` : ""} + if (state.draft) { try { const v = await post("/api/validate", {}); if (v.plan && v.plan.pages.length) note = `
    Layout changed: Apply re-imports the ${v.plan.pages.filter((p) => p === "status" || p === "restart").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.
    `; } catch (e) {} } + const adders = editing() ? (restart ? `` : ``) : ""; + main.innerHTML = `

    Keys${hint("The deck as people hold it. Click a key to see what it watches and what holding it does.")}

    ${tabs}${note} +
    ${restart && !c.restart_page ? "

    No Restart page.

    " : deckHtml(o.deck, c, restart ? "restart" : "status", sel, true)} + ${!restart ? `

    In a group only${hint("Keys with no spot of their own: they show inside their group key. Drop a key here to hide it.")}: ${tray || "none"}

    ` : ""}
    ${adders}
    ${panel}
    `; wireKeyPanel(sel, restart); wireLayout(restart); @@ -520,9 +380,9 @@ else { const pos = firstEmpty(c, "status"); if (!pos) return alertBox("No free spot on the Status page for the RESTART PAGE key."); c.restart_page = { page: c.page + 1, page_name: `${c.page + 1}\nRestart`, jump_pos: pos, back_pos: pos, keys: [] }; saveDraft(); location.hash = "#keys/restart"; route(); } if (!hr.checked) hr.checked = true; }; - const nk = main.querySelector("#newkey"); if (nk) nk.onclick = () => dialog(`

    New key

    It starts with no checks: add them on the key, then Apply.

    `, (b) => { + const nk = main.querySelector("#newkey"); if (nk) nk.onclick = () => dialog(`

    New key

    `, (b) => { b.querySelector("#dcancel").onclick = closeDialog; - b.querySelector("#nlabel").oninput = () => { b.querySelector("#nid").value = b.querySelector("#nlabel").value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""); }; + b.querySelector("#nlabel").oninput = () => { b.querySelector("#nid").value = slug(b.querySelector("#nlabel").value); }; b.querySelector("#dok").onclick = () => { const label = b.querySelector("#nlabel").value.trim(), id = b.querySelector("#nid").value.trim(); if (!label || !/^[A-Za-z0-9_]+$/.test(id)) return; @@ -533,14 +393,14 @@ state.draft.config.tiles.push(t); saveDraft(); closeDialog(); location.hash = "#keys/" + id; }; }); - const nr = main.querySelector("#newrkey"); if (nr) nr.onclick = () => { const c = cfg(); dialog(`

    New restart key

    `, (b) => { + const nr = main.querySelector("#newrkey"); if (nr) nr.onclick = () => { const c = cfg(); dialog(`

    New restart key

    `, (b) => { b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = () => { const label = b.querySelector("#nlabel").value.trim(), fix = b.querySelector("#nfix").value; if (!label) return; const pos = parsePos(b.querySelector("#npos").value, cfg(), "restart", false); if (!pos) return; if (!ensureDraft()) return; - let id = "rs_" + label.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""), n = 2; + let id = "rs_" + slug(label), n = 2; while (state.draft.config.restart_page.keys.some((k) => k.id === id)) id = id.replace(/_\d+$/, "") + "_" + n++; state.draft.config.restart_page.keys.push({ id, label, fix, pos, watch: [] }); saveDraft(); closeDialog(); location.hash = "#keys/restart/" + id; }; @@ -555,33 +415,33 @@ 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 `
  • ${esc(sentence)}
    Advanced
    ${Object.entries(chk).map(([n, v]) => `
    ${esc(n)}
    ${esc(typeof v === "object" ? JSON.stringify(v) : v)}
    `).join("")}
  • `; + if (!editing()) return `
  • ${esc(sentence)}
  • `; const base = `tiles.${i}.checks.${j}.`; let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join(""); if (chk.type === "remote") { const ctpls = state.builders.checks.concat([{ name: "custom", words: "Custom command (typed by hand)", params: [] }]); const ctName = chk.template || "custom", ct = ctpls.find((x) => x.name === ctName); - fields = `
    ${paramsHtml(ct, chk.params || {}, "ck" + j)}
    ` - + (ctName === "custom" ? fields : inputHtml(base + "msg", F("tiles[].checks[].msg"), chk.msg) + (chk.cmd ? `
    ${esc(chk.cmd)}
    ` : "")); + fields = `
    built as
    ${paramsHtml(ct, chk.params || {}, "ck" + j)}
    ` + + (ctName === "custom" ? fields : inputHtml(base + "msg", F("tiles[].checks[].msg"), chk.msg) + (chk.cmd ? `
    the command
    ${esc(chk.cmd)}
    ` : "")); } - const fixSel = typeof chk.fix === "object" && chk.fix ? `
    A table: sub-check → fix, "*" for everything else.
    ` - : `
    `; - const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `` : ""; - return `
  • ${esc(spec.question || chk.type)} ${esc(sentence)}
    ${fields}${fixSel} -
    Advanced${["tag", "service_only", "debounce_seconds"].filter((f) => !(spec.fields || []).includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("")}

    type ${esc(chk.type)}

    -
    ${run}
  • `; + const fixSel = typeof chk.fix === "object" && chk.fix ? `
    fix per device${hint('A table: device → fix, "*" for everything else')}
    ` + : `
    fix${hint("What holding the key runs for this check")}
    `; + const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `` : ""; + return `
  • ${esc(spec.question || chk.type)} ${esc(sentence)}
    ${fields}${fixSel} +
    more${["tag", "service_only", "debounce_seconds"].filter((f) => !(spec.fields || []).includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("")}

    type ${esc(chk.type)}

    +
    ${run}
  • `; }).join(""); const plain = !t.members && t.kind !== "master"; - const members = t.members ? (editing() ? `
    ${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => ``).join(" ")}
    ` : `

    Sums up: ${t.members.map((m) => `${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}`).join(", ")}

    `) : ""; - const addMenu = editing() && plain ? `
    ` : ""; + const members = t.members ? (editing() ? `
    group members${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => ``).join(" ")}
    ` : `

    Sums up: ${t.members.map((m) => `${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}`).join(", ")}

    `) : ""; + const addMenu = editing() && plain ? `
    add a check
    ` : ""; const label = editing() ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : ""; - const holdAlways = editing() && plain ? `
    ` : ""; - const pos = `
    Advanced
    id
    ${esc(t.id)}
    ${editing() && !t.kind ? `
    ` : ""}
    `; - return `

    ${esc(t.label)}

    ${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)}` : ""}

    - ${kt.says && kt.says.length ? `

    WHAT THIS KEY WILL SAY: ${esc(kt.says.join(", "))}

    ` : ""} -

    HOLDING ${esc(hold)} WILL: ${esc(kt.holding)}${t.hold_always ? " also when green" : ""}

    - ${label}${members}${holdAlways}${checks ? `

    Checks:

      ${checks}
    ` : ""}${addMenu}${pos} - ${editing() && (!t.kind || t.kind === "network") ? `

    ` : ""}
    `; + const holdAlways = editing() && plain ? `
    hold also when green${hint("A fix that runs on every hold, even with nothing failing")}
    ` : ""; + const pos = `
    more
    id
    ${esc(t.id)}
    ${editing() && !t.kind ? `
    position${hint("row, column; blank = inside its group only")}
    ` : ""}
    `; + return `

    ${esc(t.label)} ${t.pos ? `row ${t.pos[0]}, col ${t.pos[1]}` : "in a group"}${t.kind ? ` · ${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}` : ""}

    + ${kt.says && kt.says.length ? `

    Can say ${esc(kt.says.join(", "))}

    ` : ""} +

    Hold ${esc(hold)} ${esc(kt.holding)}${t.hold_always ? " also when green" : ""}

    + ${label}${members}${holdAlways}${checks ? `
      ${checks}
    ` : ""}${addMenu}${pos} + ${editing() && (!t.kind || t.kind === "network") ? `

    ` : ""}
    `; } 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) { @@ -617,10 +477,10 @@ 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)} -
    -
    ` : ""; - return `

    ${esc(r.label)}

    row ${r.pos[0]}, column ${r.pos[1]}

    HOLDING ${esc(hold)} WILL: ${esc(f.label || r.fix)}, even when the Status page says it is fine.

    ${r.watch && r.watch.length ? `

    Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}

    ` : ""}${edit} - ${canRunNow(r.fix) && editing() ? `` : ""}
    Advanced
    id
    ${esc(r.id)}
    ${editing() ? `

    ` : ""}
    `; +
    hold runs
    +
    flash while down${hint("Keeps flashing HOLD TO FIX while these are down; hold cmd/ctrl to pick several")}
    ` : ""; + return `

    ${esc(r.label)} row ${r.pos[0]}, col ${r.pos[1]}

    Hold ${esc(hold)} ${esc(f.label || r.fix)}, even when the Status page says it is fine.

    ${edit} + ${canRunNow(r.fix) && editing() ? `` : ""}
    more
    id
    ${esc(r.id)}
    ${r.watch && r.watch.length ? `
    flashes while down
    ${esc(r.watch.join(", "))}
    ` : ""}
    ${editing() ? `

    ` : ""}
    `; } // ---- Computers --------------------------------------------------------------- @@ -630,47 +490,48 @@ const m = await api("/api/machines"); const selected = rest[0] && rest[0] !== "gear" && rest[0] !== "setup" ? rest[0] : null; compTab = rest[0] === "gear" || rest[0] === "setup" ? rest[0] : "map"; - const tabs = `
    -
    `; + const tabs = `
    + ${editing() ? `` : ""}
    `; let body = ""; if (compTab === "gear") body = await gearHtml(); else if (compTab === "setup") body = setupHtml(m, (await api("/api/setup").catch(() => ({ computers: [] }))).computers); else { const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `${wordOf(c.word)}${esc(c.name)}${c.self ? ' (this Mac)' : ""}${esc(c.role)}${(c.keys || []).map((k) => `${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}`).join(" ")}${esc(c.reason)}${c.looked ? `
    looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}`).join(""); - body = `
    ${rows}
    statuscomputerwhat it does for the panelkeys that turn red if it dies
    `; + body = `
    ${rows}
    statuscomputerdoeskeys${hint("The keys that turn red if it dies")}
    `; const c = m.computers.find((x) => x.alias === selected); if (c) body += computerCard(c); } - main.innerHTML = `

    Computers

    What runs where. One row per computer; click it for everything that runs there, with a light each.

    ${tabs}${body}`; + main.innerHTML = `

    Computers${hint("What runs where. Click a row for everything that runs there, with a light each.")}

    ${tabs}${body}`; for (const b of main.querySelectorAll("[data-copy]")) b.onclick = async () => { const ta = document.getElementById(b.dataset.copy); try { await navigator.clipboard.writeText(ta.value); b.textContent = "Copied"; } catch (e) { ta.select(); document.execCommand("copy"); b.textContent = "Copied"; } }; const rm = main.querySelector("#removehost"); if (rm) rm.onclick = () => removeComputer(selected); + for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job); 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).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) => `${lightDot(i.light)}${esc(i.light === "ok" ? "OK" : i.light === "problem" ? "Problem" : i.light === "not_needed" ? "Not needed" : "Can't see from here")}${esc(i.name)}${i.watched === false ? ' (not watched)' : ""}${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 || ""))}`).join(""); + const items = (c.items || []).map((i) => `${lightDot(i.light, lightWord[i.light])}${esc(lightWord[i.light] || "")}${esc(i.name)}${i.watched === false ? ' (not watched)' : ""}${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 || ""))}`).join(""); const found = c.found || {}; - const foundHtml = Object.entries(found).filter(([, v]) => Array.isArray(v) && v.length).map(([k, v]) => `

    ${esc(k)}: ${esc(typeof v[0] === "object" ? v.map((x) => x.iface + " " + x.ip + (x.active ? "" : " (down)")).join(", ") : v.join(", "))}

    `).join("") || `

    Press "Check computers now" to look.

    `; + const foundHtml = Object.entries(found).filter(([, v]) => Array.isArray(v) && v.length).map(([k, v]) => `

    ${esc(k)}: ${esc(typeof v[0] === "object" ? v.map((x) => x.iface + " " + x.ip + (x.active ? "" : " (down)")).join(", ") : v.join(", "))}

    `).join("") || `

    Press "Check now" to look.

    `; const done = Object.entries(c.manual_done || {}).map(([k, v]) => `
  • ${lightDot("hand")}${esc(k.replace(/_/g, " "))}: done by hand (${esc(v.by)}, ${esc(v.on)})
  • `).join(""); const base = `hosts.${c.alias}.`; - const edit = editing() ? `
    Change this computer${["name", "role", "notes", "os"].concat(c.self ? [] : ["ssh", "mac", "wake_broadcast", "pingable"]).map((f) => inputHtml(base + f, F("hosts.*." + f), getPath(base + f))).join("")} -
    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.
    ` : ""; - const remove = editing() && !c.self ? `

    ` : ""; - return `

    ${esc(c.name)} ${wordOf(c.word)}

    ${esc(c.role)}${c.ssh ? ` · logs in as ${esc(c.ssh)}` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "no answer"}` : ""}${c.error ? ` · ${esc(c.error)}` : ""}

    + const edit = editing() ? `
    Change${["name", "role", "notes", "os"].concat(c.self ? [] : ["ssh", "mac", "wake_broadcast", "pingable"]).map((f) => inputHtml(base + f, F("hosts.*." + f), getPath(base + f))).join("")} +
    what runs here${hint('One per line, as {"kind": ..., "name": ...}. 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 = listed only.')}
    + ${c.os === "windows" ? `` : ""}
    ` : ""; + const remove = editing() && !c.self ? `

    ` : ""; + return `

    ${esc(c.name)} ${wordOf(c.word)}

    ${esc(c.role)}${c.ssh ? ` · ${esc(c.ssh)}` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "no answer"}` : ""}${c.error ? ` · ${esc(c.error)}` : ""}

    ${c.notes ? `

    ${esc(c.notes)}

    ` : ""} -

    What runs here

    ${items}
    +
    ${items}
    ${done ? `
      ${done}
    ` : ""} -
    Everything found on this computer${foundHtml}
    - ${c.self ? `
    This Mac's set-up lights
    ${Object.entries(c.setup || {}).map(([k, v]) => `
    ${esc(k.replace(/_/g, " "))}
    ${typeof v === "boolean" ? lightDot(v ? "ok" : "problem") + (v ? "OK" : "Problem") : esc(JSON.stringify(v))}
    `).join("")}
    ` : ""} +
    everything found${foundHtml}
    + ${c.self ? `
    this Mac's set-up lights
    ${Object.entries(c.setup || {}).map(([k, v]) => `
    ${esc(k.replace(/_/g, " "))}
    ${typeof v === "boolean" ? lightDot(v ? "ok" : "problem") + (v ? "OK" : "Problem") : esc(JSON.stringify(v))}
    `).join("")}
    ` : ""} ${edit}${remove}
    `; } function removeComputer(alias) { const c = cfg(), h = c.hosts[alias]; if (!h) return; const fixes = Object.entries(c.fixes).filter(([, f]) => f.host === alias || (f.params || {}).host === alias).map(([id]) => id); const checks = []; for (const t of c.tiles) (t.checks || []).forEach((ch, j) => { if (ch.host === alias || (ch.params || {}).host === alias) checks.push([t.id, j, t.label]); }); - const usesFix = []; 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])) if (fixes.includes(f)) usesFix.push(t.label); if (fixes.includes(t.hold_always)) usesFix.push(t.label); } const rkeys = (c.restart_page ? c.restart_page.keys : []).filter((k) => fixes.includes(k.fix)).map((k) => k.label); const what = [`the computer ${h.name || alias}`].concat(fixes.length ? [`${fixes.length} fix${fixes.length > 1 ? "es" : ""} that ran on it (${fixes.join(", ")})`] : [], checks.length ? [`${checks.length} check${checks.length > 1 ? "s" : ""} on the keys ${[...new Set(checks.map((x) => x[2]))].join(", ")}`] : [], rkeys.length ? [`the restart keys ${rkeys.join(", ")}`] : []); confirmBox(`Remove ${what.join(", ")}? The keys stay; a key left with nothing to check will say OK.`, "Remove", () => { @@ -690,8 +551,8 @@ } async function gearHtml() { const g = await api("/api/gear"); - return `

    Devices that are not computers, from the keys that watch them.${editing() ? " Type where each one lives and press Save." : ""}

    - ${g.map((r) => ``).join("")}
    lightdevicekindaddresswhere / noteswatched by
    ${lightDot(r.light)}${esc(r.light === "ok" ? "OK" : r.light === "problem" ? "Problem" : "–")}${esc(r.name)}${esc(r.kind)}${esc(r.address || "")}${r.detail ? `
    ${esc(r.detail)}` : ""}
    ${editing() ? ` ` : `${esc(r.location || "")} ${esc(r.notes || "")}`} ${esc(r.reason || "")}${(r.watched_by || []).map((k) => `${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}`).join(", ")}
    `; + return `
    + ${g.map((r) => ``).join("")}
    devicekindaddresswhere${hint("Where it is in the building, and notes")}watched by
    ${lightDot(r.light, lightWord[r.light])}${esc(r.name)}${esc(r.kind)}${esc(r.address || "")}${r.detail ? `
    ${esc(r.detail)}` : ""}
    ${editing() ? ` ` : `${esc(r.location || "")} ${esc(r.notes || "")}`} ${esc(r.reason || "")}${(r.watched_by || []).map((k) => `${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}`).join(", ")}
    `; } function setupHtml(m, generated) { return m.computers.map((c) => { @@ -703,6 +564,7 @@ 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 (/panel's key is accepted on this Mac/.test(step)) return "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"; @@ -711,7 +573,7 @@ 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 = item("service"); return it ? it.light : "not_needed"; } + if (/services restart|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 = 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"; } @@ -719,8 +581,8 @@ if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown"; return "unknown"; }; - const scriptsHtml = gen.scripts.map((sc, i) => `
    ${esc(sc.name)}

    ${esc(sc.how)}

    `).join(""); - return `

    ${esc(c.name)}

      ${steps.map((s) => { const l = lightFor(s); const how = howOf(s); return `
    • ${lightDot(l)}${esc(s)} ${l === "hand" ? "(done by hand)" : l === "unknown" ? "– can't see from here" : ""}${how ? `
      how

      ${esc(how)}

      ` : ""}
    • `; }).join("")}
    ${scriptsHtml}
    `; + const scriptsHtml = gen.scripts.map((sc, i) => `
    ${esc(sc.name)}${hint(sc.how)}
    `).join(""); + return `

    ${esc(c.name)}

      ${steps.map((s) => { const l = lightFor(s); const how = howOf(s); return `
    • ${lightDot(l, lightWord[l])}${esc(s)}${hint(how)}
    • `; }).join("")}
    ${scriptsHtml}
    `; }).join(""); } @@ -728,13 +590,13 @@ let logTab = "watchdog", logFilter = ""; async function logs(rest) { logTab = rest[0] || logTab; - const tabs = ["watchdog", "errors", "autofix", "settings"].map((n) => ``).join(""); - const filters = logTab === "watchdog" ? `
    ${["", "faults", "holds", "fixes", "auto", "errors"].map((f) => ``).join("")}
    ` : ""; + const tabs = ["watchdog", "errors", "autofix", "settings"].map((n) => ``).join(""); + const filters = logTab === "watchdog" ? `
    ${["", "faults", "holds", "fixes", "auto", "errors"].map((f) => ``).join("")}
    ` : ""; let data; 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" ? `

    ${(data.files || []).slice().reverse().map((f) => `${esc(f.replace(" Auto-Fix.log", ""))}`).join(" · ") || "no auto-fix days yet"}

    ` : ""; - main.innerHTML = `

    Logs

    What the watchdog did and saw. Newest lines at the bottom.

    ${tabs}
    ${filters}${files}
    ${esc((data.lines || []).join("\n")) || "(empty)"}
    `; + main.innerHTML = `

    Logs${hint("What the watchdog did and saw. Newest at the bottom.")}

    ${tabs}
    ${filters}${files}
    ${esc((data.lines || []).join("\n")) || "(empty)"}
    `; for (const b of main.querySelectorAll("[data-f]")) b.onclick = () => { logFilter = b.dataset.f; logs(rest); }; const pre = main.querySelector("pre.log"); pre.scrollTop = pre.scrollHeight; } @@ -745,15 +607,14 @@ let detail = ""; if (rest[0]) { const d = await api("/api/history/" + encodeURIComponent(rest[0])); - detail = `

    ${esc(rest[0])}

    What is different between that version and now:

    ${d.changes_to_now.length ? `
      ${d.changes_to_now.map((c) => `
    • ${esc(c.text)}
    • `).join("")}
    ` : "

    Nothing: it is the same as now.

    "} - ${editing() ? ` It becomes your draft; Review & Apply puts it live.` : `

    Switch Editing on to go back to it.

    `}
    `; + detail = `

    ${esc(rest[0])}

    ${d.changes_to_now.length ? `
      ${d.changes_to_now.map((c) => `
    • ${esc(c.text)}
    • `).join("")}
    ` : "

    The same as now.

    "} + ${editing() ? `` : ""}
    `; } const rows = h.entries.map((e) => `${esc(when(e.when))}${esc(e.by)}${esc(e.note)}${esc((e.actions || []).join(", "))}${e.outcome && e.outcome !== "applied" ? " · " + esc(e.outcome) : ""}`).join(""); - main.innerHTML = `

    History

    Every version of the settings that was ever applied: who, when, the note, what was different.

    -
    ${editing() && h.entries.length ? `` : ""}
    + main.innerHTML = `

    History${hint("Every version ever applied: who, when, the note. Open one to see what differs from now.")}${editing() && h.entries.length ? `` : ""}

    ${rows || ""}
    whenwhonote
    nothing yet
    ${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); } }); + const un = main.querySelector("#undo"); if (un) un.onclick = () => confirmBox("Undo the last change? The version before it becomes your draft; 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); } }); } // ---- Review & Apply ------------------------------------------------------------- @@ -763,20 +624,19 @@ 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 = `

    Review & Apply

    Nothing to apply: there is no draft. Change something on any screen with Editing on.

    ${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`; + main.innerHTML = `

    Apply

    No changes. Change something with Editing on.

    ${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`; return; } const r = await post("/api/validate", {}); const changes = r.changes.map((c) => `
  • ${esc(c.text)}${c.command ? ' command' : ""}
  • `).join("") + (r.atem_pending ? "
  • A new saved switcher file replaces the current one.
  • " : ""); const errors = r.errors.map((e) => `
  • ${esc(e)}
  • `).join(""); const warnings = r.warnings.map((w) => `
  • ${esc(w)}
  • `).join(""); - const live = r.live && r.live.live ? `
    Live now: ${esc(r.live.reason)}. Apply waits until it is over; your draft is saved. ${editing() ? '' : ""}
    ` : ""; - main.innerHTML = `

    Review & Apply

    Started by ${esc(state.draft.started_by || state.draft.by || "?")}${state.draft.started ? " at " + esc(when(state.draft.started)) : ""}. ${state.draftStale ? "The live settings changed since this draft was started: look again before applying." : ""}

    -

    Your changes (${r.changes.length + (r.atem_pending ? 1 : 0)})

    ${changes ? `
      ${changes}
    ` : "

    No change yet.

    "} -

    What will happen: ${esc(r.feel)}

    - ${errors ? `
    Not possible yet:
      ${errors}
    ` : ""}${warnings ? `
    Worth knowing:
      ${warnings}
    ` : ""}${live} -
    -
    + const live = r.live && r.live.live ? `
    Live now: ${esc(r.live.reason)}. Apply waits; your draft is saved. ${editing() ? '' : ""}
    ` : ""; + main.innerHTML = `

    Apply${hint("Your changes in words, and what will happen on the deck. Nothing is live until you press APPLY.")}

    Started by ${esc(state.draft.started_by || state.draft.by || "?")}${state.draft.started ? " at " + esc(when(state.draft.started)) : ""}. ${state.draftStale ? "The live settings changed since: look again before applying." : ""}

    +
    ${changes ? `
      ${changes}
    ` : "

    No change yet.

    "} +

    Then: ${esc(r.feel)}

    + ${errors ? `
    Not possible yet:
      ${errors}
    ` : ""}${warnings ? `
      ${warnings}
    ` : ""}${live} +
    ${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`; const go = (emergency) => async () => { const note = main.querySelector("#note").value.trim(); @@ -788,7 +648,7 @@ 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 = `

    Applying…

    ${esc(st.note || "")}

    ${stepsHtml(st)}

    This page updates by itself.

    `; + main.innerHTML = `

    Applying…

    ${esc(st.note || "")}

    ${stepsHtml(st)}`; 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 `
      ${(st.steps || []).map((s) => `
    1. ${lightDot(s.state === "ok" ? "ok" : s.state === "failed" ? "problem" : s.state === "running" ? "warn" : s.state === "skipped" ? "not_needed" : "unknown")}${esc(s.name)} ${esc(s.state === "skipped" ? "skipped: " + s.detail : s.detail || s.state)}
    2. `).join("")}
    `; } @@ -799,86 +659,189 @@ const facts = await api("/api/facts"); 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 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]) => `

    ${t}

    ${b}

    `).join(""); - const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `
    ${esc(g)}

    ${esc(words.join(" · "))}

    `).join(""); - main.innerHTML = `

    Help

    Start with the four cards. The pages below are made from the settings, so they are never out of date.

    ${cards}
    -

    Pages

    What each key does · Cheat sheet (one page) · Where things are

    -

    Changing something

    1. Switch Editing on (top right): the editing code and your first name.
    2. Change it where it lives: a key on Keys, a computer on Computers, a fix on Fixes, the rest on Site setup.
    3. The badge at the top counts your changes; it opens Review & Apply.
    4. 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.
    5. 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.
    -

    First-day walkthrough (10 minutes, no code needed)

    1. Overview: the live deck. Green, red, purple.
    2. Click a red key (or any key): read what it checks and what holding it will do.
    3. Logs › Watchdog, filter "holds": find a hold and what it did.
    4. Keys › Restart page: each key restarts one thing, even when green.
    5. Overview: when alarms are on and when auto-fix may act by itself.
    6. Computers › Map: which computer a key talks to, and what runs there.
    7. Help › What each key does: the whole deck in words.
    -

    Try it (ten minutes with Editing on; nothing is live until you press APPLY)

      -
    1. Keys › HD2: tick "may have no disk" off and on; the badge counts the change; Review & Apply reads it back. Throw the draft away.
    2. -
    3. Keys: drag LED onto the tray, then back onto its cell. Keys › + New key "HD7", give it a check (Is a recorder OK?), delete it again.
    4. -
    5. Overview › Add or remove a HyperDeck: untick 5 and 6 and look at Keys (the cells stay empty); Review says the page re-imports. Throw it away.
    6. -
    7. Overview › Change the playlist: paste a playlist link, Look it up, see the name follow on Site setup › Spotify and the play fix. Apply if you mean it.
    8. -
    9. Overview › Add a MIDI link to the MIDI key for a peer that exists; Run this fix now on that check (it goes through the watchdog like a hold).
    10. -
    11. Fixes › VISTA: change the program file to Vista2.exe and watch the command rebuild; put it back.
    12. -
    13. Computers › Setup › Lighting PC: open the script, Copy; Computers › Lighting PC › Remove this computer (read what would go), Cancel.
    14. -
    15. Site setup › ATEM › Pick the newest from the folder (it says what differs), Discard. Site setup › Site › Export site bundle.
    16. -
    17. History: open the last version, Go back to this version, look at Review, Throw it away. Undo the last change does the same for the newest.
    18. -
    19. Make a small real change (a note on Computers › Gear), APPLY, watch the steps, then Undo it and APPLY again.
    -

    The words a key can show

    ${glossary} -

    Things only a person can do

    • 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.
    • Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.
    • Type the Netgear login into the keychain; keep Google Drive signed in for the saved switcher files.
    • Save a new switcher file from ATEM Software Control when the show changes, then pick it on Site setup › ATEM.
    -

    Companion facts (found by trying)

    ${(facts.facts || []).map(([q, a]) => `
    ${esc(q)}
    ${esc(a)}
    `).join("")}
    `; + ["Red key", "It names the problem. Press once to acknowledge (stays dark red). Hold 2 s: it runs the fix for what is failing."], + ["Purple keys", "The watchdog on the Ingest Mac has been silent 15 s. Overview › Restart watchdog, unless Companion itself is down."], + ["Which computer or device?", "Computers › Map: every computer and the keys that turn red if it dies. Computers › Gear: switcher, decks, wireless, LED."], + ["Changing something", "Edit (top right, the PIN). Change it where it lives: a key on Keys, a computer on Computers, the rest on Settings. The badge counts your changes and opens Apply."], + ].map(([t, b]) => `

    ${t}

    ${b}

    `).join(""); + const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `
    ${esc(g)}

    ${esc(words.join(" · "))}

    `).join(""); + main.innerHTML = `

    Help

    ${cards}
    +

    What each key does Cheat sheet Where things are

    +
    Try it (ten minutes with Editing on; nothing is live until APPLY)
      +
    1. Keys › HD2: untick "may have no disk"; the badge counts it; Apply reads it back. Throw away.
    2. +
    3. Keys: drag LED onto the tray and back. + Key "HD7", add a check (Is a recorder OK?), delete it.
    4. +
    5. Settings › HyperDecks: untick 5 and 6, look at Keys (cells stay empty). Throw away.
    6. +
    7. Settings › Playlist: paste a link, Look it up; the name follows everywhere.
    8. +
    9. Settings › Auto-fix: paint Sunday morning on the calendar.
    10. +
    11. Settings › MIDI links: add one to the MIDI key; Run the fix on that check.
    12. +
    13. Keys › Fixes › VISTA: change the program file to Vista2.exe, watch the command rebuild; put it back.
    14. +
    15. Computers › Setup › Lighting PC: open the script, Copy. Computers › Lighting PC › Remove (read, then Cancel).
    16. +
    17. Settings › Switcher: Pick the newest file; Discard. Settings › Program & campus: Export bundle.
    18. +
    19. A real change (a note on Computers › Gear): APPLY, then History › Undo, APPLY again.
    +
    The words a key can show${glossary}
    +
    Only a person can
    • Run the set-up script on a new computer, allow Accessibility on the Studio B Mac, click Allow on the Spotify prompt once.
    • Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.
    • Type the Netgear login into the keychain; keep Google Drive signed in.
    • Save a new switcher file from ATEM Software Control when the show changes, then pick it under Settings › Switcher.
    +
    Companion facts (found by trying)
    ${(facts.facts || []).map(([q, a]) => `
    ${esc(q)}
    ${esc(a)}
    `).join("")}
    `; } - // ---- 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) { + // ---- Settings: one page, every section ------------------------------------------ + 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", "site.editing_code"]; + const SECTIONS = [ + ["playlist", "Playlist", "The Spotify playlist the SPOTIFY key and the play fix use"], + ["autofix", "Auto-fix", "When the panel may fix things by itself, and which fixes"], + ["wait", "Changes wait", "When changes to the panel must wait (a recording deck always counts)"], + ["hyperdecks", "HyperDecks", "Which decks are in use"], + ["midi", "MIDI links", "Network MIDI sessions and the peers that must stay in them"], + ["computers", "Computers", "Every computer the panel talks to"], + ["dante", "Dante", "The devices the DANTE key expects"], + ["switcher", "Switcher", "The saved ATEM file the panel keeps the switcher in line with"], + ["companion", "Companion", "Connections, and names the keys use that are not here"], + ["network", "Network", "This Mac's network port and the Dante switches"], + ["site", "Site & PIN", "The campus, contacts, this Mac, the PIN"], + ["program", "Program & campus", "Update the code, export or start a campus"], + ["advanced", "Advanced", "Every number and name, by screen"], + ]; + async function settings(rest) { + await flush(); + const which = SECTIONS.some(([k]) => k === rest[0]) ? rest[0] : "playlist"; 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]) => ``).join(""); - const screen = sections[which]; + const nav = ` + `; + const sec = SECTIONS.find(([k]) => k === which); + let body = ""; + if (which === "playlist") body = spotifyHtml(c); + else if (which === "autofix") body = autofixHtml(c); + else if (which === "wait") body = `
    ${calendarHtml("site.live_services", (c.site || {}).live_services)}

    A recording HyperDeck always counts, whatever the clock says.

    `; + else if (which === "hyperdecks") body = await hyperdecksHtml(c); + else if (which === "midi") body = midiHtml(c); + else if (which === "computers") body = computersHtml(c); + else if (which === "dante") body = await danteHtml(c) || "

    No DANTE key.

    "; + else if (which === "switcher") body = await atemHtml(c) + fieldsHtml("ATEM"); + else if (which === "companion") body = (await namesHtml()) + (await companionHtml()); + else if (which === "network") body = fieldsHtml("Network & Dante"); + else if (which === "site") body = siteHtml(c); + else if (which === "program") body = await programHtml(); + else if (which === "advanced") body = advancedHtml(); + main.innerHTML = `

    Settings${editing() ? "" : hint("Switch Editing on (Edit, top right) to change anything")}

    ${nav}

    ${esc(sec[1])}${hint(sec[2])}

    ${body}
    `; + main.querySelector("#subsel").onchange = (e) => { location.hash = "#settings/" + e.target.value; }; + wireSettings(which); + } + function fieldsHtml(screen, includeAdvanced) { 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 ? `

    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 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; - if (which === "atem") body = (await atemHtml(c)) + body; - main.innerHTML = `

    Site setup

    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."}

    ${tabs}
    ${body}`; - wireSite(); + if (!own.length) return ""; + const fh = ([p, f]) => inputHtml(p, f, getPath(p)); + return `
    ${plain.map(fh).join("")}${adv.length ? (includeAdvanced ? adv.map(fh).join("") : `
    more${adv.map(fh).join("")}
    `) : ""}
    `; } - 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) => `
    ${days.map((d) => ``).join("")} from to ${editing() ? `` : ""}
    `).join(""); - return `
    ${rows || "

    none

    "}${editing() ? `` : ""}
    ${esc(help)}
    `; + function advancedHtml() { + const screens = [...new Set(Object.values(state.schema.fields).map((f) => f.screen))]; + return screens.map((s) => { const h = fieldsHtml(s, true); return h ? `

    ${esc(s)}

    ${h}` : ""; }).join("") || "

    Nothing.

    "; } - 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 ``; }).join(" "); - return `
    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.
    -

    Auto-fix

    ${windowsHtml("auto_fix.windows", c.auto_fix.windows, "When auto-fix may act by itself", "An end earlier than the start runs past midnight.")}
    ${fixes}
    -

    Changes wait

    ${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.")}
    `; + function siteHtml(c) { + const f = F("site.editing_code"); + return `
    ${inputHtml("site.editing_code", Object.assign({}, f, { help: "The PIN that switches Editing on. Digits are easiest. It lives in the settings file, so everyone with the repo has it." }), (c.site || {}).editing_code)}
    ` + fieldsHtml("Site"); + } + function spotifyHtml(c) { + const s = c.spotify || {}; + return `
    playlist link${hint("Paste the link from Spotify (Share › Copy link). Look it up fills the name.")} ${editing() ? `` : ""}
    +
    name${hint("As the keys say it")}

    ` + fieldsHtml("Spotify"); + } + function autofixHtml(c) { + const fixes = Object.entries(c.fixes).map(([id, f]) => { const cl = closes(f); const on = (c.auto_fix.fixes || []).includes(id); return ``; }).join(" "); + return `
    ${calendarHtml("auto_fix.windows", c.auto_fix.windows)}
    +

    Fixes it may run${hint("Gentle ones only: a fix that closes a program is never run by itself")}

    ${fixes}
    ` + fieldsHtml("Schedules"); + } + // --- the weekly calendar: half-hour cells; click or drag to paint --- + const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; + const toMin = (hhmm) => { const m = String(hhmm || "").match(/^(\d{1,2}):(\d{2})$/); return m ? +m[1] * 60 + +m[2] : null; }; + const toHHMM = (min) => `${String(Math.floor(min / 60) % 24).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`; + function cellsOf(windows) { + const on = new Set(); + for (const w of windows || []) { + const s = toMin(w.start), e = toMin(w.end); if (s == null || e == null) continue; + for (const d of w.days || []) { + const di = DAYS.indexOf(d); if (di < 0) continue; + if (e > s) { for (let m = s; m < e; m += 30) on.add(di + ":" + m); } + else { for (let m = s; m < 1440; m += 30) on.add(di + ":" + m); for (let m = 0; m < e; m += 30) on.add(((di + 1) % 7) + ":" + m); } + } + } + return on; + } + function windowsOf(on) { + const runs = []; + for (let d = 0; d < 7; d++) { let s = null; for (let m = 0; m <= 1440; m += 30) { const here = m < 1440 && on.has(d + ":" + m); if (here && s == null) s = m; if (!here && s != null) { runs.push([d, s, m]); s = null; } } } + for (let i = 0; i < runs.length; i++) { const r = runs[i]; if (r[2] !== 1440) continue; const j = runs.findIndex((x) => x[0] === (r[0] + 1) % 7 && x[1] === 0); if (j >= 0 && j !== i && runs[j][2] < 1440) { r[2] = runs[j][2] + 1440; runs.splice(j, 1); if (j < i) i--; } } + const byTime = {}; + for (const [d, s, e] of runs) { const k = toHHMM(s) + "-" + toHHMM(e >= 1440 ? e - 1440 : e); (byTime[k] = byTime[k] || []).push(DAYS[d]); } + return Object.entries(byTime).map(([k, days]) => { const [start, end] = k.split("-"); return { days: days.sort((a, b) => DAYS.indexOf(a) - DAYS.indexOf(b)), start, end: end === "00:00" ? "24:00" : end }; }); + } + function calendarHtml(path, windows) { + const on = cellsOf(windows); + let grid = `
    ` + DAYS.map((d) => `
    ${d[0].toUpperCase() + d.slice(1)}
    `).join(""); + for (let m = 0; m < 1440; m += 30) { + grid += `
    ${m % 120 === 0 ? toHHMM(m) : ""}
    `; + for (let d = 0; d < 7; d++) grid += `
    `; + } + const words = (windows || []).length ? (windows || []).map((w) => `${(w.days || []).map((d) => d[0].toUpperCase() + d.slice(1)).join(", ")} ${esc(w.start)}–${esc(w.end)}`).join(" · ") : "never"; + return `
    ${grid}
    ${words}${editing() ? "
    Click or drag to paint; drag over painted cells to clear." : ""}
    `; + } + function wireCalendars() { + for (const cal of main.querySelectorAll(".cal[data-calpath]")) { + if (!editing()) continue; + let painting = null; + const apply = (cell) => { if (!cell || !cell.dataset || !cell.dataset.cell) return; cell.classList.toggle("on", painting); }; + cal.addEventListener("mousedown", (e) => { const cell = e.target.closest(".c"); if (!cell) return; e.preventDefault(); painting = !cell.classList.contains("on"); apply(cell); }); + cal.addEventListener("mouseover", (e) => { if (painting == null) return; apply(e.target.closest(".c")); }); + const finish = () => { + if (painting == null) return; painting = null; + const on = new Set([...cal.querySelectorAll(".c.on")].map((x) => x.dataset.cell)); + setPath(cal.dataset.calpath, windowsOf(on)); route(); + }; + cal.addEventListener("mouseup", finish); cal.addEventListener("mouseleave", finish); + cal.addEventListener("touchstart", (e) => { const cell = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY); if (!cell || !cell.dataset.cell) return; painting = !cell.classList.contains("on"); apply(cell); }, { passive: true }); + cal.addEventListener("touchmove", (e) => { if (painting == null) return; apply(document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)); }, { passive: true }); + cal.addEventListener("touchend", finish); + } + } + async function hyperdecksHtml(c) { + let cc = null; + try { cc = await api("/api/companion"); } catch (e) { cc = null; } + const decks = cc ? (cc.connections || []).filter((r) => r.module === "bmd-hyperdeck") : []; + const keyFor = (conn) => c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn)); + const known = new Set(decks.map((d) => d.label)); + for (const t of c.tiles) for (const ch of t.checks || []) if (ch.type === "hyperdeck" && !known.has(ch.conn)) { decks.push({ label: ch.conn, missing: true }); known.add(ch.conn); } + const rows = decks.map((d) => { const k = keyFor(d.label); const chk = k && k.checks.find((ch) => ch.conn === d.label); return `${d.missing ? ` not in Companion` : ""}${k ? `${esc(k.label)}` : "no key"}`; }).join(""); + return `
    ${!cc ? "

    Companion is not answering; the list is from the keys.

    " : ""}
    ${rows || ""}
    in use${hint("Tick a deck and it gets an HD key in the REC group; untick and the key goes (its cell stays empty)")}key
    No HyperDeck connections in Companion.
    + ${editing() && rows ? `
    ` : ""}
    `; + } + function midiHtml(c) { + const rows = []; + c.tiles.forEach((t) => (t.checks || []).forEach((ch, j) => { if (ch.type === "midi_peer") rows.push(`${esc(t.label)}${esc(ch.host ? (c.hosts[ch.host] || {}).name || ch.host : (c.site || {}).ingest_name || "this Mac")}${esc(ch.session)}${esc(ch.peer)}${esc(ch.tag || "")}${editing() ? `` : ""}`); })); + return `
    ${rows.join("") || ""}
    keyMacsessionpeerkey text
    none
    + ${editing() ? `
    ` : ""}
    `; + } + function computersHtml(c) { + const rows = Object.entries(c.hosts).map(([a, h]) => `${esc(h.name || a)}${h.self ? " (this Mac)" : ""}${esc(h.self ? "Mac" : h.os === "windows" ? "Windows" : "Mac")}${esc(h.ssh || "")}${esc(h.role || "")}${(h.runs || []).filter((r) => r.kind === "task").map((r) => esc(r.name.replace(/^Start /, ""))).join(", ")}`).join(""); + return `
    ${rows}
    computerkindlog-indoesprograms it can start
    + ${editing() ? `
    ` : ""}
    `; } async function programHtml() { const p = await api("/api/program/staged").catch(() => null); if (!p) return ""; - const staged = p.present ? `${esc(p.staged_version || "?")}${p.staged_when ? " (staged " + esc(when(p.staged_when)) + ")" : ""}` : "nothing staged"; + const staged = p.present ? `${esc(p.staged_version || "?")}${p.staged_when ? " · staged " + esc(when(p.staged_when)) : ""}` : "nothing staged"; const same = p.present && p.staged_version === p.running_version; - return `

    The program

    Running
    code ${esc(p.running_version || "?")}
    Staged
    ${staged}${same ? " · the same code" : ""}
    Folder
    ${esc(p.folder)}
    -

    Copy the new folder to this Mac with scp as before, then press the button: install.sh runs by itself (the config stays as it is), the watchdog restarts, this page comes back.

    -
    ${p.job === "running" ? " install.sh is running…" : ""}
    - ${p.log && p.log.length ? `
    The last install's log${p.log_when ? " (" + esc(when(p.log_when)) + ")" : ""}
    ${esc(p.log.join("\n"))}
    ` : ""}
    -

    This campus as a bundle

    A zip of the settings, the saved switcher file and the page-key record: drop it in the repo as sites/${esc((cfg().site || {}).id || "site")}/, or feed it to another campus's page.

    -
    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.

    `; + return `
    running
    code ${esc(p.running_version || "?")}
    staged
    ${staged}${same ? " · the same" : ""}
    folder
    ${esc(p.folder)}
    +
    ${p.job === "running" ? " running…" : ""}
    + ${p.log && p.log.length ? `
    last install log${p.log_when ? " (" + esc(when(p.log_when)) + ")" : ""}
    ${esc(p.log.join("\n"))}
    ` : ""}
    +
    Export campus bundle${editing() ? `` : ""}
    `; } 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 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 ${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${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.

    + return `

    Names the keys use that are not here (${n.missing})${hint("A copied campus, or something renamed. Map each name to what this campus has; everything that uses it follows.")}

    ${conns}${dante}${midi}
    nameused by
    `; } function renameConn(from, to) { @@ -892,12 +855,11 @@ } 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(""); - const conns = (cc.connections || []).map((r) => `${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed")}${esc(r.label)}${esc(r.module || "")}${esc(r.enabled ? r.category || "" : "off")}${r.message ? ` ${esc(r.message)}` : ""}${(r.watched_by || []).length ? r.watched_by.map((k) => `${esc(k.toUpperCase())}`).join(", ") : `not watched${editing() ? ` ` : ""}`}`).join(""); - return `

    Companion ${cc.ok ? "OK" : "not answering"}${cc.build ? ` ${esc(cc.build)}` : ""}

    Set up once by the person who installed Companion. You normally only come here to see which connection a key watches.

      ${lights}
    + const lights = Object.entries(cc.lights || {}).map(([k, v]) => `${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}`).join(""); + const conns = (cc.connections || []).map((r) => `${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed", r.enabled ? r.category || "" : "off")}${esc(r.label)}${esc(r.module || "")}${esc(r.message || "")}${(r.watched_by || []).length ? r.watched_by.map((k) => `${esc(k.toUpperCase())}`).join(", ") : `not watched${editing() ? ` ` : ""}`}`).join(""); + return `
    ${lights}${cc.build ? `${esc(cc.build)}` : ""}${cc.page_number_buttons ? `${cc.page_number_buttons.pages_coloured} pages coloured` : ""}
    ${(cc.missing || []).length ? `

    Keys watch connections Companion does not have: ${esc(cc.missing.join(", "))}

    ` : ""} - ${cc.page_number_buttons ? `

    Page-number buttons coloured on ${cc.page_number_buttons.pages_coloured} pages (${esc(cc.page_number_buttons.recorded_in)}).

    ` : ""} -

    Connections (live)

    ${conns}
    connectionmodulestatuswatched by
    `; +
    ${conns}
    connectionmodulewatched by
    ` + fieldsHtml("Companion"); } 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) { @@ -907,55 +869,155 @@ const map = snap.dante_map || {}; const fixOf = (n) => typeof chk.fix === "object" && chk.fix ? (chk.fix[n] || "") : ""; const fixSel = (n) => ``; - const rows = Object.entries(chk.devices || {}).map(([n, a]) => { const e = map[n] || {}; return `${esc(n)}${editing() ? `` : esc(a)}${esc(e.ip || "")}${e.switch ? esc(e.switch + " port " + e.port) : ""}${e.poe ? "yes" : ""}${fixSel(n)}${editing() ? `` : ""}`; }).join(""); + const rows = Object.entries(chk.devices || {}).map(([n, a]) => { const e = map[n] || {}; return `${esc(n)}${editing() ? `` : esc(a)}${esc(e.ip || "")}${e.switch ? esc(e.switch + " port " + e.port) : ""}${e.poe ? "yes" : ""}${fixSel(n)}${editing() ? `` : ""}`; }).join(""); const extra = Object.keys(map).filter((n) => !((chk.devices || {})[n])); - return `

    Dante device list (the DANTE key's devices)

    ${rows}
    Dante namekey textaddressswitch / portPoEwhen it is gone, hold runs
    + return `
    ${rows}
    Dante namekey textaddressswitch / portPoEwhen gone, hold runs

    Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.${editing() ? ` ` : ""}

    - ${editing() ? `
    ${extra.map((n) => ` ${extra.length ? `
    Seen on the network but not listed: ${esc(extra.join(", "))}
    ` : ""}
    ` : ""}
    `; - } - function spotifyHtml(c) { - const s = c.spotify || {}; - return `

    The service playlist

    ${editing() ? `` : ""}
    The SPOTIFY key, the play fix and the key guide all use it.
    -
    As the keys say it.

    `; + ${editing() ? `
    ${extra.map((n) => ` ${extra.length ? `seen, not listed: ${esc(extra.join(", "))}` : ""}
    ` : ""}
    `; } async function atemHtml(c) { const folder = await api("/api/atem-reference/folder").catch(() => ({ files: [], mounted: false })); 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.blocked ? `
    The folder is there but this page cannot read it (macOS keeps background programs out of cloud folders, and this Mac's own login key is not accepted on this Mac). Use Upload a saved file, or let the panel's key log in to this Mac: Computers › Setup › ${esc((cfg().site || {}).ingest_name || "the Ingest Mac")}.
    ` : ""}

    `; + return `
    ${pending} + ${editing() ? `
    ` : ""} +

    ${esc(folder.folder ? folder.folder.replace(/^.*\/Shared drives\//, "Shared drives/") : "(no folder 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 cannot read it. Use Upload, or let the panel's key log in to this Mac (Computers › Setup).
    ` : ""}

    `; } - function wireSite() { + function wireSettings(which) { const q = (s) => main.querySelector(s); + wireCalendars(); + for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job); 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(); }; - 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]); }; + for (const b of main.querySelectorAll("[data-rmcheck]")) b.onclick = () => confirmBox("Remove this MIDI link from its key?", "Remove", () => { if (!ensureDraft()) return; const [tid, j] = b.dataset.rmcheck.split("|"); const t = state.draft.config.tiles.find((x) => x.id === tid); t.checks.splice(+j, 1); saveDraft(); route(); }); + const ds = q("#decksave"); if (ds) ds.onclick = () => applyDecks(main); 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 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; const c = state.draft.config; c.spotify = Object.assign({}, c.spotify, { playlist: r.uri }); if (r.name) { c.spotify.name = r.name; q("#spname").value = r.name; } q("#splink").value = r.uri; await saveDraft(); for (const f of Object.values(c.fixes)) if (f.kind === "spotify") { const bb = await post("/api/build", { fix: f }); if (bb.ok && bb.label) f.label = bb.label; } saveDraft(); info.textContent = r.name ? `${r.name}: ${r.songs} songs.` : (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 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."); }; const ib = q("#installbtn"); if (ib) ib.onclick = () => confirmBox("Install the staged folder? The watchdog restarts (the deck goes purple for a few seconds) and this page restarts too.", "Install", async () => { try { const r = await post("/api/program/install", {}); alertBox(r.ok ? r.detail : "Not started: " + r.error); setTimeout(() => location.reload(), 25000); } catch (e) { alertBox(e.message); } }); - const bf = q("#bundlefile"); if (bf) bf.onchange = async () => { const f = bf.files[0]; if (!f) return; confirmBox(`Start a new campus from ${f.name}? Your current draft is replaced.`, "Start", async () => { try { const r = await fetch("/api/site/import", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/octet-stream" }, body: await f.arrayBuffer() }).then((x) => x.json()); if (r.error) return alertBox(r.error); state.draft = r.draft; state.atemPending = true; badge(); alertBox(`Copied from ${r.from}. ${r.errors.length ? "Not applicable yet: " + r.errors.slice(0, 3).join("; ") : "Look through Computers, Keys and Fixes, then Review & Apply."}`); location.hash = "#computers"; } catch (e) { alertBox(e.message); } }); }; + const bf = q("#bundlefile"); if (bf) bf.onchange = async () => { const f = bf.files[0]; if (!f) return; confirmBox(`Start a new campus from ${f.name}? Your current draft is replaced.`, "Start", async () => { try { const r = await fetch("/api/site/import", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/octet-stream" }, body: await f.arrayBuffer() }).then((x) => x.json()); if (r.error) return alertBox(r.error); state.draft = r.draft; state.atemPending = true; badge(); alertBox(`Copied from ${r.from}. ${r.errors.length ? "Not applicable yet: " + r.errors.slice(0, 3).join("; ") : "Look through Computers, Keys and the Companion names, then Apply."}`); location.hash = "#settings/companion"; } catch (e) { alertBox(e.message); } }); }; 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 an = q("#atemnewest"); if (an) an.onclick = async () => { an.disabled = true; an.textContent = "Reading…"; try { showAtem(await post("/api/atem-reference/pick-newest")); } catch (e) { alertBox(e.message); route(); } }; 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; } + if (!r.ok) { alertBox(r.error || "Not taken."); route(); 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(); } } + function applyDecks(root) { + if (!ensureDraft()) return; + const d = state.draft.config; + const rec = d.tiles.find((t) => (t.members || []).some((m) => d.tiles.some((x) => x.id === m && (x.checks || []).some((ch) => ch.type === "hyperdeck")))) || d.tiles.find((t) => t.id === "rec"); + const fixId = Object.keys(d.fixes).find((id) => d.fixes[id].kind === "hyperdeck"); + for (const cb of root.querySelectorAll("[data-deck]")) { + const conn = cb.dataset.deck, nodisk = root.querySelector(`[data-nodisk="${CSS.escape(conn)}"]`).checked; + const existing = d.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn)); + if (cb.checked && existing) { const ch = existing.checks.find((x) => x.conn === conn); if (nodisk) ch.optional = true; else delete ch.optional; } + else if (cb.checked) { + const n = (conn.match(/(\d+)\s*$/) || [])[1]; let id = n ? "hd" + n : slug(conn), k = 2; while (d.tiles.some((t) => t.id === id)) id = (n ? "hd" + n : slug(conn)) + "_" + k++; + const t = { id, label: n ? "HD" + n : conn.toUpperCase().slice(0, 8), hold_always: fixId || undefined, checks: [Object.assign({ type: "hyperdeck", conn }, nodisk ? { optional: true } : {}, fixId ? { fix: { conn: fixId } } : {})] }; + const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; + d.tiles.push(t); if (rec) rec.members.push(id); + } else if (existing) { d.tiles.splice(d.tiles.indexOf(existing), 1); for (const t of d.tiles) if (t.members) t.members = t.members.filter((m) => m !== existing.id); } + } + saveDraft(); route(); + } + + // ---- Jobs: the dialogs that add something ---------------------------------------- + async function job(which) { + if (!needEditing()) return; + clearInterval(timer); timer = null; + if (which === "midi") return jobMidi(); + if (which === "computer") return jobComputer(); + if (which === "pcprogram") return jobPcProgram(); + } + async function jobMidi() { + const live = await api("/api/live/midi").catch(() => ({ local: {}, remote: {} })); + const c = cfg(); + const macs = Object.entries(c.hosts).filter(([, h]) => h.self || (h.os || "mac") === "mac"); + const midiKey = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "midi_peer")); + const fixId = Object.keys(c.fixes).find((id) => c.fixes[id].kind === "midi_connect"); + const sessionsOf = (alias) => { const h = c.hosts[alias]; const src = h.self ? (live.local || {}) : ((live.remote || {})[alias] || {}); return Object.entries(src).map(([name, v]) => [name, (v.peers || []).map((p) => p.name)]); }; + dialog(`

    MIDI link${hint("A network MIDI session on one of the Macs, and the peer that must stay in it. Pick from what the Macs report now, or type the names if that Mac is off.")}

    + + + + + + +
    `, (b) => { + b.querySelector("#dcancel").onclick = closeDialog; + const fill = () => { const ss = sessionsOf(b.querySelector("#jhost").value); b.querySelector("#jsessions").innerHTML = ss.map(([n]) => `