/* The settings page. Plain JS, no framework. Everything is readable; with Editing on (the PIN + your first name) the same screens grow inputs; changes live in one shared draft on the server until Review & Apply. Short labels everywhere; the explanation sits in a hint you hover. */ (function () { "use strict"; const main = document.getElementById("main"); const state = { live: null, liveSha: null, schema: null, draft: null, draftStale: false, atemPending: false, editing: null, name: null, saveTimer: null }; const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const ago = (s) => s == null ? "" : s < 90 ? Math.round(s) + " s ago" : s < 5400 ? Math.round(s / 60) + " min ago" : Math.round(s / 3600) + " h ago"; const when = (t) => t ? new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) : ""; const hex = (n) => "#" + (n >>> 0).toString(16).padStart(6, "0").slice(-6); const lightDot = (l, hintText) => ``; const wordOf = (w) => `${esc(w)}`; const hint = (text) => text ? `i` : ""; const deep = (o) => JSON.parse(JSON.stringify(o)); const canon = (v) => Array.isArray(v) ? "[" + v.map(canon).join(",") + "]" : v && typeof v === "object" ? "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canon(v[k])).join(",") + "}" : JSON.stringify(v); const cfg = () => (state.draft ? state.draft.config : state.live); const editing = () => !!state.editing; const store = { get: (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch (e) {} } }; const closes = (f) => /pkill|taskkill|killall/.test(f.run || "") && !f.restart_ok; const slug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""); const tagOf = (t) => t.toUpperCase().replace(/[^A-Z0-9 ]+/g, " ").trim().slice(0, 10).trim(); const lightWord = { ok: "OK", problem: "Problem", not_needed: "Not needed", hand: "Done by hand", unknown: "Can't see from here", warn: "Warning" }; async function api(path, opts) { opts = opts || {}; const headers = Object.assign({ "Content-Type": "application/json" }, opts.headers || {}); if (state.editing) { headers["X-Edit-Key"] = state.editing; headers["X-Editor"] = state.name || ""; } const r = await fetch(path, Object.assign({}, opts, { headers })); let body = null; try { body = await r.json(); } catch (e) { body = {}; } if (!r.ok) { const err = new Error(body.error || r.statusText); err.status = r.status; err.body = body; throw err; } return body; } const post = (path, body, method) => api(path, { method: method || "POST", body: JSON.stringify(body || {}) }); // ---- top bar: legend, the PIN ---------------------------------------------- document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; }; const editbtn = document.getElementById("editbtn"); function showEditing() { editbtn.querySelector("span").textContent = state.editing ? state.name : "Edit"; editbtn.dataset.hint = state.editing ? "Editing as " + state.name + " · click to switch off" : "Switch Editing on with the PIN"; editbtn.classList.toggle("on", !!state.editing); document.body.classList.toggle("editing", !!state.editing); } state.editing = store.get("sp-code"); state.name = store.get("sp-name"); showEditing(); editbtn.onclick = () => { if (state.editing) { state.editing = null; state.name = null; store.set("sp-code", null); store.set("sp-name", null); showEditing(); route(); return; } dialog(`

Editing

`, (box) => { box.querySelector("#dcancel").onclick = closeDialog; const go = async () => { const code = box.querySelector("#dcode").value, name = box.querySelector("#dname").value.trim(); const r = await post("/api/editing/check", { code }); if (r.ok && name) { state.editing = code; state.name = name; store.set("sp-code", code); store.set("sp-name", name); showEditing(); closeDialog(); route(); } else box.querySelector("#dmsg").textContent = r.locked_for ? `Locked for ${Math.ceil(r.locked_for / 60)} minutes after too many tries.` : r.no_code ? "No PIN is set yet (Settings › Site)." : !name ? "Your first name, please." : `Not the PIN (${r.tries_left} tries left).`; }; box.querySelector("#dok").onclick = go; box.querySelector("#dname").onkeydown = (e) => { if (e.key === "Enter") go(); }; box.querySelector("#dcode").onkeydown = (e) => { if (e.key === "Enter") box.querySelector("#dname").focus(); }; box.querySelector("#dcode").focus(); }); }; function dialog(html, wire) { const d = document.getElementById("dialog"); d.innerHTML = `
${html}
`; d.hidden = false; wire(d.querySelector(".box")); } function closeDialog() { document.getElementById("dialog").hidden = true; } function alertBox(msg) { dialog(`

${esc(msg)}

`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); } function confirmBox(msg, label, onOk, word) { dialog(`

${esc(msg)}

