Files
status-indicators/settings/ui/app.js
T
christianandClaude Fable 5.1 02d0c77cd9 Step 2: the settings page can change things - shared draft, Review & Apply, rollback, history, undo
- settings/apply.py: the Apply pipeline (check, keep history, write, restart the watchdog, re-import
  pages by the marker-only rule, recolour page keys), rollback when the watchdog does not come back,
  the recording guard with the Emergency (LIVE) override, history entries and Go back / Undo.
- settings/server.py: the write routes behind the editing code (draft PUT/DELETE with 409 on a
  conflict, validate, apply, restore, undo, watchdog restart/stop/start, commands to the watchdog,
  the switcher file upload / pick newest / discard, gear notes).
- settings/ui/app.js: every screen grows inputs with Editing on; keys (checks, fixes, hold-always,
  members), computers, fixes, site setup (schedules, Dante devices, Spotify, ATEM), Review & Apply
  with live progress, History with Go back and Undo, Run this fix now.
- schema.unmask, push_page.push, nav_keys.colour; tests for all of it (163 pass).
- Verified live on NA Ingest: a page-only Apply, a restart Apply, Undo, history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 14:06:42 -04:00

642 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* The settings page. Plain JS, no framework. Everything is readable; with Editing on (the editing
code + your first name) the same screens grow inputs; changes live in one shared draft on the server
until Review & Apply. */
(function () {
"use strict";
const main = document.getElementById("main");
const state = { live: null, liveSha: null, schema: null, draft: null, draftStale: false, atemPending: false, editing: null, name: null, saveTimer: null };
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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) => `<i class="light ${esc(l || "unknown")}"></i>`;
const wordOf = (w) => `<span class="word ${esc((w || "").split(" ")[0])}">${esc(w)}</span>`;
const deep = (o) => JSON.parse(JSON.stringify(o));
const canon = (v) => Array.isArray(v) ? "[" + v.map(canon).join(",") + "]" : v && typeof v === "object" ? "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canon(v[k])).join(",") + "}" : JSON.stringify(v);
const cfg = () => (state.draft ? state.draft.config : state.live);
const editing = () => !!state.editing;
const store = { get: (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch (e) {} } };
const closes = (f) => /pkill|taskkill|killall/.test(f.run || "") && !f.restart_ok;
async function api(path, opts) {
opts = opts || {};
const headers = Object.assign({ "Content-Type": "application/json" }, opts.headers || {});
if (state.editing) { headers["X-Edit-Key"] = state.editing; headers["X-Editor"] = state.name || ""; }
const r = await fetch(path, Object.assign({}, opts, { headers }));
let body = null;
try { body = await r.json(); } catch (e) { body = {}; }
if (!r.ok) { const err = new Error(body.error || r.statusText); err.status = r.status; err.body = body; throw err; }
return body;
}
const post = (path, body, method) => api(path, { method: method || "POST", body: JSON.stringify(body || {}) });
// ---- top bar: legend, editing code ----------------------------------------
document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; };
const editbtn = document.getElementById("editbtn");
function showEditing() {
editbtn.textContent = state.editing ? "Editing as " + state.name : "Editing: off";
editbtn.classList.toggle("on", !!state.editing);
document.body.classList.toggle("editing", !!state.editing);
}
state.editing = store.get("sp-code"); state.name = store.get("sp-name");
showEditing();
editbtn.onclick = () => {
if (state.editing) { state.editing = null; state.name = null; store.set("sp-code", null); store.set("sp-name", null); showEditing(); route(); return; }
dialog(`<h3>Switch Editing on</h3><p class="help">The editing code is in the campus's settings file in the repo; ask Chris.</p>
<input id="dcode" placeholder="editing code" autocomplete="off"><input id="dname" placeholder="your first name" autocomplete="off">
<div class="row"><button class="primary" id="dok">Switch on</button><button id="dcancel">Cancel</button></div><p id="dmsg" class="help"></p>`, (box) => {
box.querySelector("#dcancel").onclick = closeDialog;
const go = async () => {
const code = box.querySelector("#dcode").value, name = box.querySelector("#dname").value.trim();
const r = await post("/api/editing/check", { code });
if (r.ok && name) { state.editing = code; state.name = name; store.set("sp-code", code); store.set("sp-name", name); showEditing(); closeDialog(); route(); }
else box.querySelector("#dmsg").textContent = r.locked_for ? `Locked for ${Math.ceil(r.locked_for / 60)} minutes after too many tries.` : r.no_code ? "No editing code is set in the config yet." : !name ? "Your first name, please." : `Not the code (${r.tries_left} tries left).`;
};
box.querySelector("#dok").onclick = go;
box.querySelector("#dname").onkeydown = (e) => { if (e.key === "Enter") go(); };
box.querySelector("#dcode").focus();
});
};
function dialog(html, wire) { const d = document.getElementById("dialog"); d.innerHTML = `<div class="box">${html}</div>`; d.hidden = false; wire(d.querySelector(".box")); }
function closeDialog() { document.getElementById("dialog").hidden = true; }
function alertBox(msg) { dialog(`<p>${esc(msg)}</p><button id="dok" class="primary">OK</button>`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); }
function confirmBox(msg, label, onOk, word) {
dialog(`<p>${esc(msg)}</p>${word ? `<input id="dword" placeholder="type ${esc(word)} to confirm" autocomplete="off">` : ""}<div class="row"><button class="primary" id="dok">${esc(label)}</button><button id="dcancel">Cancel</button></div>`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = () => { if (word && b.querySelector("#dword").value.trim() !== word) return; closeDialog(); onOk(); };
});
}
// ---- the draft ------------------------------------------------------------
async function loadAll() {
const [c, s, d] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft")]);
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending;
document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
badge();
}
function ensureDraft() {
if (!editing()) { alertBox("Switch Editing on first (top right)."); return false; }
if (!state.draft) state.draft = { config: deep(state.live), version: null, by: state.name };
return true;
}
function changed() {
if (!state.draft) return 0;
let n = 0;
const walk = (a, b) => {
if (Array.isArray(a) && Array.isArray(b)) { if (canon(a) !== canon(b)) n++; return; }
if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) { for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) walk(a[k], b[k]); return; }
if (canon(a) !== canon(b)) n++;
};
walk(state.live, state.draft.config);
return n + (state.atemPending ? 1 : 0);
}
function badge() {
let b = document.getElementById("badge");
if (!b) { b = document.createElement("a"); b.id = "badge"; b.className = "badge"; b.href = "#review"; document.querySelector(".topright").prepend(b); }
const n = changed();
b.hidden = !n && !state.draft;
b.textContent = n ? `${n} change${n > 1 ? "s" : ""} – Review & Apply` : "Draft – Review";
b.classList.toggle("warn", !!state.draftStale);
}
function saveDraft() {
badge();
clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(async () => {
try {
const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT");
state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by;
} catch (e) {
if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); }
else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); }
else alertBox("Could not save the draft: " + e.message);
}
}, 400);
}
// set a value at a dotted path in the draft (creating objects on the way)
function setPath(path, value) {
if (!ensureDraft()) return;
const parts = path.split("."); let o = state.draft.config;
for (let i = 0; i < parts.length - 1; i++) { if (o[parts[i]] == null || typeof o[parts[i]] !== "object") o[parts[i]] = {}; o = o[parts[i]]; }
if (value === undefined) delete o[parts[parts.length - 1]]; else o[parts[parts.length - 1]] = value;
saveDraft();
}
const getPath = (path, from) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), from || cfg());
// ---- generic inputs from the registry -----------------------------------
function parse(f, raw) {
if (f.type === "int") { const n = parseInt(raw, 10); return isNaN(n) ? undefined : n; }
if (f.type === "float") { const n = parseFloat(raw); return isNaN(n) ? undefined : n; }
if (f.type === "bool") return !!raw;
if (f.type === "list") return raw.split(",").map((s) => s.trim()).filter(Boolean);
if (f.type === "dict") { try { return raw.trim() ? JSON.parse(raw) : undefined; } catch (e) { return undefined; } }
return raw === "" ? undefined : raw;
}
function inputHtml(path, f, value, extra) {
if (!f) return "";
const id = "f-" + path.replace(/[^A-Za-z0-9]/g, "-");
const dis = editing() ? "" : "disabled";
let html;
if (f.type === "bool") html = `<input type="checkbox" id="${id}" data-path="${esc(path)}" ${value ? "checked" : ""} ${dis}>`;
else if (f.type === "enum" && f.options) html = `<select id="${id}" data-path="${esc(path)}" ${dis}><option value="">–</option>${f.options.map((o) => `<option ${value === o ? "selected" : ""}>${esc(o)}</option>`).join("")}</select>`;
else if (f.type === "dict") html = `<textarea id="${id}" data-path="${esc(path)}" rows="2" ${dis}>${esc(value == null ? "" : JSON.stringify(value))}</textarea>`;
else if (f.type === "list") html = `<input id="${id}" data-path="${esc(path)}" value="${esc(Array.isArray(value) ? value.join(", ") : value || "")}" ${dis}>`;
else html = `<input id="${id}" data-path="${esc(path)}" type="${f.type === "int" || f.type === "float" ? "number" : f.secret ? "password" : "text"}" value="${esc(value == null ? "" : value)}" ${f.lo != null ? `min="${f.lo}"` : ""} ${f.hi != null ? `max="${f.hi}"` : ""} ${f.type === "float" ? 'step="any"' : ""} placeholder="${esc(f.default == null || typeof f.default === "object" ? "" : f.default)}" ${dis}>`;
const dflt = f.default != null && typeof f.default !== "object" && f.default !== "" ? ` <span class="muted">(default ${esc(f.default)})</span>` : "";
return `<div class="field"><label for="${id}">${esc(path.split(".").pop().replace(/_/g, " "))}${f.required ? " *" : ""}</label>${html}<div class="help">${esc(f.help)}${dflt}${extra || ""}</div></div>`;
}
function wireInputs(root) {
for (const el of root.querySelectorAll("[data-path]")) {
if (el.onchange) continue; // a screen wired it itself
el.onchange = () => {
const path = el.dataset.path, f = fieldFor(path);
const v = el.type === "checkbox" ? el.checked : el.value;
setPath(path, f ? parse(f, v) : v);
};
}
}
function fieldFor(path) {
const F = state.schema.fields;
if (F[path]) return F[path];
const generic = path.replace(/^tiles\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^tiles\.\d+\./, "tiles[].")
.replace(/^restart_page\.keys\.\d+\./, "restart_page.keys[].").replace(/^fixes\.[^.]+\./, "fixes.*.").replace(/^hosts\.[^.]+\./, "hosts.*.")
.replace(/^gear\.[^.]+\./, "gear.*.").replace(/^(auto_fix\.windows|site\.live_services|service_windows)\.\d+\./, "$1[].");
return F[generic] || null;
}
const F = (name) => state.schema.fields[name];
// ---- search ----------------------------------------------------------------
const search = document.getElementById("search"), results = document.getElementById("searchresults");
search.oninput = () => {
const q = search.value.trim().toLowerCase();
if (!q || !cfg()) { results.hidden = true; return; }
const c = cfg(), hits = [];
c.tiles.forEach((t) => { if ((t.label + " " + t.id).toLowerCase().includes(q)) hits.push([`Key ${t.label}`, `#keys/${t.id}`]); });
for (const k of (c.restart_page || { keys: [] }).keys) if (k.label.toLowerCase().includes(q)) hits.push([`Restart key ${k.label}`, `#keys/restart/${k.id}`]);
for (const [a, h] of Object.entries(c.hosts || {})) if ((h.name || a).toLowerCase().includes(q) || (h.role || "").toLowerCase().includes(q)) hits.push([`Computer ${h.name || a}`, `#computers/${a}`]);
for (const [id, f] of Object.entries(c.fixes || {})) if ((id + " " + (f.label || "") + " " + (f.short || "")).toLowerCase().includes(q)) hits.push([`Fix ${f.short || id}: ${f.label || ""}`, `#fixes/${id}`]);
for (const [path, f] of Object.entries(state.schema.fields)) if (!path.includes("[]") && !path.includes("*") && (path + " " + f.help).toLowerCase().includes(q)) hits.push([`Setting ${path}`, `#site/${f.screen.toLowerCase().replace(/[^a-z]+/g, "-")}`]);
results.innerHTML = hits.slice(0, 20).map(([t, h]) => `<a href="${h}">${esc(t)}</a>`).join("") || `<a>Nothing called "${esc(q)}"</a>`;
results.hidden = false;
};
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
// ---- routing ---------------------------------------------------------------
const screens = { overview, keys, computers, logs, history, help, site, fixes, review };
let timer = null;
async function route() {
const [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
for (const a of document.querySelectorAll(".menu a")) a.classList.toggle("active", a.getAttribute("href") === "#" + name);
clearInterval(timer); timer = null;
try {
if (!state.live) await loadAll();
const fn = screens[name] || overview;
await fn(rest);
wireInputs(main);
const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0])); // never under a typist's hands
if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000);
} catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
}
window.addEventListener("hashchange", route);
route();
// ---- Overview --------------------------------------------------------------
async function overview(_, quiet) {
if (quiet && document.activeElement && document.activeElement.tagName === "INPUT") return; // never pull the field out from under a typist
const o = await api("/api/overview");
const dot = document.getElementById("hbdot");
dot.className = "dot " + (o.heartbeat_age != null && o.heartbeat_age < 15 ? "ok" : "bad");
const isLive = o.live && o.live.live;
const runnable = (p) => {
const t = cfg().tiles.find((x) => x.id === p.key); if (!t || !editing() || isLive) return "";
const fixes = (t.checks || []).map((c, j) => [typeof c.fix === "string" ? c.fix : null, j]).filter(([f]) => f && canRunNow(f));
return fixes.slice(0, 1).map(([f, j]) => ` <button class="small" data-run="${esc(t.id)}|${esc(f)}|${j}">Run the fix now</button>`).join("");
};
const prob = o.problems.length ? o.problems.map((p) => `<li><span class="lvl" style="background:${p.level === "err" ? "var(--problem)" : "var(--warn)"}"></span><b>${esc(p.text)}</b>${p.acked ? ' <span class="muted">(acknowledged)</span>' : ""} <span class="muted">— ${esc(p.action)}</span> <a href="#keys/${esc(p.key)}" class="small">open the key</a>${runnable(p)}</li>`).join("") : `<li class="muted">Everything is fine.</li>`;
const needs = (o.needs || []).map((n) => `<div class="notice grey">Needs Chris: ${esc(n)}</div>`).join("");
const failed = o.failed_last ? `<div class="notice grey">The last Apply was put back: ${esc(o.failed_last.why || "")} <a href="#history">History</a></div>` : "";
const live = isLive ? `<div class="notice">Live now: ${esc(o.live.reason)}. Changes to the panel wait.</div>` : "";
const comps = o.computers.map((c) => `<tr class="click" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}</td><td class="muted small">${esc(c.reason)}</td></tr>`).join("");
const w = o.watchdog;
const dis = editing() ? "" : "disabled title='switch Editing on'";
main.innerHTML = `
<h1>${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") + " in the room" : "Everything is fine"}</h1>
<p class="what">Is the room OK, and what do I do. Problems mirror the red keys; hold the key, or open it for the details.</p>
<ul class="problems">${prob}</ul>${needs}${failed}${live}
<div class="cols">
<div><h2>Live deck <span class="muted small">${o.deck ? "as the Stream Deck looks now · click a key" : "(Companion is not answering)"}</span></h2>${deckHtml(o.deck, cfg(), "status")}
<div class="row" style="margin-top:10px"><button disabled title="arrives with step 4">Change the playlist</button><button disabled title="arrives with step 4">Add or remove a HyperDeck</button><button disabled title="arrives with step 4">Add a MIDI link</button></div></div>
<div><h2>Computers</h2><div class="tablewrap"><table>${comps}</table></div></div>
</div>
<div class="card">
<dl class="kv">
<dt>Watchdog</dt><dd>${w.running ? "OK, running" : "<b>not running</b>"}${w.since ? " since " + esc(when(w.since)) : ""}${w.version ? ` <span class="pill">code ${esc(w.version)}</span>` : ""}${o.heartbeat_age != null ? ` · heartbeat ${esc(ago(o.heartbeat_age))}` : ""}</dd>
<dt>Companion</dt><dd>${o.companion_ok ? "OK" : "<b>not answering</b>"}${o.pages && o.pages.status ? (o.pages.status.ok ? ", pages OK" : ", page " + o.pages.status.page + " is called " + esc(o.pages.status.live || "?")) : ""}</dd>
<dt>Alarms</dt><dd>${o.service_on == null ? "<span class='muted'>not reported yet</span>" : o.service_on ? "ON" : "silenced until midnight"}${o.alarms_override != null ? " (by hand)" : ""} <span class="muted small">· ${esc(o.phrases.alarms)}</span></dd>
<dt>Auto-fix</dt><dd>${esc(o.auto_fix.phrase)}${o.auto_fix.state && o.auto_fix.state.on ? " · <b>may act now</b>" : ""}${o.auto_fix.state && o.auto_fix.state.paused ? " (waiting out a network blip)" : ""}</dd>
<dt>Running now</dt><dd>${Object.keys(o.running || {}).length ? Object.values(o.running).map((r) => esc(r.what + " on " + r.key)).join(", ") : "no fix is running"}</dd>
<dt>Last change</dt><dd>${o.last_change ? esc(o.last_change.when + " · " + o.last_change.note) : "—"} <a href="#history" class="small">History</a></dd>
</dl>
<div class="row" style="margin-top:10px">
<button id="restartwd" ${dis}>${w.running ? "Restart the watchdog" : "Start the watchdog"}</button>
<button id="alarms" ${dis}>${o.service_on === false ? "Alarms back on" : "Silence alarms until midnight"}</button>
${w.running ? `<button id="stopwd" ${dis}>Stop the watchdog</button>` : ""}
</div>
</div>`;
main.querySelector("#restartwd").onclick = () => confirmBox(w.running ? "Restart the watchdog? The deck goes purple for about 5 seconds; a fix that is running finishes first." : "Start the watchdog?", w.running ? "Restart" : "Start", async () => { try { const r = await post(w.running ? "/api/watchdog/restart" : "/api/watchdog/start"); alertBox(r.ok ? "Done: " + r.detail : "Not done: " + r.detail); } catch (e) { alertBox(e.message); } });
const stop = main.querySelector("#stopwd"); if (stop) stop.onclick = () => confirmBox("Stop the watchdog? The deck goes purple and every alarm is off until it is started again.", "Stop", async () => { try { const r = await post("/api/watchdog/stop", { word: "STOP" }); alertBox(r.ok ? "Stopped." : "Not stopped: " + r.detail); } catch (e) { alertBox(e.message); } }, "STOP");
main.querySelector("#alarms").onclick = async () => { try { await post("/api/command", { text: o.service_on === false ? "service on" : "service off" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } };
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
if (!quiet) window.scrollTo(0, 0);
}
function canRunNow(fixId) {
const f = cfg().fixes[fixId]; if (!f) return false;
if (["reconnect", "hyperdeck", "midi_connect", "spotify"].includes(f.kind)) return true;
if ((f.kind || "ssh") === "ssh" || f.kind === "local") return !closes(f) && !f.never_auto;
return false;
}
function runNow(key, fixId, which) {
const f = cfg().fixes[fixId];
confirmBox(`Run "${f.label || fixId}" now? It runs through the watchdog exactly like a hold on the ${key.toUpperCase()} key.`, "Run it", async () => {
try { await post("/api/command", { text: `test ${key} ${fixId} ${which}` }); alertBox("Sent. Watch the key, or Logs › Watchdog (TEST)."); } catch (e) { alertBox(e.message); }
});
}
function deckHtml(deck, c, which, selected) {
const cells = {};
const rp = c.restart_page;
if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; }
else { for (const t of c.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : c.page), "2,0": "DOWN" };
let out = `<div class="deck">`;
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 += `<div class="key nav">${esc(nav[k])}</div>`;
else if (cell && cell.nav) out += `<div class="key nav">${esc(cell.nav)}</div>`;
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 += `<div class="key${blink}${selected === cell.id ? " sel" : ""}" style="${style}" onclick="location.hash='#keys/${which === "restart" ? "restart/" : ""}${esc(cell.id)}'">${esc(s ? s.x : cell.label)}</div>`;
} else out += `<div class="key empty"></div>`;
}
return out + `</div>`;
}
// ---- Keys ------------------------------------------------------------------
async function keys(rest) {
const o = await api("/api/overview").catch(() => ({ deck: null }));
const c = cfg();
const restart = rest[0] === "restart";
const sel = restart ? rest[1] : rest[0];
const hold = ((c.page_hold_ms || 2000) / 1000) + " s";
const tabs = `<div class="tabs"><button class="${restart ? "" : "on"}" onclick="location.hash='#keys'">Status page (page ${c.page})</button>${c.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page (page ${c.restart_page.page})</button>` : ""}</div>`;
let panel = `<p class="muted">Click a key to see what it watches and what holding it does.${editing() ? " With Editing on you can change it here." : ""}</p>`;
if (sel && !restart) { const i = c.tiles.findIndex((x) => x.id === sel); if (i >= 0) panel = await keyPanel(i, hold); }
else if (sel && restart && c.restart_page) { const i = c.restart_page.keys.findIndex((x) => x.id === sel); if (i >= 0) panel = restartKeyPanel(i, hold); }
const tray = c.tiles.filter((t) => !t.pos && t.kind !== "master").map((t) => `<button class="small" onclick="location.hash='#keys/${esc(t.id)}'">${esc(t.label)}</button>`).join(" ");
main.innerHTML = `<h1>Keys</h1><p class="what">The deck as people hold it. Click a key; with Editing on, change what it watches and what holding it does (moving keys arrives with step 3).</p>${tabs}
<div class="cols"><div>${deckHtml(o.deck, c, restart ? "restart" : "status", sel)}${!restart && tray ? `<p class="small muted" style="margin-top:8px">Shown inside their group, no key of their own: ${tray}</p>` : ""}</div><div id="panel">${panel}</div></div>`;
wireKeyPanel(sel, restart);
}
async function keyPanel(i, hold) {
const c = cfg(), t = c.tiles[i];
const k = await api("/api/keys");
const kt = k.tiles.find((x) => x.id === t.id) || { says: [], holding: "", checks: [] };
const fixOptions = (cur) => `<option value="">none</option><option value="restart-connection" ${cur === "restart-connection" ? "selected" : ""}>reconnect its Companion connection</option><option value="dante-auto" ${cur === "dante-auto" ? "selected" : ""}>Dante auto (PoE port or the Dante module)</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${cur === id ? "selected" : ""}>${esc(f.short || id)} – ${esc(f.label || "")}</option>`).join("")}`;
const checks = (t.checks || []).map((chk, j) => {
const spec = state.schema.checks[chk.type] || { fields: [], question: "" };
const sentence = (kt.checks[j] || {}).sentence || chk.type;
if (!editing()) return `<li>${esc(sentence)}<details><summary>Advanced</summary><dl class="kv small">${Object.entries(chk).map(([n, v]) => `<dt>${esc(n)}</dt><dd class="mono">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details></li>`;
const base = `tiles.${i}.checks.${j}.`;
const fields = (spec.fields || []).filter((f) => f !== "fix" && f !== "type").map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("");
const fixSel = typeof chk.fix === "object" && chk.fix ? `<div class="field"><label>Fix (per sub-check)</label><textarea data-path="${base}fix" rows="2" class="fixtable">${esc(JSON.stringify(chk.fix))}</textarea><div class="help">A table: sub-check → fix, "*" for everything else.</div></div>`
: `<div class="field"><label>Fix: what holding runs for this check</label><select data-path="${base}fix" class="fixsel">${fixOptions(chk.fix)}</select></div>`;
const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `<button class="small" data-run="${esc(t.id)}|${esc(chk.fix)}|${j}">Run this fix now</button>` : "";
return `<li><b>${esc(spec.question || chk.type)}</b> <span class="muted">${esc(sentence)}</span><div class="checkbox">${fields}${fixSel}
<details><summary>Advanced</summary>${["tag", "service_only", "debounce_seconds"].filter((f) => !(spec.fields || []).includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("")}<p class="mono small">type ${esc(chk.type)}</p></details>
<div class="row">${run}<button class="small danger" data-removecheck="${j}">Remove this check</button></div></div></li>`;
}).join("");
const plain = !t.members && t.kind !== "master";
const members = t.members ? (editing() ? `<div class="field"><label>Group members</label>${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => `<label class="inline"><input type="checkbox" data-member="${esc(x.id)}" ${t.members.includes(x.id) ? "checked" : ""}> ${esc(x.label)}</label>`).join(" ")}</div>` : `<p><b>Sums up:</b> ${t.members.map((m) => `<a href="#keys/${esc(m)}">${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}</a>`).join(", ")}</p>`) : "";
const addMenu = editing() && plain ? `<div class="field"><label>Add a check</label><select id="addcheck"><option value="">– pick a question –</option>${Object.entries(groupBy(state.schema.checks, (s) => s.question || "Other")).map(([q, types]) => `<optgroup label="${esc(q)}">${types.map((ty) => `<option value="${esc(ty)}">${esc(state.schema.checks[ty].words || ty)}</option>`).join("")}</optgroup>`).join("")}</select></div>` : "";
const label = editing() ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : "";
const holdAlways = editing() && plain ? `<div class="field"><label>Hold runs even when the key is green</label><select data-path="tiles.${i}.hold_always" class="holdsel"><option value="">no – only what is failing</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${t.hold_always === id ? "selected" : ""}>${esc(f.short || id)} – ${esc(f.label || "")}</option>`).join("")}</select></div>` : "";
const pos = `<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(t.id)}</dd></dl>${editing() && !t.kind ? `<div class="field"><label>Position (row, column; blank = inside its group only)</label><input id="pos" value="${esc(t.pos ? t.pos.join(", ") : "")}" placeholder="row, column"></div>` : ""}</details>`;
return `<div class="card"><h2>${esc(t.label)}</h2><p class="muted">${t.pos ? `row ${t.pos[0]}, column ${t.pos[1]}` : "inside its group"}${t.kind ? ` · ${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}` : ""}</p>
${kt.says && kt.says.length ? `<p><b>WHAT THIS KEY WILL SAY:</b> <span class="mono">${esc(kt.says.join(", "))}</span></p>` : ""}
<p><b>HOLDING ${esc(hold)} WILL:</b> ${esc(kt.holding)}${t.hold_always ? " <span class='pill'>also when green</span>" : ""}</p>
${label}${members}${holdAlways}${checks ? `<p><b>Checks:</b></p><ol class="checks">${checks}</ol>` : ""}${addMenu}${pos}</div>`;
}
function groupBy(obj, fn) { const out = {}; for (const [k, v] of Object.entries(obj)) (out[fn(v)] = out[fn(v)] || []).push(k); return out; }
function wireKeyPanel(sel, restart) {
const c = cfg();
const i = restart ? -1 : c.tiles.findIndex((x) => x.id === sel);
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
for (const b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; state.draft.config.tiles[i].checks.splice(+b.dataset.removecheck, 1); saveDraft(); route(); });
for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); };
const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ type: add.value }); saveDraft(); route(); };
const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const m = pos.value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); if (m) t.pos = [+m[1], +m[2]]; else if (!pos.value.trim()) delete t.pos; else return alertBox("Row, column: two numbers, like 2, 5."); saveDraft(); route(); };
for (const s of main.querySelectorAll("select.fixsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
for (const ta of main.querySelectorAll("textarea.fixtable")) ta.onchange = () => { try { setPath(ta.dataset.path, JSON.parse(ta.value)); } catch (e) { alertBox("Not a valid table."); } };
for (const s of main.querySelectorAll("select.holdsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
for (const s of main.querySelectorAll("select.watchsel")) s.onchange = () => setPath(s.dataset.path, [...s.selectedOptions].map((o) => o.value));
}
function restartKeyPanel(i, hold) {
const c = cfg(), r = c.restart_page.keys[i];
const f = c.fixes[r.fix] || {};
const conns = new Set(); for (const t of c.tiles) for (const ch of t.checks || []) if (ch.conn) conns.add(ch.conn);
const edit = editing() ? `${inputHtml(`restart_page.keys.${i}.label`, F("restart_page.keys[].label"), r.label)}
<div class="field"><label>What holding runs</label><select data-path="restart_page.keys.${i}.fix">${Object.entries(c.fixes).map(([id, fx]) => `<option value="${esc(id)}" ${r.fix === id ? "selected" : ""}>${esc(fx.short || id)} – ${esc(fx.label || "")}</option>`).join("")}</select></div>
<div class="field"><label>Keep flashing HOLD TO FIX while these are down (hold cmd/ctrl for several)</label><select multiple size="5" class="watchsel" data-path="restart_page.keys.${i}.watch">${Object.keys(c.fixes).map((id) => `<option value="${esc(id)}" ${(r.watch || []).includes(id) ? "selected" : ""}>fix ${esc(id)}</option>`).join("")}${[...conns].sort().map((cn) => `<option value="conn:${esc(cn)}" ${(r.watch || []).includes("conn:" + cn) ? "selected" : ""}>connection ${esc(cn)}</option>`).join("")}</select></div>` : "";
return `<div class="card"><h2>${esc(r.label)}</h2><p class="muted">row ${r.pos[0]}, column ${r.pos[1]}</p><p><b>HOLDING ${esc(hold)} WILL:</b> ${esc(f.label || r.fix)}, even when the Status page says it is fine.</p>${r.watch && r.watch.length ? `<p class="small muted">Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}</p>` : ""}${edit}
${canRunNow(r.fix) && editing() ? `<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run this fix now</button>` : ""}<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd></dl></details></div>`;
}
// ---- 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 = `<div class="tabs"><button class="${compTab === "map" ? "on" : ""}" onclick="location.hash='#computers'">Map</button><button class="${compTab === "gear" ? "on" : ""}" onclick="location.hash='#computers/gear'">Gear</button><button class="${compTab === "setup" ? "on" : ""}" onclick="location.hash='#computers/setup'">Setup</button>
<button id="probe" ${m.probing ? "disabled" : ""}>${m.probing ? "Looking…" : "Check computers now"}</button></div>`;
let body = "";
if (compTab === "gear") body = await gearHtml();
else if (compTab === "setup") body = setupHtml(m);
else {
const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `<tr class="click${selected === c.alias ? " sel" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}${c.self ? ' <span class="muted small">(this Mac)</span>' : ""}</td><td class="small">${esc(c.role)}</td><td class="small">${(c.keys || []).map((k) => `<span class="pill">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</span>`).join(" ")}</td><td class="muted small">${esc(c.reason)}${c.looked ? `<br>looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}</td></tr>`).join("");
body = `<div class="tablewrap"><table><tr><th>status</th><th>computer</th><th>what it does for the panel</th><th>keys that turn red if it dies</th><th></th></tr>${rows}</table></div>`;
const c = m.computers.find((x) => x.alias === selected);
if (c) body += computerCard(c);
}
main.innerHTML = `<h1>Computers</h1><p class="what">What runs where. One row per computer; click it for everything that runs there, with a light each.</p>${tabs}${body}`;
const pb = document.getElementById("probe");
if (pb) pb.onclick = async () => { pb.disabled = true; pb.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => computers(rest, true).then(() => wireInputs(main)), 4000); };
for (const b of main.querySelectorAll("[data-gear]")) b.onclick = async () => { const row = b.closest("tr"); try { await post("/api/gear", { name: b.dataset.gear, location: row.querySelector("[data-loc]").value, notes: row.querySelector("[data-notes]").value }); const d = await api("/api/draft"); state.draft = d.draft; badge(); b.textContent = "Saved"; } catch (e) { alertBox(e.message); } };
const runs = main.querySelector("#runs"); if (runs) runs.onchange = () => { if (!ensureDraft()) return; try { state.draft.config.hosts[selected].runs = runs.value.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l)); saveDraft(); } catch (err) { alertBox("Each line must be a valid {...} entry."); } };
}
function computerCard(c) {
const items = (c.items || []).map((i) => `<tr><td>${lightDot(i.light)}${esc(i.light === "ok" ? "OK" : i.light === "problem" ? "Problem" : i.light === "not_needed" ? "Not needed" : "Can't see from here")}</td><td>${esc(i.name)}${i.watched === false ? ' <span class="muted small">(not watched)</span>' : ""}</td><td class="muted small">${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 || ""))}</td></tr>`).join("");
const found = c.found || {};
const foundHtml = Object.entries(found).filter(([, v]) => Array.isArray(v) && v.length).map(([k, v]) => `<p class="small"><b>${esc(k)}:</b> <span class="mono">${esc(typeof v[0] === "object" ? v.map((x) => x.iface + " " + x.ip + (x.active ? "" : " (down)")).join(", ") : v.join(", "))}</span></p>`).join("") || `<p class="muted small">Press "Check computers now" to look.</p>`;
const done = Object.entries(c.manual_done || {}).map(([k, v]) => `<li>${lightDot("hand")}${esc(k.replace(/_/g, " "))}: done by hand (${esc(v.by)}, ${esc(v.on)})</li>`).join("");
const base = `hosts.${c.alias}.`;
const edit = editing() ? `<details open><summary>Change this computer</summary>${["name", "role", "notes", "os"].concat(c.self ? [] : ["ssh", "mac", "wake_broadcast", "pingable"]).map((f) => inputHtml(base + f, F("hosts.*." + f), getPath(base + f))).join("")}
<div class="field"><label>What runs here (one per line, as {"kind": ..., "name": ...})</label><textarea id="runs" rows="${Math.max(4, (getPath(base + "runs") || []).length + 1)}" class="mono">${esc((getPath(base + "runs") || []).map((r) => JSON.stringify(r)).join("\n"))}</textarea><div class="help">Kinds: sshd, app (process), launchagent (label), daemon (process), task (name, exe), service (name), web (url), helper_dvs, helper_solo (label), midi_session (name, peer_of), artnet (source), folder (path), disabled (label). "watched": false for things the panel only lists.</div></div></details>` : "";
return `<div class="card" id="card"><h2>${esc(c.name)} ${wordOf(c.word)}</h2><p class="muted">${esc(c.role)}${c.ssh ? ` · logs in as <span class="mono">${esc(c.ssh)}</span>` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "<b>no answer</b>"}` : ""}${c.error ? ` · <b>${esc(c.error)}</b>` : ""}</p>
${c.notes ? `<p class="notice grey">${esc(c.notes)}</p>` : ""}
<h3>What runs here</h3><div class="tablewrap"><table>${items}</table></div>
${done ? `<ul class="small" style="list-style:none;padding:0">${done}</ul>` : ""}
<details><summary>Everything found on this computer</summary>${foundHtml}</details>
${c.self ? `<details><summary>This Mac's set-up lights</summary><dl class="kv small">${Object.entries(c.setup || {}).map(([k, v]) => `<dt>${esc(k.replace(/_/g, " "))}</dt><dd>${typeof v === "boolean" ? lightDot(v ? "ok" : "problem") + (v ? "OK" : "Problem") : esc(JSON.stringify(v))}</dd>`).join("")}</dl></details>` : ""}
${edit}</div>`;
}
async function gearHtml() {
const g = await api("/api/gear");
return `<p class="small muted">Devices that are not computers, from the keys that watch them.${editing() ? " Type where each one lives and press Save." : ""}</p><div class="tablewrap"><table><tr><th>light</th><th>device</th><th>kind</th><th>address</th><th>where / notes</th><th>watched by</th></tr>
${g.map((r) => `<tr><td>${lightDot(r.light)}${esc(r.light === "ok" ? "OK" : r.light === "problem" ? "Problem" : "–")}</td><td>${esc(r.name)}</td><td>${esc(r.kind)}</td><td class="mono small">${esc(r.address || "")}${r.detail ? `<br><span class="muted">${esc(r.detail)}</span>` : ""}</td><td class="small">${editing() ? `<input data-loc value="${esc(r.location || "")}" placeholder="where it is"> <input data-notes value="${esc(r.notes || "")}" placeholder="notes"> <button class="small" data-gear="${esc(r.name)}">Save</button>` : `${esc(r.location || "")} ${esc(r.notes || "")}`} <span class="muted">${esc(r.reason || "")}</span></td><td class="small">${(r.watched_by || []).map((k) => `<a href="#keys/${esc(k)}">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</a>`).join(", ")}</td></tr>`).join("")}</table></div>`;
}
function setupHtml(m) {
return m.computers.map((c) => {
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
const steps = (m.setup_steps || {})[kind] || [];
const lightFor = (step) => {
const s = c.setup || {};
const item = (k) => (c.items || []).find((i) => i.kind === k);
if (/login key/.test(step)) return s.key_ok === true || s.login_key === true ? "ok" : s.key_ok === false || s.login_key === false ? "problem" : "unknown";
if (/python/.test(step)) return s.python_ok === true ? "ok" : s.python_ok === false ? "problem" : "unknown";
if (/SSH service/.test(step)) return c.reachable ? "ok" : "problem";
if (/firewall/.test(step)) return s.firewall_allow ? "ok" : "unknown";
if (/wake-on-LAN/.test(step)) return s.wake_on_lan === "Enabled" ? "ok" : s.wake_on_lan ? "problem" : "unknown";
if (/Public/.test(step)) return s.profile === "Public" ? "ok" : s.profile ? "problem" : "unknown";
if (/DVS helper/.test(step)) { const it = item("helper_dvs"); return it ? it.light : "not_needed"; }
if (/Pro Tools helper/.test(step)) { const it = item("helper_solo"); return it ? it.light : "not_needed"; }
if (/StatusPanel/.test(step)) { const ts = (c.items || []).filter((i) => i.kind === "task"); return ts.length ? (ts.every((i) => i.light === "ok") ? "ok" : "problem") : "unknown"; }
if (/rtpMIDI/.test(step)) { const it = item("service"); return it ? it.light : "not_needed"; }
if (/Netgear/.test(step)) return s.netgear_keychain === true ? "ok" : s.netgear_keychain === false ? "problem" : "not_needed";
if (/Google Drive/.test(step)) { const it = item("folder"); return it ? it.light : "not_needed"; }
if (/watchdog and the settings/.test(step)) { const ok = (c.items || []).filter((i) => i.kind === "launchagent" && /watchdog|settings/i.test(i.name)); return ok.length && ok.every((i) => i.light === "ok") ? "ok" : "unknown"; }
if (/Wi-Fi/.test(step)) return c.manual_done && c.manual_done.wifi_power ? "hand" : "unknown";
if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown";
return "unknown";
};
return `<div class="card"><h3>${esc(c.name)}</h3><ul style="list-style:none;padding:0;margin:0">${steps.map((s) => { const l = lightFor(s); return `<li>${lightDot(l)}${esc(s)} <span class="muted small">${l === "hand" ? "(done by hand)" : l === "unknown" ? "– can't see from here" : ""}</span></li>`; }).join("")}</ul><p class="help">Generated set-up scripts and the [how] steps arrive with step 5.</p></div>`;
}).join("");
}
// ---- Logs ------------------------------------------------------------------
let logTab = "watchdog", logFilter = "";
async function logs(rest) {
logTab = rest[0] || logTab;
const tabs = ["watchdog", "errors", "autofix", "settings"].map((n) => `<button class="${logTab === n ? "on" : ""}" onclick="location.hash='#logs/${n}'">${{ watchdog: "Watchdog", errors: "Errors", autofix: "Auto-fix days", settings: "Settings page" }[n]}</button>`).join("");
const filters = logTab === "watchdog" ? `<div class="tabs">${["", "faults", "holds", "fixes", "auto", "errors"].map((f) => `<button class="${logFilter === f ? "on" : ""}" data-f="${f}">${f || "everything"}</button>`).join("")}</div>` : "";
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" ? `<p class="small">${(data.files || []).slice().reverse().map((f) => `<a href="#logs/autofix/${encodeURIComponent(f)}" class="${data.file === f ? "pill" : ""}">${esc(f.replace(" Auto-Fix.log", ""))}</a>`).join(" · ") || "no auto-fix days yet"}</p>` : "";
main.innerHTML = `<h1>Logs</h1><p class="what">What the watchdog did and saw. Newest lines at the bottom.</p><div class="tabs">${tabs}</div>${filters}${files}<pre class="log">${esc((data.lines || []).join("\n")) || "(empty)"}</pre>`;
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 = `<div class="card"><h2>${esc(rest[0])}</h2><p class="muted">What is different between that version and now:</p>${d.changes_to_now.length ? `<ul>${d.changes_to_now.map((c) => `<li>${esc(c.text)}</li>`).join("")}</ul>` : "<p>Nothing: it is the same as now.</p>"}
${editing() ? `<button class="primary" id="goback">Go back to this version</button> <span class="help">It becomes your draft; Review & Apply puts it live.</span>` : `<p class="help">Switch Editing on to go back to it.</p>`}</div>`;
}
const rows = h.entries.map((e) => `<tr class="click" onclick="location.hash='#history/${encodeURIComponent(e.id)}'"><td>${esc(when(e.when))}</td><td>${esc(e.by)}</td><td>${esc(e.note)}</td><td class="small muted">${esc((e.actions || []).join(", "))}${e.outcome && e.outcome !== "applied" ? " · " + esc(e.outcome) : ""}</td></tr>`).join("");
main.innerHTML = `<h1>History</h1><p class="what">Every version of the settings that was ever applied: who, when, the note, what was different.</p>
<div class="row">${editing() && h.entries.length ? `<button id="undo">Undo the last change</button>` : ""}</div>
<div class="tablewrap"><table><tr><th>when</th><th>who</th><th>note</th><th></th></tr>${rows || "<tr><td colspan=4 class='muted'>nothing yet</td></tr>"}</table></div>${detail}`;
const gb = main.querySelector("#goback"); if (gb) gb.onclick = async () => { try { const r = await post("/api/restore/" + encodeURIComponent(rest[0])); state.draft = r.draft; badge(); location.hash = "#review"; } catch (e) { alertBox(e.message); } };
const un = main.querySelector("#undo"); if (un) un.onclick = () => confirmBox("Undo the last change? The version before it becomes your draft; Review & Apply puts it live.", "Undo", async () => { try { const r = await post("/api/undo"); state.draft = r.draft; badge(); location.hash = "#review"; } catch (e) { alertBox(e.message); } });
}
// ---- Review & Apply -------------------------------------------------------------
async function review() {
const st = await api("/api/apply/status");
if (st.state === "running") return applyProgress(st);
const d = await api("/api/draft"); state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; badge();
if (!state.draft) {
main.innerHTML = `<h1>Review & Apply</h1><p class="what">Nothing to apply: there is no draft. Change something on any screen with Editing on.</p>${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`;
return;
}
const r = await post("/api/validate", {});
const changes = r.changes.map((c) => `<li class="${c.command ? "command" : ""}">${esc(c.text)}${c.command ? ' <span class="pill">command</span>' : ""}</li>`).join("") + (r.atem_pending ? "<li>A new saved switcher file replaces the current one.</li>" : "");
const errors = r.errors.map((e) => `<li>${esc(e)}</li>`).join("");
const warnings = r.warnings.map((w) => `<li>${esc(w)}</li>`).join("");
const live = r.live && r.live.live ? `<div class="notice">Live now: ${esc(r.live.reason)}. Apply waits until it is over; your draft is saved. ${editing() ? '<button id="emergency" class="danger">Apply anyway (emergency)</button>' : ""}</div>` : "";
main.innerHTML = `<h1>Review & Apply</h1><p class="what">Started by ${esc(state.draft.started_by || state.draft.by || "?")}${state.draft.started ? " at " + esc(when(state.draft.started)) : ""}. ${state.draftStale ? "<b>The live settings changed since this draft was started: look again before applying.</b>" : ""}</p>
<div class="card"><h3>Your changes (${r.changes.length + (r.atem_pending ? 1 : 0)})</h3>${changes ? `<ul class="changes">${changes}</ul>` : "<p class='muted'>No change yet.</p>"}
<p><b>What will happen:</b> ${esc(r.feel)}</p>
${errors ? `<div class="notice problem"><b>Not possible yet:</b><ul>${errors}</ul></div>` : ""}${warnings ? `<div class="notice grey"><b>Worth knowing:</b><ul>${warnings}</ul></div>` : ""}${live}
<div class="field"><label for="note">Note for history</label><input id="note" placeholder="what you changed and why" maxlength="200"></div>
<div class="row"><button class="primary" id="apply" ${errors || (r.live && r.live.live) || !editing() || !changes ? "disabled" : ""}>APPLY</button><button id="discard" ${editing() ? "" : "disabled"}>Throw the draft away</button></div></div>
${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(`<h3>Apply during a live service</h3><p>${esc(r.feel)} Say why, and type LIVE.</p><input id="why" placeholder="why it cannot wait"><input id="word" placeholder="type LIVE"><div class="row"><button class="primary danger" id="dok">Apply now</button><button id="dcancel">Cancel</button></div>`, (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 = `<h1>Applying…</h1><p class="what">${esc(st.note || "")}</p>${stepsHtml(st)}<p class="muted">This page updates by itself.</p>`;
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 `<ol class="steps">${(st.steps || []).map((s) => `<li class="${esc(s.state)}">${lightDot(s.state === "ok" ? "ok" : s.state === "failed" ? "problem" : s.state === "running" ? "warn" : s.state === "skipped" ? "not_needed" : "unknown")}<b>${esc(s.name)}</b> ${esc(s.state === "skipped" ? "skipped: " + s.detail : s.detail || s.state)}</li>`).join("")}</ol>`; }
function applyResult(st) { return `<div class="card"><h3>${st.state === "done" ? "Applied" : "Not applied"} <span class="muted small">${esc(when(st.finished))}${st.by ? " · " + esc(st.by) : ""}</span></h3><p>${esc(st.outcome)}</p>${stepsHtml(st)}</div>`; }
// ---- Help ------------------------------------------------------------------
async function help() {
const facts = await api("/api/facts");
const gl = state.schema.glossary || {};
const cards = [
["Read a red key", "Green is fine. A flashing red key names the problem. Press it once to acknowledge (it stops flashing, stays dark red). Hold it 2 s and it runs the fix for what is failing."],
["Purple means the watchdog is down", "Every key turns purple when the watchdog on the Ingest Mac has been silent for 15 s. Open this page: if the Overview says Companion is not answering, the problem is Companion; otherwise press Restart the watchdog on the Overview."],
["Which computer or device is that?", "Computers › Map shows every computer and the keys that turn red if it dies; Computers › Gear lists the switcher, decks, recorders, wireless and LED gear with their addresses."],
["What is a connection?", "Companion's link to one device (a HyperDeck, the Tascam, the switcher). The keys watch those connections; Site setup › Companion lists them all and which key watches each."],
].map(([t, b]) => `<div class="card"><h3>${t}</h3><p>${b}</p></div>`).join("");
const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `<details open><summary><b>${esc(g)}</b></summary><p class="mono small">${esc(words.join(" · "))}</p></details>`).join("");
main.innerHTML = `<h1>Help</h1><p class="what">Start with the four cards. The pages below are made from the settings, so they are never out of date.</p><div class="cards">${cards}</div>
<h2>Pages</h2><p><a href="/key-guide.html" target="_blank">What each key does</a> · <a href="/cheat.html" target="_blank">Cheat sheet (one page)</a> · <a href="/where.html" target="_blank">Where things are</a></p>
<h2>Changing something</h2><ol><li>Switch Editing on (top right): the editing code and your first name.</li><li>Change it where it lives: a key on Keys, a computer on Computers, a fix on Fixes, the rest on Site setup.</li><li>The badge at the top counts your changes; it opens Review & Apply.</li><li>Review reads your changes back in plain words and says what will happen (the deck goes purple for a few seconds; pages re-import only when the layout changed). Press APPLY.</li><li>If the watchdog does not come back, the previous version is put back by itself. History keeps every version; Go back to any of them.</li></ol>
<h2>First-day walkthrough (10 minutes, no code needed)</h2><ol><li>Overview: the live deck. Green, red, purple.</li><li>Click a red key (or any key): read what it checks and what holding it will do.</li><li>Logs › Watchdog, filter "holds": find a hold and what it did.</li><li>Keys › Restart page: each key restarts one thing, even when green.</li><li>Overview: when alarms are on and when auto-fix may act by itself.</li><li>Computers › Map: which computer a key talks to, and what runs there.</li><li>Help › What each key does: the whole deck in words.</li></ol>
<h2>The words a key can show</h2>${glossary}
<h2>Things only a person can do</h2><ul><li>Run the set-up script on a new computer (ARD on a Mac, PowerShell as administrator on a PC), allow Accessibility on the Studio B Mac, click Allow on the Spotify prompt once.</li><li>Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.</li><li>Type the Netgear login into the keychain; keep Google Drive signed in for the saved switcher files.</li><li>Save a new switcher file from ATEM Software Control when the show changes, then pick it on Site setup › ATEM.</li></ul>
<h2>Companion facts (found by trying)</h2><dl class="kv">${(facts.facts || []).map(([q, a]) => `<dt>${esc(q)}</dt><dd>${esc(a)}</dd>`).join("")}</dl>`;
}
// ---- Site setup -----------------------------------------------------------------
const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys"];
async function site(rest) {
const c = cfg();
const sections = { site: "Site", schedules: "Schedules", companion: "Companion", "network-dante": "Network & Dante", spotify: "Spotify", atem: "ATEM" };
const which = sections[rest[0]] ? rest[0] : "site";
const tabs = Object.entries(sections).map(([k, n]) => `<button class="${which === k ? "on" : ""}" onclick="location.hash='#site/${k}'">${n}</button>`).join("");
const screen = sections[which];
const own = Object.entries(state.schema.fields).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !HAND_MADE.includes(p) && !HAND_MADE.some((h) => h.startsWith(p + ".")) && !Object.keys(state.schema.fields).some((k) => k.startsWith(p + ".") || k.startsWith(p + "[]")));
const fieldHtml = ([path, f]) => inputHtml(path, f, getPath(path));
const plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced);
let body = own.length ? `<div class="card"><h3>Settings</h3>${plain.map(fieldHtml).join("") || "<p class='muted small'>Only numbers here; they sit under Advanced.</p>"}${adv.length ? `<details><summary>Advanced</summary>${adv.map(fieldHtml).join("")}</details>` : ""}</div>` : "";
if (which === "companion") body = (await companionHtml()) + body;
if (which === "schedules") body = schedulesHtml(c) + body;
if (which === "network-dante") body += await danteHtml(c);
if (which === "spotify") body = spotifyHtml(c) + body;
if (which === "atem") body = (await atemHtml(c)) + body;
main.innerHTML = `<h1>Site setup</h1><p class="what">Set up once. Each section opens on the few things a person changes; the numbers sit under Advanced.${editing() ? "" : " Switch Editing on to change anything."}</p><div class="tabs">${tabs}</div>${body}`;
wireSite();
}
function windowsHtml(path, list, title, help) {
const days = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
const dis = editing() ? "" : "disabled";
const rows = (list || []).map((w, i) => `<div class="window" data-win="${path}.${i}">${days.map((d) => `<label class="inline"><input type="checkbox" data-day="${d}" ${(w.days || []).includes(d) ? "checked" : ""} ${dis}> ${d}</label>`).join("")} from <input class="hhmm" data-start value="${esc(w.start)}" ${dis}> to <input class="hhmm" data-end value="${esc(w.end)}" ${dis}> ${editing() ? `<button class="small" data-delwin="${path}.${i}">remove</button>` : ""}</div>`).join("");
return `<div class="field"><label>${esc(title)}</label>${rows || "<p class='muted small'>none</p>"}${editing() ? `<button class="small" data-addwin="${path}">+ window</button>` : ""}<div class="help">${esc(help)}</div></div>`;
}
function schedulesHtml(c) {
const fixes = Object.entries(c.fixes).map(([id, f]) => { const cl = closes(f); const on = (c.auto_fix.fixes || []).includes(id); return `<label class="inline"><input type="checkbox" data-autofix="${esc(id)}" ${on ? "checked" : ""} ${editing() && !cl && !f.never_auto ? "" : "disabled"}> ${esc(f.short || id)}${cl ? ' <span class="muted small">(closes a program: hold only)</span>' : f.never_auto ? ' <span class="muted small">(never by itself)</span>' : ""}</label>`; }).join(" ");
return `<div class="notice">Alarms are on every day. The ALARMS key silences them until midnight, then they come back by themselves. Changes to the panel wait while a HyperDeck is recording.</div>
<div class="card"><h3>Auto-fix</h3>${windowsHtml("auto_fix.windows", c.auto_fix.windows, "When auto-fix may act by itself", "An end earlier than the start runs past midnight.")}<div class="field"><label>Fixes it may run (gentle ones only)</label>${fixes}</div></div>
<div class="card"><h3>Changes wait</h3>${windowsHtml("site.live_services", (c.site || {}).live_services, "Clock windows during which changes to the panel wait", "A recording HyperDeck always counts, whatever the clock says. NA has no clock windows.")}</div>`;
}
async function companionHtml() {
const cc = await api("/api/companion");
const lights = Object.entries(cc.lights || {}).map(([k, v]) => `<li>${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</li>`).join("");
const conns = (cc.connections || []).map((r) => `<tr><td>${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed")}${esc(r.label)}</td><td class="mono small">${esc(r.module || "")}</td><td class="small">${esc(r.enabled ? r.category || "" : "off")}${r.message ? ` <span class="muted">${esc(r.message)}</span>` : ""}</td><td class="small">${(r.watched_by || []).length ? r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc(k.toUpperCase())}</a>`).join(", ") : '<span class="muted">not watched</span>'}</td></tr>`).join("");
return `<div class="card"><h3>Companion ${cc.ok ? "OK" : "<b>not answering</b>"}${cc.build ? ` <span class="pill">${esc(cc.build)}</span>` : ""}</h3><p class="small muted">Set up once by the person who installed Companion. You normally only come here to see which connection a key watches.</p><ul style="list-style:none;padding:0">${lights}</ul>
${(cc.missing || []).length ? `<p class="notice">Keys watch connections Companion does not have: ${esc(cc.missing.join(", "))}</p>` : ""}
${cc.page_number_buttons ? `<p class="small">Page-number buttons coloured on ${cc.page_number_buttons.pages_coloured} pages (${esc(cc.page_number_buttons.recorded_in)}).</p>` : ""}
<h3>Connections (live)</h3><div class="tablewrap"><table><tr><th>connection</th><th>module</th><th>status</th><th>watched by</th></tr>${conns}</table></div></div>`;
}
function danteCheck(c) { const t = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "dante")); return t ? t.checks.find((ch) => ch.type === "dante") : null; }
async function danteHtml(c) {
const chk = danteCheck(c);
if (!chk) return "";
const snap = await api("/api/snapshot").catch(() => ({}));
const map = snap.dante_map || {};
const fixOf = (n) => typeof chk.fix === "object" && chk.fix ? (chk.fix[n] || "") : "";
const fixSel = (n) => `<select data-dantefix="${esc(n)}" ${editing() ? "" : "disabled"}><option value="">(everything else)</option><option value="dante-auto" ${fixOf(n) === "dante-auto" ? "selected" : ""}>Dante auto</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${fixOf(n) === id ? "selected" : ""}>${esc(f.short || id)}</option>`).join("")}</select>`;
const rows = Object.entries(chk.devices || {}).map(([n, a]) => { const e = map[n] || {}; return `<tr><td class="mono small">${esc(n)}</td><td>${editing() ? `<input data-dantekey="${esc(n)}" value="${esc(a)}" size="10">` : esc(a)}</td><td class="mono small">${esc(e.ip || "")}</td><td class="small">${e.switch ? esc(e.switch + " port " + e.port) : ""}</td><td>${e.poe ? "yes" : ""}</td><td>${fixSel(n)}</td><td>${editing() ? `<button class="small danger" data-dantedel="${esc(n)}">remove</button>` : ""}</td></tr>`; }).join("");
const extra = Object.keys(map).filter((n) => !((chk.devices || {})[n]));
return `<div class="card"><h3>Dante device list (the DANTE key's devices)</h3><div class="tablewrap"><table><tr><th>Dante name</th><th>key text</th><th>address</th><th>switch / port</th><th>PoE</th><th>when it is gone, hold runs</th><th></th></tr>${rows}</table></div>
<p class="small muted">Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.${editing() ? ` <button class="small" id="resurvey">Check the Dante network now</button>` : ""}</p>
${editing() ? `<div class="field"><label>Add a device</label><input id="dantenew" placeholder="Dante name (as Dante Controller shows it)" list="dantefound"><datalist id="dantefound">${extra.map((n) => `<option value="${esc(n)}">`).join("")}</datalist> <input id="dantenewkey" placeholder="key text" size="10"> <button class="small" id="danteadd">Add</button>${extra.length ? `<div class="help">Seen on the network but not listed: ${esc(extra.join(", "))}</div>` : ""}</div>` : ""}</div>`;
}
function spotifyHtml(c) {
const s = c.spotify || {};
return `<div class="card"><h3>The service playlist</h3><div class="field"><label>Playlist link</label><input id="splink" value="${esc(s.playlist || "")}" placeholder="paste the open.spotify.com/playlist/... link" ${editing() ? "" : "disabled"}> ${editing() ? `<button class="small" id="splook">Look it up</button>` : ""}<div class="help">The SPOTIFY key, the play fix and the key guide all use it.</div></div>
<div class="field"><label>Name</label><input id="spname" value="${esc(s.name || "")}" ${editing() ? "" : "disabled"}><div class="help">As the keys say it.</div></div><p id="spinfo" class="help"></p></div>`;
}
async function atemHtml(c) {
const folder = await api("/api/atem-reference/folder").catch(() => ({ files: [], mounted: false }));
const pending = state.atemPending ? `<div class="notice">A new saved switcher file is waiting for Apply. ${editing() ? `<button class="small" id="atemdiscard">Discard it</button>` : ""}</div>` : "";
return `<div class="card"><h3>The saved switcher file</h3><p class="small muted">The panel keeps the switcher's audio and camera settings in line with this file (atem-reference.xml). Save a new one from ATEM Software Control when the show changes.</p>${pending}
${editing() ? `<div class="row"><label class="btn">Upload a saved file <input type="file" id="atemfile" accept=".xml" hidden></label><button id="atemnewest" ${folder.mounted && (folder.files || []).length ? "" : "disabled"}>Pick the newest from the folder</button></div>` : ""}
<p class="small">Folder: <span class="mono">${esc(folder.folder || "(not set)")}</span> ${folder.mounted ? `· newest: ${(folder.files || []).length ? esc(folder.files[0].name) + " (" + esc(when(folder.files[0].when)) + ")" : "none"}` : "· <b>not mounted</b>"}</p><p id="ateminfo" class="help"></p></div>`;
}
function wireSite() {
const q = (s) => main.querySelector(s);
for (const b of main.querySelectorAll("[data-addwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.addwin; const list = (getPath(path, state.draft.config) || []).slice(); list.push({ days: ["sun"], start: "09:00", end: "12:00" }); setPath(path, list); route(); };
for (const b of main.querySelectorAll("[data-delwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.delwin.replace(/\.\d+$/, ""), i = +b.dataset.delwin.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list.splice(i, 1); setPath(path, list); route(); };
for (const w of main.querySelectorAll("[data-win]")) for (const el of w.querySelectorAll("input")) el.onchange = () => { if (!ensureDraft()) return; const path = w.dataset.win.replace(/\.\d+$/, ""), i = +w.dataset.win.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list[i] = { days: [...w.querySelectorAll("[data-day]")].filter((x) => x.checked).map((x) => x.dataset.day), start: w.querySelector("[data-start]").value.trim(), end: w.querySelector("[data-end]").value.trim() }; setPath(path, list); };
for (const cb of main.querySelectorAll("[data-autofix]")) cb.onchange = () => { if (!ensureDraft()) return; const set = new Set(state.draft.config.auto_fix.fixes || []); cb.checked ? set.add(cb.dataset.autofix) : set.delete(cb.dataset.autofix); setPath("auto_fix.fixes", [...set]); };
const dc = () => danteCheck(state.draft.config);
for (const el of main.querySelectorAll("[data-dantekey]")) el.onchange = () => { if (!ensureDraft()) return; dc().devices[el.dataset.dantekey] = el.value.trim(); saveDraft(); };
for (const el of main.querySelectorAll("[data-dantefix]")) el.onchange = () => { if (!ensureDraft()) return; const ch = dc(); if (typeof ch.fix !== "object" || !ch.fix) ch.fix = { "*": ch.fix || "dante-auto" }; if (el.value) ch.fix[el.dataset.dantefix] = el.value; else delete ch.fix[el.dataset.dantefix]; saveDraft(); };
for (const b of main.querySelectorAll("[data-dantedel]")) b.onclick = () => { if (!ensureDraft()) return; const ch = dc(); delete ch.devices[b.dataset.dantedel]; if (typeof ch.fix === "object" && ch.fix) delete ch.fix[b.dataset.dantedel]; saveDraft(); route(); };
const da = q("#danteadd"); if (da) da.onclick = () => { if (!ensureDraft()) return; const n = q("#dantenew").value.trim(), k = q("#dantenewkey").value.trim(); if (!n) return; const ch = dc(); ch.devices = ch.devices || {}; ch.devices[n] = k || n.slice(0, 10).toUpperCase(); saveDraft(); route(); };
const rs = q("#resurvey"); if (rs) rs.onclick = async () => { try { await post("/api/command", { text: "resurvey" }); alertBox("Asked the watchdog to look at the switches; the map updates within a minute."); } catch (e) { alertBox(e.message); } };
const look = q("#splook"); if (look) look.onclick = async () => { const info = q("#spinfo"); info.textContent = "Looking…"; try { const r = await api("/api/live/spotify?url=" + encodeURIComponent(q("#splink").value)); if (!r.ok) { info.textContent = r.error; return; } if (!ensureDraft()) return; setPath("spotify.playlist", r.uri); if (r.name) { setPath("spotify.name", r.name); q("#spname").value = r.name; } q("#splink").value = r.uri; info.textContent = r.name ? `${r.name}: ${r.songs} songs. The play fix's words follow the name.` : (r.warning || ""); } catch (e) { info.textContent = e.message; } };
const spname = q("#spname"); if (spname) spname.onchange = () => setPath("spotify.name", spname.value.trim());
const splink = q("#splink"); if (splink) splink.onchange = () => { const m = splink.value.match(/(?:playlist[/:])([A-Za-z0-9]{22})/); if (m) setPath("spotify.playlist", "spotify:playlist:" + m[1]); else if (splink.value.trim()) alertBox("That is not a playlist link or URI."); };
const af = q("#atemfile"); if (af) af.onchange = async () => { const f = af.files[0]; if (!f) return; try { const r = await fetch("/api/atem-reference", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/xml" }, body: await f.arrayBuffer() }).then((x) => x.json()); showAtem(r); } catch (e) { alertBox(e.message); } };
const an = q("#atemnewest"); if (an) an.onclick = async () => { try { showAtem(await post("/api/atem-reference/pick-newest")); } catch (e) { alertBox(e.message); } };
const ad = q("#atemdiscard"); if (ad) ad.onclick = async () => { try { await post("/api/atem-reference/discard"); state.atemPending = false; badge(); route(); } catch (e) { alertBox(e.message); } };
function showAtem(r) {
if (!r.ok) { alertBox(r.error || "Not taken."); return; }
state.atemPending = true; if (!state.draft && ensureDraft()) saveDraft(); badge();
const diffs = (r.diffs || []).slice(0, 12).map((d) => d.part + " " + d.key + ": " + JSON.stringify(d.before) + " → " + JSON.stringify(d.after)).join("\n");
alertBox(`${r.file ? r.file + ": " : ""}${r.product || ""}, ${r.audio} audio and ${r.camera} camera values; ${r.diff_count} differ from the current file${diffs ? ":\n" + diffs : "."}\nApply replaces the file.`);
route();
}
}
// ---- Fixes -------------------------------------------------------------------------
async function fixes(rest) {
const c = cfg(), auto = new Set(c.auto_fix.fixes || []);
const used = {};
const use = (f, who) => { if (f) (used[f] = used[f] || new Set()).add(who); };
for (const t of c.tiles) { for (const ch of t.checks || []) for (const f of (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix])) use(f, t.label); use(t.hold_always, t.label); }
for (const k of (c.restart_page || { keys: [] }).keys) use(k.fix, "restart key " + k.label);
const rows = Object.entries(c.fixes).map(([id, f]) => `<tr class="click${rest[0] === id ? " sel" : ""}" onclick="location.hash='#fixes/${esc(id)}'"><td><b>${esc(f.short || id)}</b></td><td>${esc(f.label || "")}</td><td class="small">${esc(f.kind || "ssh")}${f.host ? " on " + esc((c.hosts[f.host] || {}).name || f.host) : f.kind === "local" || f.kind === "spotify" ? " on this Mac" : ""}</td><td class="small">${[...(used[id] || [])].join(", ")}</td><td class="small">${auto.has(id) ? "may run by itself" : f.never_auto ? "never by itself" : "hold only"}</td></tr>`).join("");
const id = rest[0], f = c.fixes[id];
let detail = "";
if (f) {
const kind = f.kind || "ssh";
const fields = ["label", "short"].concat((state.schema.fix_kind_fields || {})[kind] || []).concat(["restart_ok", "never_auto", "timeout"]);
const inputs = editing() ? [...new Set(fields)].map((fl) => {
const Fd = F("fixes.*." + fl); if (!Fd) return "";
if (fl === "host") return `<div class="field"><label>Computer</label><select data-path="fixes.${esc(id)}.host"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${f.host === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select></div>`;
if (fl === "run") return `<div class="field"><label>Command (shown as a command at Review; never run before Apply)</label><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run || "")}</textarea><div class="help">zsh on a Mac, cmd.exe on a PC. ${esc(Fd.help)}</div></div>`;
return inputHtml(`fixes.${id}.${fl}`, Fd, f[fl]);
}).join("") : "";
const kindSel = editing() ? `<div class="field"><label>Built as</label><select data-path="fixes.${esc(id)}.kind" class="kindsel">${(state.schema.fix_kinds || ["ssh", "local"]).map((k) => `<option ${kind === k ? "selected" : ""}>${k}</option>`).join("")}</select></div>` : "";
const keys = [...(used[id] || [])];
detail = `<div class="card"><h2>${esc(f.short || id)}</h2><p>${esc(f.label || "")}</p>${kindSel}${inputs}${canRunNow(id) && editing() ? `<p class="help">Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).</p>` : ""}<details ${editing() ? "" : "open"}><summary>Advanced</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}<dt>id</dt><dd class="mono">${esc(id)}</dd></dl></details>${editing() && !keys.length ? `<button class="danger small" id="delfix">Delete this fix</button>` : ""}</div>`;
}
const add = editing() ? `<div class="row"><input id="newfix" placeholder="new fix id (letters, digits, _)"><button id="addfix" class="small">+ New fix</button></div>` : "";
main.innerHTML = `<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(c.fixes).length} today.</p>${add}<div class="tablewrap"><table><tr><th>key text</th><th>plain words</th><th>built as</th><th>used by</th><th>by itself</th></tr>${rows}</table></div>${detail}`;
const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value); route(); };
const del = main.querySelector("#delfix"); if (del) del.onclick = () => confirmBox(`Delete the fix ${id}? No key uses it.`, "Delete", () => { if (!ensureDraft()) return; delete state.draft.config.fixes[id]; const af2 = state.draft.config.auto_fix.fixes || []; state.draft.config.auto_fix.fixes = af2.filter((x) => x !== id); saveDraft(); location.hash = "#fixes"; });
const af = main.querySelector("#addfix"); if (af) af.onclick = () => { const nid = main.querySelector("#newfix").value.trim(); if (!/^[A-Za-z0-9_]+$/.test(nid) || c.fixes[nid]) return alertBox("A new id: letters, digits and _ only, not in use."); if (!ensureDraft()) return; state.draft.config.fixes[nid] = { kind: "local", short: nid.toUpperCase().slice(0, 12), label: "", run: "" }; saveDraft(); location.hash = "#fixes/" + nid; };
}
})();