/* The settings page. Plain JS, no framework. Everything is readable; with Editing on (the editing code + your first name) the same screens grow inputs; changes live in one shared draft on the server until Review & Apply. */ (function () { "use strict"; const main = document.getElementById("main"); const state = { live: null, liveSha: null, schema: null, draft: null, draftStale: false, atemPending: false, editing: null, name: null, saveTimer: null }; const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const ago = (s) => s == null ? "" : s < 90 ? Math.round(s) + " s ago" : s < 5400 ? Math.round(s / 60) + " min ago" : Math.round(s / 3600) + " h ago"; const when = (t) => t ? new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) : ""; const hex = (n) => "#" + (n >>> 0).toString(16).padStart(6, "0").slice(-6); const lightDot = (l) => ``; const wordOf = (w) => `${esc(w)}`; const deep = (o) => JSON.parse(JSON.stringify(o)); const canon = (v) => Array.isArray(v) ? "[" + v.map(canon).join(",") + "]" : v && typeof v === "object" ? "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canon(v[k])).join(",") + "}" : JSON.stringify(v); const cfg = () => (state.draft ? state.draft.config : state.live); const editing = () => !!state.editing; const store = { get: (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch (e) {} } }; const closes = (f) => /pkill|taskkill|killall/.test(f.run || "") && !f.restart_ok; async function api(path, opts) { opts = opts || {}; const headers = Object.assign({ "Content-Type": "application/json" }, opts.headers || {}); if (state.editing) { headers["X-Edit-Key"] = state.editing; headers["X-Editor"] = state.name || ""; } const r = await fetch(path, Object.assign({}, opts, { headers })); let body = null; try { body = await r.json(); } catch (e) { body = {}; } if (!r.ok) { const err = new Error(body.error || r.statusText); err.status = r.status; err.body = body; throw err; } return body; } const post = (path, body, method) => api(path, { method: method || "POST", body: JSON.stringify(body || {}) }); // ---- top bar: legend, editing code ---------------------------------------- document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; }; const editbtn = document.getElementById("editbtn"); function showEditing() { editbtn.textContent = state.editing ? "Editing as " + state.name : "Editing: off"; editbtn.classList.toggle("on", !!state.editing); document.body.classList.toggle("editing", !!state.editing); } state.editing = store.get("sp-code"); state.name = store.get("sp-name"); showEditing(); editbtn.onclick = () => { if (state.editing) { state.editing = null; state.name = null; store.set("sp-code", null); store.set("sp-name", null); showEditing(); route(); return; } dialog(`

Switch Editing on

The editing code is in the campus's settings file in the repo; ask Chris.

