Site setup > Companion lists every connection, Dante device and MIDI session the keys use that this campus does not report, with a map-to choice that renames it across checks, restart keys and fixes. The ATEM section explains the Full Disk Access step when the cloud folder looks empty to the background page. Verified on NA Ingest; Install the staged folder tested live there too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1028 lines
116 KiB
JavaScript
1028 lines
116 KiB
JavaScript
/* The settings page. Plain JS, no framework. Everything is readable; with Editing on (the editing
|
||
code + your first name) the same screens grow inputs; changes live in one shared draft on the server
|
||
until Review & Apply. */
|
||
(function () {
|
||
"use strict";
|
||
const main = document.getElementById("main");
|
||
const state = { live: null, liveSha: null, schema: null, draft: null, draftStale: false, atemPending: false, editing: null, name: null, saveTimer: null };
|
||
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
const ago = (s) => s == null ? "" : s < 90 ? Math.round(s) + " s ago" : s < 5400 ? Math.round(s / 60) + " min ago" : Math.round(s / 3600) + " h ago";
|
||
const when = (t) => t ? new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) : "";
|
||
const hex = (n) => "#" + (n >>> 0).toString(16).padStart(6, "0").slice(-6);
|
||
const lightDot = (l) => `<i class="light ${esc(l || "unknown")}"></i>`;
|
||
const wordOf = (w) => `<span class="word ${esc((w || "").split(" ")[0])}">${esc(w)}</span>`;
|
||
const deep = (o) => JSON.parse(JSON.stringify(o));
|
||
const canon = (v) => Array.isArray(v) ? "[" + v.map(canon).join(",") + "]" : v && typeof v === "object" ? "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canon(v[k])).join(",") + "}" : JSON.stringify(v);
|
||
const cfg = () => (state.draft ? state.draft.config : state.live);
|
||
const editing = () => !!state.editing;
|
||
const store = { get: (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch (e) {} } };
|
||
const closes = (f) => /pkill|taskkill|killall/.test(f.run || "") && !f.restart_ok;
|
||
|
||
async function api(path, opts) {
|
||
opts = opts || {};
|
||
const headers = Object.assign({ "Content-Type": "application/json" }, opts.headers || {});
|
||
if (state.editing) { headers["X-Edit-Key"] = state.editing; headers["X-Editor"] = state.name || ""; }
|
||
const r = await fetch(path, Object.assign({}, opts, { headers }));
|
||
let body = null;
|
||
try { body = await r.json(); } catch (e) { body = {}; }
|
||
if (!r.ok) { const err = new Error(body.error || r.statusText); err.status = r.status; err.body = body; throw err; }
|
||
return body;
|
||
}
|
||
const post = (path, body, method) => api(path, { method: method || "POST", body: JSON.stringify(body || {}) });
|
||
|
||
// ---- top bar: legend, editing code ----------------------------------------
|
||
document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; };
|
||
const editbtn = document.getElementById("editbtn");
|
||
function showEditing() {
|
||
editbtn.textContent = state.editing ? "Editing as " + state.name : "Editing: off";
|
||
editbtn.classList.toggle("on", !!state.editing);
|
||
document.body.classList.toggle("editing", !!state.editing);
|
||
}
|
||
state.editing = store.get("sp-code"); state.name = store.get("sp-name");
|
||
showEditing();
|
||
editbtn.onclick = () => {
|
||
if (state.editing) { state.editing = null; state.name = null; store.set("sp-code", null); store.set("sp-name", null); showEditing(); route(); return; }
|
||
dialog(`<h3>Switch Editing on</h3><p class="help">The editing code is in the campus's settings file in the repo; ask Chris.</p>
|
||
<input id="dcode" placeholder="editing code" autocomplete="off"><input id="dname" placeholder="your first name" autocomplete="off">
|
||
<div class="row"><button class="primary" id="dok">Switch on</button><button id="dcancel">Cancel</button></div><p id="dmsg" class="help"></p>`, (box) => {
|
||
box.querySelector("#dcancel").onclick = closeDialog;
|
||
const go = async () => {
|
||
const code = box.querySelector("#dcode").value, name = box.querySelector("#dname").value.trim();
|
||
const r = await post("/api/editing/check", { code });
|
||
if (r.ok && name) { state.editing = code; state.name = name; store.set("sp-code", code); store.set("sp-name", name); showEditing(); closeDialog(); route(); }
|
||
else box.querySelector("#dmsg").textContent = r.locked_for ? `Locked for ${Math.ceil(r.locked_for / 60)} minutes after too many tries.` : r.no_code ? "No editing code is set in the config yet." : !name ? "Your first name, please." : `Not the code (${r.tries_left} tries left).`;
|
||
};
|
||
box.querySelector("#dok").onclick = go;
|
||
box.querySelector("#dname").onkeydown = (e) => { if (e.key === "Enter") go(); };
|
||
box.querySelector("#dcode").focus();
|
||
});
|
||
};
|
||
function dialog(html, wire) { const d = document.getElementById("dialog"); d.innerHTML = `<div class="box">${html}</div>`; d.hidden = false; wire(d.querySelector(".box")); }
|
||
function closeDialog() { document.getElementById("dialog").hidden = true; }
|
||
function alertBox(msg) { dialog(`<p>${esc(msg)}</p><button id="dok" class="primary">OK</button>`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); }
|
||
function confirmBox(msg, label, onOk, word) {
|
||
dialog(`<p>${esc(msg)}</p>${word ? `<input id="dword" placeholder="type ${esc(word)} to confirm" autocomplete="off">` : ""}<div class="row"><button class="primary" id="dok">${esc(label)}</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#dok").onclick = () => { if (word && b.querySelector("#dword").value.trim() !== word) return; closeDialog(); onOk(); };
|
||
});
|
||
}
|
||
|
||
// ---- the draft ------------------------------------------------------------
|
||
async function loadAll() {
|
||
const [c, s, d, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]);
|
||
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.builders = b;
|
||
document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
|
||
badge();
|
||
}
|
||
function ensureDraft() {
|
||
if (!editing()) { alertBox("Switch Editing on first (top right)."); return false; }
|
||
if (!state.draft) state.draft = { config: deep(state.live), version: null, by: state.name };
|
||
return true;
|
||
}
|
||
function changed() {
|
||
if (!state.draft) return 0;
|
||
let n = 0;
|
||
const walk = (a, b) => {
|
||
if (Array.isArray(a) && Array.isArray(b)) { if (canon(a) !== canon(b)) n++; return; }
|
||
if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) { for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) walk(a[k], b[k]); return; }
|
||
if (canon(a) !== canon(b)) n++;
|
||
};
|
||
walk(state.live, state.draft.config);
|
||
return n + (state.atemPending ? 1 : 0);
|
||
}
|
||
function badge() {
|
||
let b = document.getElementById("badge");
|
||
if (!b) { b = document.createElement("a"); b.id = "badge"; b.className = "badge"; b.href = "#review"; document.querySelector(".topright").prepend(b); }
|
||
const n = changed();
|
||
b.hidden = !n && !state.draft;
|
||
b.textContent = n ? `${n} change${n > 1 ? "s" : ""} – Review & Apply` : "Draft – Review";
|
||
b.classList.toggle("warn", !!state.draftStale);
|
||
}
|
||
function saveDraft() {
|
||
badge();
|
||
clearTimeout(state.saveTimer);
|
||
state.saving = new Promise((done) => {
|
||
state.saveTimer = setTimeout(async () => {
|
||
try {
|
||
const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT");
|
||
state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by;
|
||
} catch (e) {
|
||
if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); }
|
||
else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); }
|
||
else alertBox("Could not save the draft: " + e.message);
|
||
}
|
||
done();
|
||
}, 400);
|
||
});
|
||
return state.saving;
|
||
}
|
||
const flush = () => state.saving || Promise.resolve();
|
||
// set a value at a dotted path in the draft (creating objects on the way)
|
||
function setPath(path, value) {
|
||
if (!ensureDraft()) return;
|
||
const parts = path.split("."); let o = state.draft.config;
|
||
for (let i = 0; i < parts.length - 1; i++) { if (o[parts[i]] == null || typeof o[parts[i]] !== "object") o[parts[i]] = {}; o = o[parts[i]]; }
|
||
if (value === undefined) delete o[parts[parts.length - 1]]; else o[parts[parts.length - 1]] = value;
|
||
saveDraft();
|
||
}
|
||
const getPath = (path, from) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), from || cfg());
|
||
|
||
// ---- generic inputs from the registry -----------------------------------
|
||
function parse(f, raw) {
|
||
if (f.type === "int") { const n = parseInt(raw, 10); return isNaN(n) ? undefined : n; }
|
||
if (f.type === "float") { const n = parseFloat(raw); return isNaN(n) ? undefined : n; }
|
||
if (f.type === "bool") return !!raw;
|
||
if (f.type === "list") return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
||
if (f.type === "dict") { try { return raw.trim() ? JSON.parse(raw) : undefined; } catch (e) { return undefined; } }
|
||
return raw === "" ? undefined : raw;
|
||
}
|
||
function inputHtml(path, f, value, extra) {
|
||
if (!f) return "";
|
||
const id = "f-" + path.replace(/[^A-Za-z0-9]/g, "-");
|
||
const dis = editing() ? "" : "disabled";
|
||
let html;
|
||
if (f.type === "bool") html = `<input type="checkbox" id="${id}" data-path="${esc(path)}" ${value ? "checked" : ""} ${dis}>`;
|
||
else if (f.type === "enum" && f.options) html = `<select id="${id}" data-path="${esc(path)}" ${dis}><option value="">–</option>${f.options.map((o) => `<option ${value === o ? "selected" : ""}>${esc(o)}</option>`).join("")}</select>`;
|
||
else if (f.type === "dict") html = `<textarea id="${id}" data-path="${esc(path)}" rows="2" ${dis}>${esc(value == null ? "" : JSON.stringify(value))}</textarea>`;
|
||
else if (f.type === "list") html = `<input id="${id}" data-path="${esc(path)}" value="${esc(Array.isArray(value) ? value.join(", ") : value || "")}" ${dis}>`;
|
||
else html = `<input id="${id}" data-path="${esc(path)}" type="${f.type === "int" || f.type === "float" ? "number" : f.secret ? "password" : "text"}" value="${esc(value == null ? "" : value)}" ${f.lo != null ? `min="${f.lo}"` : ""} ${f.hi != null ? `max="${f.hi}"` : ""} ${f.type === "float" ? 'step="any"' : ""} placeholder="${esc(f.default == null || typeof f.default === "object" ? "" : f.default)}" ${dis}>`;
|
||
const dflt = f.default != null && typeof f.default !== "object" && f.default !== "" ? ` <span class="muted">(default ${esc(f.default)})</span>` : "";
|
||
return `<div class="field"><label for="${id}">${esc(path.split(".").pop().replace(/_/g, " "))}${f.required ? " *" : ""}</label>${html}<div class="help">${esc(f.help)}${dflt}${extra || ""}</div></div>`;
|
||
}
|
||
function wireInputs(root) {
|
||
for (const el of root.querySelectorAll("[data-path]")) {
|
||
if (el.onchange) continue; // a screen wired it itself
|
||
el.onchange = () => {
|
||
const path = el.dataset.path, f = fieldFor(path);
|
||
const v = el.type === "checkbox" ? el.checked : el.value;
|
||
setPath(path, f ? parse(f, v) : v);
|
||
};
|
||
}
|
||
}
|
||
function fieldFor(path) {
|
||
const F = state.schema.fields;
|
||
if (F[path]) return F[path];
|
||
const generic = path.replace(/^tiles\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^tiles\.\d+\./, "tiles[].")
|
||
.replace(/^restart_page\.keys\.\d+\./, "restart_page.keys[].").replace(/^fixes\.[^.]+\./, "fixes.*.").replace(/^hosts\.[^.]+\./, "hosts.*.")
|
||
.replace(/^gear\.[^.]+\./, "gear.*.").replace(/^(auto_fix\.windows|site\.live_services|service_windows)\.\d+\./, "$1[].");
|
||
return F[generic] || null;
|
||
}
|
||
const F = (name) => state.schema.fields[name];
|
||
|
||
// ---- search ----------------------------------------------------------------
|
||
const search = document.getElementById("search"), results = document.getElementById("searchresults");
|
||
search.oninput = () => {
|
||
const q = search.value.trim().toLowerCase();
|
||
if (!q || !cfg()) { results.hidden = true; return; }
|
||
const c = cfg(), hits = [];
|
||
c.tiles.forEach((t) => { if ((t.label + " " + t.id).toLowerCase().includes(q)) hits.push([`Key ${t.label}`, `#keys/${t.id}`]); });
|
||
for (const k of (c.restart_page || { keys: [] }).keys) if (k.label.toLowerCase().includes(q)) hits.push([`Restart key ${k.label}`, `#keys/restart/${k.id}`]);
|
||
for (const [a, h] of Object.entries(c.hosts || {})) if ((h.name || a).toLowerCase().includes(q) || (h.role || "").toLowerCase().includes(q)) hits.push([`Computer ${h.name || a}`, `#computers/${a}`]);
|
||
for (const [id, f] of Object.entries(c.fixes || {})) if ((id + " " + (f.label || "") + " " + (f.short || "")).toLowerCase().includes(q)) hits.push([`Fix ${f.short || id}: ${f.label || ""}`, `#fixes/${id}`]);
|
||
for (const [path, f] of Object.entries(state.schema.fields)) if (!path.includes("[]") && !path.includes("*") && (path + " " + f.help).toLowerCase().includes(q)) hits.push([`Setting ${path}`, `#site/${f.screen.toLowerCase().replace(/[^a-z]+/g, "-")}`]);
|
||
results.innerHTML = hits.slice(0, 20).map(([t, h]) => `<a href="${h}">${esc(t)}</a>`).join("") || `<a>Nothing called "${esc(q)}"</a>`;
|
||
results.hidden = false;
|
||
};
|
||
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
|
||
|
||
// ---- routing ---------------------------------------------------------------
|
||
const screens = { overview, keys, computers, logs, history, help, site, fixes, review };
|
||
let timer = null;
|
||
async function route() {
|
||
const [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
|
||
for (const a of document.querySelectorAll(".menu a")) a.classList.toggle("active", a.getAttribute("href") === "#" + name);
|
||
clearInterval(timer); timer = null;
|
||
try {
|
||
if (!state.live) await loadAll();
|
||
const fn = screens[name] || overview;
|
||
await fn(rest);
|
||
wireInputs(main);
|
||
const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0])); // never under a typist's hands
|
||
if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000);
|
||
} catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
|
||
}
|
||
window.addEventListener("hashchange", route);
|
||
route();
|
||
|
||
// ---- Overview --------------------------------------------------------------
|
||
async function overview(_, quiet) {
|
||
if (quiet && document.activeElement && document.activeElement.tagName === "INPUT") return; // never pull the field out from under a typist
|
||
const o = await api("/api/overview");
|
||
const dot = document.getElementById("hbdot");
|
||
dot.className = "dot " + (o.heartbeat_age != null && o.heartbeat_age < 15 ? "ok" : "bad");
|
||
const isLive = o.live && o.live.live;
|
||
const runnable = (p) => {
|
||
const t = cfg().tiles.find((x) => x.id === p.key); if (!t || !editing() || isLive) return "";
|
||
const fixes = (t.checks || []).map((c, j) => [typeof c.fix === "string" ? c.fix : null, j]).filter(([f]) => f && canRunNow(f));
|
||
return fixes.slice(0, 1).map(([f, j]) => ` <button class="small" data-run="${esc(t.id)}|${esc(f)}|${j}">Run the fix now</button>`).join("");
|
||
};
|
||
const prob = o.problems.length ? o.problems.map((p) => `<li><span class="lvl" style="background:${p.level === "err" ? "var(--problem)" : "var(--warn)"}"></span><b>${esc(p.text)}</b>${p.acked ? ' <span class="muted">(acknowledged)</span>' : ""} <span class="muted">— ${esc(p.action)}</span> <a href="#keys/${esc(p.key)}" class="small">open the key</a>${runnable(p)}</li>`).join("") : `<li class="muted">Everything is fine.</li>`;
|
||
const needs = (o.needs || []).map((n) => `<div class="notice grey">Needs Chris: ${esc(n)}</div>`).join("");
|
||
const failed = o.failed_last ? `<div class="notice grey">The last Apply was put back: ${esc(o.failed_last.why || "")} <a href="#history">History</a></div>` : "";
|
||
const live = isLive ? `<div class="notice">Live now: ${esc(o.live.reason)}. Changes to the panel wait.</div>` : "";
|
||
const comps = o.computers.map((c) => `<tr class="click" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}</td><td class="muted small">${esc(c.reason)}</td></tr>`).join("");
|
||
const w = o.watchdog;
|
||
const dis = editing() ? "" : "disabled title='switch Editing on'";
|
||
main.innerHTML = `
|
||
<h1>${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") + " in the room" : "Everything is fine"}</h1>
|
||
<p class="what">Is the room OK, and what do I do. Problems mirror the red keys; hold the key, or open it for the details.</p>
|
||
<ul class="problems">${prob}</ul>${needs}${failed}${live}
|
||
<div class="cols">
|
||
<div><h2>Live deck <span class="muted small">${o.deck ? "as the Stream Deck looks now · click a key" : "(Companion is not answering)"}</span></h2>${deckHtml(o.deck, cfg(), "status")}
|
||
<div class="row" style="margin-top:10px"><button data-job="playlist">Change the playlist</button><button data-job="hyperdecks">Add or remove a HyperDeck</button><button data-job="midi">Add a MIDI link</button>
|
||
<select id="morejobs"><option value="">More jobs…</option><option value="computer">Add a computer</option><option value="pcprogram">Make a PC program startable</option></select></div></div>
|
||
<div><h2>Computers</h2><div class="tablewrap"><table>${comps}</table></div></div>
|
||
</div>
|
||
<div class="card">
|
||
<dl class="kv">
|
||
<dt>Watchdog</dt><dd>${w.running ? "OK, running" : "<b>not running</b>"}${w.since ? " since " + esc(when(w.since)) : ""}${w.version ? ` <span class="pill">code ${esc(w.version)}</span>` : ""}${o.heartbeat_age != null ? ` · heartbeat ${esc(ago(o.heartbeat_age))}` : ""}</dd>
|
||
<dt>Companion</dt><dd>${o.companion_ok ? "OK" : "<b>not answering</b>"}${o.pages && o.pages.status ? (o.pages.status.ok ? ", pages OK" : ", page " + o.pages.status.page + " is called " + esc(o.pages.status.live || "?")) : ""}</dd>
|
||
<dt>Alarms</dt><dd>${o.service_on == null ? "<span class='muted'>not reported yet</span>" : o.service_on ? "ON" : "silenced until midnight"}${o.alarms_override != null ? " (by hand)" : ""} <span class="muted small">· ${esc(o.phrases.alarms)}</span></dd>
|
||
<dt>Auto-fix</dt><dd>${esc(o.auto_fix.phrase)}${o.auto_fix.state && o.auto_fix.state.on ? " · <b>may act now</b>" : ""}${o.auto_fix.state && o.auto_fix.state.paused ? " (waiting out a network blip)" : ""}</dd>
|
||
<dt>Running now</dt><dd>${Object.keys(o.running || {}).length ? Object.values(o.running).map((r) => esc(r.what + " on " + r.key)).join(", ") : "no fix is running"}</dd>
|
||
<dt>Last change</dt><dd>${o.last_change ? esc(o.last_change.when + " · " + o.last_change.note) : "—"} <a href="#history" class="small">History</a></dd>
|
||
</dl>
|
||
<div class="row" style="margin-top:10px">
|
||
<button id="restartwd" ${dis}>${w.running ? "Restart the watchdog" : "Start the watchdog"}</button>
|
||
<button id="alarms" ${dis}>${o.service_on === false ? "Alarms back on" : "Silence alarms until midnight"}</button>
|
||
${w.running ? `<button id="stopwd" ${dis}>Stop the watchdog</button>` : ""}
|
||
</div>
|
||
</div>`;
|
||
main.querySelector("#restartwd").onclick = () => confirmBox(w.running ? "Restart the watchdog? The deck goes purple for about 5 seconds; a fix that is running finishes first." : "Start the watchdog?", w.running ? "Restart" : "Start", async () => { try { const r = await post(w.running ? "/api/watchdog/restart" : "/api/watchdog/start"); alertBox(r.ok ? "Done: " + r.detail : "Not done: " + r.detail); } catch (e) { alertBox(e.message); } });
|
||
const stop = main.querySelector("#stopwd"); if (stop) stop.onclick = () => confirmBox("Stop the watchdog? The deck goes purple and every alarm is off until it is started again.", "Stop", async () => { try { const r = await post("/api/watchdog/stop", { word: "STOP" }); alertBox(r.ok ? "Stopped." : "Not stopped: " + r.detail); } catch (e) { alertBox(e.message); } }, "STOP");
|
||
main.querySelector("#alarms").onclick = async () => { try { await post("/api/command", { text: o.service_on === false ? "service on" : "service off" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } };
|
||
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
|
||
for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job);
|
||
const mj = main.querySelector("#morejobs"); if (mj) mj.onchange = () => { const j = mj.value; mj.value = ""; if (j) job(j); };
|
||
if (!quiet) window.scrollTo(0, 0);
|
||
}
|
||
|
||
// ---- Jobs: the changes people actually make, one dialog each --------------------
|
||
const slug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
|
||
const tagOf = (t) => t.toUpperCase().replace(/[^A-Z0-9 ]+/g, " ").trim().slice(0, 10).trim();
|
||
async function job(which) {
|
||
if (!editing()) return alertBox("Switch Editing on first (top right).");
|
||
clearInterval(timer); timer = null;
|
||
if (which === "playlist") return jobPlaylist();
|
||
if (which === "hyperdecks") return jobHyperdecks();
|
||
if (which === "midi") return jobMidi();
|
||
if (which === "computer") return jobComputer();
|
||
if (which === "pcprogram") return jobPcProgram();
|
||
}
|
||
function jobPlaylist() {
|
||
const sp = cfg().spotify || {};
|
||
dialog(`<h3>Change the playlist</h3><p class="help">Paste the playlist link from Spotify (Share › Copy link). The SPOTIFY key, the play fix and the key guide follow it.</p>
|
||
<input id="jlink" placeholder="https://open.spotify.com/playlist/..." value="${esc(sp.playlist || "")}"><p id="jinfo" class="help">Now: ${esc(sp.name || "?")}</p>
|
||
<div class="row"><button class="primary" id="dok">Look it up and use it</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#dok").onclick = async () => {
|
||
const info = b.querySelector("#jinfo"); info.textContent = "Looking…";
|
||
try {
|
||
const r = await api("/api/live/spotify?url=" + encodeURIComponent(b.querySelector("#jlink").value));
|
||
if (!r.ok) { info.textContent = r.error; return; }
|
||
if (!ensureDraft()) return;
|
||
const c = state.draft.config; c.spotify = Object.assign({}, c.spotify, { playlist: r.uri }); if (r.name) c.spotify.name = r.name;
|
||
await saveDraft();
|
||
for (const [id, f] of Object.entries(c.fixes)) if (f.kind === "spotify") { const bb = await post("/api/build", { fix: f }); if (bb.ok && bb.label) f.label = bb.label; }
|
||
await saveDraft(); closeDialog(); location.hash = "#review";
|
||
} catch (e) { info.textContent = e.message; }
|
||
};
|
||
});
|
||
}
|
||
async function jobHyperdecks() {
|
||
let cc;
|
||
try { cc = await api("/api/companion"); } catch (e) { return alertBox("Companion is not answering right now (" + e.message + "); try again in a moment."); }
|
||
const c = cfg();
|
||
const decks = (cc.connections || []).filter((r) => r.module === "bmd-hyperdeck");
|
||
const keyFor = (conn) => c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn));
|
||
const rows = decks.map((d) => { const k = keyFor(d.label); const chk = k && k.checks.find((ch) => ch.conn === d.label); return `<tr><td><label class="inline"><input type="checkbox" data-deck="${esc(d.label)}" ${k ? "checked" : ""}> ${esc(d.label)}</label></td><td>${k ? esc(k.label) + (k.pos ? ` (row ${k.pos[0]}, col ${k.pos[1]})` : "") : "<span class='muted'>no key</span>"}</td><td><label class="inline"><input type="checkbox" data-nodisk="${esc(d.label)}" ${chk && chk.optional ? "checked" : ""}> may have no disk</label></td></tr>`; }).join("");
|
||
if (!decks.length) return alertBox("Companion has no HyperDeck connections (module bmd-hyperdeck). Add the connection in Companion first.");
|
||
dialog(`<h3>Add or remove a HyperDeck</h3><p class="help">Tick the decks in use. A ticked deck gets an HD key in the REC group; an unticked one loses its key (its cell stays empty; drag another key there if you like). "May have no disk" means NO DISK is a warning, not an alarm.</p>
|
||
<div class="tablewrap"><table><tr><th>Companion connection</th><th>key</th><th></th></tr>${rows}</table></div>
|
||
<div class="row"><button class="primary" id="dok">Use these decks</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#dok").onclick = () => {
|
||
if (!ensureDraft()) return;
|
||
const d = state.draft.config;
|
||
const rec = d.tiles.find((t) => (t.members || []).some((m) => d.tiles.some((x) => x.id === m && (x.checks || []).some((ch) => ch.type === "hyperdeck")))) || d.tiles.find((t) => t.id === "rec");
|
||
const fixId = Object.keys(d.fixes).find((id) => d.fixes[id].kind === "hyperdeck");
|
||
for (const cb of b.querySelectorAll("[data-deck]")) {
|
||
const conn = cb.dataset.deck, nodisk = b.querySelector(`[data-nodisk="${CSS.escape(conn)}"]`).checked;
|
||
const existing = d.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn));
|
||
if (cb.checked && existing) { const ch = existing.checks.find((x) => x.conn === conn); if (nodisk) ch.optional = true; else delete ch.optional; }
|
||
else if (cb.checked) {
|
||
const n = (conn.match(/(\d+)\s*$/) || [])[1]; let id = n ? "hd" + n : slug(conn), k = 2; while (d.tiles.some((t) => t.id === id)) id = (n ? "hd" + n : slug(conn)) + "_" + k++;
|
||
const t = { id, label: n ? "HD" + n : conn.toUpperCase().slice(0, 8), hold_always: fixId || undefined, checks: [Object.assign({ type: "hyperdeck", conn }, nodisk ? { optional: true } : {}, fixId ? { fix: { conn: fixId } } : {})] };
|
||
const pos = firstEmpty(d, "status"); if (pos) t.pos = pos;
|
||
d.tiles.push(t); if (rec) rec.members.push(id);
|
||
} else if (existing) { d.tiles.splice(d.tiles.indexOf(existing), 1); for (const t of d.tiles) if (t.members) t.members = t.members.filter((m) => m !== existing.id); }
|
||
}
|
||
saveDraft(); closeDialog(); location.hash = "#keys";
|
||
};
|
||
});
|
||
}
|
||
async function jobMidi() {
|
||
const live = await api("/api/live/midi").catch(() => ({ local: {}, remote: {} }));
|
||
const c = cfg();
|
||
const macs = Object.entries(c.hosts).filter(([, h]) => h.self || (h.os || "mac") === "mac");
|
||
const midiKey = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "midi_peer"));
|
||
const fixId = Object.keys(c.fixes).find((id) => c.fixes[id].kind === "midi_connect");
|
||
const sessionsOf = (alias) => { const h = c.hosts[alias]; const src = h.self ? (live.local || {}) : ((live.remote || {})[alias] || {}); return Object.entries(src).map(([name, v]) => [name, (v.peers || []).map((p) => p.name)]); };
|
||
dialog(`<h3>Add a MIDI link</h3><p class="help">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.</p>
|
||
<div class="field"><label>The Mac that holds the session</label><select id="jhost">${macs.map(([a, h]) => `<option value="${esc(a)}">${esc(h.name || a)}${h.self ? " (this Mac)" : ""}</option>`).join("")}</select></div>
|
||
<div class="field"><label>Session</label><input id="jsession" list="jsessions" placeholder="as Audio MIDI Setup names it"><datalist id="jsessions"></datalist></div>
|
||
<div class="field"><label>Peer</label><input id="jpeer" list="jpeers" placeholder="the other end's name"><datalist id="jpeers"></datalist></div>
|
||
<div class="field"><label>Key text when it is lost (short)</label><input id="jtag" placeholder="LIGHT PC" maxlength="10"></div>
|
||
<div class="field"><label>Address if the peer must be reconnected by address (optional)</label><input id="jaddr" placeholder="10.1.10.206:5004"></div>
|
||
<div class="field"><label>Which key</label><select id="jkey">${midiKey ? `<option value="${esc(midiKey.id)}">${esc(midiKey.label)} (the MIDI key)</option>` : ""}<option value="__new">a new key</option></select></div>
|
||
<div class="row"><button class="primary" id="dok">Add the link</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
const fill = () => { const ss = sessionsOf(b.querySelector("#jhost").value); b.querySelector("#jsessions").innerHTML = ss.map(([n]) => `<option value="${esc(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) => `<option value="${esc(n)}">`).join(""); };
|
||
b.querySelector("#jhost").onchange = () => { b.querySelector("#jsession").value = ""; fill(); };
|
||
b.querySelector("#jsession").oninput = fill;
|
||
b.querySelector("#jpeer").oninput = () => { if (!b.querySelector("#jtag").value) b.querySelector("#jtag").placeholder = tagOf(b.querySelector("#jpeer").value) || "LIGHT PC"; };
|
||
fill();
|
||
b.querySelector("#dok").onclick = () => {
|
||
const host = b.querySelector("#jhost").value, session = b.querySelector("#jsession").value.trim(), peer = b.querySelector("#jpeer").value.trim();
|
||
const tag = b.querySelector("#jtag").value.trim() || tagOf(peer), addr = b.querySelector("#jaddr").value.trim(), keyId = b.querySelector("#jkey").value;
|
||
if (!session || !peer) return alertBox("A session and a peer, please.");
|
||
if (!ensureDraft()) return;
|
||
const d = state.draft.config;
|
||
const chk = Object.assign({ type: "midi_peer", session, peer, tag }, d.hosts[host].self ? {} : { host }, fixId ? { fix: fixId } : {}, addr ? { address: addr } : {});
|
||
let target = d.tiles.find((t) => t.id === keyId);
|
||
if (!target) { let id = "midi", n = 2; while (d.tiles.some((t) => t.id === id)) id = "midi_" + n++; target = { id, label: "MIDI", checks: [] }; const pos = firstEmpty(d, "status"); if (pos) target.pos = pos; d.tiles.push(target); }
|
||
target.checks = target.checks || []; target.checks.push(chk);
|
||
saveDraft(); closeDialog(); location.hash = "#keys/" + target.id;
|
||
};
|
||
});
|
||
}
|
||
function jobComputer() {
|
||
dialog(`<h3>Add a computer</h3><p class="help">It appears on Computers with what runs on it; the Setup tab then shows what to do on it (login key, Remote Login). Fixes and checks for it come after.</p>
|
||
<div class="field"><label>Name, as people say it</label><input id="jname" placeholder="Lobby PC"></div>
|
||
<div class="field"><label>Short name (letters, digits, _)</label><input id="jalias" placeholder="lobby"></div>
|
||
<div class="field"><label>Mac or Windows</label><select id="jos"><option value="mac">Mac</option><option value="windows">Windows</option></select></div>
|
||
<div class="field"><label>Log-in address</label><input id="jssh" placeholder="user@10.1.x.x"></div>
|
||
<div class="field"><label>What it does for the panel</label><input id="jrole" placeholder="runs the lobby screen"></div>
|
||
<div class="row"><button class="primary" id="dok">Add it</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#jname").oninput = () => { b.querySelector("#jalias").value = slug(b.querySelector("#jname").value); };
|
||
b.querySelector("#dok").onclick = () => {
|
||
const name = b.querySelector("#jname").value.trim(), alias = b.querySelector("#jalias").value.trim(), os = b.querySelector("#jos").value, ssh = b.querySelector("#jssh").value.trim(), role = b.querySelector("#jrole").value.trim();
|
||
if (!name || !/^[a-z0-9_]+$/.test(alias)) return alertBox("A name, and a short name of lowercase letters, digits and _.");
|
||
if (!/^[^@\s]+@[^@\s]+$/.test(ssh)) return alertBox("The log-in address is user@address.");
|
||
if (cfg().hosts[alias]) return alertBox("That short name is taken.");
|
||
if (!ensureDraft()) return;
|
||
state.draft.config.hosts[alias] = { name, os, ssh, role, runs: [{ kind: "sshd" }] };
|
||
saveDraft(); closeDialog(); location.hash = "#computers/" + alias;
|
||
};
|
||
});
|
||
}
|
||
function jobPcProgram() {
|
||
const pcs = Object.entries(cfg().hosts).filter(([, h]) => h.os === "windows");
|
||
if (!pcs.length) return alertBox("No Windows PC is listed yet: Add a computer first.");
|
||
dialog(`<h3>Make a PC program startable</h3><p class="help">The panel starts programs on a PC through a scheduled task named Start <program> under \\StatusPanel\\ (the Setup tab shows how to create it). This adds the task to the PC's list and a "start it if it is not running" fix.</p>
|
||
<div class="field"><label>PC</label><select id="jhost">${pcs.map(([a, h]) => `<option value="${esc(a)}">${esc(h.name || a)}</option>`).join("")}</select></div>
|
||
<div class="field"><label>Program, as people say it</label><input id="jname" placeholder="Vista"></div>
|
||
<div class="field"><label>Program file, as Task Manager shows it</label><input id="jexe" placeholder="Vista.exe"></div>
|
||
<div class="row"><button class="primary" id="dok">Add it</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#dok").onclick = async () => {
|
||
const host = b.querySelector("#jhost").value, name = b.querySelector("#jname").value.trim(), exe = b.querySelector("#jexe").value.trim();
|
||
if (!name || !exe) return;
|
||
if (!ensureDraft()) return;
|
||
const d = state.draft.config, task = "Start " + name;
|
||
const h = d.hosts[host]; h.runs = h.runs || []; if (!h.runs.some((r) => r.kind === "task" && r.name === task)) h.runs.push({ kind: "task", name: task, exe });
|
||
let id = slug(name), n = 2; while (d.fixes[id]) id = slug(name) + "_" + n++;
|
||
const r = await post("/api/build", { fix: { template: "win_start", params: { host, task, exe }, short: name.toUpperCase().slice(0, 12) } });
|
||
if (!r.ok) return alertBox(r.error);
|
||
d.fixes[id] = Object.assign(r.fix, { label: r.label || ("start " + name + " if it is not running") });
|
||
saveDraft(); closeDialog(); location.hash = "#fixes/" + id;
|
||
};
|
||
});
|
||
}
|
||
function canRunNow(fixId) {
|
||
const f = cfg().fixes[fixId]; if (!f) return false;
|
||
if (["reconnect", "hyperdeck", "midi_connect", "spotify"].includes(f.kind)) return true;
|
||
if ((f.kind || "ssh") === "ssh" || f.kind === "local") return !closes(f) && !f.never_auto;
|
||
return false;
|
||
}
|
||
function runNow(key, fixId, which) {
|
||
const f = cfg().fixes[fixId];
|
||
confirmBox(`Run "${f.label || fixId}" now? It runs through the watchdog exactly like a hold on the ${key.toUpperCase()} key.`, "Run it", async () => {
|
||
try { await post("/api/command", { text: `test ${key} ${fixId} ${which}` }); alertBox("Sent. Watch the key, or Logs › Watchdog (TEST)."); } catch (e) { alertBox(e.message); }
|
||
});
|
||
}
|
||
|
||
function deckHtml(deck, c, which, selected, draggable) {
|
||
const cells = {};
|
||
const rp = c.restart_page;
|
||
if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; }
|
||
else { for (const t of c.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
|
||
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : c.page), "2,0": "DOWN" };
|
||
const drag = (what) => editing() && draggable ? ` draggable="true" data-drag="${esc(what)}"` : "";
|
||
let out = `<div class="deck">`;
|
||
for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) {
|
||
const k = `${r},${col}`, cell = cells[k];
|
||
if (nav[k]) out += `<div class="key nav locked">${esc(nav[k])}</div>`;
|
||
else if (cell && cell.nav) out += `<div class="key nav"${drag(cell.nav.startsWith("RESTART") ? "jump:" : "back:")}>${esc(cell.nav)}</div>`;
|
||
else if (cell) {
|
||
const s = deck && deck[cell.id];
|
||
const style = s ? `--b1:${hex(s.b)};--f1:${hex(s.f)};--b2:${hex(s.b2)};--f2:${hex(s.f2)};background:${hex(s.b)};color:${hex(s.f)}` : "background:#444;color:#ddd";
|
||
const blink = s && s.b !== s.b2 ? " blink" : "";
|
||
out += `<div class="key${blink}${selected === cell.id ? " sel" : ""}" style="${style}"${drag((which === "restart" ? "rkey:" : "tile:") + cell.id)} onclick="location.hash='#keys/${which === "restart" ? "restart/" : ""}${esc(cell.id)}'">${esc(s ? s.x : cell.label)}</div>`;
|
||
} else out += `<div class="key empty"${editing() && draggable ? ` data-drop="${r},${col}"` : ""}></div>`;
|
||
}
|
||
return out + `</div>`;
|
||
}
|
||
|
||
// ---- Keys ------------------------------------------------------------------
|
||
async function keys(rest) {
|
||
await flush();
|
||
const o = await api("/api/overview").catch(() => ({ deck: null }));
|
||
const c = cfg();
|
||
const restart = rest[0] === "restart";
|
||
const sel = restart ? rest[1] : rest[0];
|
||
const hold = ((c.page_hold_ms || 2000) / 1000) + " s";
|
||
const tabs = `<div class="tabs"><button class="${restart ? "" : "on"}" onclick="location.hash='#keys'">Status page (page ${c.page})</button>${c.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page (page ${c.restart_page.page})</button>` : ""}</div>
|
||
${editing() ? `<p class="small row">In Companion the Status page is page <input class="pagenum" data-which="status" type="number" min="1" value="${c.page}">${c.restart_page ? ` and the Restart page is page <input class="pagenum" data-which="restart" type="number" min="1" value="${c.restart_page.page}">` : ""} <label class="inline"><input type="checkbox" id="hasrestart" ${c.restart_page ? "checked" : ""}> this campus has a Restart page</label></p>` : ""}`;
|
||
let panel = `<p class="muted">Click a key to see what it watches and what holding it does.${editing() ? " With Editing on you can change it here, drag a key to move it, drag it onto the tray to hide it." : ""}</p>`;
|
||
if (sel && !restart) { const i = c.tiles.findIndex((x) => x.id === sel); if (i >= 0) panel = await keyPanel(i, hold); }
|
||
else if (sel && restart && c.restart_page) { const i = c.restart_page.keys.findIndex((x) => x.id === sel); if (i >= 0) panel = restartKeyPanel(i, hold); }
|
||
const trayKeys = c.tiles.filter((t) => !t.pos && t.kind !== "master");
|
||
const tray = trayKeys.map((t) => `<button class="small"${editing() ? ` draggable="true" data-drag="tile:${esc(t.id)}"` : ""} onclick="location.hash='#keys/${esc(t.id)}'">${esc(t.label)}</button>`).join(" ");
|
||
let note = "";
|
||
if (state.draft) { try { const v = await post("/api/validate", {}); if (v.plan && v.plan.pages.length) note = `<div class="notice">Layout changed: Apply will re-import the ${v.plan.pages.filter((p) => p === "status" || p === "restart").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.</div>`; } catch (e) {} }
|
||
const adders = editing() ? (restart ? `<button id="newrkey" class="small">+ New restart key</button>` : `<button id="newkey" class="small">+ New key</button>`) : "";
|
||
main.innerHTML = `<h1>Keys</h1><p class="what">The deck as people hold it. Click a key; with Editing on, change what it watches and what holding it does, drag it to move it.</p>${tabs}${note}
|
||
<div class="cols"><div>${restart && !c.restart_page ? "<p class='muted'>This campus has no Restart page.</p>" : deckHtml(o.deck, c, restart ? "restart" : "status", sel, true)}
|
||
${!restart ? `<p class="small muted tray" id="tray" style="margin-top:8px">Shown inside their group, no key of their own: ${tray || "<span class='muted'>(none)</span>"}${editing() ? " <span class='muted'>· drop a key here to hide it</span>" : ""}</p>` : ""}
|
||
<div class="row" style="margin-top:8px">${adders}</div></div><div id="panel">${panel}</div></div>`;
|
||
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(`<h3>New key</h3><input id="nlabel" placeholder="label, as the key shows it (HD7)"><input id="nid" placeholder="id (letters, digits, _) – filled from the label"><input id="npos" placeholder="row, column" value="${(firstEmpty(cfg(), "status") || []).join(", ")}"><div class="row"><button class="primary" id="dok">Add the key</button><button id="dcancel">Cancel</button></div><p class="help">It starts with no checks: add them on the key, then Apply.</p>`, (b) => {
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#nlabel").oninput = () => { b.querySelector("#nid").value = b.querySelector("#nlabel").value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""); };
|
||
b.querySelector("#dok").onclick = () => {
|
||
const label = b.querySelector("#nlabel").value.trim(), id = b.querySelector("#nid").value.trim();
|
||
if (!label || !/^[A-Za-z0-9_]+$/.test(id)) return;
|
||
if (cfg().tiles.some((t) => t.id === id)) return alertBox("A key with that id exists.");
|
||
const pos = parsePos(b.querySelector("#npos").value, cfg(), "status", true); if (pos === false) return;
|
||
if (!ensureDraft()) return;
|
||
const t = { id, label, checks: [] }; if (pos) t.pos = pos;
|
||
state.draft.config.tiles.push(t); saveDraft(); closeDialog(); location.hash = "#keys/" + id;
|
||
};
|
||
});
|
||
const nr = main.querySelector("#newrkey"); if (nr) nr.onclick = () => { const c = cfg(); dialog(`<h3>New restart key</h3><input id="nlabel" placeholder="label (PROP TV)"><select id="nfix">${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}">${esc(f.short || id)} – ${esc(f.label || "")}</option>`).join("")}</select><input id="npos" placeholder="row, column" value="${(firstEmpty(c, "restart") || []).join(", ")}"><div class="row"><button class="primary" id="dok">Add the key</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#dok").onclick = () => {
|
||
const label = b.querySelector("#nlabel").value.trim(), fix = b.querySelector("#nfix").value;
|
||
if (!label) return;
|
||
const pos = parsePos(b.querySelector("#npos").value, cfg(), "restart", false); if (!pos) return;
|
||
if (!ensureDraft()) return;
|
||
let id = "rs_" + label.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""), n = 2;
|
||
while (state.draft.config.restart_page.keys.some((k) => k.id === id)) id = id.replace(/_\d+$/, "") + "_" + n++;
|
||
state.draft.config.restart_page.keys.push({ id, label, fix, pos, watch: [] }); saveDraft(); closeDialog(); location.hash = "#keys/restart/" + id;
|
||
};
|
||
}); };
|
||
}
|
||
|
||
async function keyPanel(i, hold) {
|
||
const c = cfg(), t = c.tiles[i];
|
||
const k = await api("/api/keys");
|
||
const kt = k.tiles.find((x) => x.id === t.id) || { says: [], holding: "", checks: [] };
|
||
const fixOptions = (cur) => `<option value="">none</option><option value="restart-connection" ${cur === "restart-connection" ? "selected" : ""}>reconnect its Companion connection</option><option value="dante-auto" ${cur === "dante-auto" ? "selected" : ""}>Dante auto (PoE port or the Dante module)</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${cur === id ? "selected" : ""}>${esc(f.short || id)} – ${esc(f.label || "")}</option>`).join("")}`;
|
||
const checks = (t.checks || []).map((chk, j) => {
|
||
const spec = state.schema.checks[chk.type] || { fields: [], question: "" };
|
||
const sentence = (kt.checks[j] || {}).sentence || chk.type;
|
||
if (!editing()) return `<li>${esc(sentence)}<details><summary>Advanced</summary><dl class="kv small">${Object.entries(chk).map(([n, v]) => `<dt>${esc(n)}</dt><dd class="mono">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details></li>`;
|
||
const base = `tiles.${i}.checks.${j}.`;
|
||
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 = `<div class="field"><label>Built as</label><select class="ctplsel" data-check="${j}">${ctpls.map((x) => `<option value="${x.name}" ${x.name === ctName ? "selected" : ""}>${esc(x.words)}</option>`).join("")}</select></div><div class="ctplform" data-check="${j}">${paramsHtml(ct, chk.params || {}, "ck" + j)}</div>`
|
||
+ (ctName === "custom" ? fields : inputHtml(base + "msg", F("tiles[].checks[].msg"), chk.msg) + (chk.cmd ? `<div class="field"><label>The command it makes (on ${esc((c.hosts[chk.host] || {}).name || chk.host)})</label><pre class="log small">${esc(chk.cmd)}</pre></div>` : ""));
|
||
}
|
||
const fixSel = typeof chk.fix === "object" && chk.fix ? `<div class="field"><label>Fix (per sub-check)</label><textarea data-path="${base}fix" rows="2" class="fixtable">${esc(JSON.stringify(chk.fix))}</textarea><div class="help">A table: sub-check → fix, "*" for everything else.</div></div>`
|
||
: `<div class="field"><label>Fix: what holding runs for this check</label><select data-path="${base}fix" class="fixsel">${fixOptions(chk.fix)}</select></div>`;
|
||
const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `<button class="small" data-run="${esc(t.id)}|${esc(chk.fix)}|${j}">Run this fix now</button>` : "";
|
||
return `<li><b>${esc(spec.question || chk.type)}</b> <span class="muted">${esc(sentence)}</span><div class="checkbox">${fields}${fixSel}
|
||
<details><summary>Advanced</summary>${["tag", "service_only", "debounce_seconds"].filter((f) => !(spec.fields || []).includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("")}<p class="mono small">type ${esc(chk.type)}</p></details>
|
||
<div class="row">${run}<button class="small danger" data-removecheck="${j}">Remove this check</button></div></div></li>`;
|
||
}).join("");
|
||
const plain = !t.members && t.kind !== "master";
|
||
const members = t.members ? (editing() ? `<div class="field"><label>Group members</label>${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => `<label class="inline"><input type="checkbox" data-member="${esc(x.id)}" ${t.members.includes(x.id) ? "checked" : ""}> ${esc(x.label)}</label>`).join(" ")}</div>` : `<p><b>Sums up:</b> ${t.members.map((m) => `<a href="#keys/${esc(m)}">${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}</a>`).join(", ")}</p>`) : "";
|
||
const addMenu = editing() && plain ? `<div class="field"><label>Add a check</label><select id="addcheck"><option value="">– pick a question –</option>${Object.entries(groupBy(state.schema.checks, (s) => s.question || "Other")).map(([q, types]) => `<optgroup label="${esc(q)}">${types.map((ty) => `<option value="${esc(ty)}">${esc(state.schema.checks[ty].words || ty)}</option>`).join("")}</optgroup>`).join("")}</select></div>` : "";
|
||
const label = editing() ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : "";
|
||
const holdAlways = editing() && plain ? `<div class="field"><label>Hold runs even when the key is green</label><select data-path="tiles.${i}.hold_always" class="holdsel"><option value="">no – only what is failing</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${t.hold_always === id ? "selected" : ""}>${esc(f.short || id)} – ${esc(f.label || "")}</option>`).join("")}</select></div>` : "";
|
||
const pos = `<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(t.id)}</dd></dl>${editing() && !t.kind ? `<div class="field"><label>Position (row, column; blank = inside its group only)</label><input id="pos" value="${esc(t.pos ? t.pos.join(", ") : "")}" placeholder="row, column"></div>` : ""}</details>`;
|
||
return `<div class="card"><h2>${esc(t.label)}</h2><p class="muted">${t.pos ? `row ${t.pos[0]}, column ${t.pos[1]}` : "inside its group"}${t.kind ? ` · ${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}` : ""}</p>
|
||
${kt.says && kt.says.length ? `<p><b>WHAT THIS KEY WILL SAY:</b> <span class="mono">${esc(kt.says.join(", "))}</span></p>` : ""}
|
||
<p><b>HOLDING ${esc(hold)} WILL:</b> ${esc(kt.holding)}${t.hold_always ? " <span class='pill'>also when green</span>" : ""}</p>
|
||
${label}${members}${holdAlways}${checks ? `<p><b>Checks:</b></p><ol class="checks">${checks}</ol>` : ""}${addMenu}${pos}
|
||
${editing() && (!t.kind || t.kind === "network") ? `<p style="margin-top:12px"><button class="small danger" id="delkey">Delete this key</button></p>` : ""}</div>`;
|
||
}
|
||
function groupBy(obj, fn) { const out = {}; for (const [k, v] of Object.entries(obj)) (out[fn(v)] = out[fn(v)] || []).push(k); return out; }
|
||
function wireKeyPanel(sel, restart) {
|
||
const c = cfg();
|
||
const i = restart ? -1 : c.tiles.findIndex((x) => x.id === sel);
|
||
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
|
||
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)}
|
||
<div class="field"><label>What holding runs</label><select data-path="restart_page.keys.${i}.fix">${Object.entries(c.fixes).map(([id, fx]) => `<option value="${esc(id)}" ${r.fix === id ? "selected" : ""}>${esc(fx.short || id)} – ${esc(fx.label || "")}</option>`).join("")}</select></div>
|
||
<div class="field"><label>Keep flashing HOLD TO FIX while these are down (hold cmd/ctrl for several)</label><select multiple size="5" class="watchsel" data-path="restart_page.keys.${i}.watch">${Object.keys(c.fixes).map((id) => `<option value="${esc(id)}" ${(r.watch || []).includes(id) ? "selected" : ""}>fix ${esc(id)}</option>`).join("")}${[...conns].sort().map((cn) => `<option value="conn:${esc(cn)}" ${(r.watch || []).includes("conn:" + cn) ? "selected" : ""}>connection ${esc(cn)}</option>`).join("")}</select></div>` : "";
|
||
return `<div class="card"><h2>${esc(r.label)}</h2><p class="muted">row ${r.pos[0]}, column ${r.pos[1]}</p><p><b>HOLDING ${esc(hold)} WILL:</b> ${esc(f.label || r.fix)}, even when the Status page says it is fine.</p>${r.watch && r.watch.length ? `<p class="small muted">Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}</p>` : ""}${edit}
|
||
${canRunNow(r.fix) && editing() ? `<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run this fix now</button>` : ""}<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd></dl></details>${editing() ? `<p style="margin-top:12px"><button class="small danger" id="delrkey">Delete this key</button></p>` : ""}</div>`;
|
||
}
|
||
|
||
// ---- Computers ---------------------------------------------------------------
|
||
let compTab = "map";
|
||
async function computers(rest, quiet) {
|
||
if (quiet && document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) return;
|
||
const m = await api("/api/machines");
|
||
const selected = rest[0] && rest[0] !== "gear" && rest[0] !== "setup" ? rest[0] : null;
|
||
compTab = rest[0] === "gear" || rest[0] === "setup" ? rest[0] : "map";
|
||
const tabs = `<div class="tabs"><button class="${compTab === "map" ? "on" : ""}" onclick="location.hash='#computers'">Map</button><button class="${compTab === "gear" ? "on" : ""}" onclick="location.hash='#computers/gear'">Gear</button><button class="${compTab === "setup" ? "on" : ""}" onclick="location.hash='#computers/setup'">Setup</button>
|
||
<button id="probe" ${m.probing ? "disabled" : ""}>${m.probing ? "Looking…" : "Check computers now"}</button></div>`;
|
||
let body = "";
|
||
if (compTab === "gear") body = await gearHtml();
|
||
else if (compTab === "setup") body = setupHtml(m, (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) => `<tr class="click${selected === c.alias ? " sel" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}${c.self ? ' <span class="muted small">(this Mac)</span>' : ""}</td><td class="small">${esc(c.role)}</td><td class="small">${(c.keys || []).map((k) => `<span class="pill">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</span>`).join(" ")}</td><td class="muted small">${esc(c.reason)}${c.looked ? `<br>looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}</td></tr>`).join("");
|
||
body = `<div class="tablewrap"><table><tr><th>status</th><th>computer</th><th>what it does for the panel</th><th>keys that turn red if it dies</th><th></th></tr>${rows}</table></div>`;
|
||
const c = m.computers.find((x) => x.alias === selected);
|
||
if (c) body += computerCard(c);
|
||
}
|
||
main.innerHTML = `<h1>Computers</h1><p class="what">What runs where. One row per computer; click it for everything that runs there, with a light each.</p>${tabs}${body}`;
|
||
for (const b of main.querySelectorAll("[data-copy]")) b.onclick = async () => { const ta = document.getElementById(b.dataset.copy); try { await navigator.clipboard.writeText(ta.value); b.textContent = "Copied"; } catch (e) { ta.select(); document.execCommand("copy"); b.textContent = "Copied"; } };
|
||
const rm = main.querySelector("#removehost"); if (rm) rm.onclick = () => removeComputer(selected);
|
||
const pb = document.getElementById("probe");
|
||
if (pb) pb.onclick = async () => { pb.disabled = true; pb.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => computers(rest, true).then(() => wireInputs(main)), 4000); };
|
||
for (const b of main.querySelectorAll("[data-gear]")) b.onclick = async () => { const row = b.closest("tr"); try { await post("/api/gear", { name: b.dataset.gear, location: row.querySelector("[data-loc]").value, notes: row.querySelector("[data-notes]").value }); const d = await api("/api/draft"); state.draft = d.draft; badge(); b.textContent = "Saved"; } catch (e) { alertBox(e.message); } };
|
||
const runs = main.querySelector("#runs"); if (runs) runs.onchange = () => { if (!ensureDraft()) return; try { state.draft.config.hosts[selected].runs = runs.value.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l)); saveDraft(); } catch (err) { alertBox("Each line must be a valid {...} entry."); } };
|
||
}
|
||
function computerCard(c) {
|
||
const items = (c.items || []).map((i) => `<tr><td>${lightDot(i.light)}${esc(i.light === "ok" ? "OK" : i.light === "problem" ? "Problem" : i.light === "not_needed" ? "Not needed" : "Can't see from here")}</td><td>${esc(i.name)}${i.watched === false ? ' <span class="muted small">(not watched)</span>' : ""}</td><td class="muted small">${esc(i.reason || (typeof i.detail === "object" && i.detail ? Object.entries(i.detail).map(([k, v]) => k + " " + (v === true ? "yes" : v === false ? "no" : k === "age" ? ago(v) : v)).join(", ") : i.detail || ""))}</td></tr>`).join("");
|
||
const found = c.found || {};
|
||
const foundHtml = Object.entries(found).filter(([, v]) => Array.isArray(v) && v.length).map(([k, v]) => `<p class="small"><b>${esc(k)}:</b> <span class="mono">${esc(typeof v[0] === "object" ? v.map((x) => x.iface + " " + x.ip + (x.active ? "" : " (down)")).join(", ") : v.join(", "))}</span></p>`).join("") || `<p class="muted small">Press "Check computers now" to look.</p>`;
|
||
const done = Object.entries(c.manual_done || {}).map(([k, v]) => `<li>${lightDot("hand")}${esc(k.replace(/_/g, " "))}: done by hand (${esc(v.by)}, ${esc(v.on)})</li>`).join("");
|
||
const base = `hosts.${c.alias}.`;
|
||
const edit = editing() ? `<details open><summary>Change this computer</summary>${["name", "role", "notes", "os"].concat(c.self ? [] : ["ssh", "mac", "wake_broadcast", "pingable"]).map((f) => inputHtml(base + f, F("hosts.*." + f), getPath(base + f))).join("")}
|
||
<div class="field"><label>What runs here (one per line, as {"kind": ..., "name": ...})</label><textarea id="runs" rows="${Math.max(4, (getPath(base + "runs") || []).length + 1)}" class="mono">${esc((getPath(base + "runs") || []).map((r) => JSON.stringify(r)).join("\n"))}</textarea><div class="help">Kinds: sshd, app (process), launchagent (label), daemon (process), task (name, exe), service (name), web (url), helper_dvs, helper_solo (label), midi_session (name, peer_of), artnet (source), folder (path), disabled (label). "watched": false for things the panel only lists.</div></div></details>` : "";
|
||
const remove = editing() && !c.self ? `<p style="margin-top:12px"><button class="small danger" id="removehost">Remove this computer</button></p>` : "";
|
||
return `<div class="card" id="card"><h2>${esc(c.name)} ${wordOf(c.word)}</h2><p class="muted">${esc(c.role)}${c.ssh ? ` · logs in as <span class="mono">${esc(c.ssh)}</span>` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "<b>no answer</b>"}` : ""}${c.error ? ` · <b>${esc(c.error)}</b>` : ""}</p>
|
||
${c.notes ? `<p class="notice grey">${esc(c.notes)}</p>` : ""}
|
||
<h3>What runs here</h3><div class="tablewrap"><table>${items}</table></div>
|
||
${done ? `<ul class="small" style="list-style:none;padding:0">${done}</ul>` : ""}
|
||
<details><summary>Everything found on this computer</summary>${foundHtml}</details>
|
||
${c.self ? `<details><summary>This Mac's set-up lights</summary><dl class="kv small">${Object.entries(c.setup || {}).map(([k, v]) => `<dt>${esc(k.replace(/_/g, " "))}</dt><dd>${typeof v === "boolean" ? lightDot(v ? "ok" : "problem") + (v ? "OK" : "Problem") : esc(JSON.stringify(v))}</dd>`).join("")}</dl></details>` : ""}
|
||
${edit}${remove}</div>`;
|
||
}
|
||
function removeComputer(alias) {
|
||
const c = cfg(), h = c.hosts[alias]; if (!h) return;
|
||
const fixes = Object.entries(c.fixes).filter(([, f]) => f.host === alias || (f.params || {}).host === alias).map(([id]) => id);
|
||
const checks = []; for (const t of c.tiles) (t.checks || []).forEach((ch, j) => { if (ch.host === alias || (ch.params || {}).host === alias) checks.push([t.id, j, t.label]); });
|
||
const usesFix = []; for (const t of c.tiles) { for (const ch of t.checks || []) for (const f of (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix])) if (fixes.includes(f)) usesFix.push(t.label); if (fixes.includes(t.hold_always)) usesFix.push(t.label); }
|
||
const rkeys = (c.restart_page ? c.restart_page.keys : []).filter((k) => fixes.includes(k.fix)).map((k) => k.label);
|
||
const what = [`the computer ${h.name || alias}`].concat(fixes.length ? [`${fixes.length} fix${fixes.length > 1 ? "es" : ""} that ran on it (${fixes.join(", ")})`] : [], checks.length ? [`${checks.length} check${checks.length > 1 ? "s" : ""} on the keys ${[...new Set(checks.map((x) => x[2]))].join(", ")}`] : [], rkeys.length ? [`the restart keys ${rkeys.join(", ")}`] : []);
|
||
confirmBox(`Remove ${what.join(", ")}? The keys stay; a key left with nothing to check will say OK.`, "Remove", () => {
|
||
if (!ensureDraft()) return;
|
||
const d = state.draft.config;
|
||
delete d.hosts[alias];
|
||
for (const id of fixes) { delete d.fixes[id]; if (d.auto_fix && d.auto_fix.fixes) d.auto_fix.fixes = d.auto_fix.fixes.filter((x) => x !== id); }
|
||
for (const t of d.tiles) {
|
||
t.checks = (t.checks || []).filter((ch) => !(ch.host === alias || (ch.params || {}).host === alias));
|
||
for (const ch of t.checks) { if (typeof ch.fix === "string" && fixes.includes(ch.fix)) delete ch.fix; else if (ch.fix && typeof ch.fix === "object") for (const k of Object.keys(ch.fix)) if (fixes.includes(ch.fix[k])) delete ch.fix[k]; }
|
||
if (fixes.includes(t.hold_always)) delete t.hold_always;
|
||
if (!t.checks.length && !t.members) delete t.checks;
|
||
}
|
||
if (d.restart_page) d.restart_page.keys = d.restart_page.keys.filter((k) => !fixes.includes(k.fix));
|
||
saveDraft(); location.hash = "#computers";
|
||
});
|
||
}
|
||
async function gearHtml() {
|
||
const g = await api("/api/gear");
|
||
return `<p class="small muted">Devices that are not computers, from the keys that watch them.${editing() ? " Type where each one lives and press Save." : ""}</p><div class="tablewrap"><table><tr><th>light</th><th>device</th><th>kind</th><th>address</th><th>where / notes</th><th>watched by</th></tr>
|
||
${g.map((r) => `<tr><td>${lightDot(r.light)}${esc(r.light === "ok" ? "OK" : r.light === "problem" ? "Problem" : "–")}</td><td>${esc(r.name)}</td><td>${esc(r.kind)}</td><td class="mono small">${esc(r.address || "")}${r.detail ? `<br><span class="muted">${esc(r.detail)}</span>` : ""}</td><td class="small">${editing() ? `<input data-loc value="${esc(r.location || "")}" placeholder="where it is"> <input data-notes value="${esc(r.notes || "")}" placeholder="notes"> <button class="small" data-gear="${esc(r.name)}">Save</button>` : `${esc(r.location || "")} ${esc(r.notes || "")}`} <span class="muted">${esc(r.reason || "")}</span></td><td class="small">${(r.watched_by || []).map((k) => `<a href="#keys/${esc(k)}">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</a>`).join(", ")}</td></tr>`).join("")}</table></div>`;
|
||
}
|
||
function setupHtml(m, generated) {
|
||
return m.computers.map((c) => {
|
||
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
|
||
const gen = (generated || []).find((g) => g.alias === c.alias) || { steps: [], scripts: [] };
|
||
const steps = gen.steps.length ? gen.steps.map((x) => x.text) : ((m.setup_steps || {})[kind] || []);
|
||
const howOf = (t) => (gen.steps.find((x) => x.text === t) || {}).how;
|
||
const lightFor = (step) => {
|
||
const s = c.setup || {};
|
||
const item = (k) => (c.items || []).find((i) => i.kind === k);
|
||
if (/login key/.test(step)) return s.key_ok === true || s.login_key === true ? "ok" : s.key_ok === false || s.login_key === false ? "problem" : "unknown";
|
||
if (/python/.test(step)) return s.python_ok === true ? "ok" : s.python_ok === false ? "problem" : "unknown";
|
||
if (/SSH service/.test(step)) return c.reachable ? "ok" : "problem";
|
||
if (/firewall/.test(step)) return s.firewall_allow ? "ok" : "unknown";
|
||
if (/wake-on-LAN/.test(step)) return s.wake_on_lan === "Enabled" ? "ok" : s.wake_on_lan ? "problem" : "unknown";
|
||
if (/Public/.test(step)) return s.profile === "Public" ? "ok" : s.profile ? "problem" : "unknown";
|
||
if (/DVS helper/.test(step)) { const it = item("helper_dvs"); return it ? it.light : "not_needed"; }
|
||
if (/Pro Tools helper/.test(step)) { const it = item("helper_solo"); return it ? it.light : "not_needed"; }
|
||
if (/StatusPanel/.test(step)) { const ts = (c.items || []).filter((i) => i.kind === "task"); return ts.length ? (ts.every((i) => i.light === "ok") ? "ok" : "problem") : "unknown"; }
|
||
if (/rtpMIDI/.test(step)) { const it = item("service"); return it ? it.light : "not_needed"; }
|
||
if (/Netgear/.test(step)) return s.netgear_keychain === true ? "ok" : s.netgear_keychain === false ? "problem" : "not_needed";
|
||
if (/Google Drive/.test(step)) { const it = item("folder"); return it ? it.light : "not_needed"; }
|
||
if (/watchdog and the settings/.test(step)) { const ok = (c.items || []).filter((i) => i.kind === "launchagent" && /watchdog|settings/i.test(i.name)); return ok.length && ok.every((i) => i.light === "ok") ? "ok" : "unknown"; }
|
||
if (/Wi-Fi/.test(step)) return c.manual_done && c.manual_done.wifi_power ? "hand" : "unknown";
|
||
if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown";
|
||
return "unknown";
|
||
};
|
||
const scriptsHtml = gen.scripts.map((sc, i) => `<details class="script"><summary>${esc(sc.name)}</summary><p class="help">${esc(sc.how)}</p><textarea readonly rows="${Math.min(14, sc.text.split("\n").length + 1)}" class="mono" id="sc-${esc(c.alias)}-${i}">${esc(sc.text)}</textarea><div class="row"><button class="small" data-copy="sc-${esc(c.alias)}-${i}">Copy</button><a class="small" download="${esc(sc.name.replace(/[^A-Za-z0-9]+/g, "-").toLowerCase())}${/PowerShell/.test(sc.name) ? ".ps1" : ".sh"}" href="data:text/plain;charset=utf-8,${encodeURIComponent(sc.text)}">Save as a file</a></div></details>`).join("");
|
||
return `<div class="card"><h3>${esc(c.name)}</h3><ul style="list-style:none;padding:0;margin:0">${steps.map((s) => { const l = lightFor(s); const how = howOf(s); return `<li>${lightDot(l)}${esc(s)} <span class="muted small">${l === "hand" ? "(done by hand)" : l === "unknown" ? "– can't see from here" : ""}</span>${how ? `<details class="how"><summary class="small">how</summary><p class="help">${esc(how)}</p></details>` : ""}</li>`; }).join("")}</ul>${scriptsHtml}</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
// ---- Logs ------------------------------------------------------------------
|
||
let logTab = "watchdog", logFilter = "";
|
||
async function logs(rest) {
|
||
logTab = rest[0] || logTab;
|
||
const tabs = ["watchdog", "errors", "autofix", "settings"].map((n) => `<button class="${logTab === n ? "on" : ""}" onclick="location.hash='#logs/${n}'">${{ watchdog: "Watchdog", errors: "Errors", autofix: "Auto-fix days", settings: "Settings page" }[n]}</button>`).join("");
|
||
const filters = logTab === "watchdog" ? `<div class="tabs">${["", "faults", "holds", "fixes", "auto", "errors"].map((f) => `<button class="${logFilter === f ? "on" : ""}" data-f="${f}">${f || "everything"}</button>`).join("")}</div>` : "";
|
||
let data;
|
||
if (logTab === "autofix") data = await api("/api/logs/autofix" + (rest[1] ? "?file=" + encodeURIComponent(rest[1]) : ""));
|
||
else data = await api(`/api/logs/${logTab}?filter=${encodeURIComponent(logFilter)}`);
|
||
const files = logTab === "autofix" ? `<p class="small">${(data.files || []).slice().reverse().map((f) => `<a href="#logs/autofix/${encodeURIComponent(f)}" class="${data.file === f ? "pill" : ""}">${esc(f.replace(" Auto-Fix.log", ""))}</a>`).join(" · ") || "no auto-fix days yet"}</p>` : "";
|
||
main.innerHTML = `<h1>Logs</h1><p class="what">What the watchdog did and saw. Newest lines at the bottom.</p><div class="tabs">${tabs}</div>${filters}${files}<pre class="log">${esc((data.lines || []).join("\n")) || "(empty)"}</pre>`;
|
||
for (const b of main.querySelectorAll("[data-f]")) b.onclick = () => { logFilter = b.dataset.f; logs(rest); };
|
||
const pre = main.querySelector("pre.log"); pre.scrollTop = pre.scrollHeight;
|
||
}
|
||
|
||
// ---- History ----------------------------------------------------------------
|
||
async function history(rest) {
|
||
const h = await api("/api/history");
|
||
let detail = "";
|
||
if (rest[0]) {
|
||
const d = await api("/api/history/" + encodeURIComponent(rest[0]));
|
||
detail = `<div class="card"><h2>${esc(rest[0])}</h2><p class="muted">What is different between that version and now:</p>${d.changes_to_now.length ? `<ul>${d.changes_to_now.map((c) => `<li>${esc(c.text)}</li>`).join("")}</ul>` : "<p>Nothing: it is the same as now.</p>"}
|
||
${editing() ? `<button class="primary" id="goback">Go back to this version</button> <span class="help">It becomes your draft; Review & Apply puts it live.</span>` : `<p class="help">Switch Editing on to go back to it.</p>`}</div>`;
|
||
}
|
||
const rows = h.entries.map((e) => `<tr class="click" onclick="location.hash='#history/${encodeURIComponent(e.id)}'"><td>${esc(when(e.when))}</td><td>${esc(e.by)}</td><td>${esc(e.note)}</td><td class="small muted">${esc((e.actions || []).join(", "))}${e.outcome && e.outcome !== "applied" ? " · " + esc(e.outcome) : ""}</td></tr>`).join("");
|
||
main.innerHTML = `<h1>History</h1><p class="what">Every version of the settings that was ever applied: who, when, the note, what was different.</p>
|
||
<div class="row">${editing() && h.entries.length ? `<button id="undo">Undo the last change</button>` : ""}</div>
|
||
<div class="tablewrap"><table><tr><th>when</th><th>who</th><th>note</th><th></th></tr>${rows || "<tr><td colspan=4 class='muted'>nothing yet</td></tr>"}</table></div>${detail}`;
|
||
const gb = main.querySelector("#goback"); if (gb) gb.onclick = async () => { try { const r = await post("/api/restore/" + encodeURIComponent(rest[0])); state.draft = r.draft; badge(); location.hash = "#review"; } catch (e) { alertBox(e.message); } };
|
||
const un = main.querySelector("#undo"); if (un) un.onclick = () => confirmBox("Undo the last change? The version before it becomes your draft; Review & Apply puts it live.", "Undo", async () => { try { const r = await post("/api/undo"); state.draft = r.draft; badge(); location.hash = "#review"; } catch (e) { alertBox(e.message); } });
|
||
}
|
||
|
||
// ---- Review & Apply -------------------------------------------------------------
|
||
async function review() {
|
||
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 = `<h1>Review & Apply</h1><p class="what">Nothing to apply: there is no draft. Change something on any screen with Editing on.</p>${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`;
|
||
return;
|
||
}
|
||
const r = await post("/api/validate", {});
|
||
const changes = r.changes.map((c) => `<li class="${c.command ? "command" : ""}">${esc(c.text)}${c.command ? ' <span class="pill">command</span>' : ""}</li>`).join("") + (r.atem_pending ? "<li>A new saved switcher file replaces the current one.</li>" : "");
|
||
const errors = r.errors.map((e) => `<li>${esc(e)}</li>`).join("");
|
||
const warnings = r.warnings.map((w) => `<li>${esc(w)}</li>`).join("");
|
||
const live = r.live && r.live.live ? `<div class="notice">Live now: ${esc(r.live.reason)}. Apply waits until it is over; your draft is saved. ${editing() ? '<button id="emergency" class="danger">Apply anyway (emergency)</button>' : ""}</div>` : "";
|
||
main.innerHTML = `<h1>Review & Apply</h1><p class="what">Started by ${esc(state.draft.started_by || state.draft.by || "?")}${state.draft.started ? " at " + esc(when(state.draft.started)) : ""}. ${state.draftStale ? "<b>The live settings changed since this draft was started: look again before applying.</b>" : ""}</p>
|
||
<div class="card"><h3>Your changes (${r.changes.length + (r.atem_pending ? 1 : 0)})</h3>${changes ? `<ul class="changes">${changes}</ul>` : "<p class='muted'>No change yet.</p>"}
|
||
<p><b>What will happen:</b> ${esc(r.feel)}</p>
|
||
${errors ? `<div class="notice problem"><b>Not possible yet:</b><ul>${errors}</ul></div>` : ""}${warnings ? `<div class="notice grey"><b>Worth knowing:</b><ul>${warnings}</ul></div>` : ""}${live}
|
||
<div class="field"><label for="note">Note for history</label><input id="note" placeholder="what you changed and why" maxlength="200"></div>
|
||
<div class="row"><button class="primary" id="apply" ${errors || (r.live && r.live.live) || !editing() || !changes ? "disabled" : ""}>APPLY</button><button id="discard" ${editing() ? "" : "disabled"}>Throw the draft away</button></div></div>
|
||
${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`;
|
||
const go = (emergency) => async () => {
|
||
const note = main.querySelector("#note").value.trim();
|
||
try { const res = await post("/api/apply", { note, emergency }); if (res.ok) { state.draft = null; state.atemPending = false; badge(); review(); } else alertBox(res.error); }
|
||
catch (e) { alertBox(e.message); if (e.body && e.body.changed_since) { state.draftStale = true; badge(); } }
|
||
};
|
||
main.querySelector("#apply").onclick = go(null);
|
||
const em = main.querySelector("#emergency"); if (em) em.onclick = () => dialog(`<h3>Apply during a live service</h3><p>${esc(r.feel)} Say why, and type LIVE.</p><input id="why" placeholder="why it cannot wait"><input id="word" placeholder="type LIVE"><div class="row"><button class="primary danger" id="dok">Apply now</button><button id="dcancel">Cancel</button></div>`, (b) => { b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = () => { const why = b.querySelector("#why").value.trim(), word = b.querySelector("#word").value.trim(); if (word !== "LIVE" || !why) return; closeDialog(); go({ word, reason: why })(); }; });
|
||
main.querySelector("#discard").onclick = () => confirmBox("Throw the draft away? Every change since the last Apply is lost.", "Throw it away", async () => { try { await post("/api/draft", {}, "DELETE"); state.draft = null; state.atemPending = false; badge(); review(); } catch (e) { alertBox(e.message); } });
|
||
}
|
||
function applyProgress(st) {
|
||
main.innerHTML = `<h1>Applying…</h1><p class="what">${esc(st.note || "")}</p>${stepsHtml(st)}<p class="muted">This page updates by itself.</p>`;
|
||
setTimeout(async () => { try { const s2 = await api("/api/apply/status"); if (s2.state === "running") applyProgress(s2); else { await loadAll(); route(); } } catch (e) { setTimeout(() => applyProgress(st), 2000); } }, 1500);
|
||
}
|
||
function stepsHtml(st) { return `<ol class="steps">${(st.steps || []).map((s) => `<li class="${esc(s.state)}">${lightDot(s.state === "ok" ? "ok" : s.state === "failed" ? "problem" : s.state === "running" ? "warn" : s.state === "skipped" ? "not_needed" : "unknown")}<b>${esc(s.name)}</b> ${esc(s.state === "skipped" ? "skipped: " + s.detail : s.detail || s.state)}</li>`).join("")}</ol>`; }
|
||
function applyResult(st) { return `<div class="card"><h3>${st.state === "done" ? "Applied" : "Not applied"} <span class="muted small">${esc(when(st.finished))}${st.by ? " · " + esc(st.by) : ""}</span></h3><p>${esc(st.outcome)}</p>${stepsHtml(st)}</div>`; }
|
||
|
||
// ---- Help ------------------------------------------------------------------
|
||
async function help() {
|
||
const facts = await api("/api/facts");
|
||
const gl = state.schema.glossary || {};
|
||
const cards = [
|
||
["Read a red key", "Green is fine. A flashing red key names the problem. Press it once to acknowledge (it stops flashing, stays dark red). Hold it 2 s and it runs the fix for what is failing."],
|
||
["Purple means the watchdog is down", "Every key turns purple when the watchdog on the Ingest Mac has been silent for 15 s. Open this page: if the Overview says Companion is not answering, the problem is Companion; otherwise press Restart the watchdog on the Overview."],
|
||
["Which computer or device is that?", "Computers › Map shows every computer and the keys that turn red if it dies; Computers › Gear lists the switcher, decks, recorders, wireless and LED gear with their addresses."],
|
||
["What is a connection?", "Companion's link to one device (a HyperDeck, the Tascam, the switcher). The keys watch those connections; Site setup › Companion lists them all and which key watches each."],
|
||
].map(([t, b]) => `<div class="card"><h3>${t}</h3><p>${b}</p></div>`).join("");
|
||
const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `<details open><summary><b>${esc(g)}</b></summary><p class="mono small">${esc(words.join(" · "))}</p></details>`).join("");
|
||
main.innerHTML = `<h1>Help</h1><p class="what">Start with the four cards. The pages below are made from the settings, so they are never out of date.</p><div class="cards">${cards}</div>
|
||
<h2>Pages</h2><p><a href="/key-guide.html" target="_blank">What each key does</a> · <a href="/cheat.html" target="_blank">Cheat sheet (one page)</a> · <a href="/where.html" target="_blank">Where things are</a></p>
|
||
<h2>Changing something</h2><ol><li>Switch Editing on (top right): the editing code and your first name.</li><li>Change it where it lives: a key on Keys, a computer on Computers, a fix on Fixes, the rest on Site setup.</li><li>The badge at the top counts your changes; it opens Review & Apply.</li><li>Review reads your changes back in plain words and says what will happen (the deck goes purple for a few seconds; pages re-import only when the layout changed). Press APPLY.</li><li>If the watchdog does not come back, the previous version is put back by itself. History keeps every version; Go back to any of them.</li></ol>
|
||
<h2>First-day walkthrough (10 minutes, no code needed)</h2><ol><li>Overview: the live deck. Green, red, purple.</li><li>Click a red key (or any key): read what it checks and what holding it will do.</li><li>Logs › Watchdog, filter "holds": find a hold and what it did.</li><li>Keys › Restart page: each key restarts one thing, even when green.</li><li>Overview: when alarms are on and when auto-fix may act by itself.</li><li>Computers › Map: which computer a key talks to, and what runs there.</li><li>Help › What each key does: the whole deck in words.</li></ol>
|
||
<h2>The words a key can show</h2>${glossary}
|
||
<h2>Things only a person can do</h2><ul><li>Run the set-up script on a new computer (ARD on a Mac, PowerShell as administrator on a PC), allow Accessibility on the Studio B Mac, click Allow on the Spotify prompt once.</li><li>Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.</li><li>Type the Netgear login into the keychain; keep Google Drive signed in for the saved switcher files.</li><li>Save a new switcher file from ATEM Software Control when the show changes, then pick it on Site setup › ATEM.</li></ul>
|
||
<h2>Companion facts (found by trying)</h2><dl class="kv">${(facts.facts || []).map(([q, a]) => `<dt>${esc(q)}</dt><dd>${esc(a)}</dd>`).join("")}</dl>`;
|
||
}
|
||
|
||
// ---- Site setup -----------------------------------------------------------------
|
||
const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys"];
|
||
async function site(rest) {
|
||
const c = cfg();
|
||
const sections = { site: "Site", schedules: "Schedules", companion: "Companion", "network-dante": "Network & Dante", spotify: "Spotify", atem: "ATEM" };
|
||
const which = sections[rest[0]] ? rest[0] : "site";
|
||
const tabs = Object.entries(sections).map(([k, n]) => `<button class="${which === k ? "on" : ""}" onclick="location.hash='#site/${k}'">${n}</button>`).join("");
|
||
const screen = sections[which];
|
||
const own = Object.entries(state.schema.fields).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !HAND_MADE.includes(p) && !HAND_MADE.some((h) => h.startsWith(p + ".")) && !Object.keys(state.schema.fields).some((k) => k.startsWith(p + ".") || k.startsWith(p + "[]")));
|
||
const fieldHtml = ([path, f]) => inputHtml(path, f, getPath(path));
|
||
const plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced);
|
||
let body = own.length ? `<div class="card"><h3>Settings</h3>${plain.map(fieldHtml).join("") || "<p class='muted small'>Only numbers here; they sit under Advanced.</p>"}${adv.length ? `<details><summary>Advanced</summary>${adv.map(fieldHtml).join("")}</details>` : ""}</div>` : "";
|
||
if (which === "site") body += await programHtml();
|
||
if (which === "companion") body = (await namesHtml()) + (await companionHtml()) + body;
|
||
if (which === "schedules") body = schedulesHtml(c) + body;
|
||
if (which === "network-dante") body += await danteHtml(c);
|
||
if (which === "spotify") body = spotifyHtml(c) + body;
|
||
if (which === "atem") body = (await atemHtml(c)) + body;
|
||
main.innerHTML = `<h1>Site setup</h1><p class="what">Set up once. Each section opens on the few things a person changes; the numbers sit under Advanced.${editing() ? "" : " Switch Editing on to change anything."}</p><div class="tabs">${tabs}</div>${body}`;
|
||
wireSite();
|
||
}
|
||
function windowsHtml(path, list, title, help) {
|
||
const days = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
||
const dis = editing() ? "" : "disabled";
|
||
const rows = (list || []).map((w, i) => `<div class="window" data-win="${path}.${i}">${days.map((d) => `<label class="inline"><input type="checkbox" data-day="${d}" ${(w.days || []).includes(d) ? "checked" : ""} ${dis}> ${d}</label>`).join("")} from <input class="hhmm" data-start value="${esc(w.start)}" ${dis}> to <input class="hhmm" data-end value="${esc(w.end)}" ${dis}> ${editing() ? `<button class="small" data-delwin="${path}.${i}">remove</button>` : ""}</div>`).join("");
|
||
return `<div class="field"><label>${esc(title)}</label>${rows || "<p class='muted small'>none</p>"}${editing() ? `<button class="small" data-addwin="${path}">+ window</button>` : ""}<div class="help">${esc(help)}</div></div>`;
|
||
}
|
||
function schedulesHtml(c) {
|
||
const fixes = Object.entries(c.fixes).map(([id, f]) => { const cl = closes(f); const on = (c.auto_fix.fixes || []).includes(id); return `<label class="inline"><input type="checkbox" data-autofix="${esc(id)}" ${on ? "checked" : ""} ${editing() && !cl && !f.never_auto ? "" : "disabled"}> ${esc(f.short || id)}${cl ? ' <span class="muted small">(closes a program: hold only)</span>' : f.never_auto ? ' <span class="muted small">(never by itself)</span>' : ""}</label>`; }).join(" ");
|
||
return `<div class="notice">Alarms are on every day. The ALARMS key silences them until midnight, then they come back by themselves. Changes to the panel wait while a HyperDeck is recording.</div>
|
||
<div class="card"><h3>Auto-fix</h3>${windowsHtml("auto_fix.windows", c.auto_fix.windows, "When auto-fix may act by itself", "An end earlier than the start runs past midnight.")}<div class="field"><label>Fixes it may run (gentle ones only)</label>${fixes}</div></div>
|
||
<div class="card"><h3>Changes wait</h3>${windowsHtml("site.live_services", (c.site || {}).live_services, "Clock windows during which changes to the panel wait", "A recording HyperDeck always counts, whatever the clock says. NA has no clock windows.")}</div>`;
|
||
}
|
||
async function 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 `<div class="card"><h3>The program</h3><dl class="kv"><dt>Running</dt><dd>code ${esc(p.running_version || "?")}</dd><dt>Staged</dt><dd>${staged}${same ? " <span class='muted'>· the same code</span>" : ""}</dd><dt>Folder</dt><dd class="mono small">${esc(p.folder)}</dd></dl>
|
||
<p class="help">Copy the new folder to this Mac with scp as before, then press the button: install.sh runs by itself (the config stays as it is), the watchdog restarts, this page comes back.</p>
|
||
<div class="row"><button id="installbtn" ${editing() && p.present ? "" : "disabled"}>Install the staged folder</button>${p.job === "running" ? " <span class='muted'>install.sh is running…</span>" : ""}</div>
|
||
${p.log && p.log.length ? `<details><summary>The last install's log${p.log_when ? " (" + esc(when(p.log_when)) + ")" : ""}</summary><pre class="log small">${esc(p.log.join("\n"))}</pre></details>` : ""}</div>
|
||
<div class="card"><h3>This campus as a bundle</h3><p class="help">A zip of the settings, the saved switcher file and the page-key record: drop it in the repo as sites/${esc((cfg().site || {}).id || "site")}/, or feed it to another campus's page.</p>
|
||
<div class="row"><a class="btn" href="/api/site/bundle" download>Export site bundle</a>${editing() ? `<label class="btn">New campus from a bundle <input type="file" id="bundlefile" accept=".zip,.json" hidden></label>` : ""}</div>
|
||
<p class="help">New campus from a bundle: this campus's name, addresses and editing code stay; every computer from the bundle is flagged "copied from … - check it" until you touch it; keys, checks and fixes come along. Then Review & Apply.</p></div>`;
|
||
}
|
||
async function namesHtml() {
|
||
const n = await api("/api/site/names").catch(() => null);
|
||
if (!n || !n.missing) return "";
|
||
const sel = (kind, name, cands) => editing() ? `<select data-map="${kind}|${esc(name)}"><option value="">– map it to –</option>${cands.map((c) => `<option>${esc(c)}</option>`).join("")}</select>` : "";
|
||
const conns = n.connections.filter((c) => !c.live).map((c) => `<tr><td>${lightDot("problem")}<span class="mono">${esc(c.name)}</span></td><td class="small">${esc(c.keys.join(", "))}</td><td class="small">${n.companion_ok ? "not in this Companion" : "Companion not answering"}${c.module ? ` <span class="muted">(needs module ${esc(c.module)})</span>` : ""}</td><td>${sel("conn", c.name, c.candidates)}</td></tr>`).join("");
|
||
const dante = n.dante.filter((d) => !d.seen).map((d) => `<tr><td>${lightDot("warn")}<span class="mono">${esc(d.name)}</span></td><td class="small">DANTE</td><td class="small">not seen on the Dante network (last survey)</td><td>${sel("dante", d.name, d.candidates)}</td></tr>`).join("");
|
||
const midi = n.midi.filter((m) => !m.session_seen || !m.peer_seen).map((m) => `<tr><td>${lightDot("warn")}<span class="mono">${esc(m.session)} › ${esc(m.peer)}</span></td><td class="small">${esc(m.key)}</td><td class="small">${!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(", ")) + ")" : ""}`}</td><td class="small muted">change it on the key</td></tr>`).join("");
|
||
return `<div class="card"><h3>Names the keys use that are not here (${n.missing})</h3><p class="small muted">A copied campus, or something renamed. Map each name to what this campus has; the keys, checks and restart keys that use it follow.</p>
|
||
<div class="tablewrap"><table><tr><th>name</th><th>used by</th><th></th><th></th></tr>${conns}${dante}${midi}</table></div></div>`;
|
||
}
|
||
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]) => `<li>${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</li>`).join("");
|
||
const conns = (cc.connections || []).map((r) => `<tr><td>${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed")}${esc(r.label)}</td><td class="mono small">${esc(r.module || "")}</td><td class="small">${esc(r.enabled ? r.category || "" : "off")}${r.message ? ` <span class="muted">${esc(r.message)}</span>` : ""}</td><td class="small">${(r.watched_by || []).length ? r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc(k.toUpperCase())}</a>`).join(", ") : `<span class="muted">not watched</span>${editing() ? ` <button class="small" data-addconn="${esc(r.label)}">Add a key for it</button>` : ""}`}</td></tr>`).join("");
|
||
return `<div class="card"><h3>Companion ${cc.ok ? "OK" : "<b>not answering</b>"}${cc.build ? ` <span class="pill">${esc(cc.build)}</span>` : ""}</h3><p class="small muted">Set up once by the person who installed Companion. You normally only come here to see which connection a key watches.</p><ul style="list-style:none;padding:0">${lights}</ul>
|
||
${(cc.missing || []).length ? `<p class="notice">Keys watch connections Companion does not have: ${esc(cc.missing.join(", "))}</p>` : ""}
|
||
${cc.page_number_buttons ? `<p class="small">Page-number buttons coloured on ${cc.page_number_buttons.pages_coloured} pages (${esc(cc.page_number_buttons.recorded_in)}).</p>` : ""}
|
||
<h3>Connections (live)</h3><div class="tablewrap"><table><tr><th>connection</th><th>module</th><th>status</th><th>watched by</th></tr>${conns}</table></div></div>`;
|
||
}
|
||
function danteCheck(c) { const t = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "dante")); return t ? t.checks.find((ch) => ch.type === "dante") : null; }
|
||
async function danteHtml(c) {
|
||
const chk = danteCheck(c);
|
||
if (!chk) return "";
|
||
const snap = await api("/api/snapshot").catch(() => ({}));
|
||
const map = snap.dante_map || {};
|
||
const fixOf = (n) => typeof chk.fix === "object" && chk.fix ? (chk.fix[n] || "") : "";
|
||
const fixSel = (n) => `<select data-dantefix="${esc(n)}" ${editing() ? "" : "disabled"}><option value="">(everything else)</option><option value="dante-auto" ${fixOf(n) === "dante-auto" ? "selected" : ""}>Dante auto</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${fixOf(n) === id ? "selected" : ""}>${esc(f.short || id)}</option>`).join("")}</select>`;
|
||
const rows = Object.entries(chk.devices || {}).map(([n, a]) => { const e = map[n] || {}; return `<tr><td class="mono small">${esc(n)}</td><td>${editing() ? `<input data-dantekey="${esc(n)}" value="${esc(a)}" size="10">` : esc(a)}</td><td class="mono small">${esc(e.ip || "")}</td><td class="small">${e.switch ? esc(e.switch + " port " + e.port) : ""}</td><td>${e.poe ? "yes" : ""}</td><td>${fixSel(n)}</td><td>${editing() ? `<button class="small danger" data-dantedel="${esc(n)}">remove</button>` : ""}</td></tr>`; }).join("");
|
||
const extra = Object.keys(map).filter((n) => !((chk.devices || {})[n]));
|
||
return `<div class="card"><h3>Dante device list (the DANTE key's devices)</h3><div class="tablewrap"><table><tr><th>Dante name</th><th>key text</th><th>address</th><th>switch / port</th><th>PoE</th><th>when it is gone, hold runs</th><th></th></tr>${rows}</table></div>
|
||
<p class="small muted">Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.${editing() ? ` <button class="small" id="resurvey">Check the Dante network now</button>` : ""}</p>
|
||
${editing() ? `<div class="field"><label>Add a device</label><input id="dantenew" placeholder="Dante name (as Dante Controller shows it)" list="dantefound"><datalist id="dantefound">${extra.map((n) => `<option value="${esc(n)}">`).join("")}</datalist> <input id="dantenewkey" placeholder="key text" size="10"> <button class="small" id="danteadd">Add</button>${extra.length ? `<div class="help">Seen on the network but not listed: ${esc(extra.join(", "))}</div>` : ""}</div>` : ""}</div>`;
|
||
}
|
||
function spotifyHtml(c) {
|
||
const s = c.spotify || {};
|
||
return `<div class="card"><h3>The service playlist</h3><div class="field"><label>Playlist link</label><input id="splink" value="${esc(s.playlist || "")}" placeholder="paste the open.spotify.com/playlist/... link" ${editing() ? "" : "disabled"}> ${editing() ? `<button class="small" id="splook">Look it up</button>` : ""}<div class="help">The SPOTIFY key, the play fix and the key guide all use it.</div></div>
|
||
<div class="field"><label>Name</label><input id="spname" value="${esc(s.name || "")}" ${editing() ? "" : "disabled"}><div class="help">As the keys say it.</div></div><p id="spinfo" class="help"></p></div>`;
|
||
}
|
||
async function atemHtml(c) {
|
||
const folder = await api("/api/atem-reference/folder").catch(() => ({ files: [], mounted: false }));
|
||
const pending = state.atemPending ? `<div class="notice">A new saved switcher file is waiting for Apply. ${editing() ? `<button class="small" id="atemdiscard">Discard it</button>` : ""}</div>` : "";
|
||
return `<div class="card"><h3>The saved switcher file</h3><p class="small muted">The panel keeps the switcher's audio and camera settings in line with this file (atem-reference.xml). Save a new one from ATEM Software Control when the show changes.</p>${pending}
|
||
${editing() ? `<div class="row"><label class="btn">Upload a saved file <input type="file" id="atemfile" accept=".xml" hidden></label><button id="atemnewest" ${folder.mounted && (folder.files || []).length ? "" : "disabled"}>Pick the newest from the folder</button></div>` : ""}
|
||
<p class="small">Folder: <span class="mono">${esc(folder.folder || "(not set)")}</span> ${folder.mounted ? `· newest: ${(folder.files || []).length ? esc(folder.files[0].name) + " (" + esc(when(folder.files[0].when)) + ")" : "none"}` : "· <b>not mounted</b>"}</p>
|
||
${folder.blocked ? `<div class="notice grey">The folder is there but this page is not allowed to read Google Drive (macOS keeps background programs out of cloud folders). Once, on the Ingest Mac: System Settings › Privacy & Security › Full Disk Access › add <span class="mono">/usr/bin/python3</span>, then restart the settings page from the Overview. Until then, use Upload a saved file.</div>` : ""}<p id="ateminfo" class="help"></p></div>`;
|
||
}
|
||
function wireSite() {
|
||
const q = (s) => main.querySelector(s);
|
||
for (const m of main.querySelectorAll("[data-map]")) m.onchange = () => { if (!m.value || !ensureDraft()) return; const [kind, name] = m.dataset.map.split("|"); (kind === "conn" ? renameConn : renameDante)(name, m.value); saveDraft(); route(); };
|
||
for (const b of main.querySelectorAll("[data-addconn]")) b.onclick = () => { const conn = b.dataset.addconn; if (!ensureDraft()) return; const d = state.draft.config; let id = slug(conn) || "key", n = 2; while (d.tiles.some((t) => t.id === id)) id = slug(conn) + "_" + n++; const t = { id, label: conn.toUpperCase().replace(/[_-]+/g, " ").slice(0, 10).trim(), checks: [{ type: "conn", conn, fix: "restart-connection" }] }; const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; d.tiles.push(t); saveDraft(); location.hash = "#keys/" + id; };
|
||
for (const b of main.querySelectorAll("[data-addwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.addwin; const list = (getPath(path, state.draft.config) || []).slice(); list.push({ days: ["sun"], start: "09:00", end: "12:00" }); setPath(path, list); route(); };
|
||
for (const b of main.querySelectorAll("[data-delwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.delwin.replace(/\.\d+$/, ""), i = +b.dataset.delwin.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list.splice(i, 1); setPath(path, list); route(); };
|
||
for (const w of main.querySelectorAll("[data-win]")) for (const el of w.querySelectorAll("input")) el.onchange = () => { if (!ensureDraft()) return; const path = w.dataset.win.replace(/\.\d+$/, ""), i = +w.dataset.win.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list[i] = { days: [...w.querySelectorAll("[data-day]")].filter((x) => x.checked).map((x) => x.dataset.day), start: w.querySelector("[data-start]").value.trim(), end: w.querySelector("[data-end]").value.trim() }; setPath(path, list); };
|
||
for (const cb of main.querySelectorAll("[data-autofix]")) cb.onchange = () => { if (!ensureDraft()) return; const set = new Set(state.draft.config.auto_fix.fixes || []); cb.checked ? set.add(cb.dataset.autofix) : set.delete(cb.dataset.autofix); setPath("auto_fix.fixes", [...set]); };
|
||
const dc = () => danteCheck(state.draft.config);
|
||
for (const el of main.querySelectorAll("[data-dantekey]")) el.onchange = () => { if (!ensureDraft()) return; dc().devices[el.dataset.dantekey] = el.value.trim(); saveDraft(); };
|
||
for (const el of main.querySelectorAll("[data-dantefix]")) el.onchange = () => { if (!ensureDraft()) return; const ch = dc(); if (typeof ch.fix !== "object" || !ch.fix) ch.fix = { "*": ch.fix || "dante-auto" }; if (el.value) ch.fix[el.dataset.dantefix] = el.value; else delete ch.fix[el.dataset.dantefix]; saveDraft(); };
|
||
for (const b of main.querySelectorAll("[data-dantedel]")) b.onclick = () => { if (!ensureDraft()) return; const ch = dc(); delete ch.devices[b.dataset.dantedel]; if (typeof ch.fix === "object" && ch.fix) delete ch.fix[b.dataset.dantedel]; saveDraft(); route(); };
|
||
const da = q("#danteadd"); if (da) da.onclick = () => { if (!ensureDraft()) return; const n = q("#dantenew").value.trim(), k = q("#dantenewkey").value.trim(); if (!n) return; const ch = dc(); ch.devices = ch.devices || {}; ch.devices[n] = k || n.slice(0, 10).toUpperCase(); saveDraft(); route(); };
|
||
const rs = q("#resurvey"); if (rs) rs.onclick = async () => { try { await post("/api/command", { text: "resurvey" }); alertBox("Asked the watchdog to look at the switches; the map updates within a minute."); } catch (e) { alertBox(e.message); } };
|
||
const look = q("#splook"); if (look) look.onclick = async () => { const info = q("#spinfo"); info.textContent = "Looking…"; try { const r = await api("/api/live/spotify?url=" + encodeURIComponent(q("#splink").value)); if (!r.ok) { info.textContent = r.error; return; } if (!ensureDraft()) return; setPath("spotify.playlist", r.uri); if (r.name) { setPath("spotify.name", r.name); q("#spname").value = r.name; } q("#splink").value = r.uri; info.textContent = r.name ? `${r.name}: ${r.songs} songs. The play fix's words follow the name.` : (r.warning || ""); } catch (e) { info.textContent = e.message; } };
|
||
const spname = q("#spname"); if (spname) spname.onchange = () => setPath("spotify.name", spname.value.trim());
|
||
const splink = q("#splink"); if (splink) splink.onchange = () => { const m = splink.value.match(/(?:playlist[/:])([A-Za-z0-9]{22})/); if (m) setPath("spotify.playlist", "spotify:playlist:" + m[1]); else if (splink.value.trim()) alertBox("That is not a playlist link or URI."); };
|
||
const ib = q("#installbtn"); if (ib) ib.onclick = () => confirmBox("Install the staged folder? The watchdog restarts (the deck goes purple for a few seconds) and this page restarts too.", "Install", async () => { try { const r = await post("/api/program/install", {}); alertBox(r.ok ? r.detail : "Not started: " + r.error); setTimeout(() => location.reload(), 25000); } catch (e) { alertBox(e.message); } });
|
||
const bf = q("#bundlefile"); if (bf) bf.onchange = async () => { const f = bf.files[0]; if (!f) return; confirmBox(`Start a new campus from ${f.name}? Your current draft is replaced.`, "Start", async () => { try { const r = await fetch("/api/site/import", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/octet-stream" }, body: await f.arrayBuffer() }).then((x) => x.json()); if (r.error) return alertBox(r.error); state.draft = r.draft; state.atemPending = true; badge(); alertBox(`Copied from ${r.from}. ${r.errors.length ? "Not applicable yet: " + r.errors.slice(0, 3).join("; ") : "Look through Computers, Keys and Fixes, then Review & Apply."}`); location.hash = "#computers"; } catch (e) { alertBox(e.message); } }); };
|
||
const af = q("#atemfile"); if (af) af.onchange = async () => { const f = af.files[0]; if (!f) return; try { const r = await fetch("/api/atem-reference", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/xml" }, body: await f.arrayBuffer() }).then((x) => x.json()); showAtem(r); } catch (e) { alertBox(e.message); } };
|
||
const an = q("#atemnewest"); if (an) an.onclick = async () => { try { showAtem(await post("/api/atem-reference/pick-newest")); } catch (e) { alertBox(e.message); } };
|
||
const ad = q("#atemdiscard"); if (ad) ad.onclick = async () => { try { await post("/api/atem-reference/discard"); state.atemPending = false; badge(); route(); } catch (e) { alertBox(e.message); } };
|
||
function showAtem(r) {
|
||
if (!r.ok) { alertBox(r.error || "Not taken."); return; }
|
||
state.atemPending = true; if (!state.draft && ensureDraft()) saveDraft(); badge();
|
||
const diffs = (r.diffs || []).slice(0, 12).map((d) => d.part + " " + d.key + ": " + JSON.stringify(d.before) + " → " + JSON.stringify(d.after)).join("\n");
|
||
alertBox(`${r.file ? r.file + ": " : ""}${r.product || ""}, ${r.audio} audio and ${r.camera} camera values; ${r.diff_count} differ from the current file${diffs ? ":\n" + diffs : "."}\nApply replaces the file.`);
|
||
route();
|
||
}
|
||
}
|
||
|
||
// ---- builders: a fix or a check is made from fields ---------------------------------
|
||
function paramsHtml(tpl, params, prefix) {
|
||
const c = cfg();
|
||
return tpl.params.map((p) => {
|
||
const v = params[p.name] != null ? params[p.name] : p.default;
|
||
const id = `${prefix}-${p.name}`;
|
||
let input;
|
||
if (p.type === "host") input = `<select id="${id}" data-param="${p.name}"><option value="">–</option>${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]) => `<option value="${esc(a)}" ${v === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select>`;
|
||
else if (p.type === "bool") input = `<input type="checkbox" id="${id}" data-param="${p.name}" ${v ? "checked" : ""}>`;
|
||
else if (p.type === "int") input = `<input type="number" id="${id}" data-param="${p.name}" value="${esc(v == null ? "" : v)}">`;
|
||
else if (p.type === "list") input = `<input id="${id}" data-param="${p.name}" value="${esc(Array.isArray(v) ? v.join(", ") : v || "")}" placeholder="one or more, with commas">`;
|
||
else input = `<input id="${id}" data-param="${p.name}" value="${esc(v == null ? "" : v)}">`;
|
||
return `<div class="field"><label for="${id}">${esc(p.words)}</label>${input}<div class="help">${esc(p.help)}</div></div>`;
|
||
}).join("");
|
||
}
|
||
function readParams(root, tpl) {
|
||
const out = {};
|
||
for (const p of tpl.params) {
|
||
const el = root.querySelector(`[data-param="${p.name}"]`); if (!el) continue;
|
||
if (p.type === "bool") out[p.name] = el.checked;
|
||
else if (p.type === "int") out[p.name] = el.value === "" ? undefined : parseInt(el.value, 10);
|
||
else if (p.type === "list") out[p.name] = el.value.split(",").map((x) => x.trim()).filter(Boolean);
|
||
else out[p.name] = el.value.trim();
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---- Fixes -------------------------------------------------------------------------
|
||
async function fixes(rest) {
|
||
const c = cfg(), auto = new Set(c.auto_fix.fixes || []);
|
||
const used = {};
|
||
const use = (f, who) => { if (f) (used[f] = used[f] || new Set()).add(who); };
|
||
for (const t of c.tiles) { for (const ch of t.checks || []) for (const f of (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix])) use(f, t.label); use(t.hold_always, t.label); }
|
||
for (const k of (c.restart_page || { keys: [] }).keys) use(k.fix, "restart key " + k.label);
|
||
const rows = Object.entries(c.fixes).map(([id, f]) => `<tr class="click${rest[0] === id ? " sel" : ""}" onclick="location.hash='#fixes/${esc(id)}'"><td><b>${esc(f.short || id)}</b></td><td>${esc(f.label || "")}</td><td class="small">${esc(f.kind || "ssh")}${f.host ? " on " + esc((c.hosts[f.host] || {}).name || f.host) : f.kind === "local" || f.kind === "spotify" ? " on this Mac" : ""}</td><td class="small">${[...(used[id] || [])].join(", ")}</td><td class="small">${auto.has(id) ? "may run by itself" : f.never_auto ? "never by itself" : "hold only"}</td></tr>`).join("");
|
||
const id = rest[0], f = c.fixes[id];
|
||
let detail = "";
|
||
if (f) {
|
||
const kind = f.kind || "ssh";
|
||
const 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 += `<div class="field"><label>Built as</label><select id="tplsel">${tpls.map((t) => `<option value="${t.name}" ${t.name === tplName ? "selected" : ""}>${esc(t.words)}</option>`).join("")}</select></div>`;
|
||
inputs += `<div id="tplform">${paramsHtml(tpl, f.params || {}, "fx")}</div><p id="tplmsg" class="help"></p>`;
|
||
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 += `<div class="field"><label>Runs on</label><select data-path="fixes.${esc(id)}.kind" class="kindsel">${["ssh", "local"].map((k) => `<option ${kind === k ? "selected" : ""}>${k === "ssh" ? "another computer" : "this Mac"}</option>`).join("")}</select></div>`;
|
||
if (kind === "ssh") inputs += `<div class="field"><label>Computer</label><select data-path="fixes.${esc(id)}.host"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${f.host === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select></div>`;
|
||
inputs += `<div class="field"><label>Command (typed by hand; shown as a command at Review; never run before Apply)</label><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run || "")}</textarea><div class="help">zsh on a Mac, cmd.exe on a PC.</div></div>`;
|
||
} else if (f.run) inputs += `<div class="field"><label>The command it makes (${f.kind === "local" ? "this Mac" : "on " + esc((c.hosts[f.host] || {}).name || f.host)})</label><pre class="log small" style="max-height:160px">${esc(f.run)}</pre></div>`;
|
||
inputs += `<details><summary>Advanced</summary>${["reconnect", "conn", "restart_ok", "never_auto", "timeout"].map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("")}</details>`;
|
||
} else inputs = `<p class="small muted">Built as: ${esc(tpl.words)}${f.run ? `</p><pre class="log small" style="max-height:160px">${esc(f.run)}</pre>` : "</p>"}`;
|
||
detail = `<div class="card"><h2>${esc(f.short || id)}</h2><p>${esc(f.label || "")}</p>${inputs}${canRunNow(id) && editing() ? `<p class="help">Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).</p>` : ""}<details><summary>Everything stored</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}<dt>id</dt><dd class="mono">${esc(id)}</dd></dl></details>${editing() && !keys.length ? `<button class="danger small" id="delfix">Delete this fix</button>` : ""}</div>`;
|
||
}
|
||
const add = editing() ? `<div class="row"><input id="newfix" placeholder="new fix id (letters, digits, _)"><button id="addfix" class="small">+ New fix</button></div>` : "";
|
||
main.innerHTML = `<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(c.fixes).length} today.</p>${add}<div class="tablewrap"><table><tr><th>key text</th><th>plain words</th><th>built as</th><th>used by</th><th>by itself</th></tr>${rows}</table></div>${detail}`;
|
||
const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value === "another computer" ? "ssh" : ks.value === "this Mac" ? "local" : ks.value); route(); };
|
||
const rebuild = async () => {
|
||
if (!ensureDraft()) return;
|
||
const fx = state.draft.config.fixes[id], tplName = main.querySelector("#tplsel").value, tpl = state.builders.fixes.find((t) => t.name === tplName);
|
||
const params = readParams(main.querySelector("#tplform"), tpl);
|
||
if (tplName === "custom") { fx.template = "custom"; delete fx.params; saveDraft(); route(); return; }
|
||
const r = await post("/api/build", { fix: Object.assign({}, fx, { template: tplName, params }) });
|
||
const msg = main.querySelector("#tplmsg");
|
||
if (!r.ok) { msg.textContent = r.error; fx.template = tplName; fx.params = params; saveDraft(); return; }
|
||
const keep = { label: fx.label, short: fx.short };
|
||
Object.assign(fx, r.fix); for (const k of ["run", "host"]) if (!(k in r.fix)) delete fx[k];
|
||
if (!keep.label && r.label) fx.label = r.label;
|
||
msg.textContent = "";
|
||
saveDraft(); route();
|
||
};
|
||
const ts = main.querySelector("#tplsel"); if (ts) { ts.onchange = rebuild; for (const el of main.querySelectorAll("#tplform [data-param]")) el.onchange = rebuild; }
|
||
const del = main.querySelector("#delfix"); if (del) del.onclick = () => confirmBox(`Delete the fix ${id}? No key uses it.`, "Delete", () => { if (!ensureDraft()) return; delete state.draft.config.fixes[id]; const af2 = state.draft.config.auto_fix.fixes || []; state.draft.config.auto_fix.fixes = af2.filter((x) => x !== id); saveDraft(); location.hash = "#fixes"; });
|
||
const af = main.querySelector("#addfix"); if (af) af.onclick = () => { const nid = main.querySelector("#newfix").value.trim(); if (!/^[A-Za-z0-9_]+$/.test(nid) || c.fixes[nid]) return alertBox("A new id: letters, digits and _ only, not in use."); if (!ensureDraft()) return; state.draft.config.fixes[nid] = { kind: "local", short: nid.toUpperCase().slice(0, 12), label: "", run: "" }; saveDraft(); location.hash = "#fixes/" + nid; };
|
||
}
|
||
})();
|