${word ? `` : ""}
`, (b) => { b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = () => { if (word && b.querySelector("#dword").value.trim() !== word) return; closeDialog(); onOk(); }; }); } const needEditing = () => { if (!editing()) { alertBox("Switch Editing on first (Edit, top right)."); return false; } return true; }; // ---- the draft ------------------------------------------------------------ async function loadAll() { const [c, s, d, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]); state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.builders = b; 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 = ``; 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]) => ``).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 ? `` : ""}
    ${comps}
    `; main.querySelector("#restartwd").onclick = () => confirmBox(w.running ? "Restart the watchdog? The deck goes purple for about 5 seconds; a fix that is running finishes first." : "Start the watchdog?", w.running ? "Restart" : "Start", async () => { try { const r = await post(w.running ? "/api/watchdog/restart" : "/api/watchdog/start"); alertBox(r.ok ? "Done: " + r.detail : "Not done: " + r.detail); } catch (e) { alertBox(e.message); } }); const stop = main.querySelector("#stopwd"); if (stop) stop.onclick = () => confirmBox("Stop the watchdog? The deck goes purple and every alarm is off until it is started again.", "Stop", async () => { try { const r = await post("/api/watchdog/stop", { word: "STOP" }); alertBox(r.ok ? "Stopped." : "Not stopped: " + r.detail); } catch (e) { alertBox(e.message); } }, "STOP"); main.querySelector("#alarms").onclick = async () => { try { await post("/api/command", { text: o.service_on === false ? "service on" : "service off" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } }; 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 += ``; else if (cell && cell.nav) out += ``; else if (cell) { const s = deck && deck[cell.id]; const style = s ? `--b1:${hex(s.b)};--f1:${hex(s.f)};--b2:${hex(s.b2)};--f2:${hex(s.f2)};background:${hex(s.b)};color:${hex(s.f)}` : "background:#444;color:#ddd"; const blink = s && s.b !== s.b2 ? " blink" : ""; out += `
    ${esc(s ? s.x : cell.label)}
    `; } else out += `
    `; } return out + `
    `; } // ---- Keys: the two pages and the fixes ------------------------------------- async function keys(rest) { await flush(); if (rest[0] === "fixes") return fixes(rest.slice(1)); const o = await api("/api/overview").catch(() => ({ deck: null })); const c = cfg(); const restart = rest[0] === "restart"; const sel = restart ? rest[1] : rest[0]; const hold = ((c.page_hold_ms || 2000) / 1000) + " s"; const tabs = `
    ${c.restart_page ? `` : ""} ${editing() ? `page ${c.restart_page ? ` · restart page ` : ""} ` : ""}
    `; 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) => ``).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 ? `` : ``) : ""; 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

    `, (b) => { b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#nlabel").oninput = () => { b.querySelector("#nid").value = slug(b.querySelector("#nlabel").value); }; b.querySelector("#dok").onclick = () => { const label = b.querySelector("#nlabel").value.trim(), id = b.querySelector("#nid").value.trim(); if (!label || !/^[A-Za-z0-9_]+$/.test(id)) return; if (cfg().tiles.some((t) => t.id === id)) return alertBox("A key with that id exists."); const pos = parsePos(b.querySelector("#npos").value, cfg(), "status", true); if (pos === false) return; if (!ensureDraft()) return; const t = { id, label, checks: [] }; if (pos) t.pos = pos; state.draft.config.tiles.push(t); saveDraft(); closeDialog(); location.hash = "#keys/" + id; }; }); const nr = main.querySelector("#newrkey"); if (nr) nr.onclick = () => { const c = cfg(); dialog(`

    New restart key

    `, (b) => { b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = () => { const label = b.querySelector("#nlabel").value.trim(), fix = b.querySelector("#nfix").value; if (!label) return; const pos = parsePos(b.querySelector("#npos").value, cfg(), "restart", false); if (!pos) return; if (!ensureDraft()) return; let id = "rs_" + slug(label), n = 2; while (state.draft.config.restart_page.keys.some((k) => k.id === id)) id = id.replace(/_\d+$/, "") + "_" + n++; state.draft.config.restart_page.keys.push({ id, label, fix, pos, watch: [] }); saveDraft(); closeDialog(); location.hash = "#keys/restart/" + id; }; }); }; } 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) => `${Object.entries(c.fixes).map(([id, f]) => ``).join("")}`; const short = (text, n) => { text = String(text || ""); return text.length > n ? text.slice(0, n - 1) + "…" : text; }; let lastQ = null; const checks = (t.checks || []).map((chk, j) => { const spec = state.schema.checks[chk.type] || { fields: [], question: "" }; const sentence = (kt.checks[j] || {}).sentence || chk.type; const what = chk.type === "midi_peer" ? `${chk.peer || "?"} in ${esc(chk.session || "?")}` : esc(chk.conn || chk.name || (chk.params || {}).process || chk.msg || chk.host || chk.label || chk.url || chk.session || short(sentence, 40)); const q = spec.question || chk.type; const header = q !== lastQ ? `
    ${esc(q)}
    ` : ""; lastQ = q; const summary = `${what}${hint(sentence)}`; if (!editing()) return `${header}
    ${summary}

    ${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
    ${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")}
    `; const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `` : ""; return `${header}
    ${summary}
    ${fields}${fixSel}
    more${["tag", "service_only", "debounce_seconds"].filter((f) => !(spec.fields || []).includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("")}

    type ${esc(chk.type)}

    ${run}
    `; }).join(""); const plain = !t.members && t.kind !== "master"; const members = t.members ? (editing() ? `
    group members${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => ``).join(" ")}
    ` : `

    Sums up ${t.members.map((m) => `${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}`).join(", ")}

    `) : ""; const addMenu = editing() && plain ? `` : ""; const label = editing() ? 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")}
    ` : ""; const holdList = (kt.holding || "").split(/;\s*/).filter(Boolean); const more = `
    more
    id
    ${esc(t.id)}
    ${t.kind ? `
    kind
    ${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}
    ` : ""}
    ${editing() && !t.kind ? `
    position${hint("row, column; blank = inside its group only")}
    ` : ""}${editing() && (!t.kind || t.kind === "network") ? `

    ` : ""}
    `; return `

    ${esc(t.label)} ${t.pos ? `row ${t.pos[0]}, col ${t.pos[1]}` : "in a group"}

    ${label}${members}${holdAlways} ${holdList.length ? `
    Hold ${esc(hold)}: ${esc(short(holdList[0], 48))}${holdList.length > 1 ? ` +${holdList.length - 1}` : ""}${t.hold_always ? " also when green" : ""}
      ${holdList.map((h) => `
    1. ${esc(h)}
    2. `).join("")}
    ` : ""} ${kt.says && kt.says.length ? `
    Words it can show ${kt.says.length}

    ${esc(kt.says.join(" · "))}

    ` : ""} ${checks ? `
    ${checks}
    ` : ""}${addMenu}${more}
    `; } function groupBy(obj, fn) { const out = {}; for (const [k, v] of Object.entries(obj)) (out[fn(v)] = out[fn(v)] || []).push(k); return out; } function wireKeyPanel(sel, restart) { 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
    flash while down${hint("Keeps flashing HOLD TO FIX while these are down; hold cmd/ctrl to pick several")}
    ` : ""; 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() ? `` : ""}
    more
    id
    ${esc(r.id)}
    ${r.watch && r.watch.length ? `
    flashes while down
    ${esc(r.watch.join(", "))}
    ` : ""}
    ${editing() ? `

    ` : ""}
    `; } // ---- Computers --------------------------------------------------------------- let compTab = "map"; async function computers(rest, quiet) { if (quiet && document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) return; const m = await api("/api/machines"); const selected = rest[0] && rest[0] !== "gear" && rest[0] !== "setup" ? rest[0] : null; compTab = rest[0] === "gear" || rest[0] === "setup" ? rest[0] : "map"; const tabs = `
    ${editing() ? `` : ""}
    `; let body = ""; if (compTab === "gear") body = await gearHtml(); else if (compTab === "setup") body = setupHtml(m, (await api("/api/setup").catch(() => ({ computers: [] }))).computers); else { const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `${wordOf(c.word)}${esc(c.name)}${c.self ? ' (this Mac)' : ""}${esc(c.role)}${(c.keys || []).map((k) => `${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}`).join(" ")}${esc(c.reason)}${c.looked ? `
    looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}`).join(""); const looked = Math.max(0, ...m.computers.map((c) => c.looked || 0)); body = `
    ${looked ? "looked " + esc(ago(Date.now() / 1000 - looked)) : "not looked yet"}
    ${rows}
    statuscomputerdoeskeys${hint("The keys that turn red if it dies")}
    `; const c = m.computers.find((x) => x.alias === selected); if (c) body += computerCard(c); } main.innerHTML = `

    Computers${hint("What runs where. Click a row for everything that runs there, with a light each.")}

    ${tabs}${body}`; for (const b of main.querySelectorAll("[data-copy]")) b.onclick = async () => { const ta = document.getElementById(b.dataset.copy); try { await navigator.clipboard.writeText(ta.value); b.textContent = "Copied"; } catch (e) { ta.select(); document.execCommand("copy"); b.textContent = "Copied"; } }; const rm = main.querySelector("#removehost"); if (rm) rm.onclick = () => removeComputer(selected); for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job); const pb = document.getElementById("probe"); if (pb) pb.onclick = async () => { pb.disabled = true; pb.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => computers(rest, true).then(() => wireInputs(main)), 4000); }; const ag = main.querySelector("#addgear"); if (ag) ag.onclick = () => gearDialog(null); for (const a of main.querySelectorAll("[data-gearedit]")) a.onclick = (e) => { e.preventDefault(); gearDialog(a.dataset.gearedit); }; for (const b of main.querySelectorAll("[data-geardel]")) b.onclick = () => confirmBox(`Remove ${b.dataset.geardel} from the gear list?`, "Remove", async () => { try { const r = await post("/api/gear", { name: b.dataset.geardel, remove: true }); state.draft = r.draft; badge(); route(); } catch (e) { alertBox(e.message); } }); for (const b of main.querySelectorAll("[data-gear]")) b.onclick = async () => { const row = b.closest("tr"); try { const prev = (cfg().gear || {})[b.dataset.gear] || {}; await post("/api/gear", { name: b.dataset.gear, location: row.querySelector("[data-loc]").value, notes: row.querySelector("[data-notes]").value, kind: prev.kind || "", address: prev.address || "" }); const d = await api("/api/draft"); state.draft = d.draft; badge(); b.textContent = "Saved"; } catch (e) { alertBox(e.message); } }; const runs = main.querySelector("#runs"); if (runs) runs.onchange = () => { if (!ensureDraft()) return; try { state.draft.config.hosts[selected].runs = runs.value.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l)); saveDraft(); } catch (err) { alertBox("Each line must be a valid {...} entry."); } }; } function computerCard(c) { const items = (c.items || []).map((i) => `${lightDot(i.light, lightWord[i.light])}${esc(lightWord[i.light] || "")}${esc(i.name)}${i.watched === false ? ' (not watched)' : ""}${esc(i.reason || (typeof i.detail === "object" && i.detail ? Object.entries(i.detail).map(([k, v]) => k + " " + (v === true ? "yes" : v === false ? "no" : k === "age" ? ago(v) : v)).join(", ") : i.detail || ""))}`).join(""); const found = c.found || {}; const foundHtml = Object.entries(found).filter(([, v]) => Array.isArray(v) && v.length).map(([k, v]) => `

    ${esc(k)}: ${esc(typeof v[0] === "object" ? v.map((x) => x.iface + " " + x.ip + (x.active ? "" : " (down)")).join(", ") : v.join(", "))}

    `).join("") || `

    Press "Check now" to look.

    `; const done = Object.entries(c.manual_done || {}).map(([k, v]) => `
  • ${lightDot("hand")}${esc(k.replace(/_/g, " "))}: done by hand (${esc(v.by)}, ${esc(v.on)})
  • `).join(""); const base = `hosts.${c.alias}.`; const edit = editing() ? `
    Change${["name", "role", "notes", "os"].concat(c.self ? [] : ["ssh", "mac", "wake_broadcast", "pingable"]).map((f) => inputHtml(base + f, F("hosts.*." + f), getPath(base + f))).join("")}
    what runs here${hint('One per line, as {"kind": ..., "name": ...}. Kinds: sshd, app (process), launchagent (label), daemon (process), task (name, exe), service (name), web (url), helper_dvs, helper_solo (label), midi_session (name, peer_of), artnet (source), folder (path), disabled (label). "watched": false = listed only.')}
    ${c.os === "windows" ? `` : ""}
    ` : ""; const remove = editing() && !c.self ? `

    ` : ""; return `

    ${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)}

    ` : ""}
    ${items}
    ${done ? `` : ""}
    everything found${foundHtml}
    ${c.self ? `
    this Mac's set-up lights
    ${Object.entries(c.setup || {}).map(([k, v]) => `
    ${esc(k.replace(/_/g, " "))}
    ${typeof v === "boolean" ? lightDot(v ? "ok" : "problem") + (v ? "OK" : "Problem") : esc(JSON.stringify(v))}
    `).join("")}
    ` : ""} ${edit}${remove}
    `; } function removeComputer(alias) { const c = cfg(), h = c.hosts[alias]; if (!h) return; const fixes = Object.entries(c.fixes).filter(([, f]) => f.host === alias || (f.params || {}).host === alias).map(([id]) => id); const checks = []; for (const t of c.tiles) (t.checks || []).forEach((ch, j) => { if (ch.host === alias || (ch.params || {}).host === alias) checks.push([t.id, j, t.label]); }); const rkeys = (c.restart_page ? c.restart_page.keys : []).filter((k) => fixes.includes(k.fix)).map((k) => k.label); const what = [`the computer ${h.name || alias}`].concat(fixes.length ? [`${fixes.length} fix${fixes.length > 1 ? "es" : ""} that ran on it (${fixes.join(", ")})`] : [], checks.length ? [`${checks.length} check${checks.length > 1 ? "s" : ""} on the keys ${[...new Set(checks.map((x) => x[2]))].join(", ")}`] : [], rkeys.length ? [`the restart keys ${rkeys.join(", ")}`] : []); confirmBox(`Remove ${what.join(", ")}? The keys stay; a key left with nothing to check will say OK.`, "Remove", () => { if (!ensureDraft()) return; const d = state.draft.config; delete d.hosts[alias]; for (const id of fixes) { delete d.fixes[id]; if (d.auto_fix && d.auto_fix.fixes) d.auto_fix.fixes = d.auto_fix.fixes.filter((x) => x !== id); } for (const t of d.tiles) { t.checks = (t.checks || []).filter((ch) => !(ch.host === alias || (ch.params || {}).host === alias)); for (const ch of t.checks) { if (typeof ch.fix === "string" && fixes.includes(ch.fix)) delete ch.fix; else if (ch.fix && typeof ch.fix === "object") for (const k of Object.keys(ch.fix)) if (fixes.includes(ch.fix[k])) delete ch.fix[k]; } if (fixes.includes(t.hold_always)) delete t.hold_always; if (!t.checks.length && !t.members) delete t.checks; } if (d.restart_page) d.restart_page.keys = d.restart_page.keys.filter((k) => !fixes.includes(k.fix)); saveDraft(); location.hash = "#computers"; }); } const GEAR_KINDS = ["recorder", "switcher", "Dante device", "Dante switch", "wireless receiver", "in-ear transmitter", "LED processor", "camera", "network switch", "display", "device"]; async function gearHtml() { const g = await api("/api/gear"); return `
    ${g.map((r) => ``).join("")}
    devicekindaddresswhere${hint("Where it is in the building, and notes")}watched by
    ${lightDot(r.light, lightWord[r.light])}${editing() ? `${esc(r.name)}` : esc(r.name)}${esc(r.kind)}${esc(r.address || "")}${r.detail ? `
    ${esc(r.detail)}` : ""}
    ${editing() ? ` ` : `${esc(r.location || "")} ${esc(r.notes || "")}`} ${esc(r.reason || "")}${(r.watched_by || []).length ? r.watched_by.map((k) => `${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}`).join(", ") : editing() && r.own ? `` : ""}
    ${editing() ? `
    ` : ""}`; } async function gearDialog(existing) { const c = cfg(); const cur = existing ? Object.assign({ name: existing }, (c.gear || {})[existing] || {}) : {}; let conns = []; try { const cc = await api("/api/companion"); conns = (cc.connections || []).filter((r) => !(r.watched_by || []).length).map((r) => r.label); } catch (e) {} const plainKeys = c.tiles.filter((t) => !t.members && t.kind !== "master"); dialog(`

    ${existing ? "Gear" : "New gear"}${hint("A device that is not a computer. Give it a kind and an address; choose whether a key watches it.")}

    `, (b) => { b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = async () => { const name = b.querySelector("#gname").value.trim(), kind = b.querySelector("#gkind").value, address = b.querySelector("#gaddr").value.trim(); const where = b.querySelector("#gloc").value.trim(), notes = b.querySelector("#gnotes").value.trim(), watch = b.querySelector("#gwatch").value, keyId = b.querySelector("#gkey").value; if (!name) return alertBox("A name, please."); if (watch === "ping" && !/^[\w.:-]+$/.test(address)) return alertBox("To ping it, give it an address."); if (!ensureDraft()) return; try { const r = await post("/api/gear", { name, kind, address, location: where, notes }); state.draft = r.draft; } catch (e) { return alertBox(e.message); } if (watch) { const d = state.draft.config; const chk = watch === "ping" ? { type: "ping", host: address, name: tagOf(name) } : { type: "conn", conn: watch.slice(5), fix: "restart-connection" }; let t = d.tiles.find((x) => x.id === keyId); if (!t) { let id = slug(name) || "gear", n = 2; while (d.tiles.some((x) => x.id === id)) id = slug(name) + "_" + n++; t = { id, label: tagOf(name), checks: [] }; const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; d.tiles.push(t); } t.checks = t.checks || []; t.checks.push(chk); saveDraft(); closeDialog(); location.hash = "#keys/" + t.id; return; } badge(); closeDialog(); route(); }; }); } function setupHtml(m, generated) { return m.computers.map((c) => { const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac"; const gen = (generated || []).find((g) => g.alias === c.alias) || { steps: [], scripts: [] }; const steps = gen.steps.length ? gen.steps.map((x) => x.text) : ((m.setup_steps || {})[kind] || []); const howOf = (t) => (gen.steps.find((x) => x.text === t) || {}).how; const lightFor = (step) => { const s = c.setup || {}; const item = (k) => (c.items || []).find((i) => i.kind === k); if (/login key/.test(step)) return s.key_ok === true || s.login_key === true ? "ok" : s.key_ok === false || s.login_key === false ? "problem" : "unknown"; if (/panel's key is accepted on this Mac/.test(step)) return "unknown"; if (/python/.test(step)) return s.python_ok === true ? "ok" : s.python_ok === false ? "problem" : "unknown"; if (/SSH service/.test(step)) return c.reachable ? "ok" : "problem"; if (/firewall/.test(step)) return s.firewall_allow ? "ok" : "unknown"; if (/wake-on-LAN/.test(step)) return s.wake_on_lan === "Enabled" ? "ok" : s.wake_on_lan ? "problem" : "unknown"; if (/Public/.test(step)) return s.profile === "Public" ? "ok" : s.profile ? "problem" : "unknown"; if (/DVS helper/.test(step)) { const it = item("helper_dvs"); return it ? it.light : "not_needed"; } if (/Pro Tools helper/.test(step)) { const it = item("helper_solo"); return it ? it.light : "not_needed"; } if (/StatusPanel/.test(step)) { const ts = (c.items || []).filter((i) => i.kind === "task"); return ts.length ? (ts.every((i) => i.light === "ok") ? "ok" : "problem") : "unknown"; } if (/services restart|rtpMIDI/.test(step)) { const it = item("service"); return it ? it.light : "not_needed"; } if (/Netgear/.test(step)) return s.netgear_keychain === true ? "ok" : s.netgear_keychain === false ? "problem" : "not_needed"; if (/Google Drive/.test(step)) { const it = item("folder"); return it ? it.light : "not_needed"; } if (/watchdog and the settings/.test(step)) { const ok = (c.items || []).filter((i) => i.kind === "launchagent" && /watchdog|settings/i.test(i.name)); return ok.length && ok.every((i) => i.light === "ok") ? "ok" : "unknown"; } if (/Wi-Fi/.test(step)) return c.manual_done && c.manual_done.wifi_power ? "hand" : "unknown"; if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown"; return "unknown"; }; const scriptsHtml = gen.scripts.map((sc, i) => `
    ${esc(sc.name)}${hint(sc.how)}
    Save as a file
    `).join(""); return `

    ${esc(c.name)}

    ${scriptsHtml}
    `; }).join(""); } // ---- Logs ------------------------------------------------------------------ let logTab = "watchdog", logFilter = ""; async function logs(rest) { logTab = rest[0] || logTab; const tabs = ["watchdog", "errors", "autofix", "settings"].map((n) => ``).join(""); const filters = logTab === "watchdog" ? `
    ${["", "faults", "holds", "fixes", "auto", "errors"].map((f) => ``).join("")}
    ` : ""; let data; if (logTab === "autofix") data = await api("/api/logs/autofix" + (rest[1] ? "?file=" + encodeURIComponent(rest[1]) : "")); else data = await api(`/api/logs/${logTab}?filter=${encodeURIComponent(logFilter)}`); const files = logTab === "autofix" ? `

    ${(data.files || []).slice().reverse().map((f) => `${esc(f.replace(" Auto-Fix.log", ""))}`).join(" · ") || "no auto-fix days yet"}

    ` : ""; main.innerHTML = `

    Logs${hint("What the watchdog did and saw. Newest at the bottom.")}

    ${tabs}
    ${filters}${files}
    ${esc((data.lines || []).join("\n")) || "(empty)"}
    `; for (const b of main.querySelectorAll("[data-f]")) b.onclick = () => { logFilter = b.dataset.f; logs(rest); }; const pre = main.querySelector("pre.log"); pre.scrollTop = pre.scrollHeight; } // ---- History ---------------------------------------------------------------- async function history(rest) { const h = await api("/api/history"); let detail = ""; if (rest[0]) { const d = await api("/api/history/" + encodeURIComponent(rest[0])); detail = `

    ${esc(rest[0])}

    ${d.changes_to_now.length ? `` : "

    The same as now.

    "} ${editing() ? `` : ""}
    `; } const rows = h.entries.map((e) => `${esc(when(e.when))}${esc(e.by)}${esc(e.note)}${esc((e.actions || []).join(", "))}${e.outcome && e.outcome !== "applied" ? " · " + esc(e.outcome) : ""}`).join(""); main.innerHTML = `

    History${hint("Every version ever applied: who, when, the note. Open one to see what differs from now.")}${editing() && h.entries.length ? `` : ""}

    ${rows || ""}
    whenwhonote
    nothing yet
    ${detail}`; const gb = main.querySelector("#goback"); if (gb) gb.onclick = async () => { try { const r = await post("/api/restore/" + encodeURIComponent(rest[0])); state.draft = r.draft; badge(); location.hash = "#review"; } catch (e) { alertBox(e.message); } }; const un = main.querySelector("#undo"); if (un) un.onclick = () => confirmBox("Undo the last change? The version before it becomes your draft; Apply puts it live.", "Undo", async () => { try { const r = await post("/api/undo"); state.draft = r.draft; badge(); location.hash = "#review"; } catch (e) { alertBox(e.message); } }); } // ---- Review & Apply ------------------------------------------------------------- async function review() { await flush(); const st = await api("/api/apply/status"); if (st.state === "running") return applyProgress(st); const d = await api("/api/draft"); state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; badge(); if (!state.draft) { main.innerHTML = `

    Apply

    No changes. Change something with Editing on.

    ${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`; return; } const r = await post("/api/validate", {}); const changes = r.changes.map((c) => c.before !== undefined || c.after !== undefined ? `
  • ${esc(c.text)}${c.command ? ' command' : ""}${c.before != null ? `
    was ${esc(c.before)}
    ` : ""}${c.after != null ? `
    now ${esc(c.after)}
    ` : ""}
  • ` : `
  • ${esc(c.text)}
  • `).join("") + (r.atem_pending ? "
  • A new saved switcher file replaces the current one.
  • " : ""); const errors = r.errors.map((e) => `
  • ${esc(e)}
  • `).join(""); const warnings = r.warnings.map((w) => `
  • ${esc(w)}
  • `).join(""); const live = r.live && r.live.live ? `
    Live now: ${esc(r.live.reason)}. Apply waits; your draft is saved. ${editing() ? '' : ""}
    ` : ""; main.innerHTML = `

    Apply${hint("Your changes in words, and what will happen on the deck. Nothing is live until you press APPLY.")}

    ${state.draftStale ? "

    The live settings changed since this draft was started and could not be merged: look through the list before applying.

    " : ""}
    ${changes ? `` : "

    No change yet.

    "}

    Then: ${esc(r.feel)}

    ${errors ? `
    Not possible yet:
    ` : ""}${warnings ? `
    ` : ""}${live}
    ${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`; const go = (emergency) => async () => { const note = main.querySelector("#note").value.trim(); try { const res = await post("/api/apply", { note, emergency }); if (res.ok) { state.draft = null; state.atemPending = false; badge(); review(); } else alertBox(res.error); } catch (e) { alertBox(e.message); if (e.body && e.body.changed_since) { state.draftStale = true; badge(); } } }; main.querySelector("#apply").onclick = go(null); const em = main.querySelector("#emergency"); if (em) em.onclick = () => dialog(`

    Apply during a live service

    ${esc(r.feel)} Say why, and type LIVE.

    `, (b) => { b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = () => { const why = b.querySelector("#why").value.trim(), word = b.querySelector("#word").value.trim(); if (word !== "LIVE" || !why) return; closeDialog(); go({ word, reason: why })(); }; }); main.querySelector("#discard").onclick = () => confirmBox("Throw the draft away? Every change since the last Apply is lost.", "Throw it away", async () => { try { await post("/api/draft", {}, "DELETE"); state.draft = null; state.atemPending = false; badge(); review(); } catch (e) { alertBox(e.message); } }); } function applyProgress(st) { main.innerHTML = `

    Applying…

    ${esc(st.note || "")}

    ${stepsHtml(st)}`; setTimeout(async () => { try { const s2 = await api("/api/apply/status"); if (s2.state === "running") applyProgress(s2); else { await loadAll(); route(); } } catch (e) { setTimeout(() => applyProgress(st), 2000); } }, 1500); } function stepsHtml(st) { return `
      ${(st.steps || []).map((s) => `
    1. ${lightDot(s.state === "ok" ? "ok" : s.state === "failed" ? "problem" : s.state === "running" ? "warn" : s.state === "skipped" ? "not_needed" : "unknown")}${esc(s.name)} ${esc(s.state === "skipped" ? "skipped: " + s.detail : s.detail || s.state)}
    2. `).join("")}
    `; } function applyResult(st) { return `

    ${st.state === "done" ? "Applied" : "Not applied"} ${esc(when(st.finished))}${st.by ? " · " + esc(st.by) : ""}

    ${esc(st.outcome)}

    ${stepsHtml(st)}
    `; } // ---- Help ------------------------------------------------------------------ async function help() { const facts = await api("/api/facts"); const gl = state.schema.glossary || {}; const cards = [ ["Red key", "It names the problem. Press once to acknowledge (stays dark red). Hold 2 s: it runs the fix for what is failing."], ["Purple keys", "The watchdog on the Ingest Mac has been silent 15 s. Overview › Restart watchdog, unless Companion itself is down."], ["Which computer or device?", "Computers › Map: every computer and the keys that turn red if it dies. Computers › Gear: switcher, decks, wireless, LED."], ["Changing something", "Edit (top right, the PIN). Change it where it lives: a key on Keys, a computer on Computers, the rest on Settings. The badge counts your changes and opens Apply."], ].map(([t, b]) => `

    ${t}

    ${b}

    `).join(""); const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `
    ${esc(g)}

    ${esc(words.join(" · "))}

    `).join(""); main.innerHTML = `

    Help

    ${cards}

    What each key does Cheat sheet Where things are

    Try it (ten minutes with Editing on; nothing is live until APPLY)
    1. Keys › HD2: untick "may have no disk"; the badge counts it; Apply reads it back. Throw away.
    2. Keys: drag LED onto the tray and back. + Key "HD7", add a check (Is a recorder OK?), delete it.
    3. Settings › HyperDecks: untick 5 and 6, look at Keys (cells stay empty). Throw away.
    4. Settings › Playlist: paste a link, Look it up; the name follows everywhere.
    5. Settings › Auto-fix: paint Sunday morning on the calendar.
    6. Settings › MIDI links: add one to the MIDI key; Run the fix on that check.
    7. Keys › Fixes › VISTA: change the program file to Vista2.exe, watch the command rebuild; put it back.
    8. Computers › Setup › Lighting PC: open the script, Copy. Computers › Lighting PC › Remove (read, then Cancel).
    9. Settings › Switcher: Pick the newest file; Discard. Settings › Program & campus: Export bundle.
    10. A real change (a note on Computers › Gear): APPLY, then History › Undo, APPLY again.
    The words a key can show${glossary}
    Only a person can
    Companion facts (found by trying)
    ${(facts.facts || []).map(([q, a]) => `
    ${esc(q)}
    ${esc(a)}
    `).join("")}
    `; } // ---- Settings: one page, every section ------------------------------------------ const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys", "site.editing_code"]; 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"], ["outlets", "Outlets", "Smart plugs the panel may switch, for a device that only a power cycle brings back"], ["companion", "Companion", "Connections, and names the keys use that are not here"], ["network", "Network", "This Mac's network port and the Dante switches"], ["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 = ` `; 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 === "outlets") body = await outletsHtml(); 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 `
    playlist link${hint("Paste the link from Spotify (Share › Copy link). Look it up fills the name.")} ${editing() ? `` : ""}
    name${hint("As the keys say it")}

    ` + fieldsHtml("Spotify"); } function autofixHtml(c) { const fixes = Object.entries(c.fixes).map(([id, f]) => { const cl = closes(f); const on = (c.auto_fix.fixes || []).includes(id); return ``; }).join(" "); return `
    ${calendarHtml("auto_fix.windows", c.auto_fix.windows)}

    Fixes it may run${hint("Ticked fixes run by themselves inside the painted hours. ⚠ = it closes a running program to do its job; ticking it allows that without a hold.")}

    ${fixes}
    ` + 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 }; }); } const h12 = (hhmm) => { const m = toMin(hhmm); if (m == null) return hhmm || ""; const h = Math.floor(m / 60) % 24, mm = m % 60; const ap = h < 12 ? "am" : "pm"; return `${h % 12 || 12}${mm ? ":" + String(mm).padStart(2, "0") : ""} ${ap}`; }; function calendarHtml(path, windows) { const on = cellsOf(windows); const SHOW = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; let grid = `
    ` + SHOW.map((d) => `
    ${d[0].toUpperCase() + d.slice(1)}
    `).join(""); for (let m = 0; m < 1440; m += 30) { grid += `
    ${m % 120 === 0 ? h12(toHHMM(m)).replace(" ", "") : ""}
    `; for (const name of SHOW) { const d = DAYS.indexOf(name); grid += `
    `; } } const words = (windows || []).length ? (windows || []).map((w) => `${(w.days || []).map((d) => d[0].toUpperCase() + d.slice(1)).join(", ")} ${esc(h12(w.start))} – ${esc(w.end === "24:00" ? "midnight" : h12(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 `${d.missing ? ` not in Companion` : ""}${k ? `${esc(k.label)}` : "no key"}`; }).join(""); return `
    ${!cc ? "

    Companion is not answering; the list is from the keys.

    " : ""}
    ${rows || ""}
    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
    No HyperDeck connections in Companion.
    ${editing() && rows ? `
    ` : ""}
    `; } 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() ? `` : ""}`); })); return `
    ${rows.join("") || ""}
    keyMacsessionpeerkey text
    none
    ${editing() ? `
    ` : ""}
    `; } 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 `
    ${rows}
    computerkindlog-indoesprograms it can start
    ${editing() ? `
    ` : ""}
    `; } 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)}
    ${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"))}
    ` : ""}
    Export campus bundle${editing() ? `` : ""}
    `; } async function outletsHtml() { const o = await api("/api/outlets"); const dis = editing() ? "" : "disabled"; const rows = Object.entries(o.outlets || {}).map(([n, v]) => `${esc(n)}${esc(v.name || "")}…${editing() ? ` ` : ""}`).join(""); const used = Object.entries(cfg().fixes).filter(([, f]) => f.template === "outlet_cycle").map(([id, f]) => `${esc(f.short || id)} → ${esc((f.params || {}).outlet || "?")}`).join(" "); return `
    SmartLife / Tuya keys${hint("Access ID and Access Secret of a Cloud Project on iot.tuya.com with your SmartLife app linked to it. They stay on this Mac in a private file, never in the settings file.")}${editing() ? `` : ""}
    ${o.configured ? "" : `
    how to get the keys
    1. iot.tuya.com, free account, Cloud › Create Cloud Project (Smart Home, your region's data center).
    2. In the project: Devices › Link App Account › scan the code with the SmartLife app (Me › scan icon).
    3. Overview tab: copy Access ID and Access Secret here.
    `}
    ${rows || ""}
    outletplugnow
    none yet
    ${editing() && o.configured ? `
    ` : ""} ${used ? `

    Used by fixes: ${used}

    ` : ""}

    A fix "Power-cycle a smart outlet" (Keys › Fixes) cuts the power only when the device does not answer, never while a HyperDeck is recording.

    `; } async function outletDialog() { let devs = []; try { const r = await api("/api/outlets/devices"); if (!r.ok) return alertBox(r.error); devs = r.devices; } catch (e) { return alertBox(e.message); } devs.sort((a, b) => (b.online - a.online) || String(a.name).localeCompare(String(b.name))); dialog(`

    New outlet

    `, (b) => { b.querySelector("#dcancel").onclick = closeDialog; const sel = b.querySelector("#odev"); sel.onchange = () => { if (!b.querySelector("#oname").value) b.querySelector("#oname").value = slug(sel.selectedOptions[0].dataset.name); }; sel.onchange(); b.querySelector("#dok").onclick = async () => { const name = b.querySelector("#oname").value.trim(); if (!name) return; try { const r = await post("/api/outlets", { name, id: sel.value, device_name: sel.selectedOptions[0].dataset.name }); if (r.error) return alertBox(r.error); closeDialog(); route(); } catch (e) { alertBox(e.message); } }; }); } async function namesHtml() { const n = await api("/api/site/names").catch(() => null); if (!n || !n.missing) return ""; const sel = (kind, name, cands) => editing() ? `` : ""; 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)}DANTEnot 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.")}

    ${conns}${dante}${midi}
    nameused by
    `; } 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() ? ` ` : ""}`}`).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(", "))}

    ` : ""}
    ${conns}
    connectionmodulewatched by
    ` + 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) => ``; 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() ? `` : ""}`; }).join(""); const extra = Object.keys(map).filter((n) => !((chk.devices || {})[n])); return `
    ${rows}
    Dante namekey textaddressswitch / portPoEwhen gone, hold runs

    Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.${editing() ? ` ` : ""}

    ${editing() ? `
    ${extra.map((n) => ` ${extra.length ? `seen, not listed: ${esc(extra.join(", "))}` : ""}
    ` : ""}
    `; } 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() ? `` : ""}
    ` : ""; return `
    ${pending} ${editing() ? `
    ` : ""}

    ${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); const ok = q("#okeys"); if (ok) ok.onclick = async () => { ok.disabled = true; ok.textContent = "Testing…"; try { const r = await post("/api/outlets/keys", { access_id: q("#oid").value, access_secret: q("#osecret").value, region: q("#oregion").value }); alertBox(r.ok ? `Works: ${r.devices} devices on the account.` : r.error); route(); } catch (e) { alertBox(e.message); route(); } }; const oa = q("#oadd"); if (oa) oa.onclick = outletDialog; for (const b of main.querySelectorAll("[data-oswitch]")) b.onclick = () => { const [n, on] = b.dataset.oswitch.split("|"); confirmBox(`Switch the ${n} outlet ${on === "1" ? "on" : "OFF"}?${on === "1" ? "" : " Whatever is plugged into it loses power."}`, on === "1" ? "On" : "Off", async () => { try { const r = await post("/api/outlets/switch", { name: n, on: on === "1" }); if (!r.ok) alertBox(r.error); setTimeout(route, 1500); } catch (e) { alertBox(e.message); } }); }; for (const b of main.querySelectorAll("[data-oremove]")) b.onclick = () => confirmBox(`Remove the ${b.dataset.oremove} outlet from the list? Fixes that use it stop working.`, "Remove", async () => { try { await post("/api/outlets", { name: b.dataset.oremove, remove: true }); route(); } catch (e) { alertBox(e.message); } }); for (const td of main.querySelectorAll("[id^='ost-']")) api("/api/outlets/status?name=" + encodeURIComponent(td.id.slice(4))).then((r) => { td.innerHTML = r.ok ? `${r.on ? "on" : "off"}` : `?`; }).catch(() => {}); 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 d = state.draft.config, id = cb.dataset.autofix, f = d.fixes[id]; const set = new Set(d.auto_fix.fixes || []); if (cb.checked) { set.add(id); if (closes(f)) f.restart_ok = true; if (f.never_auto) delete f.never_auto; } else set.delete(id); 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.")}

    `, (b) => { b.querySelector("#dcancel").onclick = closeDialog; const fill = () => { const ss = sessionsOf(b.querySelector("#jhost").value); b.querySelector("#jsessions").innerHTML = ss.map(([n]) => `