/* The settings page. Plain JS, no framework. Everything is readable; with Editing on (the PIN + your
first name) the same screens grow inputs; changes live in one shared draft on the server until
Review & Apply. Short labels everywhere; the explanation sits in a hint you hover. */
(function () {
"use strict";
const main = document.getElementById("main");
const state = { live: null, liveSha: null, schema: null, draft: null, draftStale: false, atemPending: false, editing: null, name: null, saveTimer: null };
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const ago = (s) => s == null ? "" : s < 90 ? Math.round(s) + " s ago" : s < 5400 ? Math.round(s / 60) + " min ago" : Math.round(s / 3600) + " h ago";
const when = (t) => t ? new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) : "";
const hex = (n) => "#" + (n >>> 0).toString(16).padStart(6, "0").slice(-6);
const lightDot = (l, hintText) => ` `;
const wordOf = (w) => `${esc(w)} `;
const hint = (text) => text ? `i ` : "";
const deep = (o) => JSON.parse(JSON.stringify(o));
const canon = (v) => Array.isArray(v) ? "[" + v.map(canon).join(",") + "]" : v && typeof v === "object" ? "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canon(v[k])).join(",") + "}" : JSON.stringify(v);
const cfg = () => (state.draft ? state.draft.config : state.live);
const editing = () => !!state.editing;
const store = { get: (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch (e) {} } };
const closes = (f) => /pkill|taskkill|killall/.test(f.run || "") && !f.restart_ok;
const slug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
const tagOf = (t) => t.toUpperCase().replace(/[^A-Z0-9 ]+/g, " ").trim().slice(0, 10).trim();
const lightWord = { ok: "OK", problem: "Problem", not_needed: "Not needed", hand: "Done by hand", unknown: "Can't see from here", warn: "Warning" };
async function api(path, opts) {
opts = opts || {};
const headers = Object.assign({ "Content-Type": "application/json" }, opts.headers || {});
if (state.editing) { headers["X-Edit-Key"] = state.editing; headers["X-Editor"] = state.name || ""; }
const r = await fetch(path, Object.assign({}, opts, { headers }));
let body = null;
try { body = await r.json(); } catch (e) { body = {}; }
if (!r.ok) { const err = new Error(body.error || r.statusText); err.status = r.status; err.body = body; throw err; }
return body;
}
const post = (path, body, method) => api(path, { method: method || "POST", body: JSON.stringify(body || {}) });
// ---- top bar: legend, the PIN ----------------------------------------------
document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; };
const editbtn = document.getElementById("editbtn");
function showEditing() {
editbtn.querySelector("span").textContent = state.editing ? state.name : "Edit";
editbtn.dataset.hint = state.editing ? "Editing as " + state.name + " · click to switch off" : "Switch Editing on with the PIN";
editbtn.classList.toggle("on", !!state.editing);
document.body.classList.toggle("editing", !!state.editing);
}
state.editing = store.get("sp-code"); state.name = store.get("sp-name");
showEditing();
editbtn.onclick = () => {
if (state.editing) { state.editing = null; state.name = null; store.set("sp-code", null); store.set("sp-name", null); showEditing(); route(); return; }
dialog(`
Editing
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 PIN is set yet (Settings › Site)." : !name ? "Your first name, please." : `Not the PIN (${r.tries_left} tries left).`;
};
box.querySelector("#dok").onclick = go;
box.querySelector("#dname").onkeydown = (e) => { if (e.key === "Enter") go(); };
box.querySelector("#dcode").onkeydown = (e) => { if (e.key === "Enter") box.querySelector("#dname").focus(); };
box.querySelector("#dcode").focus();
});
};
function dialog(html, wire) { const d = document.getElementById("dialog"); d.innerHTML = `${html}
`; d.hidden = false; wire(d.querySelector(".box")); }
function closeDialog() { document.getElementById("dialog").hidden = true; }
function alertBox(msg) { dialog(`${esc(msg)}
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(); };
});
}
const needEditing = () => { if (!editing()) { alertBox("Switch Editing on first (Edit, top right)."); return false; } return true; };
// ---- the draft ------------------------------------------------------------
async function loadAll() {
const [c, s, d, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]);
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.builders = b;
document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
badge();
}
function ensureDraft() {
if (!needEditing()) return false;
if (!state.draft) state.draft = { config: deep(state.live), version: null, by: state.name };
return true;
}
function changed() {
if (!state.draft) return 0;
let n = 0;
const walk = (a, b) => {
if (Array.isArray(a) && Array.isArray(b)) { if (canon(a) !== canon(b)) n++; return; }
if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) { for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) walk(a[k], b[k]); return; }
if (canon(a) !== canon(b)) n++;
};
walk(state.live, state.draft.config);
return n + (state.atemPending ? 1 : 0);
}
function badge() {
let b = document.getElementById("badge");
if (!b) { b = document.createElement("a"); b.id = "badge"; b.className = "badge"; b.href = "#review"; b.dataset.hint = "Your unsaved changes: review them, then APPLY"; document.querySelector(".topright").prepend(b); }
const n = changed();
b.hidden = !n && !state.draft;
b.textContent = n ? `${n} change${n > 1 ? "s" : ""} · Apply` : "Draft";
b.classList.toggle("warn", !!state.draftStale);
}
function saveDraft() {
badge();
clearTimeout(state.saveTimer);
state.saving = new Promise((done) => {
state.saveTimer = setTimeout(async () => {
try {
const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT");
state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by;
} catch (e) {
if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); }
else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); }
else alertBox("Could not save the draft: " + e.message);
}
done();
}, 400);
});
return state.saving;
}
const flush = () => state.saving || Promise.resolve();
function setPath(path, value) {
if (!ensureDraft()) return;
const parts = path.split("."); let o = state.draft.config;
for (let i = 0; i < parts.length - 1; i++) { if (o[parts[i]] == null || typeof o[parts[i]] !== "object") o[parts[i]] = {}; o = o[parts[i]]; }
if (value === undefined) delete o[parts[parts.length - 1]]; else o[parts[parts.length - 1]] = value;
saveDraft();
}
const getPath = (path, from) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), from || cfg());
// ---- inputs from the registry: a short label, a hint, the input ------------
function parse(f, raw) {
if (f.type === "int") { const n = parseInt(raw, 10); return isNaN(n) ? undefined : n; }
if (f.type === "float") { const n = parseFloat(raw); return isNaN(n) ? undefined : n; }
if (f.type === "bool") return !!raw;
if (f.type === "list") return raw.split(",").map((s) => s.trim()).filter(Boolean);
if (f.type === "dict") { try { return raw.trim() ? JSON.parse(raw) : undefined; } catch (e) { return undefined; } }
return raw === "" ? undefined : raw;
}
const LABELS = { editing_code: "PIN", ssh: "log-in address", mac: "hardware address", ingest_ips: "this Mac's addresses", ingest_name: "this Mac's name", ingest_hostname: "network name", hold_always: "hold also when green", page_hold_ms: "hold time (ms)", optional: "may have no disk" };
function inputHtml(path, f, value, extra) {
if (!f) return "";
const id = "f-" + path.replace(/[^A-Za-z0-9]/g, "-");
const dis = editing() ? "" : "disabled";
const last = path.split(".").pop();
let html;
if (f.type === "bool") html = ` `;
else if (f.type === "enum" && f.options) html = `– ${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 ${f.default}.` : "";
return `${esc(LABELS[last] || last.replace(/_/g, " "))}${f.required ? " *" : ""}${hint((f.help || "") + dflt)} ${html}${extra || ""}
`;
}
function wireInputs(root) {
for (const el of root.querySelectorAll("[data-path]")) {
if (el.onchange) continue;
el.onchange = () => {
const path = el.dataset.path, f = fieldFor(path);
const v = el.type === "checkbox" ? el.checked : el.value;
setPath(path, f ? parse(f, v) : v);
};
}
}
function fieldFor(path) {
const F = state.schema.fields;
if (F[path]) return F[path];
const generic = path.replace(/^tiles\.\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 || ""}`, `#keys/fixes/${id}`]);
for (const [path, f] of Object.entries(state.schema.fields)) if (!path.includes("[]") && !path.includes("*") && (path + " " + f.help).toLowerCase().includes(q)) hits.push([`Setting ${path.replace(/_/g, " ")}`, `#settings/advanced`]);
results.innerHTML = hits.slice(0, 20).map(([t, h]) => `${esc(t)} `).join("") || `Nothing called "${esc(q)}" `;
results.hidden = false;
};
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
// ---- routing ---------------------------------------------------------------
const screens = { overview, keys, computers, settings, logs, history, help, review };
let timer = null;
async function route() {
let [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
if (name === "site") { name = "settings"; rest = [{ site: "site", schedules: "autofix", companion: "companion", "network-dante": "dante", spotify: "playlist", atem: "switcher" }[rest[0]] || "playlist"]; }
if (name === "fixes") { name = "keys"; rest = ["fixes"].concat(rest); }
for (const a of document.querySelectorAll(".nav a")) a.classList.toggle("active", a.getAttribute("href") === "#" + name);
clearInterval(timer); timer = null;
try {
if (!state.live) await loadAll();
const fn = screens[name] || overview;
await fn(rest);
wireInputs(main);
const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0]));
if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000);
} catch (e) { main.innerHTML = `Could not load this screen: ${esc(e.message)}
`; }
}
window.addEventListener("hashchange", route);
route();
// ---- Overview --------------------------------------------------------------
async function overview(_, quiet) {
if (quiet && document.activeElement && document.activeElement.tagName === "INPUT") return;
const o = await api("/api/overview");
const dot = document.getElementById("hbdot");
dot.className = "dot " + (o.heartbeat_age != null && o.heartbeat_age < 15 ? "ok" : "bad");
const isLive = o.live && o.live.live;
const runnable = (p) => {
const t = cfg().tiles.find((x) => x.id === p.key); if (!t || !editing() || isLive) return "";
const fixes = (t.checks || []).map((c, j) => [typeof c.fix === "string" ? c.fix : null, j]).filter(([f]) => f && canRunNow(f));
return fixes.slice(0, 1).map(([f, j]) => `Run the fix `).join("");
};
const prob = o.problems.length ? o.problems.map((p) => `${esc(p.text)} ${p.acked ? 'acknowledged ' : ""}hold ${esc(p.label)} ↗ ${runnable(p)} `).join("") : `Everything is fine. `;
const needs = (o.needs || []).map((n) => `Needs attention: ${esc(n)}
`).join("");
const failed = o.failed_last ? `The last Apply was put back: ${esc(o.failed_last.why || "")}
History ` : "";
const live = isLive ? `Live now: ${esc(o.live.reason)}. Changes wait.
` : "";
const comps = o.computers.map((c) => `${wordOf(c.word)} ${esc(c.name)} ${esc(c.reason)} `).join("");
const w = o.watchdog;
const dis = editing() ? "" : "disabled";
const chip = (ok, text, hintText) => `${text} `;
main.innerHTML = `
${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") : "Everything is fine"}${hint("The red keys, in words. Hold the key on the deck, or open it.")}
${needs}${failed}${live}
${chip(w.running, w.running ? "Watchdog ✓" : "Watchdog down", (w.running ? "Running since " + when(w.since) : "Not running") + (w.version ? " · code " + w.version : "") + (o.heartbeat_age != null ? " · heartbeat " + ago(o.heartbeat_age) : ""))}
${chip(o.companion_ok, o.companion_ok ? "Companion ✓" : "Companion down", o.pages && o.pages.status ? (o.pages.status.ok ? "Pages OK" : "Page " + o.pages.status.page + " is called " + (o.pages.status.live || "?")) : "")}
${chip(o.service_on == null ? null : o.service_on, o.service_on == null ? "Alarms ?" : o.service_on ? "Alarms on" : "Alarms silenced", o.phrases.alarms)}
${chip(null, "Auto-fix " + (o.auto_fix.state && o.auto_fix.state.on ? "active" : "waiting"), o.auto_fix.phrase + (o.auto_fix.state && o.auto_fix.state.paused ? " (waiting out a network blip)" : ""))}
${Object.keys(o.running || {}).length ? chip(null, "Fix running", Object.values(o.running).map((r) => r.what + " on " + r.key).join(", ")) : ""}
${o.last_change ? `
Last change ${esc(o.last_change.when || "")} ` : ""}
${deckHtml(o.deck, cfg(), "status")}
${w.running ? "Restart watchdog" : "Start watchdog"}
${o.service_on === false ? "Alarms on" : "Silence alarms"}
${w.running ? `Stop ` : ""}
`;
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 (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: the two pages and the fixes -------------------------------------
async function keys(rest) {
await flush();
if (rest[0] === "fixes") return fixes(rest.slice(1));
const o = await api("/api/overview").catch(() => ({ deck: null }));
const c = cfg();
const restart = rest[0] === "restart";
const sel = restart ? rest[1] : rest[0];
const hold = ((c.page_hold_ms || 2000) / 1000) + " s";
const tabs = `Status page ${c.restart_page ? `Restart page ` : ""}Fixes
${editing() ? `page ${c.restart_page ? ` · restart page ` : ""} Restart page${hint("A second page where each key restarts one thing, even when green")} ` : ""}
`;
let panel = `Click a key.${editing() ? " Drag to move; drop on the tray to hide." : ""}
`;
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 re-imports 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 ? `+ Restart key ` : `+ Key `) : "";
main.innerHTML = `Keys${hint("The deck as people hold it. Click a key to see what it watches and what holding it does.")} ${tabs}${note}
${restart && !c.restart_page ? "
No Restart page.
" : deckHtml(o.deck, c, restart ? "restart" : "status", sel, true)}
${!restart ? `
In a group only${hint("Keys with no spot of their own: they show inside their group key. Drop a key here to hide it.")}: ${tray || "none "}
` : ""}
${adders}
${panel}
`;
wireKeyPanel(sel, restart);
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 Cancel
`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#nlabel").oninput = () => { b.querySelector("#nid").value = slug(b.querySelector("#nlabel").value); };
b.querySelector("#dok").onclick = () => {
const label = b.querySelector("#nlabel").value.trim(), id = b.querySelector("#nid").value.trim();
if (!label || !/^[A-Za-z0-9_]+$/.test(id)) return;
if (cfg().tiles.some((t) => t.id === id)) return alertBox("A key with that id exists.");
const pos = parsePos(b.querySelector("#npos").value, cfg(), "status", true); if (pos === false) return;
if (!ensureDraft()) return;
const t = { id, label, checks: [] }; if (pos) t.pos = pos;
state.draft.config.tiles.push(t); saveDraft(); closeDialog(); location.hash = "#keys/" + id;
};
});
const nr = main.querySelector("#newrkey"); if (nr) nr.onclick = () => { const c = cfg(); dialog(`New restart key ${Object.entries(c.fixes).map(([id, f]) => `${esc(f.short || id)} – ${esc(f.label || "")} `).join("")} Add 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_" + slug(label), n = 2;
while (state.draft.config.restart_page.keys.some((k) => k.id === id)) id = id.replace(/_\d+$/, "") + "_" + n++;
state.draft.config.restart_page.keys.push({ id, label, fix, pos, watch: [] }); saveDraft(); closeDialog(); location.hash = "#keys/restart/" + id;
};
}); };
}
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)} `;
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 ${esc(chk.cmd)} ` : ""));
}
const fixSel = typeof chk.fix === "object" && chk.fix ? `fix per device${hint('A table: device → fix, "*" for everything else')}
`
: `fix${hint("What holding the key runs for this check")} ${fixOptions(chk.fix)}
`;
const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `Run the fix ` : "";
return `${esc(spec.question || chk.type)} ${esc(sentence)} ${fields}${fixSel}
more ${["tag", "service_only", "debounce_seconds"].filter((f) => !(spec.fields || []).includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("")}type ${esc(chk.type)}
${run}Remove
`;
}).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 also when green${hint("A fix that runs on every hold, even with nothing failing")} no ${Object.entries(c.fixes).map(([id, f]) => `${esc(f.short || id)} – ${esc(f.label || "")} `).join("")}
` : "";
const pos = `more id ${esc(t.id)} ${editing() && !t.kind ? `position${hint("row, column; blank = inside its group only")}
` : ""} `;
return `${esc(t.label)} ${t.pos ? `row ${t.pos[0]}, col ${t.pos[1]}` : "in a 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 ? `
Can say ${esc(kt.says.join(", "))}
` : ""}
Hold ${esc(hold)} ${esc(kt.holding)}${t.hold_always ? " also when green " : ""}
${label}${members}${holdAlways}${checks ? `
${checks} ` : ""}${addMenu}${pos}
${editing() && (!t.kind || t.kind === "network") ? `
Delete 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)}
hold runs ${Object.entries(c.fixes).map(([id, fx]) => `${esc(fx.short || id)} – ${esc(fx.label || "")} `).join("")}
flash while down${hint("Keeps flashing HOLD TO FIX while these are down; hold cmd/ctrl to pick 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]}, col ${r.pos[1]} Hold ${esc(hold)} ${esc(f.label || r.fix)}, even when the Status page says it is fine.
${edit}
${canRunNow(r.fix) && editing() ? `
Run the fix ` : ""}
more id ${esc(r.id)} ${r.watch && r.watch.length ? `flashes while down ${esc(r.watch.join(", "))} ` : ""}${editing() ? `
Delete 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 now"} ${editing() ? `+ Computer ` : ""}
`;
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 does keys${hint("The 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${hint("What runs where. Click a row for everything that runs there, with a light each.")} ${tabs}${body}`;
for (const b of main.querySelectorAll("[data-copy]")) b.onclick = async () => { const ta = document.getElementById(b.dataset.copy); try { await navigator.clipboard.writeText(ta.value); b.textContent = "Copied"; } catch (e) { ta.select(); document.execCommand("copy"); b.textContent = "Copied"; } };
const rm = main.querySelector("#removehost"); if (rm) rm.onclick = () => removeComputer(selected);
for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job);
const pb = document.getElementById("probe");
if (pb) pb.onclick = async () => { pb.disabled = true; pb.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => computers(rest, true).then(() => wireInputs(main)), 4000); };
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, lightWord[i.light])}${esc(lightWord[i.light] || "")} ${esc(i.name)}${i.watched === false ? ' (not watched) ' : ""} ${esc(i.reason || (typeof i.detail === "object" && i.detail ? Object.entries(i.detail).map(([k, v]) => k + " " + (v === true ? "yes" : v === false ? "no" : k === "age" ? ago(v) : v)).join(", ") : i.detail || ""))} `).join("");
const found = c.found || {};
const foundHtml = Object.entries(found).filter(([, v]) => Array.isArray(v) && v.length).map(([k, v]) => `${esc(k)}: ${esc(typeof v[0] === "object" ? v.map((x) => x.iface + " " + x.ip + (x.active ? "" : " (down)")).join(", ") : v.join(", "))}
`).join("") || `Press "Check now" to look.
`;
const done = Object.entries(c.manual_done || {}).map(([k, v]) => `${lightDot("hand")}${esc(k.replace(/_/g, " "))}: done by hand (${esc(v.by)}, ${esc(v.on)}) `).join("");
const base = `hosts.${c.alias}.`;
const edit = editing() ? `Change ${["name", "role", "notes", "os"].concat(c.self ? [] : ["ssh", "mac", "wake_broadcast", "pingable"]).map((f) => inputHtml(base + f, F("hosts.*." + f), getPath(base + f))).join("")}
what runs here${hint('One per line, as {"kind": ..., "name": ...}. Kinds: sshd, app (process), launchagent (label), daemon (process), task (name, exe), service (name), web (url), helper_dvs, helper_solo (label), midi_session (name, peer_of), artnet (source), folder (path), disabled (label). "watched": false = listed only.')}
${c.os === "windows" ? `+ Program this PC can start ` : ""} ` : "";
const remove = editing() && !c.self ? `Remove computer
` : "";
return `${esc(c.name)} ${wordOf(c.word)} ${esc(c.role)}${c.ssh ? ` · ${esc(c.ssh)} ` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "no answer "}` : ""}${c.error ? ` · ${esc(c.error)} ` : ""}
${c.notes ? `
${esc(c.notes)}
` : ""}
${done ? `
` : ""}
everything found ${foundHtml}
${c.self ? `
this Mac's set-up lights ${Object.entries(c.setup || {}).map(([k, v]) => `${esc(k.replace(/_/g, " "))} ${typeof v === "boolean" ? lightDot(v ? "ok" : "problem") + (v ? "OK" : "Problem") : esc(JSON.stringify(v))} `).join("")} ` : ""}
${edit}${remove}
`;
}
function removeComputer(alias) {
const c = cfg(), h = c.hosts[alias]; if (!h) return;
const fixes = Object.entries(c.fixes).filter(([, f]) => f.host === alias || (f.params || {}).host === alias).map(([id]) => id);
const checks = []; for (const t of c.tiles) (t.checks || []).forEach((ch, j) => { if (ch.host === alias || (ch.params || {}).host === alias) checks.push([t.id, j, t.label]); });
const rkeys = (c.restart_page ? c.restart_page.keys : []).filter((k) => fixes.includes(k.fix)).map((k) => k.label);
const what = [`the computer ${h.name || alias}`].concat(fixes.length ? [`${fixes.length} fix${fixes.length > 1 ? "es" : ""} that ran on it (${fixes.join(", ")})`] : [], checks.length ? [`${checks.length} check${checks.length > 1 ? "s" : ""} on the keys ${[...new Set(checks.map((x) => x[2]))].join(", ")}`] : [], rkeys.length ? [`the restart keys ${rkeys.join(", ")}`] : []);
confirmBox(`Remove ${what.join(", ")}? The keys stay; a key left with nothing to check will say OK.`, "Remove", () => {
if (!ensureDraft()) return;
const d = state.draft.config;
delete d.hosts[alias];
for (const id of fixes) { delete d.fixes[id]; if (d.auto_fix && d.auto_fix.fixes) d.auto_fix.fixes = d.auto_fix.fixes.filter((x) => x !== id); }
for (const t of d.tiles) {
t.checks = (t.checks || []).filter((ch) => !(ch.host === alias || (ch.params || {}).host === alias));
for (const ch of t.checks) { if (typeof ch.fix === "string" && fixes.includes(ch.fix)) delete ch.fix; else if (ch.fix && typeof ch.fix === "object") for (const k of Object.keys(ch.fix)) if (fixes.includes(ch.fix[k])) delete ch.fix[k]; }
if (fixes.includes(t.hold_always)) delete t.hold_always;
if (!t.checks.length && !t.members) delete t.checks;
}
if (d.restart_page) d.restart_page.keys = d.restart_page.keys.filter((k) => !fixes.includes(k.fix));
saveDraft(); location.hash = "#computers";
});
}
async function gearHtml() {
const g = await api("/api/gear");
return ``;
}
function setupHtml(m, generated) {
return m.computers.map((c) => {
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
const gen = (generated || []).find((g) => g.alias === c.alias) || { steps: [], scripts: [] };
const steps = gen.steps.length ? gen.steps.map((x) => x.text) : ((m.setup_steps || {})[kind] || []);
const howOf = (t) => (gen.steps.find((x) => x.text === t) || {}).how;
const lightFor = (step) => {
const s = c.setup || {};
const item = (k) => (c.items || []).find((i) => i.kind === k);
if (/login key/.test(step)) return s.key_ok === true || s.login_key === true ? "ok" : s.key_ok === false || s.login_key === false ? "problem" : "unknown";
if (/panel's key is accepted on this Mac/.test(step)) return "unknown";
if (/python/.test(step)) return s.python_ok === true ? "ok" : s.python_ok === false ? "problem" : "unknown";
if (/SSH service/.test(step)) return c.reachable ? "ok" : "problem";
if (/firewall/.test(step)) return s.firewall_allow ? "ok" : "unknown";
if (/wake-on-LAN/.test(step)) return s.wake_on_lan === "Enabled" ? "ok" : s.wake_on_lan ? "problem" : "unknown";
if (/Public/.test(step)) return s.profile === "Public" ? "ok" : s.profile ? "problem" : "unknown";
if (/DVS helper/.test(step)) { const it = item("helper_dvs"); return it ? it.light : "not_needed"; }
if (/Pro Tools helper/.test(step)) { const it = item("helper_solo"); return it ? it.light : "not_needed"; }
if (/StatusPanel/.test(step)) { const ts = (c.items || []).filter((i) => i.kind === "task"); return ts.length ? (ts.every((i) => i.light === "ok") ? "ok" : "problem") : "unknown"; }
if (/services restart|rtpMIDI/.test(step)) { const it = item("service"); return it ? it.light : "not_needed"; }
if (/Netgear/.test(step)) return s.netgear_keychain === true ? "ok" : s.netgear_keychain === false ? "problem" : "not_needed";
if (/Google Drive/.test(step)) { const it = item("folder"); return it ? it.light : "not_needed"; }
if (/watchdog and the settings/.test(step)) { const ok = (c.items || []).filter((i) => i.kind === "launchagent" && /watchdog|settings/i.test(i.name)); return ok.length && ok.every((i) => i.light === "ok") ? "ok" : "unknown"; }
if (/Wi-Fi/.test(step)) return c.manual_done && c.manual_done.wifi_power ? "hand" : "unknown";
if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown";
return "unknown";
};
const scriptsHtml = gen.scripts.map((sc, i) => `${esc(sc.name)}${hint(sc.how)} `).join("");
return `${esc(c.name)} ${steps.map((s) => { const l = lightFor(s); const how = howOf(s); return `${lightDot(l, lightWord[l])}${esc(s)}${hint(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: "This 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${hint("What the watchdog did and saw. Newest at the bottom.")} ${tabs}
${filters}${files}${esc((data.lines || []).join("\n")) || "(empty)"} `;
for (const b of main.querySelectorAll("[data-f]")) b.onclick = () => { logFilter = b.dataset.f; logs(rest); };
const pre = main.querySelector("pre.log"); pre.scrollTop = pre.scrollHeight;
}
// ---- History ----------------------------------------------------------------
async function history(rest) {
const h = await api("/api/history");
let detail = "";
if (rest[0]) {
const d = await api("/api/history/" + encodeURIComponent(rest[0]));
detail = `${esc(rest[0])} ${d.changes_to_now.length ? `
${d.changes_to_now.map((c) => `${esc(c.text)} `).join("")} ` : "
The same as now.
"}
${editing() ? `
Go back to this ` : ""}
`;
}
const rows = h.entries.map((e) => `${esc(when(e.when))} ${esc(e.by)} ${esc(e.note)} ${esc((e.actions || []).join(", "))}${e.outcome && e.outcome !== "applied" ? " · " + esc(e.outcome) : ""} `).join("");
main.innerHTML = `History${hint("Every version ever applied: who, when, the note. Open one to see what differs from now.")}${editing() && h.entries.length ? `Undo 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; Apply puts it live.", "Undo", async () => { try { const r = await post("/api/undo"); state.draft = r.draft; badge(); location.hash = "#review"; } catch (e) { alertBox(e.message); } });
}
// ---- Review & Apply -------------------------------------------------------------
async function review() {
await flush();
const st = await api("/api/apply/status");
if (st.state === "running") return applyProgress(st);
const d = await api("/api/draft"); state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; badge();
if (!state.draft) {
main.innerHTML = `Apply No changes. Change something with Editing on.
${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`;
return;
}
const r = await post("/api/validate", {});
const changes = r.changes.map((c) => `${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; your draft is saved. ${editing() ? 'Apply anyway ' : ""}
` : "";
main.innerHTML = `Apply${hint("Your changes in words, and what will happen on the deck. Nothing is live until you press 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: look again before applying. " : ""}
${changes ? `
` : "
No change yet.
"}
Then: ${esc(r.feel)}
${errors ? `
` : ""}${warnings ? `
` : ""}${live}
APPLY Throw 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)}`;
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 = [
["Red key", "It names the problem. Press once to acknowledge (stays dark red). Hold 2 s: it runs the fix for what is failing."],
["Purple keys", "The watchdog on the Ingest Mac has been silent 15 s. Overview › Restart watchdog, unless Companion itself is down."],
["Which computer or device?", "Computers › Map: every computer and the keys that turn red if it dies. Computers › Gear: switcher, decks, wireless, LED."],
["Changing something", "Edit (top right, the PIN). Change it where it lives: a key on Keys, a computer on Computers, the rest on Settings. The badge counts your changes and opens Apply."],
].map(([t, b]) => ``).join("");
const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `${esc(g)} ${esc(words.join(" · "))}
`).join("");
main.innerHTML = `Help ${cards}
What each key does Cheat sheet Where things are
Try it (ten minutes with Editing on; nothing is live until APPLY)
Keys › HD2: untick "may have no disk"; the badge counts it; Apply reads it back. Throw away.
Keys: drag LED onto the tray and back. + Key "HD7", add a check (Is a recorder OK?), delete it.
Settings › HyperDecks: untick 5 and 6, look at Keys (cells stay empty). Throw away.
Settings › Playlist: paste a link, Look it up; the name follows everywhere.
Settings › Auto-fix: paint Sunday morning on the calendar.
Settings › MIDI links: add one to the MIDI key; Run the fix on that check.
Keys › Fixes › VISTA: change the program file to Vista2.exe, watch the command rebuild; put it back.
Computers › Setup › Lighting PC: open the script, Copy. Computers › Lighting PC › Remove (read, then Cancel).
Settings › Switcher: Pick the newest file; Discard. Settings › Program & campus: Export bundle.
A real change (a note on Computers › Gear): APPLY, then History › Undo, APPLY again.
The words a key can show ${glossary}
Only a person can Run the set-up script on a new computer, 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. Save a new switcher file from ATEM Software Control when the show changes, then pick it under Settings › Switcher.
Companion facts (found by trying)${(facts.facts || []).map(([q, a]) => `${esc(q)} ${esc(a)} `).join("")} `;
}
// ---- Settings: one page, every section ------------------------------------------
const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys", "site.editing_code"];
const SECTIONS = [
["playlist", "Playlist", "The Spotify playlist the SPOTIFY key and the play fix use"],
["autofix", "Auto-fix", "When the panel may fix things by itself, and which fixes"],
["wait", "Changes wait", "When changes to the panel must wait (a recording deck always counts)"],
["hyperdecks", "HyperDecks", "Which decks are in use"],
["midi", "MIDI links", "Network MIDI sessions and the peers that must stay in them"],
["computers", "Computers", "Every computer the panel talks to"],
["dante", "Dante", "The devices the DANTE key expects"],
["switcher", "Switcher", "The saved ATEM file the panel keeps the switcher in line with"],
["companion", "Companion", "Connections, and names the keys use that are not here"],
["network", "Network", "This Mac's network port and the Dante switches"],
["site", "Site & PIN", "The campus, contacts, this Mac, the PIN"],
["program", "Program & campus", "Update the code, export or start a campus"],
["advanced", "Advanced", "Every number and name, by screen"],
];
async function settings(rest) {
await flush();
const which = SECTIONS.some(([k]) => k === rest[0]) ? rest[0] : "playlist";
const c = cfg();
const nav = `${SECTIONS.map(([k, n, h]) => `${n} `).join("")}
${SECTIONS.map(([k, n]) => `${n} `).join("")} `;
const sec = SECTIONS.find(([k]) => k === which);
let body = "";
if (which === "playlist") body = spotifyHtml(c);
else if (which === "autofix") body = autofixHtml(c);
else if (which === "wait") body = `${calendarHtml("site.live_services", (c.site || {}).live_services)}
A recording HyperDeck always counts, whatever the clock says.
`;
else if (which === "hyperdecks") body = await hyperdecksHtml(c);
else if (which === "midi") body = midiHtml(c);
else if (which === "computers") body = computersHtml(c);
else if (which === "dante") body = await danteHtml(c) || "No DANTE key.
";
else if (which === "switcher") body = await atemHtml(c) + fieldsHtml("ATEM");
else if (which === "companion") body = (await namesHtml()) + (await companionHtml());
else if (which === "network") body = fieldsHtml("Network & Dante");
else if (which === "site") body = siteHtml(c);
else if (which === "program") body = await programHtml();
else if (which === "advanced") body = advancedHtml();
main.innerHTML = `Settings${editing() ? "" : hint("Switch Editing on (Edit, top right) to change anything")} ${nav}
${esc(sec[1])}${hint(sec[2])} ${body} `;
main.querySelector("#subsel").onchange = (e) => { location.hash = "#settings/" + e.target.value; };
wireSettings(which);
}
function fieldsHtml(screen, includeAdvanced) {
const own = Object.entries(state.schema.fields).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !HAND_MADE.includes(p) && !HAND_MADE.some((h) => h.startsWith(p + ".")) && !Object.keys(state.schema.fields).some((k) => k.startsWith(p + ".") || k.startsWith(p + "[]")));
const plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced);
if (!own.length) return "";
const fh = ([p, f]) => inputHtml(p, f, getPath(p));
return `${plain.map(fh).join("")}${adv.length ? (includeAdvanced ? adv.map(fh).join("") : `more ${adv.map(fh).join("")} `) : ""}
`;
}
function advancedHtml() {
const screens = [...new Set(Object.values(state.schema.fields).map((f) => f.screen))];
return screens.map((s) => { const h = fieldsHtml(s, true); return h ? `${esc(s)} ${h}` : ""; }).join("") || "Nothing.
";
}
function siteHtml(c) {
const f = F("site.editing_code");
return `${inputHtml("site.editing_code", Object.assign({}, f, { help: "The PIN that switches Editing on. Digits are easiest. It lives in the settings file, so everyone with the repo has it." }), (c.site || {}).editing_code)}
` + fieldsHtml("Site");
}
function spotifyHtml(c) {
const s = c.spotify || {};
return `` + fieldsHtml("Spotify");
}
function autofixHtml(c) {
const fixes = Object.entries(c.fixes).map(([id, f]) => { const cl = closes(f); const on = (c.auto_fix.fixes || []).includes(id); return ` ${esc(f.short || id)} `; }).join(" ");
return `${calendarHtml("auto_fix.windows", c.auto_fix.windows)}
Fixes it may run${hint("Gentle ones only: a fix that closes a program is never run by itself")} ${fixes}
` + fieldsHtml("Schedules");
}
// --- the weekly calendar: half-hour cells; click or drag to paint ---
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
const toMin = (hhmm) => { const m = String(hhmm || "").match(/^(\d{1,2}):(\d{2})$/); return m ? +m[1] * 60 + +m[2] : null; };
const toHHMM = (min) => `${String(Math.floor(min / 60) % 24).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
function cellsOf(windows) {
const on = new Set();
for (const w of windows || []) {
const s = toMin(w.start), e = toMin(w.end); if (s == null || e == null) continue;
for (const d of w.days || []) {
const di = DAYS.indexOf(d); if (di < 0) continue;
if (e > s) { for (let m = s; m < e; m += 30) on.add(di + ":" + m); }
else { for (let m = s; m < 1440; m += 30) on.add(di + ":" + m); for (let m = 0; m < e; m += 30) on.add(((di + 1) % 7) + ":" + m); }
}
}
return on;
}
function windowsOf(on) {
const runs = [];
for (let d = 0; d < 7; d++) { let s = null; for (let m = 0; m <= 1440; m += 30) { const here = m < 1440 && on.has(d + ":" + m); if (here && s == null) s = m; if (!here && s != null) { runs.push([d, s, m]); s = null; } } }
for (let i = 0; i < runs.length; i++) { const r = runs[i]; if (r[2] !== 1440) continue; const j = runs.findIndex((x) => x[0] === (r[0] + 1) % 7 && x[1] === 0); if (j >= 0 && j !== i && runs[j][2] < 1440) { r[2] = runs[j][2] + 1440; runs.splice(j, 1); if (j < i) i--; } }
const byTime = {};
for (const [d, s, e] of runs) { const k = toHHMM(s) + "-" + toHHMM(e >= 1440 ? e - 1440 : e); (byTime[k] = byTime[k] || []).push(DAYS[d]); }
return Object.entries(byTime).map(([k, days]) => { const [start, end] = k.split("-"); return { days: days.sort((a, b) => DAYS.indexOf(a) - DAYS.indexOf(b)), start, end: end === "00:00" ? "24:00" : end }; });
}
function calendarHtml(path, windows) {
const on = cellsOf(windows);
let grid = `
` + DAYS.map((d) => `${d[0].toUpperCase() + d.slice(1)}
`).join("");
for (let m = 0; m < 1440; m += 30) {
grid += `${m % 120 === 0 ? toHHMM(m) : ""}
`;
for (let d = 0; d < 7; d++) grid += `
`;
}
const words = (windows || []).length ? (windows || []).map((w) => `${(w.days || []).map((d) => d[0].toUpperCase() + d.slice(1)).join(", ")} ${esc(w.start)}–${esc(w.end)}`).join(" · ") : "never";
return `${grid}
${words} ${editing() ? " Click or drag to paint; drag over painted cells to clear." : ""}
`;
}
function wireCalendars() {
for (const cal of main.querySelectorAll(".cal[data-calpath]")) {
if (!editing()) continue;
let painting = null;
const apply = (cell) => { if (!cell || !cell.dataset || !cell.dataset.cell) return; cell.classList.toggle("on", painting); };
cal.addEventListener("mousedown", (e) => { const cell = e.target.closest(".c"); if (!cell) return; e.preventDefault(); painting = !cell.classList.contains("on"); apply(cell); });
cal.addEventListener("mouseover", (e) => { if (painting == null) return; apply(e.target.closest(".c")); });
const finish = () => {
if (painting == null) return; painting = null;
const on = new Set([...cal.querySelectorAll(".c.on")].map((x) => x.dataset.cell));
setPath(cal.dataset.calpath, windowsOf(on)); route();
};
cal.addEventListener("mouseup", finish); cal.addEventListener("mouseleave", finish);
cal.addEventListener("touchstart", (e) => { const cell = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY); if (!cell || !cell.dataset.cell) return; painting = !cell.classList.contains("on"); apply(cell); }, { passive: true });
cal.addEventListener("touchmove", (e) => { if (painting == null) return; apply(document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)); }, { passive: true });
cal.addEventListener("touchend", finish);
}
}
async function hyperdecksHtml(c) {
let cc = null;
try { cc = await api("/api/companion"); } catch (e) { cc = null; }
const decks = cc ? (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 known = new Set(decks.map((d) => d.label));
for (const t of c.tiles) for (const ch of t.checks || []) if (ch.type === "hyperdeck" && !known.has(ch.conn)) { decks.push({ label: ch.conn, missing: true }); known.add(ch.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)} ${d.missing ? ` not in Companion ` : ""}${k ? `${esc(k.label)} ` : "no key "} no disk OK `; }).join("");
return `${!cc ? "
Companion is not answering; the list is from the keys.
" : ""}
in use${hint("Tick a deck and it gets an HD key in the REC group; untick and the key goes (its cell stays empty)")} key ${rows || "No HyperDeck connections in Companion. "}
${editing() && rows ? `
Use these decks
` : ""}
`;
}
function midiHtml(c) {
const rows = [];
c.tiles.forEach((t) => (t.checks || []).forEach((ch, j) => { if (ch.type === "midi_peer") rows.push(`${esc(t.label)} ${esc(ch.host ? (c.hosts[ch.host] || {}).name || ch.host : (c.site || {}).ingest_name || "this Mac")} ${esc(ch.session)} ${esc(ch.peer)} ${esc(ch.tag || "")} ${editing() ? `Remove ` : ""} `); }));
return `key Mac session peer key text ${rows.join("") || "none "}
${editing() ? `
+ MIDI link
` : ""}
`;
}
function computersHtml(c) {
const rows = Object.entries(c.hosts).map(([a, h]) => `${esc(h.name || a)}${h.self ? " (this Mac) " : ""} ${esc(h.self ? "Mac" : h.os === "windows" ? "Windows" : "Mac")} ${esc(h.ssh || "")} ${esc(h.role || "")} ${(h.runs || []).filter((r) => r.kind === "task").map((r) => esc(r.name.replace(/^Start /, ""))).join(", ")} `).join("");
return `computer kind log-in does programs it can start ${rows}
${editing() ? `
+ Computer + PC program
` : ""}
`;
}
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 `running code ${esc(p.running_version || "?")} staged ${staged}${same ? " · the same " : ""} folder ${esc(p.folder)}
Install the staged folder ${p.job === "running" ? " running… " : ""}
${p.log && p.log.length ? `
last install log${p.log_when ? " (" + esc(when(p.log_when)) + ")" : ""} ${esc(p.log.join("\n"))} ` : ""}
`;
}
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 ${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 ${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})${hint("A copied campus, or something renamed. Map each name to what this campus has; everything that uses it follows.")}
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", r.enabled ? r.category || "" : "off")}${esc(r.label)} ${esc(r.module || "")} ${esc(r.message || "")} ${(r.watched_by || []).length ? r.watched_by.map((k) => `${esc(k.toUpperCase())} `).join(", ") : `not watched ${editing() ? ` + Key ` : ""}`} `).join("");
return `${lights}${cc.build ? `${esc(cc.build)} ` : ""}${cc.page_number_buttons ? `${cc.page_number_buttons.pages_coloured} pages coloured ` : ""}
${(cc.missing || []).length ? `
Keys watch connections Companion does not have: ${esc(cc.missing.join(", "))}
` : ""}
connection module watched by ${conns}
` + fieldsHtml("Companion");
}
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 ``;
}
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 `${pending}
${editing() ? `
Upload a saved file Pick the newest
` : ""}
${esc(folder.folder ? folder.folder.replace(/^.*\/Shared drives\//, "Shared drives/") : "(no folder 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. Use Upload, or let the panel's key log in to this Mac (Computers › Setup).
` : ""}
`;
}
function wireSettings(which) {
const q = (s) => main.querySelector(s);
wireCalendars();
for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job);
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 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]); };
for (const b of main.querySelectorAll("[data-rmcheck]")) b.onclick = () => confirmBox("Remove this MIDI link from its key?", "Remove", () => { if (!ensureDraft()) return; const [tid, j] = b.dataset.rmcheck.split("|"); const t = state.draft.config.tiles.find((x) => x.id === tid); t.checks.splice(+j, 1); saveDraft(); route(); });
const ds = q("#decksave"); if (ds) ds.onclick = () => applyDecks(main);
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; const c = state.draft.config; c.spotify = Object.assign({}, c.spotify, { playlist: r.uri }); if (r.name) { c.spotify.name = r.name; q("#spname").value = r.name; } q("#splink").value = r.uri; await saveDraft(); for (const f of Object.values(c.fixes)) if (f.kind === "spotify") { const bb = await post("/api/build", { fix: f }); if (bb.ok && bb.label) f.label = bb.label; } saveDraft(); info.textContent = r.name ? `${r.name}: ${r.songs} songs.` : (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."); };
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 the Companion names, then Apply."}`); location.hash = "#settings/companion"; } 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 () => { an.disabled = true; an.textContent = "Reading…"; try { showAtem(await post("/api/atem-reference/pick-newest")); } catch (e) { alertBox(e.message); route(); } };
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."); route(); 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();
}
}
function applyDecks(root) {
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 root.querySelectorAll("[data-deck]")) {
const conn = cb.dataset.deck, nodisk = root.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(); route();
}
// ---- Jobs: the dialogs that add something ----------------------------------------
async function job(which) {
if (!needEditing()) return;
clearInterval(timer); timer = null;
if (which === "midi") return jobMidi();
if (which === "computer") return jobComputer();
if (which === "pcprogram") return jobPcProgram();
}
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(`MIDI link${hint("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.")}
${macs.map(([a, h]) => `${esc(h.name || a)}${h.self ? " (this Mac)" : ""} `).join("")}
${midiKey ? `on the ${esc(midiKey.label)} key ` : ""}on a new key
Add 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(); route();
};
});
}
function jobComputer() {
dialog(`New computer${hint("It appears on Computers; the Setup tab then shows what to do on it (login key, Remote Login). Fixes and checks for it come after.")}
Mac Windows
Add 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 the computer first.");
const cur = (location.hash.split("/")[1] || "");
dialog(`Program a PC can start${hint("The panel starts programs on a PC through a scheduled task (the Setup tab's script creates it). This lists the task and adds a 'start it if it is not running' fix.")}
${pcs.map(([a, h]) => `${esc(h.name || a)} `).join("")}
Add 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 = "#keys/fixes/" + id;
};
});
}
// ---- 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.toLowerCase())}${hint(p.help)} ${input}
`;
}).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 (a tab of Keys) ----------------------------------------------------------
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) ? "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${hint("Typed by hand: zsh on a Mac, cmd.exe on a PC. Shown as a command at Apply; never run before Apply.")}
`;
} else if (f.run) inputs += `the command it makes${f.kind === "local" ? "" : " (on " + esc((c.hosts[f.host] || {}).name || f.host) + ")"} ${esc(f.run)} `;
inputs += `more ${["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 ? `the command ${esc(f.run)} ` : ""}`;
detail = `${esc(f.short || id)} ${esc(f.label || "")} ${inputs}${keys.length ? `
Used by ${esc(keys.join(", "))}.
` : ""}
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 fix ` : ""}
`;
}
const add = editing() ? `+ Fix ` : "";
main.innerHTML = `Keys${hint("Everything a hold can run. Click a fix to see how it is built.")} Status page ${c.restart_page ? `Restart page ` : ""}Fixes ${add}
key text does built as used by runs ${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 = "#keys/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 = "#keys/fixes/" + nid; };
}
})();