/* 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"); 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, 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 || {}; 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, the PIN ---------------------------------------------- document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; }; const editbtn = document.getElementById("editbtn"); function showEditing() { 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); } 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(`

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 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(); }); }; 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(); }; }); } const needEditing = () => { if (!editing()) { alertBox("Switch Editing on first (Edit, top right)."); return false; } return true; }; // ---- the draft ------------------------------------------------------------ async function loadAll() { const [c, s, d, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]); state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.builders = b; document.getElementById("sitename").textContent = (state.live.site || {}).name || ""; badge(); } function ensureDraft() { if (!needEditing()) 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"; 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" : ""} · Apply` : "Draft"; b.classList.toggle("warn", !!state.draftStale); } function saveDraft() { badge(); clearTimeout(state.saveTimer); state.saving = new Promise((done) => { 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); } done(); }, 400); }); return state.saving; } const flush = () => state.saving || Promise.resolve(); 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()); // ---- 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; } 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; } const LABELS = { after_seconds: "wait before acting (seconds)", note_seconds: "keep showing ON … after (seconds)", interval_seconds: "look every (seconds)", failures: "misses before it counts", debounce_seconds: "a fault must last (seconds)", 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 ${f.default}.` : ""; return `
${esc(LABELS[last] || last.replace(/_/g, " "))}${f.required ? " *" : ""}${hint((f.help || "") + dflt)}${html}${extra || ""}
`; } // An On/Off switch like Companion's connection toggle: switched off = kept in the settings, not used. // `path` is the object ("tiles.3", "fixes.obs", "tiles.3.checks.1"); off writes "disabled": true there. function onOff(path, obj, what) { const on = !(obj && obj.disabled); return ``; } function wireOnOff(root) { for (const el of root.querySelectorAll("[data-onoff]")) { el.closest("label").onclick = (e) => e.stopPropagation(); // inside a : do not fold it el.onchange = () => { setPath(el.dataset.onoff + ".disabled", el.checked ? undefined : true); route(); }; } for (const el of root.querySelectorAll("[data-danteoff]")) { el.closest("label").onclick = (e) => e.stopPropagation(); el.onchange = () => { if (!ensureDraft()) return; const ch = danteCheck(state.draft.config), n = el.dataset.danteoff, offs = new Set(ch.devices_off || []); el.checked ? offs.delete(n) : offs.add(n); if (offs.size) ch.devices_off = [...offs]; else delete ch.devices_off; saveDraft(); route(); }; } for (const el of root.querySelectorAll("[data-connoff]")) { el.closest("label").onclick = (e) => e.stopPropagation(); el.onchange = () => { if (!ensureDraft()) return; for (const [, , ch] of connChecks(state.draft.config, el.dataset.connoff)) { if (el.checked) delete ch.disabled; else ch.disabled = true; } saveDraft(); route(); }; } } function wireInputs(root) { for (const el of root.querySelectorAll("[data-path]")) { if (el.onchange) continue; 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|ready_page\.keys)\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^(tiles|ready_page\.keys)\.\d+\./, "tiles[].") .replace(/^restart_page\.keys\.\d+\./, "restart_page.keys[].").replace(/^fixes\.[^.]+\./, "fixes.*.").replace(/^hosts\.[^.]+\./, "hosts.*.") .replace(/^gear\.[^.]+\./, "gear.*.").replace(/^(auto_fix\.windows|failover\.windows|site\.live_services|service_windows|ready\.due)\.\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 || ""}`, `#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, settings, logs, history, help, review }; let timer = null; async function route() { 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); wireOnOff(main); 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)}

`; } } window.addEventListener("hashchange", route); route(); // ---- Overview -------------------------------------------------------------- async function overview(_, quiet) { 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"); 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' : ""}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 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"; const chip = (ok, text, hintText) => `${text}`; main.innerHTML = `

    ${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}
    ${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 || "")}` : ""}
    ${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("|")); if (!quiet) window.scrollTo(0, 0); } function canRunNow(fixId) { const f = cfg().fixes[fixId]; if (!f) return false; if (["reconnect", "hyperdeck", "midi_connect", "spotify", "companion_press"].includes(f.kind)) return true; if (f.kind === "ready") return !f.never_auto; // the READY page's fixes (not the tracks copy) 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 (TEST)."); } catch (e) { alertBox(e.message); } }); } function deckHtml(deck, c, which, selected, draggable) { const cells = {}; const rp = c.restart_page, dp = c.ready_page; if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label, off: k.disabled }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; } else if (which === "ready" && dp) { for (const k of dp.keys) cells[k.pos] = { id: k.id, label: k.label, off: k.disabled }; cells[dp.back_pos] = { nav: "STATUS\nPAGE", back: "dback:" }; } else { for (const t of c.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label, off: t.disabled }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; } const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : which === "ready" && dp ? dp.page : c.page), "2,0": "DOWN" }; const prefix = { restart: "rkey:", ready: "dkey:" }[which] || "tile:"; const route_ = { restart: "restart/", ready: "ready/" }[which] || ""; const drag = (what) => editing() && draggable ? ` draggable="true" data-drag="${esc(what)}"` : ""; 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: 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 ready = rest[0] === "ready"; const restart = rest[0] === "restart"; const sel = restart || ready ? rest[1] : rest[0]; const hold = ((c.page_hold_ms || 2000) / 1000) + " s"; const tabs = `
    ${c.restart_page ? `` : ""}${c.ready_page ? `` : ""} ${editing() ? `page ${c.restart_page ? ` · restart page ` : ""} ` : ""}
    `; let panel = `

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

    `; if (ready) { const i = sel && c.ready_page ? c.ready_page.keys.findIndex((x) => x.id === sel) : -1; panel = await readyKeyPanel(i, hold); if (i >= 0 && editing() && c.ready_page.keys[i].kind !== "ready") panel += await keyPanel(i, hold, "ready_page.keys"); } else 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 re-imports the ${v.plan.pages.filter((p) => p === "status" || p === "restart" || p === "ready").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.
    `; } catch (e) {} } const adders = editing() ? (ready ? (c.ready_page ? `` : "") : 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.

    " : ready && !c.ready_page ? "

    No READY page.

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

    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, ready ? "ready_page.keys" : "tiles"); wireLayout(restart); const nd = main.querySelector("#newdkey"); if (nd) nd.onclick = () => newReadyKey(); } // A new READY page key: a checklist line the page checks itself, a tap-to-confirm line, or any check. function newReadyKey() { const c = cfg(); dialog(`

    New READY key

    `, (b) => { const kind = b.querySelector("#nkind"), item = b.querySelector("#nitem"); kind.onchange = () => { item.hidden = kind.value !== "ready"; }; b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = () => { const label = b.querySelector("#nlabel").value.trim().toUpperCase(); if (!label) return alertBox("Give it a label."); const pos = parsePos(b.querySelector("#npos").value, cfg(), "ready", false); if (!pos) return; if (!ensureDraft()) return; const keys = state.draft.config.ready_page.keys; let id = "rd_" + (slug(label) || "key"), n = 2; while (keys.some((k) => k.id === id) || state.draft.config.tiles.some((t) => t.id === id)) id = "rd_" + slug(label) + "_" + n++; const checks = kind.value === "manual" ? [{ type: "ready_manual", item: slug(label) || id }] : kind.value === "ready" ? [{ type: "ready", item: item.value, fix: "ready_recheck" }] : []; keys.push({ id, label, pos, checks }); saveDraft(); closeDialog(); location.hash = "#keys/ready/" + id; }; }); } function takenCells(c, which) { const taken = new Set(["0,0", "1,0", "2,0"]); if (which === "restart") { for (const k of c.restart_page.keys) taken.add(String(k.pos)); taken.add(String(c.restart_page.back_pos)); } else if (which === "ready") { for (const k of c.ready_page.keys) taken.add(String(k.pos)); taken.add(String(c.ready_page.back_pos)); } else { for (const t of c.tiles) if (t.pos) taken.add(String(t.pos)); if (c.restart_page) taken.add(String(c.restart_page.jump_pos)); } return taken; } function firstEmpty(c, which) { const taken = takenCells(c, which); for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) if (!taken.has(`${r},${col}`)) return [r, col]; return null; } function parsePos(text, c, which, allowBlank) { const m = text.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); if (!m) { if (allowBlank && !text.trim()) return null; alertBox("Row, column: two numbers, like 2, 5."); return false; } const pos = [+m[1], +m[2]]; if (pos[0] > 3 || pos[1] > 7) { alertBox("The deck has rows 0-3 and columns 0-7."); return false; } if (takenCells(c, which).has(String(pos))) { alertBox(`Row ${pos[0]} column ${pos[1]} is taken. Hide or move a key first` + (allowBlank ? ", or leave the position blank." : ".")); return false; } return pos; } function wireLayout(restart) { const move = (what, pos) => { if (!ensureDraft()) return; const c = state.draft.config, [kind, id] = what.split(":"); if (kind === "tile") { const t = c.tiles.find((x) => x.id === id); if (!t) return; if (pos) t.pos = pos; else delete t.pos; } else if (kind === "rkey") { const k = c.restart_page.keys.find((x) => x.id === id); if (k && pos) k.pos = pos; } else if (kind === "jump" && pos) c.restart_page.jump_pos = pos; else if (kind === "back" && pos) c.restart_page.back_pos = pos; else if (kind === "dkey") { const k = c.ready_page.keys.find((x) => x.id === id); if (k && pos) k.pos = pos; } else if (kind === "dback" && pos) c.ready_page.back_pos = pos; saveDraft(); route(); }; let dragging = null; for (const el of main.querySelectorAll("[data-drag]")) { el.addEventListener("dragstart", (e) => { dragging = el.dataset.drag; e.dataTransfer.setData("text/plain", dragging); e.dataTransfer.effectAllowed = "move"; }); el.addEventListener("dragend", () => { dragging = null; }); } for (const el of main.querySelectorAll("[data-drop]")) { el.addEventListener("dragover", (e) => { e.preventDefault(); el.classList.add("over"); }); el.addEventListener("dragleave", () => el.classList.remove("over")); el.addEventListener("drop", (e) => { e.preventDefault(); const what = e.dataTransfer.getData("text/plain") || dragging; if (what) move(what, el.dataset.drop.split(",").map(Number)); }); } const tray = main.querySelector("#tray"); if (tray && editing()) { tray.addEventListener("dragover", (e) => { e.preventDefault(); tray.classList.add("over"); }); tray.addEventListener("dragleave", () => tray.classList.remove("over")); tray.addEventListener("drop", (e) => { e.preventDefault(); const what = e.dataTransfer.getData("text/plain") || dragging; if (!what || !what.startsWith("tile:")) return; const t = cfg().tiles.find((x) => x.id === what.slice(5)); if (t && t.kind) return alertBox("That key always has a spot on the deck."); move(what, null); }); } for (const inp of main.querySelectorAll("input.pagenum")) inp.onchange = () => { const n = parseInt(inp.value, 10); if (!(n >= 1)) return; if (!ensureDraft()) return; const c = state.draft.config; if (inp.dataset.which === "status") { c.page = n; c.page_name = `${n}\n${(c.page_name || "\nStatus").split("\n").slice(1).join("\n") || "Status"}`; } else { c.restart_page.page = n; c.restart_page.page_name = `${n}\n${(c.restart_page.page_name || "\nRestart").split("\n").slice(1).join("\n") || "Restart"}`; } saveDraft(); route(); }; const hr = main.querySelector("#hasrestart"); if (hr) hr.onchange = () => { if (!ensureDraft()) { hr.checked = !hr.checked; return; } const c = state.draft.config; if (!hr.checked) confirmBox("Switch the Restart page off? Its keys are removed from the settings; page " + c.restart_page.page + " stays in Companion until someone clears it.", "Switch it off", () => { delete c.restart_page; saveDraft(); location.hash = "#keys"; route(); }, null); 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

    `, (b) => { b.querySelector("#dcancel").onclick = closeDialog; 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; if (cfg().tiles.some((t) => t.id === id)) return alertBox("A key with that id exists."); const pos = parsePos(b.querySelector("#npos").value, cfg(), "status", true); if (pos === false) return; if (!ensureDraft()) return; const t = { id, label, checks: [] }; if (pos) t.pos = pos; 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) => { 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_" + 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; }; }); }; } // A Status page tile ("tiles") or a READY page key ("ready_page.keys"): the same editor for both. const keyList = (c, list) => (list === "ready_page.keys" ? (c.ready_page || { keys: [] }).keys : c.tiles); const READY_ONLY = ["ready", "ready_manual"]; async function keyPanel(i, hold, list = "tiles") { const c = cfg(), t = keyList(c, list)[i], onReady = list !== "tiles"; const k = await api("/api/keys"); const kt = k.tiles.find((x) => x.id === t.id) || { says: [], holding: "", checks: [] }; // A Companion connection is picked from the ones Companion has (typing still works). const cc = editing() ? await api("/api/companion").catch(() => ({})) : {}; const connList = `${(cc.connections || []).map((r) => ``).join("")}`; const fixOptions = (cur) => `${Object.entries(c.fixes).map(([id, f]) => ``).join("")}`; const short = (text, n) => { text = String(text || ""); return text.length > n ? text.slice(0, n - 1) + "…" : text; }; let lastQ = null; const checks = (t.checks || []).map((chk, j) => { const spec = state.schema.checks[chk.type] || { fields: [], question: "" }; const sentence = (kt.checks[j] || {}).sentence || chk.type; const what = chk.type === "midi_peer" ? `${chk.peer || "?"} in ${esc(chk.session || "?")}` : esc(chk.conn || chk.name || (chk.params || {}).process || chk.msg || chk.host || chk.label || chk.url || chk.session || short(sentence, 40)); const q = spec.question || chk.type; const header = q !== lastQ ? `
    ${esc(q)}
    ` : ""; lastQ = q; const summary = `${onOff(`${list}.${i}.checks.${j}`, chk, "this check")} ${what}${hint(sentence)}`; if (!editing()) return `${header}
    ${summary}

    ${esc(sentence)}

    `; const base = `${list}.${i}.checks.${j}.`; let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => f === "conn" ? inputHtml(base + f, F("tiles[].checks[]." + f), chk[f], connList).replace("checklist line${hint("What the READY page looks at for this key")}` : f === "item" && chk.type === "ready_manual" ? inputHtml(base + f, Object.assign({}, F("tiles[].checks[].item"), { help: "A short name for what is tapped done, like green_room (its tap is remembered by this name for the week)" }), chk[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 = `
    built as
    ${paramsHtml(ct, chk.params || {}, "ck" + j)}
    ` + (ctName === "custom" ? fields : inputHtml(base + "msg", F("tiles[].checks[].msg"), chk.msg) + inputHtml(base + "interval_seconds", F("tiles[].checks[].interval_seconds"), chk.interval_seconds) + (chk.cmd ? `
    the command
    ${esc(chk.cmd)}
    ` : "")); } 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 `${header}
    ${summary}
    ${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" && t.kind !== "ready"; const members = t.members && !onReady ? (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 ? `` : ""; const label = editing() && !onReady ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : ""; const holdAlways = editing() && plain && !onReady ? `
    hold also when green${hint("A fix that runs on every hold, even with nothing failing")}
    ` : ""; const holdList = (kt.holding || "").split(/;\s*/).filter(Boolean); const more = `
    more
    id
    ${esc(t.id)}
    ${t.kind ? `
    kind
    ${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}
    ` : ""}
    ${editing() && !t.kind && !onReady ? `
    position${hint("row, column; blank = inside its group only")}
    ` : ""}${editing() && (!t.kind || t.kind === "network") ? `

    ` : ""}
    `; if (onReady) return `

    ${t.kind ? "" : onOff(`ready_page.keys.${i}`, t, "this key") + " "}Edit ${esc(t.label)}

    ${checks ? `
    ${checks}
    ` : ""}${addMenu}${more}
    `; return `

    ${t.kind ? "" : onOff(`tiles.${i}`, t, "this key") + " "}${esc(t.label)} ${t.pos ? `row ${t.pos[0]}, col ${t.pos[1]}` : "in a group"}${t.disabled ? " · off: grey on the deck, checks nothing" : ""}

    ${label}${members}${holdAlways} ${holdList.length ? `
    Hold ${esc(hold)}: ${esc(short(holdList[0], 48))}${holdList.length > 1 ? ` +${holdList.length - 1}` : ""}${t.hold_always ? " also when green" : ""}
      ${holdList.map((h) => `
    1. ${esc(h)}
    2. `).join("")}
    ` : ""} ${kt.says && kt.says.length ? `
    Words it can show ${kt.says.length}

    ${esc(kt.says.join(" · "))}

    ` : ""} ${checks ? `
    ${checks}
    ` : ""}${addMenu}${more}
    `; } 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, list = "tiles") { const c = cfg(); const i = restart ? -1 : keyList(c, list).findIndex((x) => x.id === sel); const mine = () => keyList(state.draft.config, list)[i]; for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|")); const dk = main.querySelector("#delkey"); if (dk) dk.onclick = () => confirmBox(`Delete the key ${keyList(c, list)[i].label}? Its checks go with it.`, "Delete", () => { if (!ensureDraft()) return; const cc = state.draft.config; const keys = keyList(cc, list); const id = keys[i].id; keys.splice(i, 1); for (const t of cc.tiles) if (t.members) t.members = t.members.filter((m) => m !== id); saveDraft(); location.hash = list === "tiles" ? "#keys" : "#keys/ready"; }); const dr = main.querySelector("#delrkey"); if (dr) dr.onclick = () => confirmBox("Delete this restart key?", "Delete", () => { if (!ensureDraft()) return; const ks = state.draft.config.restart_page.keys; ks.splice(ks.findIndex((x) => x.id === sel), 1); saveDraft(); location.hash = "#keys/restart"; }); for (const b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; mine().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 = mine(); t.checks = t.checks || []; t.checks.push(add.value === "ready" ? { type: "ready", fix: "ready_recheck" } : { type: add.value }); saveDraft(); route(); }; const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { const t0 = cfg().tiles[i]; const was = t0.pos; delete t0.pos; const p2 = parsePos(pos.value, cfg(), "status", true); if (was) t0.pos = was; if (p2 === false) return; if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; if (p2) t.pos = p2; else delete t.pos; saveDraft(); route(); }; const rebuildCheck = async (j) => { if (!ensureDraft()) return; const t = mine(), chk = t.checks[j]; const tplName = main.querySelector(`select.ctplsel[data-check="${j}"]`).value; if (tplName === "custom") { delete chk.template; delete chk.params; saveDraft(); route(); return; } const ct = state.builders.checks.find((x) => x.name === tplName); const params = readParams(main.querySelector(`.ctplform[data-check="${j}"]`), ct); const r = await post("/api/build-check", { check: Object.assign({}, chk, { template: tplName, params }) }); if (!r.ok) { chk.template = tplName; chk.params = params; saveDraft(); alertBox(r.error); return; } t.checks[j] = r.check; saveDraft(); route(); }; for (const sel3 of main.querySelectorAll("select.ctplsel")) sel3.onchange = () => rebuildCheck(+sel3.dataset.check); for (const form of main.querySelectorAll(".ctplform")) for (const el of form.querySelectorAll("[data-param]")) el.onchange = () => rebuildCheck(+form.dataset.check); 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)); } // ---- The READY page: what each checklist key looks at, and why it is amber or red right now ---- const READY_WORDS = { tracks: "This weekend's Ableton set exists, has a song group for every Planning Center song, and is the set Live last opened.", mix_foh: "Every song is mixed (a song whose tracks are all still at 0 dB is not) and its stems go out to the console, not to Master.", lights: "The lighting cue track has a clip under every song.", prop: "The ProPresenter playlist for the plan's date has every Planning Center item; MC announcements have a slide each and this week's notes; every person listed for baptisms or dedications has a slide.", timecode: "Every song's presentation has a timeline, and Planning Center's LTC note for every song is the timecode the tracks play at that song's start.", backgrounds: "Every song has at least one slide with a background.", led_cross: "Resolume is on the Service deck and a clip is playing on the cross.", lineup: "The Band Lineup TV shows this plan, nobody who is not on it or declined, and everyone on the band and communication teams.", track_bundle: "Every location has this weekend's Ableton project folder on the shared drive.", prop_bundle: "A ProPresenter bundle was saved for the locations since Monday.", }; async function readyKeyPanel(i, hold) { const c = cfg(); const snap = await api("/api/snapshot").catch(() => ({})); const r = snap.ready || {}; const plan = r.plan ? `

    Plan: ${esc(r.plan.title || "")} ${esc(r.plan.dates || "")}${r.time ? ` · looked ${esc(new Date(r.time * 1000).toLocaleTimeString())}` : ""}

    ` : `

    Not looked yet.

    `; if (i < 0) return `

    The weekly checklist

    ${plan}

    Amber: still to do. Blinking red: still missing once it is due (${esc(((c.ready || {}).due || []).map((w) => w.days.join("/") + " " + w.start + "-" + w.end).join(", "))}). BOH MIX is tapped done on the deck and reset every ${esc((c.ready || {}).reset_day || "mon")}. Hold any key ${esc(hold)} to fix it or look again; hold READY to fix everything.

    Click a key to see what it checks and why it is amber or red.

    `; const k = c.ready_page.keys[i], chk = (k.checks || [])[0] || {}; const item = r.items && chk.item ? r.items[chk.item] : null; const fix = chk.fix && c.fixes[chk.fix]; let what; if (k.kind === "ready") what = `Sums up the page: how many keys still have something to do, and which to look at first. Hold ${esc(hold)}: fix everything on the page that can be fixed.`; else if (chk.type === "ready_manual") what = `Only a person can check this. Tap on the deck when it is done; tap again to take it back. It resets every ${esc((c.ready || {}).reset_day || "mon")}.`; else if (chk.type === "ready") what = `${esc(READY_WORDS[chk.item] || chk.item)} Hold ${esc(hold)}: ${esc(fix && fix.action !== "recheck" ? fix.label : "look again now")}.`; else what = `Watches ${esc((k.checks || []).map((x) => x.tag || x.msg || x.type).join(", "))}. Hold ${esc(hold)}: fix the one that dropped.`; const faults = item && item.faults && item.faults.length ? `` : item ? `

    Nothing to do${item.info ? ` (${esc(item.info)})` : ""}.

    ` : ""; const edit = editing() ? inputHtml(`ready_page.keys.${i}.label`, F("tiles[].label"), k.label) : ""; return `

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

    ${plan}

    ${what}

    ${faults}${edit} ${chk.fix && canRunNow(chk.fix) && editing() ? `` : ""}
    more
    id
    ${esc(k.id)}
    ${chk.item ? `
    checklist line
    ${esc(chk.item)}
    ` : ""}
    `; } 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)}
    hold runs
    flash while down${hint("Keeps flashing HOLD TO FIX while these are down; hold cmd/ctrl to pick several")}
    ` : ""; return `

    ${onOff(`restart_page.keys.${i}`, r, "this key")} ${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 --------------------------------------------------------------- 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 = `
    ${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) => `${c.self ? "" : onOff(`hosts.${c.alias}`, cfg().hosts[c.alias], "this computer")}${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(""); const looked = Math.max(0, ...m.computers.map((c) => c.looked || 0)); body = `
    ${looked ? "looked " + esc(ago(Date.now() / 1000 - looked)) : "not looked yet"}
    ${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${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); }; const ag = main.querySelector("#addgear"); if (ag) ag.onclick = () => gearDialog(null); for (const a of main.querySelectorAll("[data-gearedit]")) a.onclick = (e) => { e.preventDefault(); gearDialog(a.dataset.gearedit); }; for (const b of main.querySelectorAll("[data-geardel]")) b.onclick = () => confirmBox(`Remove ${b.dataset.geardel} from the gear list?`, "Remove", async () => { try { const r = await post("/api/gear", { name: b.dataset.geardel, remove: true }); state.draft = r.draft; badge(); route(); } catch (e) { alertBox(e.message); } }); for (const b of main.querySelectorAll("[data-gear]")) b.onclick = async () => { const row = b.closest("tr"); try { const prev = (cfg().gear || {})[b.dataset.gear] || {}; await post("/api/gear", { name: b.dataset.gear, location: row.querySelector("[data-loc]").value, notes: row.querySelector("[data-notes]").value, kind: prev.kind || "", address: prev.address || "" }); 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, 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 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${["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 `

    ${c.self ? "" : onOff(`hosts.${c.alias}`, cfg().hosts[c.alias], "this computer") + " "}${esc(c.name)} ${wordOf(c.word)}${(cfg().hosts[c.alias] || {}).disabled ? ' off: nothing logs in to it; its checks and fixes are skipped' : ""}

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

    ` : ""}
    ${items}
    ${done ? `` : ""}
    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 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", () => { if (!ensureDraft()) return; const d = state.draft.config; delete d.hosts[alias]; for (const id of fixes) { delete d.fixes[id]; if (d.auto_fix && d.auto_fix.fixes) d.auto_fix.fixes = d.auto_fix.fixes.filter((x) => x !== id); } for (const t of d.tiles) { t.checks = (t.checks || []).filter((ch) => !(ch.host === alias || (ch.params || {}).host === alias)); for (const ch of t.checks) { if (typeof ch.fix === "string" && fixes.includes(ch.fix)) delete ch.fix; else if (ch.fix && typeof ch.fix === "object") for (const k of Object.keys(ch.fix)) if (fixes.includes(ch.fix[k])) delete ch.fix[k]; } if (fixes.includes(t.hold_always)) delete t.hold_always; if (!t.checks.length && !t.members) delete t.checks; } if (d.restart_page) d.restart_page.keys = d.restart_page.keys.filter((k) => !fixes.includes(k.fix)); saveDraft(); location.hash = "#computers"; }); } const GEAR_KINDS = ["recorder", "switcher", "Dante device", "Dante switch", "wireless receiver", "in-ear transmitter", "LED processor", "camera", "network switch", "display", "device"]; async function gearHtml() { const g = await api("/api/gear"); return `
    ${g.map((r) => ``).join("")}
    devicekindaddresswhere${hint("Where it is in the building, and notes")}watched by
    ${lightDot(r.light, lightWord[r.light])}${editing() ? `${esc(r.name)}` : 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 || []).length ? r.watched_by.map((k) => `${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}`).join(", ") : editing() && r.own ? `` : ""}
    ${editing() ? `
    ` : ""}`; } async function gearDialog(existing) { const c = cfg(); const cur = existing ? Object.assign({ name: existing }, (c.gear || {})[existing] || {}) : {}; let conns = []; try { const cc = await api("/api/companion"); conns = (cc.connections || []).filter((r) => !(r.watched_by || []).length).map((r) => r.label); } catch (e) {} const plainKeys = c.tiles.filter((t) => !t.members && t.kind !== "master"); dialog(`

    ${existing ? "Gear" : "New gear"}${hint("A device that is not a computer. Give it a kind and an address; choose whether a key watches it.")}

    `, (b) => { b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = async () => { const name = b.querySelector("#gname").value.trim(), kind = b.querySelector("#gkind").value, address = b.querySelector("#gaddr").value.trim(); const where = b.querySelector("#gloc").value.trim(), notes = b.querySelector("#gnotes").value.trim(), watch = b.querySelector("#gwatch").value, keyId = b.querySelector("#gkey").value; if (!name) return alertBox("A name, please."); if (watch === "ping" && !/^[\w.:-]+$/.test(address)) return alertBox("To ping it, give it an address."); if (!ensureDraft()) return; try { const r = await post("/api/gear", { name, kind, address, location: where, notes }); state.draft = r.draft; } catch (e) { return alertBox(e.message); } if (watch) { const d = state.draft.config; const chk = watch === "ping" ? { type: "ping", host: address, name: tagOf(name) } : { type: "conn", conn: watch.slice(5), fix: "restart-connection" }; let t = d.tiles.find((x) => x.id === keyId); if (!t) { let id = slug(name) || "gear", n = 2; while (d.tiles.some((x) => x.id === id)) id = slug(name) + "_" + n++; t = { id, label: tagOf(name), checks: [] }; const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; d.tiles.push(t); } t.checks = t.checks || []; t.checks.push(chk); saveDraft(); closeDialog(); location.hash = "#keys/" + t.id; return; } badge(); closeDialog(); route(); }; }); } function setupHtml(m, generated) { return m.computers.map((c) => { const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac"; const gen = (generated || []).find((g) => g.alias === c.alias) || { steps: [], scripts: [] }; const steps = gen.steps.length ? gen.steps.map((x) => x.text) : ((m.setup_steps || {})[kind] || []); const howOf = (t) => (gen.steps.find((x) => x.text === t) || {}).how; 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 (/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"; 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 (/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"; } 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"; }; const scriptsHtml = gen.scripts.map((sc, i) => `
    ${esc(sc.name)}${hint(sc.how)}
    `).join(""); return `

    ${esc(c.name)}

    ${scriptsHtml}
    `; }).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${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; } // ---- 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])}

    ${d.changes_to_now.length ? `` : "

    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${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; 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() { await flush(); 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 = `

    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) => c.before !== undefined || c.after !== undefined ? `
  • ${esc(c.text)}${c.command ? ' command' : ""}${c.before != null ? `
    was ${esc(c.before)}
    ` : ""}${c.after != null ? `
    now ${esc(c.after)}
    ` : ""}
  • ` : `
  • ${esc(c.text)}
  • `).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; 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.")}

    ${state.draftStale ? "

    The live settings changed since this draft was started and could not be merged: look through the list before applying.

    " : ""}
    ${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(); 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)}`; 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 = [ ["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

    How do I add… (everything is done here; nothing needs a terminal)
    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. Keys: drag LED onto the tray and back. + Key "HD7", add a check (Is a recorder OK?), delete it.
    3. Settings › HyperDecks: untick 5 and 6, look at Keys (cells stay empty). Throw away.
    4. Settings › Playlist: paste a link, Look it up; the name follows everywhere.
    5. Settings › Auto-fix: paint Sunday morning on the calendar.
    6. Settings › MIDI links: add one to the MIDI key; Run the fix on that check.
    7. Keys › Fixes › VISTA: change the program file to Vista2.exe, watch the command rebuild; put it back.
    8. Computers › Setup › Lighting PC: open the script, Copy. Computers › Lighting PC › Remove (read, then Cancel).
    9. Settings › Switcher: Pick the newest file; Discard. Settings › Program & campus: Export bundle.
    10. 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
    Companion facts (found by trying)
    ${(facts.facts || []).map(([q, a]) => `
    ${esc(q)}
    ${esc(a)}
    `).join("")}
    `; } // ---- 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", "ready_page.keys", "ready_page.back_pos", "ready.prop_lists", "failover.windows", "failover.after_seconds", "failover.note_seconds"]; 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"], ["failover", "Auto switch", "Press a Companion button by itself when something dies (Studio B -> FOH Backup): which button, when, and what counts as down"], ["ready", "READY page", "Where the weekly checklist looks: Planning Center, ProPresenter, the Tracks Mac, the lineup TV, Resolume, the bundles"], ["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"], ["outlets", "Outlets", "Smart plugs the panel may switch, for a device that only a power cycle brings back"], ["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 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 === "failover") body = autoSwitchHtml(c); else if (which === "ready") body = readySettingsHtml(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 === "outlets") body = await outletsHtml(); 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 readySettingsHtml(c) { if (!c.ready_page) return "

    This campus has no READY page.

    "; const lists = JSON.stringify((c.ready || {}).prop_lists || [], null, 1); return `

    ${onOff("ready", c.ready || {}, "the READY page")} ${(c.ready || {}).disabled ? "The READY page is off: its keys show OFF and nothing is looked at. Everything is kept." : "The READY page looks at this weekend every 2 minutes."}

    When gaps turn red${hint("Before this, a READY key that is not done is amber; inside it, still missing is red and blinks")}

    ${calendarHtml("ready.due", (c.ready || {}).due)}
    ` + fieldsHtml("Ready") + `
    lists the slides must match${hint('PCO items whose description lists things: {"match": a word in the item title, "kind": "count" (one slide per - line) or "names" (every name has a slide), "short": what the key calls it}')}

    The keys themselves (add, move, delete, what each checks) are on Keys › READY page.

    `; } 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 plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced); 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 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 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 || {}; const sw = []; c.tiles.forEach((t, ti) => (t.checks || []).forEach((ch, j) => { if (ch.type === "spotify") sw.push(`

    ${onOff(`tiles.${ti}.checks.${j}`, ch, "the Spotify check")} Watch that Spotify plays this playlist

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

    ${onOff("auto_fix", c.auto_fix, "auto-fix")} Auto-fix ${c.auto_fix.disabled ? "is off: nothing is fixed by itself (holds still work)." : "fixes the ticked fixes by itself in the painted hours."}

    ${calendarHtml("auto_fix.windows", c.auto_fix.windows)}

    Fixes it may run${hint("Ticked fixes run by themselves inside the painted hours. ⚠ = it closes a running program to do its job; ticking it allows that without a hold.")}

    ${fixes}

    Pressing a Companion button by itself when something dies is under Settings › Auto switch.

    ` + fieldsHtml("Schedules"); } // Which keys name each fix (on a check, as hold-also-when-green, or on a restart key). function fixUsers(c) { const used = {}; const use = (f, who) => { if (f) (used[f] = used[f] || new Set()).add(who); }; for (const t of c.tiles.concat((c.ready_page || { keys: [] }).keys)) { 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); return used; } // ---- A Companion page as Companion draws it: pick a button by clicking it ---- const cssHex = (n) => (n == null || typeof n !== "number" ? null : "#" + (n & 0xffffff).toString(16).padStart(6, "0")); async function companionPicker(box, init, onSelect) { // init: {page, row, col}; onSelect({page, row, col, text}) on every click. box.innerHTML = `

    Reading Companion's pages…

    `; const pages = (await api("/api/companion/pages").catch(() => ({ pages: [] }))).pages || []; if (!pages.length) { box.innerHTML = `

    Companion's pages could not be read; type the numbers below instead.

    `; return; } let cur = Object.assign({}, init || {}); if (!cur.page) cur.page = pages[0].page; const draw = async () => { box.innerHTML = `
    Companion page

    Loading page ${esc(cur.page)}…

    Click the button.

    `; box.querySelector(".cp-page").onchange = (e) => { cur = { page: +e.target.value }; draw(); }; const pg = await api("/api/companion/page?n=" + cur.page).catch(() => ({ buttons: [] })); const at = {}; let maxR = 3, maxC = 7; for (const b of pg.buttons || []) { at[b.row + "," + b.col] = b; maxR = Math.max(maxR, Math.min(b.row, 5)); maxC = Math.max(maxC, Math.min(b.col, 8)); } let html = `
    `; for (let r = 0; r <= maxR; r++) for (let c = 0; c <= maxC; c++) { const b = at[r + "," + c]; const sel = +cur.row === r && +cur.col === c && cur.row !== undefined && cur.row !== ""; if (!b) { html += `
    `; continue; } if (["pageup", "pagedown", "pagenum"].includes(b.type)) { html += ``; continue; } const bg = cssHex(b.bg) || "#000", fg = cssHex(b.fg) || "#fff"; const img = b.img ? `background-image:url('${b.img}');background-size:contain;background-repeat:no-repeat;background-position:center;` : ""; html += `
    ${esc(b.text || "")}
    `; } box.querySelector(".cp-grid").innerHTML = html + "
    "; const say = () => { const p = box.querySelector(".cp-pick"); if (cur.row !== undefined && cur.row !== "") { p.classList.remove("muted"); p.innerHTML = `Picked: ${esc((cur.text || "").replace(/\n/g, " ") || "a button")} (page ${esc(cur.page)}, row ${esc(cur.row)}, column ${esc(cur.col)})`; } }; say(); for (const k of box.querySelectorAll(".cp-key")) k.onclick = () => { for (const o of box.querySelectorAll(".cp-key.sel")) o.classList.remove("sel"); k.classList.add("sel"); cur = { page: +cur.page, row: +k.dataset.r, col: +k.dataset.c, text: k.dataset.t }; say(); onSelect(Object.assign({}, cur)); }; }; await draw(); } function pickButtonDialog(title, init, onDone) { let chosen = null; dialog(`

    ${esc(title)}

    `, (b) => { b.classList.add("wide"); companionPicker(b.querySelector(".cp-box"), init, (p) => { chosen = p; }); b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = () => { if (!chosen) return alertBox("Click a button on the page first."); closeDialog(); onDone(chosen); }; }); } // ---- Auto switch: add a button / add what counts as down ---- function addSwitchButton() { const c = cfg(), presses = Object.entries(c.fixes).filter(([, f]) => f.kind === "companion_press"); dialog(`

    New Companion button

    Pick the page, then click the button, just as it looks in Companion.

    type the numbers instead
    switch back with (by hand)
    `, (b) => { b.classList.add("wide"); companionPicker(b.querySelector(".cp-box"), {}, (p) => { b.querySelector("#bpage").value = p.page; b.querySelector("#brow").value = p.row; b.querySelector("#bcol").value = p.col; const nm = b.querySelector("#bname"); if (!nm.value || nm.dataset.auto) { nm.value = (p.text || "").replace(/\n/g, " ").trim(); nm.dataset.auto = "1"; } }); b.querySelector("#bname").oninput = (e) => { delete e.target.dataset.auto; }; b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = async () => { if (b.querySelector("#brow").value === "") return alertBox("Click the button on the page (or type the numbers)."); const name = b.querySelector("#bname").value.trim(); if (!name) return alertBox("What does the button say?"); const params = { page: b.querySelector("#bpage").value, row: b.querySelector("#brow").value, col: b.querySelector("#bcol").value, button: name }; const r = await post("/api/build", { fix: { template: "companion_press", params, short: name.toUpperCase().slice(0, 14) } }); if (!r.ok) return alertBox(r.error); if (!ensureDraft()) return; const fixes = state.draft.config.fixes; let id = "press_" + (slug(name) || "button"), n = 2; while (fixes[id]) id = "press_" + slug(name) + "_" + n++; const fx = r.fix; if (r.label) fx.label = r.label; if (b.querySelector("#bauto").checked) fx.failover = true; if (b.querySelector("#bundo").value) fx.undo = b.querySelector("#bundo").value; fixes[id] = fx; saveDraft(); closeDialog(); route(); }; }); } function addSwitchTrigger() { const c = cfg(); const switches = Object.entries(c.fixes).filter(([, f]) => f.kind === "companion_press" && f.failover); if (!switches.length) return alertBox("First add a button that switches by itself (+ Button, with “press it by itself” ticked)."); const users = fixUsers(c); const home = c.tiles.find((t) => (t.checks || []).some((ch) => switches.some(([id]) => (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix]).includes(id)))); const keys = c.tiles.filter((t) => t.checks && !t.members && !t.kind); const groups = c.tiles.filter((t) => t.members); const dante = (c.tiles.flatMap((t) => t.checks || []).find((ch) => ch.type === "dante") || {}); const devices = Object.keys(dante.devices || {}); const macs = Object.entries(c.hosts).filter(([, h]) => !h.self); dialog(`

    What counts as down

    then press
    show it on
    `, async (b) => { const kind = b.querySelector("#tkind"), keySel = b.querySelector("#tkey"); const show = () => { for (const el of b.querySelectorAll("[data-k]")) el.hidden = el.dataset.k !== kind.value; b.querySelector("#tnewkey").hidden = keySel.value !== ""; }; kind.onchange = show; keySel.onchange = show; show(); api("/api/companion").then((cc) => { b.querySelector("#tconns").innerHTML = (cc.connections || []).map((r) => `