/* 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, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]);
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.builders = b;
document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
badge();
}
function ensureDraft() {
if (!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.saving = new Promise((done) => {
state.saveTimer = setTimeout(async () => {
try {
const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT");
state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by;
} catch (e) {
if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); }
else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); }
else alertBox("Could not save the draft: " + e.message);
}
done();
}, 400);
});
return state.saving;
}
const flush = () => state.saving || Promise.resolve();
// 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
More jobs… Add a computer Make a PC program startable
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("|"));
for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job);
const mj = main.querySelector("#morejobs"); if (mj) mj.onchange = () => { const j = mj.value; mj.value = ""; if (j) job(j); };
if (!quiet) window.scrollTo(0, 0);
}
// ---- Jobs: the changes people actually make, one dialog each --------------------
const slug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
const tagOf = (t) => t.toUpperCase().replace(/[^A-Z0-9 ]+/g, " ").trim().slice(0, 10).trim();
async function job(which) {
if (!editing()) return alertBox("Switch Editing on first (top right).");
clearInterval(timer); timer = null;
if (which === "playlist") return jobPlaylist();
if (which === "hyperdecks") return jobHyperdecks();
if (which === "midi") return jobMidi();
if (which === "computer") return jobComputer();
if (which === "pcprogram") return jobPcProgram();
}
function jobPlaylist() {
const sp = cfg().spotify || {};
dialog(`Change the playlist Paste the playlist link from Spotify (Share › Copy link). The SPOTIFY key, the play fix and the key guide follow it.
Now: ${esc(sp.name || "?")}
Look it up and use it Cancel
`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = async () => {
const info = b.querySelector("#jinfo"); info.textContent = "Looking…";
try {
const r = await api("/api/live/spotify?url=" + encodeURIComponent(b.querySelector("#jlink").value));
if (!r.ok) { info.textContent = r.error; return; }
if (!ensureDraft()) return;
const c = state.draft.config; c.spotify = Object.assign({}, c.spotify, { playlist: r.uri }); if (r.name) c.spotify.name = r.name;
await saveDraft();
for (const [id, f] of Object.entries(c.fixes)) if (f.kind === "spotify") { const bb = await post("/api/build", { fix: f }); if (bb.ok && bb.label) f.label = bb.label; }
await saveDraft(); closeDialog(); location.hash = "#review";
} catch (e) { info.textContent = e.message; }
};
});
}
async function jobHyperdecks() {
let cc;
try { cc = await api("/api/companion"); } catch (e) { return alertBox("Companion is not answering right now (" + e.message + "); try again in a moment."); }
const c = cfg();
const decks = (cc.connections || []).filter((r) => r.module === "bmd-hyperdeck");
const keyFor = (conn) => c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn));
const rows = decks.map((d) => { const k = keyFor(d.label); const chk = k && k.checks.find((ch) => ch.conn === d.label); return ` ${esc(d.label)}${k ? esc(k.label) + (k.pos ? ` (row ${k.pos[0]}, col ${k.pos[1]})` : "") : "no key "} may have no disk `; }).join("");
if (!decks.length) return alertBox("Companion has no HyperDeck connections (module bmd-hyperdeck). Add the connection in Companion first.");
dialog(`Add or remove a HyperDeck Tick the decks in use. A ticked deck gets an HD key in the REC group; an unticked one loses its key (its cell stays empty; drag another key there if you like). "May have no disk" means NO DISK is a warning, not an alarm.
Companion connection key ${rows}
Use these decks Cancel
`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = () => {
if (!ensureDraft()) return;
const d = state.draft.config;
const rec = d.tiles.find((t) => (t.members || []).some((m) => d.tiles.some((x) => x.id === m && (x.checks || []).some((ch) => ch.type === "hyperdeck")))) || d.tiles.find((t) => t.id === "rec");
const fixId = Object.keys(d.fixes).find((id) => d.fixes[id].kind === "hyperdeck");
for (const cb of b.querySelectorAll("[data-deck]")) {
const conn = cb.dataset.deck, nodisk = b.querySelector(`[data-nodisk="${CSS.escape(conn)}"]`).checked;
const existing = d.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn));
if (cb.checked && existing) { const ch = existing.checks.find((x) => x.conn === conn); if (nodisk) ch.optional = true; else delete ch.optional; }
else if (cb.checked) {
const n = (conn.match(/(\d+)\s*$/) || [])[1]; let id = n ? "hd" + n : slug(conn), k = 2; while (d.tiles.some((t) => t.id === id)) id = (n ? "hd" + n : slug(conn)) + "_" + k++;
const t = { id, label: n ? "HD" + n : conn.toUpperCase().slice(0, 8), hold_always: fixId || undefined, checks: [Object.assign({ type: "hyperdeck", conn }, nodisk ? { optional: true } : {}, fixId ? { fix: { conn: fixId } } : {})] };
const pos = firstEmpty(d, "status"); if (pos) t.pos = pos;
d.tiles.push(t); if (rec) rec.members.push(id);
} else if (existing) { d.tiles.splice(d.tiles.indexOf(existing), 1); for (const t of d.tiles) if (t.members) t.members = t.members.filter((m) => m !== existing.id); }
}
saveDraft(); closeDialog(); location.hash = "#keys";
};
});
}
async function jobMidi() {
const live = await api("/api/live/midi").catch(() => ({ local: {}, remote: {} }));
const c = cfg();
const macs = Object.entries(c.hosts).filter(([, h]) => h.self || (h.os || "mac") === "mac");
const midiKey = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "midi_peer"));
const fixId = Object.keys(c.fixes).find((id) => c.fixes[id].kind === "midi_connect");
const sessionsOf = (alias) => { const h = c.hosts[alias]; const src = h.self ? (live.local || {}) : ((live.remote || {})[alias] || {}); return Object.entries(src).map(([name, v]) => [name, (v.peers || []).map((p) => p.name)]); };
dialog(`Add a MIDI link A network MIDI session on one of the Macs, and the peer that must stay in it. Pick from what the Macs report now, or type the names if that Mac is off.
The Mac that holds the session ${macs.map(([a, h]) => `${esc(h.name || a)}${h.self ? " (this Mac)" : ""} `).join("")}
Session
Peer
Key text when it is lost (short)
Address if the peer must be reconnected by address (optional)
Which key ${midiKey ? `${esc(midiKey.label)} (the MIDI key) ` : ""}a new key
Add the link Cancel
`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
const fill = () => { const ss = sessionsOf(b.querySelector("#jhost").value); b.querySelector("#jsessions").innerHTML = ss.map(([n]) => ``).join(""); const cur = ss.find(([n]) => n === b.querySelector("#jsession").value) || ss[0]; if (cur && !b.querySelector("#jsession").value) b.querySelector("#jsession").value = cur[0]; b.querySelector("#jpeers").innerHTML = ((cur || [])[1] || []).map((n) => ` `).join(""); };
b.querySelector("#jhost").onchange = () => { b.querySelector("#jsession").value = ""; fill(); };
b.querySelector("#jsession").oninput = fill;
b.querySelector("#jpeer").oninput = () => { if (!b.querySelector("#jtag").value) b.querySelector("#jtag").placeholder = tagOf(b.querySelector("#jpeer").value) || "LIGHT PC"; };
fill();
b.querySelector("#dok").onclick = () => {
const host = b.querySelector("#jhost").value, session = b.querySelector("#jsession").value.trim(), peer = b.querySelector("#jpeer").value.trim();
const tag = b.querySelector("#jtag").value.trim() || tagOf(peer), addr = b.querySelector("#jaddr").value.trim(), keyId = b.querySelector("#jkey").value;
if (!session || !peer) return alertBox("A session and a peer, please.");
if (!ensureDraft()) return;
const d = state.draft.config;
const chk = Object.assign({ type: "midi_peer", session, peer, tag }, d.hosts[host].self ? {} : { host }, fixId ? { fix: fixId } : {}, addr ? { address: addr } : {});
let target = d.tiles.find((t) => t.id === keyId);
if (!target) { let id = "midi", n = 2; while (d.tiles.some((t) => t.id === id)) id = "midi_" + n++; target = { id, label: "MIDI", checks: [] }; const pos = firstEmpty(d, "status"); if (pos) target.pos = pos; d.tiles.push(target); }
target.checks = target.checks || []; target.checks.push(chk);
saveDraft(); closeDialog(); location.hash = "#keys/" + target.id;
};
});
}
function jobComputer() {
dialog(`Add a computer It appears on Computers with what runs on it; the Setup tab then shows what to do on it (login key, Remote Login). Fixes and checks for it come after.
Name, as people say it
Short name (letters, digits, _)
Mac or Windows Mac Windows
Log-in address
What it does for the panel
Add it Cancel
`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#jname").oninput = () => { b.querySelector("#jalias").value = slug(b.querySelector("#jname").value); };
b.querySelector("#dok").onclick = () => {
const name = b.querySelector("#jname").value.trim(), alias = b.querySelector("#jalias").value.trim(), os = b.querySelector("#jos").value, ssh = b.querySelector("#jssh").value.trim(), role = b.querySelector("#jrole").value.trim();
if (!name || !/^[a-z0-9_]+$/.test(alias)) return alertBox("A name, and a short name of lowercase letters, digits and _.");
if (!/^[^@\s]+@[^@\s]+$/.test(ssh)) return alertBox("The log-in address is user@address.");
if (cfg().hosts[alias]) return alertBox("That short name is taken.");
if (!ensureDraft()) return;
state.draft.config.hosts[alias] = { name, os, ssh, role, runs: [{ kind: "sshd" }] };
saveDraft(); closeDialog(); location.hash = "#computers/" + alias;
};
});
}
function jobPcProgram() {
const pcs = Object.entries(cfg().hosts).filter(([, h]) => h.os === "windows");
if (!pcs.length) return alertBox("No Windows PC is listed yet: Add a computer first.");
dialog(`Make a PC program startable The panel starts programs on a PC through a scheduled task named Start <program> under \\StatusPanel\\ (the Setup tab shows how to create it). This adds the task to the PC's list and a "start it if it is not running" fix.
PC ${pcs.map(([a, h]) => `${esc(h.name || a)} `).join("")}
Program, as people say it
Program file, as Task Manager shows it
Add it Cancel
`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = async () => {
const host = b.querySelector("#jhost").value, name = b.querySelector("#jname").value.trim(), exe = b.querySelector("#jexe").value.trim();
if (!name || !exe) return;
if (!ensureDraft()) return;
const d = state.draft.config, task = "Start " + name;
const h = d.hosts[host]; h.runs = h.runs || []; if (!h.runs.some((r) => r.kind === "task" && r.name === task)) h.runs.push({ kind: "task", name: task, exe });
let id = slug(name), n = 2; while (d.fixes[id]) id = slug(name) + "_" + n++;
const r = await post("/api/build", { fix: { template: "win_start", params: { host, task, exe }, short: name.toUpperCase().slice(0, 12) } });
if (!r.ok) return alertBox(r.error);
d.fixes[id] = Object.assign(r.fix, { label: r.label || ("start " + name + " if it is not running") });
saveDraft(); closeDialog(); location.hash = "#fixes/" + id;
};
});
}
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, draggable) {
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" };
const drag = (what) => editing() && draggable ? ` draggable="true" data-drag="${esc(what)}"` : "";
let out = ``;
for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) {
const k = `${r},${col}`, cell = cells[k];
if (nav[k]) out += `
${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) {
await flush();
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}) ` : ""}
${editing() ? `In Companion the Status page is page ${c.restart_page ? ` and the Restart page is page ` : ""} this campus has a Restart page
` : ""}`;
let panel = `Click a key to see what it watches and what holding it does.${editing() ? " With Editing on you can change it here, drag a key to move it, drag it onto the tray to hide it." : ""}
`;
if (sel && !restart) { const i = c.tiles.findIndex((x) => x.id === sel); if (i >= 0) panel = await keyPanel(i, hold); }
else if (sel && restart && c.restart_page) { const i = c.restart_page.keys.findIndex((x) => x.id === sel); if (i >= 0) panel = restartKeyPanel(i, hold); }
const trayKeys = c.tiles.filter((t) => !t.pos && t.kind !== "master");
const tray = trayKeys.map((t) => `${esc(t.label)} `).join(" ");
let note = "";
if (state.draft) { try { const v = await post("/api/validate", {}); if (v.plan && v.plan.pages.length) note = `Layout changed: Apply will re-import the ${v.plan.pages.filter((p) => p === "status" || p === "restart").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.
`; } catch (e) {} }
const adders = editing() ? (restart ? `+ New restart key ` : `+ New key `) : "";
main.innerHTML = `Keys The deck as people hold it. Click a key; with Editing on, change what it watches and what holding it does, drag it to move it.
${tabs}${note}
${restart && !c.restart_page ? "
This campus has no Restart page.
" : deckHtml(o.deck, c, restart ? "restart" : "status", sel, true)}
${!restart ? `
Shown inside their group, no key of their own: ${tray || "(none) "}${editing() ? " · drop a key here to hide it " : ""}
` : ""}
${adders}
${panel}
`;
wireKeyPanel(sel, restart);
wireLayout(restart);
}
function takenCells(c, which) {
const taken = new Set(["0,0", "1,0", "2,0"]);
if (which === "restart") { for (const k of c.restart_page.keys) taken.add(String(k.pos)); taken.add(String(c.restart_page.back_pos)); }
else { for (const t of c.tiles) if (t.pos) taken.add(String(t.pos)); if (c.restart_page) taken.add(String(c.restart_page.jump_pos)); }
return taken;
}
function firstEmpty(c, which) {
const taken = takenCells(c, which);
for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) if (!taken.has(`${r},${col}`)) return [r, col];
return null;
}
function parsePos(text, c, which, allowBlank) {
const m = text.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/);
if (!m) { if (allowBlank && !text.trim()) return null; alertBox("Row, column: two numbers, like 2, 5."); return false; }
const pos = [+m[1], +m[2]];
if (pos[0] > 3 || pos[1] > 7) { alertBox("The deck has rows 0-3 and columns 0-7."); return false; }
if (takenCells(c, which).has(String(pos))) { alertBox(`Row ${pos[0]} column ${pos[1]} is taken. Hide or move a key first` + (allowBlank ? ", or leave the position blank." : ".")); return false; }
return pos;
}
function wireLayout(restart) {
const move = (what, pos) => {
if (!ensureDraft()) return;
const c = state.draft.config, [kind, id] = what.split(":");
if (kind === "tile") { const t = c.tiles.find((x) => x.id === id); if (!t) return; if (pos) t.pos = pos; else delete t.pos; }
else if (kind === "rkey") { const k = c.restart_page.keys.find((x) => x.id === id); if (k && pos) k.pos = pos; }
else if (kind === "jump" && pos) c.restart_page.jump_pos = pos;
else if (kind === "back" && pos) c.restart_page.back_pos = pos;
saveDraft(); route();
};
let dragging = null;
for (const el of main.querySelectorAll("[data-drag]")) {
el.addEventListener("dragstart", (e) => { dragging = el.dataset.drag; e.dataTransfer.setData("text/plain", dragging); e.dataTransfer.effectAllowed = "move"; });
el.addEventListener("dragend", () => { dragging = null; });
}
for (const el of main.querySelectorAll("[data-drop]")) {
el.addEventListener("dragover", (e) => { e.preventDefault(); el.classList.add("over"); });
el.addEventListener("dragleave", () => el.classList.remove("over"));
el.addEventListener("drop", (e) => { e.preventDefault(); const what = e.dataTransfer.getData("text/plain") || dragging; if (what) move(what, el.dataset.drop.split(",").map(Number)); });
}
const tray = main.querySelector("#tray");
if (tray && editing()) {
tray.addEventListener("dragover", (e) => { e.preventDefault(); tray.classList.add("over"); });
tray.addEventListener("dragleave", () => tray.classList.remove("over"));
tray.addEventListener("drop", (e) => { e.preventDefault(); const what = e.dataTransfer.getData("text/plain") || dragging; if (!what || !what.startsWith("tile:")) return; const t = cfg().tiles.find((x) => x.id === what.slice(5)); if (t && t.kind) return alertBox("That key always has a spot on the deck."); move(what, null); });
}
for (const inp of main.querySelectorAll("input.pagenum")) inp.onchange = () => {
const n = parseInt(inp.value, 10); if (!(n >= 1)) return;
if (!ensureDraft()) return;
const c = state.draft.config;
if (inp.dataset.which === "status") { c.page = n; c.page_name = `${n}\n${(c.page_name || "\nStatus").split("\n").slice(1).join("\n") || "Status"}`; }
else { c.restart_page.page = n; c.restart_page.page_name = `${n}\n${(c.restart_page.page_name || "\nRestart").split("\n").slice(1).join("\n") || "Restart"}`; }
saveDraft(); route();
};
const hr = main.querySelector("#hasrestart"); if (hr) hr.onchange = () => {
if (!ensureDraft()) { hr.checked = !hr.checked; return; }
const c = state.draft.config;
if (!hr.checked) confirmBox("Switch the Restart page off? Its keys are removed from the settings; page " + c.restart_page.page + " stays in Companion until someone clears it.", "Switch it off", () => { delete c.restart_page; saveDraft(); location.hash = "#keys"; route(); }, null);
else { const pos = firstEmpty(c, "status"); if (!pos) return alertBox("No free spot on the Status page for the RESTART PAGE key."); c.restart_page = { page: c.page + 1, page_name: `${c.page + 1}\nRestart`, jump_pos: pos, back_pos: pos, keys: [] }; saveDraft(); location.hash = "#keys/restart"; route(); }
if (!hr.checked) hr.checked = true;
};
const nk = main.querySelector("#newkey"); if (nk) nk.onclick = () => dialog(`New key Add the key Cancel
It starts with no checks: add them on the key, then Apply.
`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#nlabel").oninput = () => { b.querySelector("#nid").value = b.querySelector("#nlabel").value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""); };
b.querySelector("#dok").onclick = () => {
const label = b.querySelector("#nlabel").value.trim(), id = b.querySelector("#nid").value.trim();
if (!label || !/^[A-Za-z0-9_]+$/.test(id)) return;
if (cfg().tiles.some((t) => t.id === id)) return alertBox("A key with that id exists.");
const pos = parsePos(b.querySelector("#npos").value, cfg(), "status", true); if (pos === false) return;
if (!ensureDraft()) return;
const t = { id, label, checks: [] }; if (pos) t.pos = pos;
state.draft.config.tiles.push(t); saveDraft(); closeDialog(); location.hash = "#keys/" + id;
};
});
const nr = main.querySelector("#newrkey"); if (nr) nr.onclick = () => { const c = cfg(); dialog(`New restart key ${Object.entries(c.fixes).map(([id, f]) => `${esc(f.short || id)} – ${esc(f.label || "")} `).join("")} Add the key Cancel
`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = () => {
const label = b.querySelector("#nlabel").value.trim(), fix = b.querySelector("#nfix").value;
if (!label) return;
const pos = parsePos(b.querySelector("#npos").value, cfg(), "restart", false); if (!pos) return;
if (!ensureDraft()) return;
let id = "rs_" + label.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""), n = 2;
while (state.draft.config.restart_page.keys.some((k) => k.id === id)) id = id.replace(/_\d+$/, "") + "_" + n++;
state.draft.config.restart_page.keys.push({ id, label, fix, pos, watch: [] }); saveDraft(); closeDialog(); location.hash = "#keys/restart/" + id;
};
}); };
}
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}.`;
let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("");
if (chk.type === "remote") {
const ctpls = state.builders.checks.concat([{ name: "custom", words: "Custom command (typed by hand)", params: [] }]);
const ctName = chk.template || "custom", ct = ctpls.find((x) => x.name === ctName);
fields = `Built as ${ctpls.map((x) => `${esc(x.words)} `).join("")}
${paramsHtml(ct, chk.params || {}, "ck" + j)}
`
+ (ctName === "custom" ? fields : inputHtml(base + "msg", F("tiles[].checks[].msg"), chk.msg) + (chk.cmd ? `The command it makes (on ${esc((c.hosts[chk.host] || {}).name || chk.host)}) ${esc(chk.cmd)} ` : ""));
}
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}
${editing() && (!t.kind || t.kind === "network") ? `
Delete this key
` : ""}
`;
}
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("|"));
const dk = main.querySelector("#delkey"); if (dk) dk.onclick = () => confirmBox(`Delete the key ${c.tiles[i].label}? Its checks go with it.`, "Delete", () => { if (!ensureDraft()) return; const cc = state.draft.config; const id = cc.tiles[i].id; cc.tiles.splice(i, 1); for (const t of cc.tiles) if (t.members) t.members = t.members.filter((m) => m !== id); saveDraft(); location.hash = "#keys"; });
const dr = main.querySelector("#delrkey"); if (dr) dr.onclick = () => confirmBox("Delete this restart key?", "Delete", () => { if (!ensureDraft()) return; const ks = state.draft.config.restart_page.keys; ks.splice(ks.findIndex((x) => x.id === sel), 1); saveDraft(); location.hash = "#keys/restart"; });
for (const b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; 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 = () => { const t0 = cfg().tiles[i]; const was = t0.pos; delete t0.pos; const p2 = parsePos(pos.value, cfg(), "status", true); if (was) t0.pos = was; if (p2 === false) return; if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; if (p2) t.pos = p2; else delete t.pos; saveDraft(); route(); };
const rebuildCheck = async (j) => {
if (!ensureDraft()) return;
const t = state.draft.config.tiles[i], chk = t.checks[j];
const tplName = main.querySelector(`select.ctplsel[data-check="${j}"]`).value;
if (tplName === "custom") { delete chk.template; delete chk.params; saveDraft(); route(); return; }
const ct = state.builders.checks.find((x) => x.name === tplName);
const params = readParams(main.querySelector(`.ctplform[data-check="${j}"]`), ct);
const r = await post("/api/build-check", { check: Object.assign({}, chk, { template: tplName, params }) });
if (!r.ok) { chk.template = tplName; chk.params = params; saveDraft(); alertBox(r.error); return; }
t.checks[j] = r.check; saveDraft(); route();
};
for (const sel3 of main.querySelectorAll("select.ctplsel")) sel3.onchange = () => rebuildCheck(+sel3.dataset.check);
for (const form of main.querySelectorAll(".ctplform")) for (const el of form.querySelectorAll("[data-param]")) el.onchange = () => rebuildCheck(+form.dataset.check);
for (const s of main.querySelectorAll("select.fixsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
for (const ta of main.querySelectorAll("textarea.fixtable")) ta.onchange = () => { try { setPath(ta.dataset.path, JSON.parse(ta.value)); } catch (e) { alertBox("Not a valid table."); } };
for (const s of main.querySelectorAll("select.holdsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
for (const s of main.querySelectorAll("select.watchsel")) s.onchange = () => setPath(s.dataset.path, [...s.selectedOptions].map((o) => o.value));
}
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)} ${editing() ? `
Delete this key
` : ""}
`;
}
// ---- 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, (await api("/api/setup").catch(() => ({ computers: [] }))).computers);
else {
const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `${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}`;
for (const b of main.querySelectorAll("[data-copy]")) b.onclick = async () => { const ta = document.getElementById(b.dataset.copy); try { await navigator.clipboard.writeText(ta.value); b.textContent = "Copied"; } catch (e) { ta.select(); document.execCommand("copy"); b.textContent = "Copied"; } };
const rm = main.querySelector("#removehost"); if (rm) rm.onclick = () => removeComputer(selected);
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.
` : "";
const remove = editing() && !c.self ? `Remove this computer
` : "";
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}${remove}
`;
}
function removeComputer(alias) {
const c = cfg(), h = c.hosts[alias]; if (!h) return;
const fixes = Object.entries(c.fixes).filter(([, f]) => f.host === alias || (f.params || {}).host === alias).map(([id]) => id);
const checks = []; for (const t of c.tiles) (t.checks || []).forEach((ch, j) => { if (ch.host === alias || (ch.params || {}).host === alias) checks.push([t.id, j, t.label]); });
const usesFix = []; 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])) if (fixes.includes(f)) usesFix.push(t.label); if (fixes.includes(t.hold_always)) usesFix.push(t.label); }
const rkeys = (c.restart_page ? c.restart_page.keys : []).filter((k) => fixes.includes(k.fix)).map((k) => k.label);
const what = [`the computer ${h.name || alias}`].concat(fixes.length ? [`${fixes.length} fix${fixes.length > 1 ? "es" : ""} that ran on it (${fixes.join(", ")})`] : [], checks.length ? [`${checks.length} check${checks.length > 1 ? "s" : ""} on the keys ${[...new Set(checks.map((x) => x[2]))].join(", ")}`] : [], rkeys.length ? [`the restart keys ${rkeys.join(", ")}`] : []);
confirmBox(`Remove ${what.join(", ")}? The keys stay; a key left with nothing to check will say OK.`, "Remove", () => {
if (!ensureDraft()) return;
const d = state.draft.config;
delete d.hosts[alias];
for (const id of fixes) { delete d.fixes[id]; if (d.auto_fix && d.auto_fix.fixes) d.auto_fix.fixes = d.auto_fix.fixes.filter((x) => x !== id); }
for (const t of d.tiles) {
t.checks = (t.checks || []).filter((ch) => !(ch.host === alias || (ch.params || {}).host === alias));
for (const ch of t.checks) { if (typeof ch.fix === "string" && fixes.includes(ch.fix)) delete ch.fix; else if (ch.fix && typeof ch.fix === "object") for (const k of Object.keys(ch.fix)) if (fixes.includes(ch.fix[k])) delete ch.fix[k]; }
if (fixes.includes(t.hold_always)) delete t.hold_always;
if (!t.checks.length && !t.members) delete t.checks;
}
if (d.restart_page) d.restart_page.keys = d.restart_page.keys.filter((k) => !fixes.includes(k.fix));
saveDraft(); location.hash = "#computers";
});
}
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, generated) {
return m.computers.map((c) => {
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
const gen = (generated || []).find((g) => g.alias === c.alias) || { steps: [], scripts: [] };
const steps = gen.steps.length ? gen.steps.map((x) => x.text) : ((m.setup_steps || {})[kind] || []);
const howOf = (t) => (gen.steps.find((x) => x.text === t) || {}).how;
const lightFor = (step) => {
const s = c.setup || {};
const item = (k) => (c.items || []).find((i) => i.kind === k);
if (/login key/.test(step)) return s.key_ok === true || s.login_key === true ? "ok" : s.key_ok === false || s.login_key === false ? "problem" : "unknown";
if (/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";
};
const scriptsHtml = gen.scripts.map((sc, i) => `${esc(sc.name)} ${esc(sc.how)}
`).join("");
return `${esc(c.name)} ${steps.map((s) => { const l = lightFor(s); const how = howOf(s); return `${lightDot(l)}${esc(s)} ${l === "hand" ? "(done by hand)" : l === "unknown" ? "– can't see from here" : ""} ${how ? `how ${esc(how)}
` : ""} `; }).join("")} ${scriptsHtml}
`;
}).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() {
await flush();
const st = await api("/api/apply/status");
if (st.state === "running") return applyProgress(st);
const d = await api("/api/draft"); state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; badge();
if (!state.draft) {
main.innerHTML = `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 === "site") body += await programHtml();
if (which === "companion") body = (await namesHtml()) + (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 programHtml() {
const p = await api("/api/program/staged").catch(() => null);
if (!p) return "";
const staged = p.present ? `${esc(p.staged_version || "?")}${p.staged_when ? " (staged " + esc(when(p.staged_when)) + ")" : ""}` : "nothing staged";
const same = p.present && p.staged_version === p.running_version;
return `The program Running code ${esc(p.running_version || "?")} Staged ${staged}${same ? " · the same code " : ""} Folder ${esc(p.folder)}
Copy the new folder to this Mac with scp as before, then press the button: install.sh runs by itself (the config stays as it is), the watchdog restarts, this page comes back.
Install the staged folder ${p.job === "running" ? " install.sh is running… " : ""}
${p.log && p.log.length ? `
The last install's log${p.log_when ? " (" + esc(when(p.log_when)) + ")" : ""} ${esc(p.log.join("\n"))} ` : ""}
This campus as a bundle A zip of the settings, the saved switcher file and the page-key record: drop it in the repo as sites/${esc((cfg().site || {}).id || "site")}/, or feed it to another campus's page.
New campus from a bundle: this campus's name, addresses and editing code stay; every computer from the bundle is flagged "copied from … - check it" until you touch it; keys, checks and fixes come along. Then Review & Apply.
`;
}
async function namesHtml() {
const n = await api("/api/site/names").catch(() => null);
if (!n || !n.missing) return "";
const sel = (kind, name, cands) => editing() ? `– map it to – ${cands.map((c) => `${esc(c)} `).join("")} ` : "";
const conns = n.connections.filter((c) => !c.live).map((c) => `${lightDot("problem")}${esc(c.name)} ${esc(c.keys.join(", "))} ${n.companion_ok ? "not in this Companion" : "Companion not answering"}${c.module ? ` (needs module ${esc(c.module)}) ` : ""} ${sel("conn", c.name, c.candidates)} `).join("");
const dante = n.dante.filter((d) => !d.seen).map((d) => `${lightDot("warn")}${esc(d.name)} DANTE not seen on the Dante network (last survey) ${sel("dante", d.name, d.candidates)} `).join("");
const midi = n.midi.filter((m) => !m.session_seen || !m.peer_seen).map((m) => `${lightDot("warn")}${esc(m.session)} › ${esc(m.peer)} ${esc(m.key)} ${!m.session_seen ? `session not seen${m.host ? " on " + esc((cfg().hosts[m.host] || {}).name || m.host) : ""}${m.sessions_seen.length ? " (seen: " + esc(m.sessions_seen.join(", ")) + ")" : ""}` : `peer not in the session now${m.peers_seen.length ? " (in it: " + esc(m.peers_seen.join(", ")) + ")" : ""}`} change it on the key `).join("");
return `Names the keys use that are not here (${n.missing}) A copied campus, or something renamed. Map each name to what this campus has; the keys, checks and restart keys that use it follow.
name used by ${conns}${dante}${midi}
`;
}
function renameConn(from, to) {
const d = state.draft.config;
for (const t of d.tiles) for (const c of t.checks || []) if (c.conn === from) c.conn = to;
if (d.restart_page) for (const k of d.restart_page.keys) k.watch = (k.watch || []).map((w) => (w === "conn:" + from ? "conn:" + to : w));
for (const f of Object.values(d.fixes)) if (f.conn === from) f.conn = to;
}
function renameDante(from, to) {
for (const t of state.draft.config.tiles) for (const c of t.checks || []) if (c.type === "dante" && c.devices && from in c.devices) { c.devices[to] = c.devices[from]; delete c.devices[from]; if (c.fix && typeof c.fix === "object" && from in c.fix) { c.fix[to] = c.fix[from]; delete c.fix[from]; } }
}
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 ${editing() ? ` Add a key for it ` : ""}`} `).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 "}
${folder.blocked ? `
The folder is there but this page cannot read it (macOS keeps background programs out of cloud folders, and this Mac's own login key is not accepted on this Mac). Use Upload a saved file, or let the panel's key log in to this Mac: Computers › Setup › ${esc((cfg().site || {}).ingest_name || "the Ingest Mac")}.
` : ""}
`;
}
function wireSite() {
const q = (s) => main.querySelector(s);
for (const m of main.querySelectorAll("[data-map]")) m.onchange = () => { if (!m.value || !ensureDraft()) return; const [kind, name] = m.dataset.map.split("|"); (kind === "conn" ? renameConn : renameDante)(name, m.value); saveDraft(); route(); };
for (const b of main.querySelectorAll("[data-addconn]")) b.onclick = () => { const conn = b.dataset.addconn; if (!ensureDraft()) return; const d = state.draft.config; let id = slug(conn) || "key", n = 2; while (d.tiles.some((t) => t.id === id)) id = slug(conn) + "_" + n++; const t = { id, label: conn.toUpperCase().replace(/[_-]+/g, " ").slice(0, 10).trim(), checks: [{ type: "conn", conn, fix: "restart-connection" }] }; const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; d.tiles.push(t); saveDraft(); location.hash = "#keys/" + id; };
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 ib = q("#installbtn"); if (ib) ib.onclick = () => confirmBox("Install the staged folder? The watchdog restarts (the deck goes purple for a few seconds) and this page restarts too.", "Install", async () => { try { const r = await post("/api/program/install", {}); alertBox(r.ok ? r.detail : "Not started: " + r.error); setTimeout(() => location.reload(), 25000); } catch (e) { alertBox(e.message); } });
const bf = q("#bundlefile"); if (bf) bf.onchange = async () => { const f = bf.files[0]; if (!f) return; confirmBox(`Start a new campus from ${f.name}? Your current draft is replaced.`, "Start", async () => { try { const r = await fetch("/api/site/import", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/octet-stream" }, body: await f.arrayBuffer() }).then((x) => x.json()); if (r.error) return alertBox(r.error); state.draft = r.draft; state.atemPending = true; badge(); alertBox(`Copied from ${r.from}. ${r.errors.length ? "Not applicable yet: " + r.errors.slice(0, 3).join("; ") : "Look through Computers, Keys and Fixes, then Review & Apply."}`); location.hash = "#computers"; } catch (e) { alertBox(e.message); } }); };
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();
}
}
// ---- builders: a fix or a check is made from fields ---------------------------------
function paramsHtml(tpl, params, prefix) {
const c = cfg();
return tpl.params.map((p) => {
const v = params[p.name] != null ? params[p.name] : p.default;
const id = `${prefix}-${p.name}`;
let input;
if (p.type === "host") input = `– ${Object.entries(c.hosts).filter(([, h]) => tpl.os === "self" ? h.self : tpl.os ? (h.self ? "mac" : h.os || "mac") === tpl.os : true).map(([a, h]) => `${esc(h.name || a)} `).join("")} `;
else if (p.type === "bool") input = ` `;
else if (p.type === "int") input = ` `;
else if (p.type === "list") input = ` `;
else input = ` `;
return `${esc(p.words)} ${input}
${esc(p.help)}
`;
}).join("");
}
function readParams(root, tpl) {
const out = {};
for (const p of tpl.params) {
const el = root.querySelector(`[data-param="${p.name}"]`); if (!el) continue;
if (p.type === "bool") out[p.name] = el.checked;
else if (p.type === "int") out[p.name] = el.value === "" ? undefined : parseInt(el.value, 10);
else if (p.type === "list") out[p.name] = el.value.split(",").map((x) => x.trim()).filter(Boolean);
else out[p.name] = el.value.trim();
}
return out;
}
// ---- 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 tpls = state.builders.fixes, tplName = f.template || "custom", tpl = tpls.find((t) => t.name === tplName) || tpls.find((t) => t.name === "custom");
const kindOnly = !!tpl.kind;
const keys = [...(used[id] || [])];
let inputs = "";
if (editing()) {
inputs += `Built as ${tpls.map((t) => `${esc(t.words)} `).join("")}
`;
inputs += `${paramsHtml(tpl, f.params || {}, "fx")}
`;
inputs += inputHtml(`fixes.${id}.label`, F("fixes.*.label"), f.label) + inputHtml(`fixes.${id}.short`, F("fixes.*.short"), f.short);
if (kindOnly) inputs += (state.schema.fix_kind_fields[tpl.kind] || []).map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("");
if (tplName === "custom") {
inputs += `Runs on ${["ssh", "local"].map((k) => `${k === "ssh" ? "another computer" : "this Mac"} `).join("")}
`;
if (kind === "ssh") inputs += `Computer – ${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `${esc(h.name || a)} `).join("")}
`;
inputs += `Command (typed by hand; shown as a command at Review; never run before Apply) zsh on a Mac, cmd.exe on a PC.
`;
} else if (f.run) inputs += `The command it makes (${f.kind === "local" ? "this Mac" : "on " + esc((c.hosts[f.host] || {}).name || f.host)}) ${esc(f.run)} `;
inputs += `Advanced ${["reconnect", "conn", "restart_ok", "never_auto", "timeout"].map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("")} `;
} else inputs = `Built as: ${esc(tpl.words)}${f.run ? `
${esc(f.run)} ` : ""}`;
detail = `${esc(f.short || id)} ${esc(f.label || "")}
${inputs}${canRunNow(id) && editing() ? `
Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).
` : ""}
Everything stored ${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 === "another computer" ? "ssh" : ks.value === "this Mac" ? "local" : ks.value); route(); };
const rebuild = async () => {
if (!ensureDraft()) return;
const fx = state.draft.config.fixes[id], tplName = main.querySelector("#tplsel").value, tpl = state.builders.fixes.find((t) => t.name === tplName);
const params = readParams(main.querySelector("#tplform"), tpl);
if (tplName === "custom") { fx.template = "custom"; delete fx.params; saveDraft(); route(); return; }
const r = await post("/api/build", { fix: Object.assign({}, fx, { template: tplName, params }) });
const msg = main.querySelector("#tplmsg");
if (!r.ok) { msg.textContent = r.error; fx.template = tplName; fx.params = params; saveDraft(); return; }
const keep = { label: fx.label, short: fx.short };
Object.assign(fx, r.fix); for (const k of ["run", "host"]) if (!(k in r.fix)) delete fx[k];
if (!keep.label && r.label) fx.label = r.label;
msg.textContent = "";
saveDraft(); route();
};
const ts = main.querySelector("#tplsel"); if (ts) { ts.onchange = rebuild; for (const el of main.querySelectorAll("#tplform [data-param]")) el.onchange = rebuild; }
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; };
}
})();