/* 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; // What this page's draft was made from: a save carries it, so the Mac can put the change on top of // settings that moved on meanwhile (an Apply elsewhere, a program update) instead of undoing them. if (state.draft && !state.draft.base && !d.stale) { state.draft.base = c.sha; state.draft.baseConfig = deep(c.config); } 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, base: state.liveSha, baseConfig: deep(state.live) }; 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, base: state.draft.base, base_config: state.draft.base ? state.draft.baseConfig : undefined }, "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; if (r.rebased) { // the settings moved on since this page loaded: keep both const c = await api("/api/config"); state.live = c.config; state.liveSha = c.sha; state.draft.config = r.draft.config; state.draft.base = c.sha; state.draft.baseConfig = deep(c.config); badge(); route(); alertBox("The settings changed on the Mac since this page loaded (an Apply elsewhere, or a program update). Your changes were put on top of the new settings; nothing of either was lost."); } else if (r.live_sha) { state.draft.base = r.live_sha; } } 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 = { auto: "add new gear by itself", 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 af = (o.auto_fix && o.auto_fix.state) || {}; 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 " + (af.paused === true ? "paused" : af.test_until ? "testing until " + new Date(af.test_until * 1000).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : af.on ? "active" : "waiting"), o.auto_fix.phrase + (af.paused === true ? " (paused with the AUTO-FIX key)" : af.net_wait ? " (waiting out a network blip)" : ""))} ${Object.keys(o.bypass || {}).length ? chip(null, Object.keys(o.bypass).map((k) => ((cfg().tiles.find((t) => t.id === k) || {}).label || k)).join(", ") + ": operator", "Handed to an operator with its key: its MIDI is out until the key is held again") : ""} ${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")}
    ${af.split ? ` ${af.test_until ? "" : ` `}` : ``} ${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); } }; const afb = main.querySelector("#autofixbtn"); if (afb) afb.onclick = async () => { try { await post("/api/command", { text: af.paused || af.test_until ? "autofix resume" : "autofix pause" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } }; const aft = main.querySelector("#aftest"); if (aft) aft.onclick = async () => { try { await post("/api/command", { text: "autofix test " + main.querySelector("#aftestmins").value }); 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 || "?")}` : chk.type === "midi_wired" ? `every MIDI peer wired ${esc((chk.nets || []).join(", "))}` : 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.", light_timing: "Every blinder flash and FX cue on the lighting track lands on the hit in the drum stem under it. A cue whose hit is a few ms before or after it (while the cue itself sits on a quiet spot) is named with its song and bar, to be moved by hand.", tune: "The Waves Tune clip that starts each song is the song's Planning Center key, and sends that key's program. Swap the clip by hand (the hold says which and where); a minor song is fine on its relative major.", routing: "Read live from Ableton (AbletonOSC): every stem goes where the routing template says (Keys → Ext. Out 5/6 Keys). Hold to put them back.", ears: "Read live from Ableton: no song stem has its in-ear send (send A) at -inf, as dragged-in songs do. Hold to set them to 0 dB.", keys: "Every song is in the key Planning Center gives it: the set's locator for the song (\"3- JEHOVAH - B\"), the pad clip under it, and a Finished Songs folder (\"JEHOVAH [B]\"). Eb and D# count as the same key.", 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: "Every Resolume column Sunday's slides call up (slide → macro → Companion → column) holds content, and Resolume is on the Service deck.", 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.", resi_vox: "The Resi song (marked RESI in Planning Center, else the last song before the message) goes out to the locations with its lead singer's mic: the lead from the song's description (\"Genesis lead\"), their VOX from the lineup screen, and what the Resi AJA's Lead Vocal channel really receives (read from the card over Dante). Hold, or auto-fix, presses that VOX's AJA button on page 13. Two leads: the song's Audio note in Planning Center says whose mic (\"V3\" or a name); without it, two leads, a lead not on the lineup are only flagged: a person decides.", start: "Every service Planning Center lists this week (Sunday and any special service: First / Together Tuesday...) has its Ableton set. Hold START on Monday: it saves last weekend's songs to Finished Songs, then builds each set from the newest weekend set (locators, tempos, Waves Tune cues, songs, markers, guide, lighting cues, pads, routing, in-ear sends). A song in another key is transposed and named here, to consolidate in Live; a song not in Finished Songs is named, to build by hand.", }; 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 & location: 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"], ["setup", "Auto-setup", "What this Mac found, the line each computer runs to join, and the line that sets up another location"], ["site", "Site & PIN", "The location, contacts, this Mac, the PIN"], ["program", "Program & location", "Update the code, export or start a location"], ["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 === "setup") body = await setupHtml(c); 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 location 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.

    "; } // Auto-setup: what was found and added, the join lines, and the install line for another location. async function setupHtml(c) { const d = await api("/api/discover").catch(() => null); if (!d) return "

    Auto-setup is not answering.

    "; const last = d.last || {}, inv = last.inventory || {}; const line = (text, h) => `
    ${h}
    `; const comps = Object.entries(inv.computers || {}).map(([a, x]) => `${lightDot(x.reachable ? "ok" : "problem", x.reachable ? "logs in" : x.error || "does not let this Mac in yet")}${esc(x.name || a)}${esc(x.os || "")}${esc(x.ip || "")}${x.reachable ? "" : esc(x.error || "run the join line on it")}`).join(""); const joined = Object.entries(d.joined || {}).filter(([n]) => !Object.values(inv.computers || {}).some((x) => x.name === n)).map(([n, j]) => `${lightDot("warn", "joined; the next look sets it up")}${esc(n)}${esc(j.os)}${esc((j.ips || [])[0] || "")}joined ${esc(when(j.at))}`).join(""); const list = (xs) => xs && xs.length ? `` : "

    nothing

    "; const locations = [["ab", "Apollo Beach"], ["sc", "Southcoast"], ["ws", "Woonsocket"], ["na", "North Attleboro"]].filter(([k]) => k !== (c.site || {}).id); return `

    ${inputHtml("discover.auto", F("discover.auto"), (c.discover || {}).auto)}

    last look
    ${last.at ? esc(when(last.at)) : "not yet"}${last.error ? ` · ${esc(last.error)}` : ""}
    found
    ${inv.connections != null ? `${inv.connections} Companion connections · ${inv.dante || 0} Dante devices · ${inv.midi_sessions || 0} MIDI sessions · wired network ${esc(inv.wired || "?")}` : "–"}

    Added last time

    ${list(last.added)}${last.waiting && last.waiting.length ? `

    Waiting${hint(last.why || "")}

    ${list(last.waiting)}

    ${esc(last.why || "")}

    ` : ""} ${inv.not_known && inv.not_known.length ? `
    Companion connections auto-setup has no recipe for (${inv.not_known.length})${list(inv.not_known)}

    Add a key for one by hand (Keys › + Key) if it matters.

    ` : ""}

    Computers${hint("Each computer runs the join line once: it lets this Mac log in with its key and says it is here. The next look gives its programs keys.")}

    ${comps}${joined}${comps || joined ? "" : ""}
    none yet
    ${line(d.join.mac, "Mac: Terminal, paste")}${line(d.join.pc, "PC: PowerShell as administrator, paste")}
    ${d.hub ? `

    Set up another location${hint("On that location's Mac that runs Companion: Terminal, paste. It installs the panel from here, finds the gear and makes the pages. Then each computer there runs that Mac's join line.")}

    ${locations.map(([k, n]) => line(`curl -fsSL "${d.base}/location/install?id=${k}" | zsh`, n)).join("")}
    ` : ""}`; } 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) => `