`, (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).`; }; box.querySelector("#dok").onclick = go; box.querySelector("#dname").onkeydown = (e) => { if (e.key === "Enter") go(); }; box.querySelector("#dcode").focus(); }); }; function dialog(html, wire) { const d = document.getElementById("dialog"); d.innerHTML = `
${html}
`; d.hidden = false; wire(d.querySelector(".box")); } function closeDialog() { document.getElementById("dialog").hidden = true; } function alertBox(msg) { dialog(`

${esc(msg)}

`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); } function confirmBox(msg, label, onOk, word) { dialog(`

${esc(msg)}

${word ? `` : ""}
`, (b) => { b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = () => { if (word && b.querySelector("#dword").value.trim() !== word) return; closeDialog(); onOk(); }; }); } // ---- the draft ------------------------------------------------------------ async function loadAll() { const [c, s, d] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft")]); state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; document.getElementById("sitename").textContent = (state.live.site || {}).name || ""; badge(); } function ensureDraft() { if (!editing()) { alertBox("Switch Editing on first (top right)."); return false; } if (!state.draft) state.draft = { config: deep(state.live), version: null, by: state.name }; return true; } function changed() { if (!state.draft) return 0; let n = 0; const walk = (a, b) => { if (Array.isArray(a) && Array.isArray(b)) { if (canon(a) !== canon(b)) n++; return; } if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) { for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) walk(a[k], b[k]); return; } if (canon(a) !== canon(b)) n++; }; walk(state.live, state.draft.config); return n + (state.atemPending ? 1 : 0); } function badge() { let b = document.getElementById("badge"); if (!b) { b = document.createElement("a"); b.id = "badge"; b.className = "badge"; b.href = "#review"; document.querySelector(".topright").prepend(b); } const n = changed(); b.hidden = !n && !state.draft; b.textContent = n ? `${n} change${n > 1 ? "s" : ""} – Review & Apply` : "Draft – Review"; b.classList.toggle("warn", !!state.draftStale); } function saveDraft() { badge(); clearTimeout(state.saveTimer); state.saveTimer = setTimeout(async () => { try { const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT"); state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by; } catch (e) { if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); } else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); } else alertBox("Could not save the draft: " + e.message); } }, 400); } // set a value at a dotted path in the draft (creating objects on the way) function setPath(path, value) { if (!ensureDraft()) return; const parts = path.split("."); let o = state.draft.config; for (let i = 0; i < parts.length - 1; i++) { if (o[parts[i]] == null || typeof o[parts[i]] !== "object") o[parts[i]] = {}; o = o[parts[i]]; } if (value === undefined) delete o[parts[parts.length - 1]]; else o[parts[parts.length - 1]] = value; saveDraft(); } const getPath = (path, from) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), from || cfg()); // ---- generic inputs from the registry ----------------------------------- function parse(f, raw) { if (f.type === "int") { const n = parseInt(raw, 10); return isNaN(n) ? undefined : n; } if (f.type === "float") { const n = parseFloat(raw); return isNaN(n) ? undefined : n; } if (f.type === "bool") return !!raw; if (f.type === "list") return raw.split(",").map((s) => s.trim()).filter(Boolean); if (f.type === "dict") { try { return raw.trim() ? JSON.parse(raw) : undefined; } catch (e) { return undefined; } } return raw === "" ? undefined : raw; } function inputHtml(path, f, value, extra) { if (!f) return ""; const id = "f-" + path.replace(/[^A-Za-z0-9]/g, "-"); const dis = editing() ? "" : "disabled"; let html; if (f.type === "bool") html = ``; else if (f.type === "enum" && f.options) html = ``; else if (f.type === "dict") html = ``; else if (f.type === "list") html = ``; else html = ``; const dflt = f.default != null && typeof f.default !== "object" && f.default !== "" ? ` (default ${esc(f.default)})` : ""; return `
${html}
${esc(f.help)}${dflt}${extra || ""}
`; } function wireInputs(root) { for (const el of root.querySelectorAll("[data-path]")) { if (el.onchange) continue; // a screen wired it itself el.onchange = () => { const path = el.dataset.path, f = fieldFor(path); const v = el.type === "checkbox" ? el.checked : el.value; setPath(path, f ? parse(f, v) : v); }; } } function fieldFor(path) { const F = state.schema.fields; if (F[path]) return F[path]; const generic = path.replace(/^tiles\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^tiles\.\d+\./, "tiles[].") .replace(/^restart_page\.keys\.\d+\./, "restart_page.keys[].").replace(/^fixes\.[^.]+\./, "fixes.*.").replace(/^hosts\.[^.]+\./, "hosts.*.") .replace(/^gear\.[^.]+\./, "gear.*.").replace(/^(auto_fix\.windows|site\.live_services|service_windows)\.\d+\./, "$1[]."); return F[generic] || null; } const F = (name) => state.schema.fields[name]; // ---- search ---------------------------------------------------------------- const search = document.getElementById("search"), results = document.getElementById("searchresults"); search.oninput = () => { const q = search.value.trim().toLowerCase(); if (!q || !cfg()) { results.hidden = true; return; } const c = cfg(), hits = []; c.tiles.forEach((t) => { if ((t.label + " " + t.id).toLowerCase().includes(q)) hits.push([`Key ${t.label}`, `#keys/${t.id}`]); }); for (const k of (c.restart_page || { keys: [] }).keys) if (k.label.toLowerCase().includes(q)) hits.push([`Restart key ${k.label}`, `#keys/restart/${k.id}`]); for (const [a, h] of Object.entries(c.hosts || {})) if ((h.name || a).toLowerCase().includes(q) || (h.role || "").toLowerCase().includes(q)) hits.push([`Computer ${h.name || a}`, `#computers/${a}`]); for (const [id, f] of Object.entries(c.fixes || {})) if ((id + " " + (f.label || "") + " " + (f.short || "")).toLowerCase().includes(q)) hits.push([`Fix ${f.short || id}: ${f.label || ""}`, `#fixes/${id}`]); for (const [path, f] of Object.entries(state.schema.fields)) if (!path.includes("[]") && !path.includes("*") && (path + " " + f.help).toLowerCase().includes(q)) hits.push([`Setting ${path}`, `#site/${f.screen.toLowerCase().replace(/[^a-z]+/g, "-")}`]); results.innerHTML = hits.slice(0, 20).map(([t, h]) => `${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 }; let timer = null; async function route() { const [name, ...rest] = (location.hash || "#overview").slice(1).split("/"); for (const a of document.querySelectorAll(".menu a")) a.classList.toggle("active", a.getAttribute("href") === "#" + name); clearInterval(timer); timer = null; try { if (!state.live) await loadAll(); const fn = screens[name] || overview; await fn(rest); wireInputs(main); const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0])); // never under a typist's hands if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000); } catch (e) { main.innerHTML = `

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

