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