Steps 4-6: the jobs, the set-up tab with generated scripts, Install the staged folder, site bundles and the campus template
- Overview jobs: Change the playlist, Add or remove a HyperDeck, Add a MIDI link, Add a computer, Make a PC program startable; 'Add a key for it' on unwatched Companion connections. - Computers > Setup: steps with a [how] each and the set-up scripts generated per computer by settings/scripts.py from the templates in remote-access/ (no site-shaped value left in them); Remove this computer drops the fixes and checks that used it. - Site setup > Site: the running and staged code, Install the staged folder (a one-shot launchd job running install.sh --keep-config, refused while live), Export site bundle (zip), New campus from a bundle (this campus's site block stays; every copied computer is flagged until touched). - sites/: na -> the repo config, _template = the empty campus; install.sh --site <id>. - Review reads appended checks as 'added'; tests for all of it. - Deployed page-only to NA Ingest. (config.json, status_panel.py and readiness.py are left to the READY-page work in progress in the same tree.) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+199
-7
@@ -229,7 +229,8 @@
|
||||
<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 disabled title="arrives with step 4">Change the playlist</button><button disabled title="arrives with step 4">Add or remove a HyperDeck</button><button disabled title="arrives with step 4">Add a MIDI link</button></div></div>
|
||||
<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">
|
||||
@@ -251,8 +252,154 @@
|
||||
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;
|
||||
@@ -487,7 +634,7 @@
|
||||
<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);
|
||||
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>`;
|
||||
@@ -495,6 +642,8 @@
|
||||
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); } };
|
||||
@@ -508,23 +657,48 @@
|
||||
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}</div>`;
|
||||
${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) {
|
||||
function setupHtml(m, generated) {
|
||||
return m.computers.map((c) => {
|
||||
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
|
||||
const steps = (m.setup_steps || {})[kind] || [];
|
||||
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);
|
||||
@@ -545,7 +719,8 @@
|
||||
if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown";
|
||||
return "unknown";
|
||||
};
|
||||
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); return `<li>${lightDot(l)}${esc(s)} <span class="muted small">${l === "hand" ? "(done by hand)" : l === "unknown" ? "– can't see from here" : ""}</span></li>`; }).join("")}</ul><p class="help">Generated set-up scripts and the [how] steps arrive with step 5.</p></div>`;
|
||||
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("");
|
||||
}
|
||||
|
||||
@@ -651,6 +826,7 @@
|
||||
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 companionHtml()) + body;
|
||||
if (which === "schedules") body = schedulesHtml(c) + body;
|
||||
if (which === "network-dante") body += await danteHtml(c);
|
||||
@@ -671,10 +847,23 @@
|
||||
<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 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>'}</td></tr>`).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>` : ""}
|
||||
@@ -708,6 +897,7 @@
|
||||
}
|
||||
function wireSite() {
|
||||
const q = (s) => main.querySelector(s);
|
||||
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); };
|
||||
@@ -721,6 +911,8 @@
|
||||
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); } };
|
||||
|
||||
@@ -95,3 +95,6 @@ tr.sel { background: var(--accent-soft); }
|
||||
.key[draggable="true"] { cursor: grab; } .key.empty[data-drop] { outline: 1px dashed #555; opacity: .7; } .key.empty.over { outline: 2px solid var(--accent); opacity: 1; }
|
||||
.tray.over { outline: 2px solid var(--accent); border-radius: 8px; } .tray button[draggable="true"] { cursor: grab; }
|
||||
input.pagenum { width: 4.5em; padding: 2px 4px; font: inherit; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); }
|
||||
/* step 5: setup */
|
||||
a.btn { display: inline-block; border: 1px solid var(--line); background: var(--surface); color: var(--fg); border-radius: 8px; padding: 6px 12px; text-decoration: none; }
|
||||
details.script textarea { width: 100%; font-size: .8rem; } details.how { display: inline-block; margin: 0 0 0 8px; } details.how summary { display: inline; color: var(--accent); cursor: pointer; }
|
||||
|
||||
Reference in New Issue
Block a user