`; } } window.addEventListener("hashchange", route); route(); // ---- Overview -------------------------------------------------------------- async function overview(_, quiet) { if (quiet && document.activeElement && document.activeElement.tagName === "INPUT") return; // never pull the field out from under a typist const o = await api("/api/overview"); const dot = document.getElementById("hbdot"); dot.className = "dot " + (o.heartbeat_age != null && o.heartbeat_age < 15 ? "ok" : "bad"); const isLive = o.live && o.live.live; const runnable = (p) => { const t = cfg().tiles.find((x) => x.id === p.key); if (!t || !editing() || isLive) return ""; const fixes = (t.checks || []).map((c, j) => [typeof c.fix === "string" ? c.fix : null, j]).filter(([f]) => f && canRunNow(f)); return fixes.slice(0, 1).map(([f, j]) => ` `).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 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 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'"; 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.

    ${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}
    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 ? `` : ""}
    `; main.querySelector("#restartwd").onclick = () => confirmBox(w.running ? "Restart the watchdog? The deck goes purple for about 5 seconds; a fix that is running finishes first." : "Start the watchdog?", w.running ? "Restart" : "Start", async () => { try { const r = await post(w.running ? "/api/watchdog/restart" : "/api/watchdog/start"); alertBox(r.ok ? "Done: " + r.detail : "Not done: " + r.detail); } catch (e) { alertBox(e.message); } }); const stop = main.querySelector("#stopwd"); if (stop) stop.onclick = () => confirmBox("Stop the watchdog? The deck goes purple and every alarm is off until it is started again.", "Stop", async () => { try { const r = await post("/api/watchdog/stop", { word: "STOP" }); alertBox(r.ok ? "Stopped." : "Not stopped: " + r.detail); } catch (e) { alertBox(e.message); } }, "STOP"); main.querySelector("#alarms").onclick = async () => { try { await post("/api/command", { text: o.service_on === false ? "service on" : "service off" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } }; for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|")); if (!quiet) window.scrollTo(0, 0); } function canRunNow(fixId) { const f = cfg().fixes[fixId]; if (!f) return false; if (["reconnect", "hyperdeck", "midi_connect", "spotify"].includes(f.kind)) return true; if ((f.kind || "ssh") === "ssh" || f.kind === "local") return !closes(f) && !f.never_auto; return false; } function runNow(key, fixId, which) { const f = cfg().fixes[fixId]; confirmBox(`Run "${f.label || fixId}" now? It runs through the watchdog exactly like a hold on the ${key.toUpperCase()} key.`, "Run it", async () => { try { await post("/api/command", { text: `test ${key} ${fixId} ${which}` }); alertBox("Sent. Watch the key, or Logs › Watchdog (TEST)."); } catch (e) { alertBox(e.message); } }); } function deckHtml(deck, c, which, selected) { const cells = {}; const rp = c.restart_page; if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; } else { for (const t of c.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; } const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : c.page), "2,0": "DOWN" }; let out = `
    `; for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) { const k = `${r},${col}`, cell = cells[k]; if (nav[k]) out += ``; else if (cell && cell.nav) out += ``; else if (cell) { const s = deck && deck[cell.id]; const style = s ? `--b1:${hex(s.b)};--f1:${hex(s.f)};--b2:${hex(s.b2)};--f2:${hex(s.f2)};background:${hex(s.b)};color:${hex(s.f)}` : "background:#444;color:#ddd"; const blink = s && s.b !== s.b2 ? " blink" : ""; out += `
    ${esc(s ? s.x : cell.label)}
    `; } else out += `
    `; } return out + `
    `; } // ---- Keys ------------------------------------------------------------------ async function keys(rest) { 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 ? `` : ""}
    `; let panel = `

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

    `; if (sel && !restart) { const i = c.tiles.findIndex((x) => x.id === sel); if (i >= 0) panel = await keyPanel(i, hold); } else if (sel && restart && c.restart_page) { const i = c.restart_page.keys.findIndex((x) => x.id === sel); if (i >= 0) panel = restartKeyPanel(i, hold); } const tray = c.tiles.filter((t) => !t.pos && t.kind !== "master").map((t) => ``).join(" "); main.innerHTML = `

    Keys

    The deck as people hold it. Click a key; with Editing on, change what it watches and what holding it does (moving keys arrives with step 3).

    ${tabs}
    ${deckHtml(o.deck, c, restart ? "restart" : "status", sel)}${!restart && tray ? `

    Shown inside their group, no key of their own: ${tray}

    ` : ""}
    ${panel}
    `; wireKeyPanel(sel, restart); } async function keyPanel(i, hold) { const c = cfg(), t = c.tiles[i]; const k = await api("/api/keys"); const kt = k.tiles.find((x) => x.id === t.id) || { says: [], holding: "", checks: [] }; const fixOptions = (cur) => `${Object.entries(c.fixes).map(([id, f]) => ``).join("")}`; const checks = (t.checks || []).map((chk, j) => { const spec = state.schema.checks[chk.type] || { fields: [], question: "" }; const sentence = (kt.checks[j] || {}).sentence || chk.type; if (!editing()) return `
  • ${esc(sentence)}
    Advanced
    ${Object.entries(chk).map(([n, v]) => `
    ${esc(n)}
    ${esc(typeof v === "object" ? JSON.stringify(v) : v)}
    `).join("")}
  • `; const base = `tiles.${i}.checks.${j}.`; const fields = (spec.fields || []).filter((f) => f !== "fix" && f !== "type").map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join(""); const fixSel = typeof chk.fix === "object" && chk.fix ? `
    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}
  • `; }).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 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}
    `; } function groupBy(obj, fn) { const out = {}; for (const [k, v] of Object.entries(obj)) (out[fn(v)] = out[fn(v)] || []).push(k); return out; } function wireKeyPanel(sel, restart) { const c = cfg(); const i = restart ? -1 : c.tiles.findIndex((x) => x.id === sel); for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|")); for (const b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; state.draft.config.tiles[i].checks.splice(+b.dataset.removecheck, 1); saveDraft(); route(); }); for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); }; const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ type: add.value }); saveDraft(); route(); }; const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const m = pos.value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); if (m) t.pos = [+m[1], +m[2]]; else if (!pos.value.trim()) delete t.pos; else return alertBox("Row, column: two numbers, like 2, 5."); saveDraft(); route(); }; for (const s of main.querySelectorAll("select.fixsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); }; for (const ta of main.querySelectorAll("textarea.fixtable")) ta.onchange = () => { try { setPath(ta.dataset.path, JSON.parse(ta.value)); } catch (e) { alertBox("Not a valid table."); } }; for (const s of main.querySelectorAll("select.holdsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); }; for (const s of main.querySelectorAll("select.watchsel")) s.onchange = () => setPath(s.dataset.path, [...s.selectedOptions].map((o) => o.value)); } function restartKeyPanel(i, hold) { const c = cfg(), r = c.restart_page.keys[i]; const f = c.fixes[r.fix] || {}; const conns = new Set(); for (const t of c.tiles) for (const ch of t.checks || []) if (ch.conn) conns.add(ch.conn); const edit = editing() ? `${inputHtml(`restart_page.keys.${i}.label`, F("restart_page.keys[].label"), r.label)}
    ` : ""; 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)}
    `; } // ---- Computers --------------------------------------------------------------- let compTab = "map"; async function computers(rest, quiet) { if (quiet && document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) return; const m = await api("/api/machines"); const selected = rest[0] && rest[0] !== "gear" && rest[0] !== "setup" ? rest[0] : null; compTab = rest[0] === "gear" || rest[0] === "setup" ? rest[0] : "map"; const tabs = `
    `; let body = ""; if (compTab === "gear") body = await gearHtml(); else if (compTab === "setup") body = setupHtml(m); else { const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `${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
    `; 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}`; 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 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 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.
    ` : ""; 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)}` : ""}

    ${c.notes ? `

    ${esc(c.notes)}

    ` : ""}

    What runs here

    ${items}
    ${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("")}
    ` : ""} ${edit}
    `; } 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(", ")}
    `; } function setupHtml(m) { return m.computers.map((c) => { const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac"; const steps = (m.setup_steps || {})[kind] || []; const lightFor = (step) => { const s = c.setup || {}; const item = (k) => (c.items || []).find((i) => i.kind === k); if (/login key/.test(step)) return s.key_ok === true || s.login_key === true ? "ok" : s.key_ok === false || s.login_key === false ? "problem" : "unknown"; if (/python/.test(step)) return s.python_ok === true ? "ok" : s.python_ok === false ? "problem" : "unknown"; if (/SSH service/.test(step)) return c.reachable ? "ok" : "problem"; if (/firewall/.test(step)) return s.firewall_allow ? "ok" : "unknown"; if (/wake-on-LAN/.test(step)) return s.wake_on_lan === "Enabled" ? "ok" : s.wake_on_lan ? "problem" : "unknown"; if (/Public/.test(step)) return s.profile === "Public" ? "ok" : s.profile ? "problem" : "unknown"; if (/DVS helper/.test(step)) { const it = 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 (/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"; } if (/Wi-Fi/.test(step)) return c.manual_done && c.manual_done.wifi_power ? "hand" : "unknown"; if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown"; return "unknown"; }; return `

    ${esc(c.name)}

    Generated set-up scripts and the [how] steps arrive with step 5.

    `; }).join(""); } // ---- Logs ------------------------------------------------------------------ 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("")}
    ` : ""; 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)"}
    `; 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; } // ---- History ---------------------------------------------------------------- async function history(rest) { const h = await api("/api/history"); 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 ? `` : "

    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.

    `}
    `; } 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 ? `` : ""}
    ${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); } }); } // ---- Review & Apply ------------------------------------------------------------- async function review() { const st = await api("/api/apply/status"); if (st.state === "running") return applyProgress(st); const d = await api("/api/draft"); state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; badge(); if (!state.draft) { main.innerHTML = `

    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) : ""}`; 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 ? `` : "

    No change yet.

    "}

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

    ${errors ? `
    Not possible yet:
    ` : ""}${warnings ? `
    Worth knowing:
    ` : ""}${live}
    ${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`; const go = (emergency) => async () => { const note = main.querySelector("#note").value.trim(); try { const res = await post("/api/apply", { note, emergency }); if (res.ok) { state.draft = null; state.atemPending = false; badge(); review(); } else alertBox(res.error); } catch (e) { alertBox(e.message); if (e.body && e.body.changed_since) { state.draftStale = true; badge(); } } }; main.querySelector("#apply").onclick = go(null); const em = main.querySelector("#emergency"); if (em) em.onclick = () => dialog(`

    Apply during a live service

    ${esc(r.feel)} Say why, and type LIVE.

    `, (b) => { b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = () => { const why = b.querySelector("#why").value.trim(), word = b.querySelector("#word").value.trim(); if (word !== "LIVE" || !why) return; closeDialog(); go({ word, reason: why })(); }; }); main.querySelector("#discard").onclick = () => confirmBox("Throw the draft away? Every change since the last Apply is lost.", "Throw it away", async () => { try { await post("/api/draft", {}, "DELETE"); state.draft = null; state.atemPending = false; badge(); review(); } catch (e) { alertBox(e.message); } }); } function applyProgress(st) { main.innerHTML = `

    Applying…

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

    ${stepsHtml(st)}

    This page updates by itself.

    `; setTimeout(async () => { try { const s2 = await api("/api/apply/status"); if (s2.state === "running") applyProgress(s2); else { await loadAll(); route(); } } catch (e) { setTimeout(() => applyProgress(st), 2000); } }, 1500); } function stepsHtml(st) { return `
      ${(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("")}
    `; } function applyResult(st) { return `

    ${st.state === "done" ? "Applied" : "Not applied"} ${esc(when(st.finished))}${st.by ? " · " + esc(st.by) : ""}

    ${esc(st.outcome)}

    ${stepsHtml(st)}
    `; } // ---- Help ------------------------------------------------------------------ async function help() { 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.

    The words a key can show

    ${glossary}

    Things only a person can do

    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) { 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 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 === "companion") body = (await companionHtml()) + body; if (which === "schedules") body = schedulesHtml(c) + body; if (which === "network-dante") body += await danteHtml(c); if (which === "spotify") body = spotifyHtml(c) + body; if (which === "atem") body = (await atemHtml(c)) + body; main.innerHTML = `

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

    ${(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
    `; } function danteCheck(c) { const t = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "dante")); return t ? t.checks.find((ch) => ch.type === "dante") : null; } async function danteHtml(c) { const chk = danteCheck(c); if (!chk) return ""; const snap = await api("/api/snapshot").catch(() => ({})); const map = snap.dante_map || {}; 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 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

    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.

    `; } 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"}

    `; } function wireSite() { const q = (s) => main.querySelector(s); for (const b of main.querySelectorAll("[data-addwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.addwin; const list = (getPath(path, state.draft.config) || []).slice(); list.push({ days: ["sun"], start: "09:00", end: "12:00" }); setPath(path, list); route(); }; for (const b of main.querySelectorAll("[data-delwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.delwin.replace(/\.\d+$/, ""), i = +b.dataset.delwin.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list.splice(i, 1); setPath(path, list); route(); }; for (const w of main.querySelectorAll("[data-win]")) for (const el of w.querySelectorAll("input")) el.onchange = () => { if (!ensureDraft()) return; const path = w.dataset.win.replace(/\.\d+$/, ""), i = +w.dataset.win.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list[i] = { days: [...w.querySelectorAll("[data-day]")].filter((x) => x.checked).map((x) => x.dataset.day), start: w.querySelector("[data-start]").value.trim(), end: w.querySelector("[data-end]").value.trim() }; setPath(path, list); }; for (const cb of main.querySelectorAll("[data-autofix]")) cb.onchange = () => { if (!ensureDraft()) return; const set = new Set(state.draft.config.auto_fix.fixes || []); cb.checked ? set.add(cb.dataset.autofix) : set.delete(cb.dataset.autofix); setPath("auto_fix.fixes", [...set]); }; const dc = () => danteCheck(state.draft.config); for (const el of main.querySelectorAll("[data-dantekey]")) el.onchange = () => { if (!ensureDraft()) return; dc().devices[el.dataset.dantekey] = el.value.trim(); saveDraft(); }; for (const el of main.querySelectorAll("[data-dantefix]")) el.onchange = () => { if (!ensureDraft()) return; const ch = dc(); if (typeof ch.fix !== "object" || !ch.fix) ch.fix = { "*": ch.fix || "dante-auto" }; if (el.value) ch.fix[el.dataset.dantefix] = el.value; else delete ch.fix[el.dataset.dantefix]; saveDraft(); }; for (const b of main.querySelectorAll("[data-dantedel]")) b.onclick = () => { if (!ensureDraft()) return; const ch = dc(); delete ch.devices[b.dataset.dantedel]; if (typeof ch.fix === "object" && ch.fix) delete ch.fix[b.dataset.dantedel]; saveDraft(); route(); }; const da = q("#danteadd"); if (da) da.onclick = () => { if (!ensureDraft()) return; const n = q("#dantenew").value.trim(), k = q("#dantenewkey").value.trim(); if (!n) return; const ch = dc(); ch.devices = ch.devices || {}; ch.devices[n] = k || n.slice(0, 10).toUpperCase(); saveDraft(); route(); }; const rs = q("#resurvey"); if (rs) rs.onclick = async () => { try { await post("/api/command", { text: "resurvey" }); alertBox("Asked the watchdog to look at the switches; the map updates within a minute."); } catch (e) { alertBox(e.message); } }; const look = q("#splook"); if (look) look.onclick = async () => { const info = q("#spinfo"); info.textContent = "Looking…"; try { const r = await api("/api/live/spotify?url=" + encodeURIComponent(q("#splink").value)); if (!r.ok) { info.textContent = r.error; return; } if (!ensureDraft()) return; setPath("spotify.playlist", r.uri); if (r.name) { setPath("spotify.name", r.name); q("#spname").value = r.name; } q("#splink").value = r.uri; info.textContent = r.name ? `${r.name}: ${r.songs} songs. The play fix's words follow the name.` : (r.warning || ""); } catch (e) { info.textContent = e.message; } }; const spname = q("#spname"); if (spname) spname.onchange = () => setPath("spotify.name", spname.value.trim()); const splink = q("#splink"); if (splink) splink.onchange = () => { const m = splink.value.match(/(?:playlist[/:])([A-Za-z0-9]{22})/); if (m) setPath("spotify.playlist", "spotify:playlist:" + m[1]); else if (splink.value.trim()) alertBox("That is not a playlist link or URI."); }; const af = q("#atemfile"); if (af) af.onchange = async () => { const f = af.files[0]; if (!f) return; try { const r = await fetch("/api/atem-reference", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/xml" }, body: await f.arrayBuffer() }).then((x) => x.json()); showAtem(r); } catch (e) { alertBox(e.message); } }; const an = q("#atemnewest"); if (an) an.onclick = async () => { try { showAtem(await post("/api/atem-reference/pick-newest")); } catch (e) { alertBox(e.message); } }; const ad = q("#atemdiscard"); if (ad) ad.onclick = async () => { try { await post("/api/atem-reference/discard"); state.atemPending = false; badge(); route(); } catch (e) { alertBox(e.message); } }; function showAtem(r) { if (!r.ok) { alertBox(r.error || "Not taken."); return; } state.atemPending = true; if (!state.draft && ensureDraft()) saveDraft(); badge(); const diffs = (r.diffs || []).slice(0, 12).map((d) => d.part + " " + d.key + ": " + JSON.stringify(d.before) + " → " + JSON.stringify(d.after)).join("\n"); alertBox(`${r.file ? r.file + ": " : ""}${r.product || ""}, ${r.audio} audio and ${r.camera} camera values; ${r.diff_count} differ from the current file${diffs ? ":\n" + diffs : "."}\nApply replaces the file.`); route(); } } // ---- Fixes ------------------------------------------------------------------------- async function fixes(rest) { const c = cfg(), auto = new Set(c.auto_fix.fixes || []); const used = {}; const use = (f, who) => { if (f) (used[f] = used[f] || new Set()).add(who); }; for (const t of c.tiles) { for (const ch of t.checks || []) for (const f of (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix])) use(f, t.label); use(t.hold_always, t.label); } for (const k of (c.restart_page || { keys: [] }).keys) use(k.fix, "restart key " + k.label); const rows = Object.entries(c.fixes).map(([id, f]) => `${esc(f.short || id)}${esc(f.label || "")}${esc(f.kind || "ssh")}${f.host ? " on " + esc((c.hosts[f.host] || {}).name || f.host) : f.kind === "local" || f.kind === "spotify" ? " on this Mac" : ""}${[...(used[id] || [])].join(", ")}${auto.has(id) ? "may run by itself" : f.never_auto ? "never by itself" : "hold only"}`).join(""); const id = rest[0], f = c.fixes[id]; let detail = ""; if (f) { const kind = f.kind || "ssh"; const fields = ["label", "short"].concat((state.schema.fix_kind_fields || {})[kind] || []).concat(["restart_ok", "never_auto", "timeout"]); const inputs = editing() ? [...new Set(fields)].map((fl) => { const Fd = F("fixes.*." + fl); if (!Fd) return ""; if (fl === "host") return `
    `; if (fl === "run") return `
    zsh on a Mac, cmd.exe on a PC. ${esc(Fd.help)}
    `; return inputHtml(`fixes.${id}.${fl}`, Fd, f[fl]); }).join("") : ""; const kindSel = editing() ? `
    ` : ""; const keys = [...(used[id] || [])]; detail = `

    ${esc(f.short || id)}

    ${esc(f.label || "")}

    ${kindSel}${inputs}${canRunNow(id) && editing() ? `

    Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).

    ` : ""}
    Advanced
    ${Object.entries(f).map(([k, v]) => `
    ${esc(k)}
    ${esc(typeof v === "object" ? JSON.stringify(v) : v)}
    `).join("")}
    id
    ${esc(id)}
    ${editing() && !keys.length ? `` : ""}
    `; } const add = editing() ? `
    ` : ""; main.innerHTML = `

    Fixes

    Everything a hold can run. ${Object.keys(c.fixes).length} today.

    ${add}
    ${rows}
    key textplain wordsbuilt asused byby itself
    ${detail}`; const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value); route(); }; const del = main.querySelector("#delfix"); if (del) del.onclick = () => confirmBox(`Delete the fix ${id}? No key uses it.`, "Delete", () => { if (!ensureDraft()) return; delete state.draft.config.fixes[id]; const af2 = state.draft.config.auto_fix.fixes || []; state.draft.config.auto_fix.fixes = af2.filter((x) => x !== id); saveDraft(); location.hash = "#fixes"; }); const af = main.querySelector("#addfix"); if (af) af.onclick = () => { const nid = main.querySelector("#newfix").value.trim(); if (!/^[A-Za-z0-9_]+$/.test(nid) || c.fixes[nid]) return alertBox("A new id: letters, digits and _ only, not in use."); if (!ensureDraft()) return; state.draft.config.fixes[nid] = { kind: "local", short: nid.toUpperCase().slice(0, 12), label: "", run: "" }; saveDraft(); location.hash = "#fixes/" + nid; }; } })();