- config.json is NA Ingest's live settings: the READY hours (Tue 14-22, Wed 9-19, Sun 5-14) and the auto-fix hours (Tue 14-22, Wed 9-21, Sun 6:30-14) set on the settings page. - Settings page: a draft carries what its page started from. A tab that loaded the settings before they changed (an Apply elsewhere, a program update) now has only its own edits put on top of the live settings; before, it saved its whole older copy and Apply quietly undid the newer values (it took ready.prop_host out twice today). - install.sh refuses (exit 4) to replace live settings that came from an Apply (their fingerprint is a history entry's sha_after) unless --replace-config; --keep-config still updates code only. - Tests pin their own auto-fix and READY hours, so the campus can change its own; the exit-timeout test expects 310 s (the Tascam outlet power-cycle). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1654 lines
184 KiB
JavaScript
1654 lines
184 KiB
JavaScript
/* The settings page. Plain JS, no framework. Everything is readable; with Editing on (the PIN + your
|
||
first name) the same screens grow inputs; changes live in one shared draft on the server until
|
||
Review & Apply. Short labels everywhere; the explanation sits in a hint you hover. */
|
||
(function () {
|
||
"use strict";
|
||
const main = document.getElementById("main");
|
||
const state = { live: null, liveSha: null, schema: null, draft: null, draftStale: false, atemPending: false, editing: null, name: null, saveTimer: null };
|
||
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
const ago = (s) => s == null ? "" : s < 90 ? Math.round(s) + " s ago" : s < 5400 ? Math.round(s / 60) + " min ago" : Math.round(s / 3600) + " h ago";
|
||
const when = (t) => t ? new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) : "";
|
||
const hex = (n) => "#" + (n >>> 0).toString(16).padStart(6, "0").slice(-6);
|
||
const lightDot = (l, hintText) => `<i class="light ${esc(l || "unknown")}"${hintText ? ` data-hint="${esc(hintText)}"` : ""}></i>`;
|
||
const wordOf = (w) => `<span class="word ${esc((w || "").split(" ")[0])}">${esc(w)}</span>`;
|
||
const hint = (text) => text ? `<span class="hint" data-hint="${esc(text)}" tabindex="0">i</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;
|
||
const slug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
|
||
const tagOf = (t) => t.toUpperCase().replace(/[^A-Z0-9 ]+/g, " ").trim().slice(0, 10).trim();
|
||
const lightWord = { ok: "OK", problem: "Problem", not_needed: "Not needed", hand: "Done by hand", unknown: "Can't see from here", warn: "Warning" };
|
||
|
||
async function api(path, opts) {
|
||
opts = opts || {};
|
||
const headers = Object.assign({ "Content-Type": "application/json" }, opts.headers || {});
|
||
if (state.editing) { headers["X-Edit-Key"] = state.editing; headers["X-Editor"] = state.name || ""; }
|
||
const r = await fetch(path, Object.assign({}, opts, { headers }));
|
||
let body = null;
|
||
try { body = await r.json(); } catch (e) { body = {}; }
|
||
if (!r.ok) { const err = new Error(body.error || r.statusText); err.status = r.status; err.body = body; throw err; }
|
||
return body;
|
||
}
|
||
const post = (path, body, method) => api(path, { method: method || "POST", body: JSON.stringify(body || {}) });
|
||
|
||
// ---- top bar: legend, the PIN ----------------------------------------------
|
||
document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; };
|
||
const editbtn = document.getElementById("editbtn");
|
||
function showEditing() {
|
||
editbtn.querySelector("span").textContent = state.editing ? state.name : "Edit";
|
||
editbtn.dataset.hint = state.editing ? "Editing as " + state.name + " · click to switch off" : "Switch Editing on with the PIN";
|
||
editbtn.classList.toggle("on", !!state.editing);
|
||
document.body.classList.toggle("editing", !!state.editing);
|
||
}
|
||
state.editing = store.get("sp-code"); state.name = store.get("sp-name");
|
||
showEditing();
|
||
editbtn.onclick = () => {
|
||
if (state.editing) { state.editing = null; state.name = null; store.set("sp-code", null); store.set("sp-name", null); showEditing(); route(); return; }
|
||
dialog(`<h3>Editing</h3><input id="dcode" type="password" inputmode="numeric" placeholder="PIN" autocomplete="off"><input id="dname" placeholder="your first name (for History)" 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 PIN is set yet (Settings › Site)." : !name ? "Your first name, please." : `Not the PIN (${r.tries_left} tries left).`;
|
||
};
|
||
box.querySelector("#dok").onclick = go;
|
||
box.querySelector("#dname").onkeydown = (e) => { if (e.key === "Enter") go(); };
|
||
box.querySelector("#dcode").onkeydown = (e) => { if (e.key === "Enter") box.querySelector("#dname").focus(); };
|
||
box.querySelector("#dcode").focus();
|
||
});
|
||
};
|
||
function dialog(html, wire) { const d = document.getElementById("dialog"); d.innerHTML = `<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(); };
|
||
});
|
||
}
|
||
const needEditing = () => { if (!editing()) { alertBox("Switch Editing on first (Edit, top right)."); return false; } return true; };
|
||
|
||
// ---- the draft ------------------------------------------------------------
|
||
async function loadAll() {
|
||
const [c, s, d, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]);
|
||
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.builders = b;
|
||
// What this page's draft was made from: a save carries it, so the Mac can put the change on top of
|
||
// settings that moved on meanwhile (an Apply elsewhere, a program update) instead of undoing them.
|
||
if (state.draft && !state.draft.base && !d.stale) { state.draft.base = c.sha; state.draft.baseConfig = deep(c.config); }
|
||
document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
|
||
badge();
|
||
}
|
||
function ensureDraft() {
|
||
if (!needEditing()) return false;
|
||
if (!state.draft) state.draft = { config: deep(state.live), version: null, by: state.name, base: state.liveSha, baseConfig: deep(state.live) };
|
||
return true;
|
||
}
|
||
function changed() {
|
||
if (!state.draft) return 0;
|
||
let n = 0;
|
||
const walk = (a, b) => {
|
||
if (Array.isArray(a) && Array.isArray(b)) { if (canon(a) !== canon(b)) n++; return; }
|
||
if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) { for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) walk(a[k], b[k]); return; }
|
||
if (canon(a) !== canon(b)) n++;
|
||
};
|
||
walk(state.live, state.draft.config);
|
||
return n + (state.atemPending ? 1 : 0);
|
||
}
|
||
function badge() {
|
||
let b = document.getElementById("badge");
|
||
if (!b) { b = document.createElement("a"); b.id = "badge"; b.className = "badge"; b.href = "#review"; b.dataset.hint = "Your unsaved changes: review them, then APPLY"; document.querySelector(".topright").prepend(b); }
|
||
const n = changed();
|
||
b.hidden = !n && !state.draft;
|
||
b.textContent = n ? `${n} change${n > 1 ? "s" : ""} · Apply` : "Draft";
|
||
b.classList.toggle("warn", !!state.draftStale);
|
||
}
|
||
function saveDraft() {
|
||
badge();
|
||
clearTimeout(state.saveTimer);
|
||
state.saving = new Promise((done) => {
|
||
state.saveTimer = setTimeout(async () => {
|
||
try {
|
||
const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version, base: state.draft.base, base_config: state.draft.base ? state.draft.baseConfig : undefined }, "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;
|
||
if (r.rebased) { // the settings moved on since this page loaded: keep both
|
||
const c = await api("/api/config");
|
||
state.live = c.config; state.liveSha = c.sha;
|
||
state.draft.config = r.draft.config; state.draft.base = c.sha; state.draft.baseConfig = deep(c.config);
|
||
badge(); route();
|
||
alertBox("The settings changed on the Mac since this page loaded (an Apply elsewhere, or a program update). Your changes were put on top of the new settings; nothing of either was lost.");
|
||
} else if (r.live_sha) { state.draft.base = r.live_sha; }
|
||
} catch (e) {
|
||
if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); }
|
||
else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); }
|
||
else alertBox("Could not save the draft: " + e.message);
|
||
}
|
||
done();
|
||
}, 400);
|
||
});
|
||
return state.saving;
|
||
}
|
||
const flush = () => state.saving || Promise.resolve();
|
||
function setPath(path, value) {
|
||
if (!ensureDraft()) return;
|
||
const parts = path.split("."); let o = state.draft.config;
|
||
for (let i = 0; i < parts.length - 1; i++) { if (o[parts[i]] == null || typeof o[parts[i]] !== "object") o[parts[i]] = {}; o = o[parts[i]]; }
|
||
if (value === undefined) delete o[parts[parts.length - 1]]; else o[parts[parts.length - 1]] = value;
|
||
saveDraft();
|
||
}
|
||
const getPath = (path, from) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), from || cfg());
|
||
|
||
// ---- inputs from the registry: a short label, a hint, the input ------------
|
||
function parse(f, raw) {
|
||
if (f.type === "int") { const n = parseInt(raw, 10); return isNaN(n) ? undefined : n; }
|
||
if (f.type === "float") { const n = parseFloat(raw); return isNaN(n) ? undefined : n; }
|
||
if (f.type === "bool") return !!raw;
|
||
if (f.type === "list") return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
||
if (f.type === "dict") { try { return raw.trim() ? JSON.parse(raw) : undefined; } catch (e) { return undefined; } }
|
||
return raw === "" ? undefined : raw;
|
||
}
|
||
const LABELS = { after_seconds: "wait before acting (seconds)", note_seconds: "keep showing ON … after (seconds)", interval_seconds: "look every (seconds)", failures: "misses before it counts", debounce_seconds: "a fault must last (seconds)", editing_code: "PIN", ssh: "log-in address", mac: "hardware address", ingest_ips: "this Mac's addresses", ingest_name: "this Mac's name", ingest_hostname: "network name", hold_always: "hold also when green", page_hold_ms: "hold time (ms)", optional: "may have no disk" };
|
||
function inputHtml(path, f, value, extra) {
|
||
if (!f) return "";
|
||
const id = "f-" + path.replace(/[^A-Za-z0-9]/g, "-");
|
||
const dis = editing() ? "" : "disabled";
|
||
const last = path.split(".").pop();
|
||
let html;
|
||
if (f.type === "bool") html = `<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" class="mono" ${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 || "")}" placeholder="a, b, c" ${dis}>`;
|
||
else html = `<input id="${id}" data-path="${esc(path)}" type="${f.type === "int" || f.type === "float" ? "number" : f.secret && last !== "editing_code" ? "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.example || "") : f.default)}" ${dis}>`;
|
||
const dflt = f.default != null && typeof f.default !== "object" && f.default !== "" ? ` Default ${f.default}.` : "";
|
||
return `<div class="field"><span class="fl">${esc(LABELS[last] || last.replace(/_/g, " "))}${f.required ? " *" : ""}${hint((f.help || "") + dflt)}</span>${html}${extra || ""}</div>`;
|
||
}
|
||
// An On/Off switch like Companion's connection toggle: switched off = kept in the settings, not used.
|
||
// `path` is the object ("tiles.3", "fixes.obs", "tiles.3.checks.1"); off writes "disabled": true there.
|
||
function onOff(path, obj, what) {
|
||
const on = !(obj && obj.disabled);
|
||
return `<label class="onoff${on ? " on" : ""}" data-hint="${esc(on ? `On. Switch off to keep ${what || "it"} here without using it.` : `Off: kept, not used. Switch on to use ${what || "it"} again.`)}"><input type="checkbox" data-onoff="${esc(path)}" ${on ? "checked" : ""} ${editing() ? "" : "disabled"}><span>${on ? "ON" : "OFF"}</span></label>`;
|
||
}
|
||
function wireOnOff(root) {
|
||
for (const el of root.querySelectorAll("[data-onoff]")) {
|
||
el.closest("label").onclick = (e) => e.stopPropagation(); // inside a <summary>: do not fold it
|
||
el.onchange = () => { setPath(el.dataset.onoff + ".disabled", el.checked ? undefined : true); route(); };
|
||
}
|
||
for (const el of root.querySelectorAll("[data-danteoff]")) {
|
||
el.closest("label").onclick = (e) => e.stopPropagation();
|
||
el.onchange = () => {
|
||
if (!ensureDraft()) return;
|
||
const ch = danteCheck(state.draft.config), n = el.dataset.danteoff, offs = new Set(ch.devices_off || []);
|
||
el.checked ? offs.delete(n) : offs.add(n);
|
||
if (offs.size) ch.devices_off = [...offs]; else delete ch.devices_off;
|
||
saveDraft(); route();
|
||
};
|
||
}
|
||
for (const el of root.querySelectorAll("[data-connoff]")) {
|
||
el.closest("label").onclick = (e) => e.stopPropagation();
|
||
el.onchange = () => {
|
||
if (!ensureDraft()) return;
|
||
for (const [, , ch] of connChecks(state.draft.config, el.dataset.connoff)) { if (el.checked) delete ch.disabled; else ch.disabled = true; }
|
||
saveDraft(); route();
|
||
};
|
||
}
|
||
}
|
||
function wireInputs(root) {
|
||
for (const el of root.querySelectorAll("[data-path]")) {
|
||
if (el.onchange) continue;
|
||
el.onchange = () => {
|
||
const path = el.dataset.path, f = fieldFor(path);
|
||
const v = el.type === "checkbox" ? el.checked : el.value;
|
||
setPath(path, f ? parse(f, v) : v);
|
||
};
|
||
}
|
||
}
|
||
function fieldFor(path) {
|
||
const F = state.schema.fields;
|
||
if (F[path]) return F[path];
|
||
const generic = path.replace(/^(tiles|ready_page\.keys)\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^(tiles|ready_page\.keys)\.\d+\./, "tiles[].")
|
||
.replace(/^restart_page\.keys\.\d+\./, "restart_page.keys[].").replace(/^fixes\.[^.]+\./, "fixes.*.").replace(/^hosts\.[^.]+\./, "hosts.*.")
|
||
.replace(/^gear\.[^.]+\./, "gear.*.").replace(/^(auto_fix\.windows|failover\.windows|site\.live_services|service_windows|ready\.due)\.\d+\./, "$1[].");
|
||
return F[generic] || null;
|
||
}
|
||
const F = (name) => state.schema.fields[name];
|
||
|
||
// ---- search ----------------------------------------------------------------
|
||
const search = document.getElementById("search"), results = document.getElementById("searchresults");
|
||
search.oninput = () => {
|
||
const q = search.value.trim().toLowerCase();
|
||
if (!q || !cfg()) { results.hidden = true; return; }
|
||
const c = cfg(), hits = [];
|
||
c.tiles.forEach((t) => { if ((t.label + " " + t.id).toLowerCase().includes(q)) hits.push([`Key ${t.label}`, `#keys/${t.id}`]); });
|
||
for (const k of (c.restart_page || { keys: [] }).keys) if (k.label.toLowerCase().includes(q)) hits.push([`Restart key ${k.label}`, `#keys/restart/${k.id}`]);
|
||
for (const [a, h] of Object.entries(c.hosts || {})) if ((h.name || a).toLowerCase().includes(q) || (h.role || "").toLowerCase().includes(q)) hits.push([`Computer ${h.name || a}`, `#computers/${a}`]);
|
||
for (const [id, f] of Object.entries(c.fixes || {})) if ((id + " " + (f.label || "") + " " + (f.short || "")).toLowerCase().includes(q)) hits.push([`Fix ${f.short || id}: ${f.label || ""}`, `#keys/fixes/${id}`]);
|
||
for (const [path, f] of Object.entries(state.schema.fields)) if (!path.includes("[]") && !path.includes("*") && (path + " " + f.help).toLowerCase().includes(q)) hits.push([`Setting ${path.replace(/_/g, " ")}`, `#settings/advanced`]);
|
||
results.innerHTML = hits.slice(0, 20).map(([t, h]) => `<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, settings, logs, history, help, review };
|
||
let timer = null;
|
||
async function route() {
|
||
let [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
|
||
if (name === "site") { name = "settings"; rest = [{ site: "site", schedules: "autofix", companion: "companion", "network-dante": "dante", spotify: "playlist", atem: "switcher" }[rest[0]] || "playlist"]; }
|
||
if (name === "fixes") { name = "keys"; rest = ["fixes"].concat(rest); }
|
||
for (const a of document.querySelectorAll(".nav a")) a.classList.toggle("active", a.getAttribute("href") === "#" + name);
|
||
clearInterval(timer); timer = null;
|
||
try {
|
||
if (!state.live) await loadAll();
|
||
const fn = screens[name] || overview;
|
||
await fn(rest);
|
||
wireInputs(main);
|
||
wireOnOff(main);
|
||
const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0]));
|
||
if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000);
|
||
} catch (e) { main.innerHTML = `<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;
|
||
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}" data-hint="${esc((cfg().fixes[f] || {}).label || f)}">Run the fix</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 small">acknowledged</span>' : ""}<a href="#keys/${esc(p.key)}" class="small" data-hint="${esc(p.action)}">hold ${esc(p.label)} ↗</a>${runnable(p)}</li>`).join("") : `<li class="muted">Everything is fine.</li>`;
|
||
const needs = (o.needs || []).map((n) => `<div class="notice grey">Needs attention: ${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 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";
|
||
const chip = (ok, text, hintText) => `<span class="chip ${ok === true ? "ok" : ok === false ? "bad" : ""}" data-hint="${esc(hintText)}">${text}</span>`;
|
||
main.innerHTML = `
|
||
<h1>${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") : "Everything is fine"}${hint("The red keys, in words. Hold the key on the deck, or open it.")}</h1>
|
||
<ul class="problems">${prob}</ul>${needs}${failed}${live}
|
||
<div class="chips" style="margin:12px 0">
|
||
${chip(w.running, w.running ? "Watchdog ✓" : "Watchdog down", (w.running ? "Running since " + when(w.since) : "Not running") + (w.version ? " · code " + w.version : "") + (o.heartbeat_age != null ? " · heartbeat " + ago(o.heartbeat_age) : ""))}
|
||
${chip(o.companion_ok, o.companion_ok ? "Companion ✓" : "Companion down", o.pages && o.pages.status ? (o.pages.status.ok ? "Pages OK" : "Page " + o.pages.status.page + " is called " + (o.pages.status.live || "?")) : "")}
|
||
${chip(o.service_on == null ? null : o.service_on, o.service_on == null ? "Alarms ?" : o.service_on ? "Alarms on" : "Alarms silenced", o.phrases.alarms)}
|
||
${chip(null, "Auto-fix " + (o.auto_fix.state && o.auto_fix.state.on ? "active" : "waiting"), o.auto_fix.phrase + (o.auto_fix.state && o.auto_fix.state.paused ? " (waiting out a network blip)" : ""))}
|
||
${Object.keys(o.running || {}).length ? chip(null, "Fix running", Object.values(o.running).map((r) => r.what + " on " + r.key).join(", ")) : ""}
|
||
${o.last_change ? `<a class="chip" href="#history" data-hint="${esc(o.last_change.when + " · " + (o.last_change.by || "") + " · " + (o.last_change.note || ""))}">Last change ${esc(o.last_change.when || "")}</a>` : ""}
|
||
</div>
|
||
<div class="cols">
|
||
<div>${deckHtml(o.deck, cfg(), "status")}
|
||
<div class="row" style="margin-top:10px">
|
||
<button id="restartwd" ${dis} data-hint="${w.running ? "Restart the watchdog: the deck goes purple for about 5 s; a running fix finishes first" : "Start the watchdog"}">${w.running ? "Restart watchdog" : "Start watchdog"}</button>
|
||
<button id="alarms" ${dis} data-hint="${o.service_on === false ? "Alarms back on now" : "Same as the ALARMS key: quiet until midnight"}">${o.service_on === false ? "Alarms on" : "Silence alarms"}</button>
|
||
${w.running ? `<button id="stopwd" ${dis} data-hint="Stop the watchdog: every key purple, no alarms until it is started again">Stop</button>` : ""}
|
||
</div></div>
|
||
<div><div class="tablewrap"><table>${comps}</table></div></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("|"));
|
||
if (!quiet) window.scrollTo(0, 0);
|
||
}
|
||
function canRunNow(fixId) {
|
||
const f = cfg().fixes[fixId]; if (!f) return false;
|
||
if (["reconnect", "hyperdeck", "midi_connect", "spotify", "companion_press"].includes(f.kind)) return true;
|
||
if (f.kind === "ready") return !f.never_auto; // the READY page's fixes (not the tracks copy)
|
||
if ((f.kind || "ssh") === "ssh" || f.kind === "local") return !closes(f) && !f.never_auto;
|
||
return false;
|
||
}
|
||
function runNow(key, fixId, which) {
|
||
const f = cfg().fixes[fixId];
|
||
confirmBox(`Run "${f.label || fixId}" now? It runs through the watchdog exactly like a hold on the ${key.toUpperCase()} key.`, "Run it", async () => {
|
||
try { await post("/api/command", { text: `test ${key} ${fixId} ${which}` }); alertBox("Sent. Watch the key, or Logs (TEST)."); } catch (e) { alertBox(e.message); }
|
||
});
|
||
}
|
||
|
||
function deckHtml(deck, c, which, selected, draggable) {
|
||
const cells = {};
|
||
const rp = c.restart_page, dp = c.ready_page;
|
||
if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label, off: k.disabled }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; }
|
||
else if (which === "ready" && dp) { for (const k of dp.keys) cells[k.pos] = { id: k.id, label: k.label, off: k.disabled }; cells[dp.back_pos] = { nav: "STATUS\nPAGE", back: "dback:" }; }
|
||
else { for (const t of c.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label, off: t.disabled }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
|
||
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : which === "ready" && dp ? dp.page : c.page), "2,0": "DOWN" };
|
||
const prefix = { restart: "rkey:", ready: "dkey:" }[which] || "tile:";
|
||
const route_ = { restart: "restart/", ready: "ready/" }[which] || "";
|
||
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.back || (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" : ""}${cell.off ? " off" : ""}" style="${style}"${drag(prefix + cell.id)} onclick="location.hash='#keys/${route_}${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: the two pages and the fixes -------------------------------------
|
||
async function keys(rest) {
|
||
await flush();
|
||
if (rest[0] === "fixes") return fixes(rest.slice(1));
|
||
const o = await api("/api/overview").catch(() => ({ deck: null }));
|
||
const c = cfg();
|
||
const ready = rest[0] === "ready";
|
||
const restart = rest[0] === "restart";
|
||
const sel = restart || ready ? rest[1] : rest[0];
|
||
const hold = ((c.page_hold_ms || 2000) / 1000) + " s";
|
||
const tabs = `<div class="tabs"><button class="${restart || ready ? "" : "on"}" onclick="location.hash='#keys'">Status page</button>${c.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page</button>` : ""}${c.ready_page ? `<button class="${ready ? "on" : ""}" onclick="location.hash='#keys/ready'" data-hint="The weekly worship checklist, checked against Planning Center">READY page</button>` : ""}<button onclick="location.hash='#keys/fixes'">Fixes</button>
|
||
${editing() ? `<span class="small muted" style="margin-left:auto">page <input class="pagenum" data-which="status" type="number" min="1" value="${c.page}" data-hint="The Status page's number in Companion">${c.restart_page ? ` · restart 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" : ""}> Restart page${hint("A second page where each key restarts one thing, even when green")}</label></span>` : ""}</div>`;
|
||
let panel = `<p class="muted small">Click a key.${editing() ? " Drag to move; drop on the tray to hide." : ""}</p>`;
|
||
if (ready) {
|
||
const i = sel && c.ready_page ? c.ready_page.keys.findIndex((x) => x.id === sel) : -1;
|
||
panel = await readyKeyPanel(i, hold);
|
||
if (i >= 0 && editing() && c.ready_page.keys[i].kind !== "ready") panel += await keyPanel(i, hold, "ready_page.keys");
|
||
}
|
||
else 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 re-imports the ${v.plan.pages.filter((p) => p === "status" || p === "restart" || p === "ready").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.</div>`; } catch (e) {} }
|
||
const adders = editing() ? (ready ? (c.ready_page ? `<button id="newdkey" class="small">+ READY key</button>` : "") : restart ? `<button id="newrkey" class="small">+ Restart key</button>` : `<button id="newkey" class="small">+ Key</button>`) : "";
|
||
main.innerHTML = `<h1>Keys${hint("The deck as people hold it. Click a key to see what it watches and what holding it does.")}</h1>${tabs}${note}
|
||
<div class="cols keycols"><div>${restart && !c.restart_page ? "<p class='muted'>No Restart page.</p>" : ready && !c.ready_page ? "<p class='muted'>No READY page.</p>" : deckHtml(o.deck, c, restart ? "restart" : ready ? "ready" : "status", sel, true)}
|
||
${!restart && !ready ? `<p class="small muted tray" id="tray" style="margin-top:8px">In a group only${hint("Keys with no spot of their own: they show inside their group key. Drop a key here to hide it.")}: ${tray || "<span class='muted'>none</span>"}</p>` : ""}
|
||
<div class="row" style="margin-top:8px">${adders}</div></div><div id="panel">${panel}</div></div>`;
|
||
wireKeyPanel(sel, restart, ready ? "ready_page.keys" : "tiles");
|
||
wireLayout(restart);
|
||
const nd = main.querySelector("#newdkey"); if (nd) nd.onclick = () => newReadyKey();
|
||
}
|
||
// A new READY page key: a checklist line the page checks itself, a tap-to-confirm line, or any check.
|
||
function newReadyKey() {
|
||
const c = cfg();
|
||
dialog(`<h3>New READY key</h3><input id="nlabel" placeholder="label, as the key shows it (GREEN ROOM)">
|
||
<select id="nkind"><option value="manual">Tap to confirm: a person taps it done (resets every week)</option><option value="ready">A checklist line the page checks by itself</option><option value="any">Something else: I pick its checks next (a Companion connection, a ping, a program...)</option></select>
|
||
<select id="nitem" hidden>${Object.entries(READY_WORDS).map(([k, w]) => `<option value="${k}">${esc(k)} – ${esc(w.slice(0, 60))}</option>`).join("")}</select>
|
||
<input id="npos" placeholder="row, column" value="${(firstEmpty(c, "ready") || []).join(", ")}"><div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
const kind = b.querySelector("#nkind"), item = b.querySelector("#nitem");
|
||
kind.onchange = () => { item.hidden = kind.value !== "ready"; };
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#dok").onclick = () => {
|
||
const label = b.querySelector("#nlabel").value.trim().toUpperCase();
|
||
if (!label) return alertBox("Give it a label.");
|
||
const pos = parsePos(b.querySelector("#npos").value, cfg(), "ready", false); if (!pos) return;
|
||
if (!ensureDraft()) return;
|
||
const keys = state.draft.config.ready_page.keys;
|
||
let id = "rd_" + (slug(label) || "key"), n = 2; while (keys.some((k) => k.id === id) || state.draft.config.tiles.some((t) => t.id === id)) id = "rd_" + slug(label) + "_" + n++;
|
||
const checks = kind.value === "manual" ? [{ type: "ready_manual", item: slug(label) || id }]
|
||
: kind.value === "ready" ? [{ type: "ready", item: item.value, fix: "ready_recheck" }] : [];
|
||
keys.push({ id, label, pos, checks }); saveDraft(); closeDialog(); location.hash = "#keys/ready/" + id;
|
||
};
|
||
});
|
||
}
|
||
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 if (which === "ready") { for (const k of c.ready_page.keys) taken.add(String(k.pos)); taken.add(String(c.ready_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;
|
||
else if (kind === "dkey") { const k = c.ready_page.keys.find((x) => x.id === id); if (k && pos) k.pos = pos; }
|
||
else if (kind === "dback" && pos) c.ready_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 – filled from the label"><input id="npos" placeholder="row, column (blank = in a group only)" value="${(firstEmpty(cfg(), "status") || []).join(", ")}"><div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#nlabel").oninput = () => { b.querySelector("#nid").value = slug(b.querySelector("#nlabel").value); };
|
||
b.querySelector("#dok").onclick = () => {
|
||
const label = b.querySelector("#nlabel").value.trim(), id = b.querySelector("#nid").value.trim();
|
||
if (!label || !/^[A-Za-z0-9_]+$/.test(id)) return;
|
||
if (cfg().tiles.some((t) => t.id === id)) return alertBox("A key with that id exists.");
|
||
const pos = parsePos(b.querySelector("#npos").value, cfg(), "status", true); if (pos === false) return;
|
||
if (!ensureDraft()) return;
|
||
const t = { id, label, checks: [] }; if (pos) t.pos = pos;
|
||
state.draft.config.tiles.push(t); saveDraft(); closeDialog(); location.hash = "#keys/" + id;
|
||
};
|
||
});
|
||
const nr = main.querySelector("#newrkey"); if (nr) nr.onclick = () => { const c = cfg(); dialog(`<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</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_" + slug(label), n = 2;
|
||
while (state.draft.config.restart_page.keys.some((k) => k.id === id)) id = id.replace(/_\d+$/, "") + "_" + n++;
|
||
state.draft.config.restart_page.keys.push({ id, label, fix, pos, watch: [] }); saveDraft(); closeDialog(); location.hash = "#keys/restart/" + id;
|
||
};
|
||
}); };
|
||
}
|
||
|
||
// A Status page tile ("tiles") or a READY page key ("ready_page.keys"): the same editor for both.
|
||
const keyList = (c, list) => (list === "ready_page.keys" ? (c.ready_page || { keys: [] }).keys : c.tiles);
|
||
const READY_ONLY = ["ready", "ready_manual"];
|
||
async function keyPanel(i, hold, list = "tiles") {
|
||
const c = cfg(), t = keyList(c, list)[i], onReady = list !== "tiles";
|
||
const k = await api("/api/keys");
|
||
const kt = k.tiles.find((x) => x.id === t.id) || { says: [], holding: "", checks: [] };
|
||
// A Companion connection is picked from the ones Companion has (typing still works).
|
||
const cc = editing() ? await api("/api/companion").catch(() => ({})) : {};
|
||
const connList = `<datalist id="connlist">${(cc.connections || []).map((r) => `<option value="${esc(r.label)}">${esc(r.module || "")}</option>`).join("")}</datalist>`;
|
||
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 short = (text, n) => { text = String(text || ""); return text.length > n ? text.slice(0, n - 1) + "…" : text; };
|
||
let lastQ = null;
|
||
const checks = (t.checks || []).map((chk, j) => {
|
||
const spec = state.schema.checks[chk.type] || { fields: [], question: "" };
|
||
const sentence = (kt.checks[j] || {}).sentence || chk.type;
|
||
const what = chk.type === "midi_peer" ? `${chk.peer || "?"} <span class="muted">in ${esc(chk.session || "?")}</span>` : esc(chk.conn || chk.name || (chk.params || {}).process || chk.msg || chk.host || chk.label || chk.url || chk.session || short(sentence, 40));
|
||
const q = spec.question || chk.type;
|
||
const header = q !== lastQ ? `<div class="chkgroup">${esc(q)}</div>` : ""; lastQ = q;
|
||
const summary = `<summary>${onOff(`${list}.${i}.checks.${j}`, chk, "this check")} <b class="${chk.disabled ? "muted" : ""}">${what}</b>${hint(sentence)}</summary>`;
|
||
if (!editing()) return `${header}<details class="chk">${summary}<p class="small">${esc(sentence)}</p></details>`;
|
||
const base = `${list}.${i}.checks.${j}.`;
|
||
let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => f === "conn"
|
||
? inputHtml(base + f, F("tiles[].checks[]." + f), chk[f], connList).replace("<input ", '<input list="connlist" ')
|
||
: f === "item" && chk.type === "ready"
|
||
? `<div class="field"><span class="fl">checklist line${hint("What the READY page looks at for this key")}</span><select data-path="${base}item"><option value="">–</option>${Object.entries(READY_WORDS).map(([k2, w]) => `<option value="${k2}" ${chk.item === k2 ? "selected" : ""}>${esc(k2)} – ${esc(w.slice(0, 70))}</option>`).join("")}</select></div>`
|
||
: f === "item" && chk.type === "ready_manual"
|
||
? inputHtml(base + f, Object.assign({}, F("tiles[].checks[].item"), { help: "A short name for what is tapped done, like green_room (its tap is remembered by this name for the week)" }), chk[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"><span class="fl">built as</span><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) + inputHtml(base + "interval_seconds", F("tiles[].checks[].interval_seconds"), chk.interval_seconds) + (chk.cmd ? `<details><summary>the command</summary><pre class="log small">${esc(chk.cmd)}</pre></details>` : ""));
|
||
}
|
||
const fixSel = typeof chk.fix === "object" && chk.fix ? `<div class="field"><span class="fl">fix per device${hint('A table: device → fix, "*" for everything else')}</span><textarea data-path="${base}fix" rows="2" class="fixtable mono">${esc(JSON.stringify(chk.fix))}</textarea></div>`
|
||
: `<div class="field"><span class="fl">fix${hint("What holding the key runs for this check")}</span><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}" data-hint="Through the watchdog, exactly like a hold">Run the fix</button>` : "";
|
||
return `${header}<details class="chk">${summary}<div class="chkbody">${fields}${fixSel}
|
||
<details><summary>more</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</button></div></div></details>`;
|
||
}).join("");
|
||
const plain = !t.members && t.kind !== "master" && t.kind !== "ready";
|
||
const members = t.members && !onReady ? (editing() ? `<div class="field"><span class="fl">group members</span><span>${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(" ")}</span></div>` : `<p class="small"><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 ? `<select id="addcheck" class="small" style="margin-top:8px"><option value="">+ check…</option>${Object.entries(groupBy(state.schema.checks, (s) => s.question || "Other")).map(([q, types]) => `<optgroup label="${esc(q)}">${types.filter((ty) => onReady || !READY_ONLY.includes(ty)).map((ty) => `<option value="${esc(ty)}">${esc(state.schema.checks[ty].words || ty)}</option>`).join("")}</optgroup>`).join("")}</select>` : "";
|
||
const label = editing() && !onReady ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : "";
|
||
const holdAlways = editing() && plain && !onReady ? `<div class="field"><span class="fl">hold also when green${hint("A fix that runs on every hold, even with nothing failing")}</span><select data-path="tiles.${i}.hold_always" class="holdsel"><option value="">no</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 holdList = (kt.holding || "").split(/;\s*/).filter(Boolean);
|
||
const more = `<details><summary>more</summary><dl class="kv small"><dt>id</dt><dd class="mono">${esc(t.id)}</dd>${t.kind ? `<dt>kind</dt><dd>${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}</dd>` : ""}</dl>${editing() && !t.kind && !onReady ? `<div class="field"><span class="fl">position${hint("row, column; blank = inside its group only")}</span><input id="pos" value="${esc(t.pos ? t.pos.join(", ") : "")}" placeholder="row, column"></div>` : ""}${editing() && (!t.kind || t.kind === "network") ? `<p><button class="small danger" id="delkey">Delete key</button></p>` : ""}</details>`;
|
||
if (onReady) return `<div class="card keycard"><h3 style="margin:4px 0 8px">${t.kind ? "" : onOff(`ready_page.keys.${i}`, t, "this key") + " "}Edit ${esc(t.label)}</h3>${checks ? `<div class="checks">${checks}</div>` : ""}${addMenu}${more}</div>`;
|
||
return `<div class="card keycard"><h2 style="margin:4px 0 8px">${t.kind ? "" : onOff(`tiles.${i}`, t, "this key") + " "}${esc(t.label)} <span class="muted small">${t.pos ? `row ${t.pos[0]}, col ${t.pos[1]}` : "in a group"}${t.disabled ? " · off: grey on the deck, checks nothing" : ""}</span></h2>
|
||
${label}${members}${holdAlways}
|
||
${holdList.length ? `<details class="fold"><summary>Hold ${esc(hold)}: ${esc(short(holdList[0], 48))}${holdList.length > 1 ? ` <span class="muted">+${holdList.length - 1}</span>` : ""}${t.hold_always ? " <span class='pill'>also when green</span>" : ""}</summary><ol class="small">${holdList.map((h) => `<li>${esc(h)}</li>`).join("")}</ol></details>` : ""}
|
||
${kt.says && kt.says.length ? `<details class="fold"><summary>Words it can show <span class="muted">${kt.says.length}</span></summary><p class="mono small">${esc(kt.says.join(" · "))}</p></details>` : ""}
|
||
${checks ? `<div class="checks">${checks}</div>` : ""}${addMenu}${more}</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, list = "tiles") {
|
||
const c = cfg();
|
||
const i = restart ? -1 : keyList(c, list).findIndex((x) => x.id === sel);
|
||
const mine = () => keyList(state.draft.config, list)[i];
|
||
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 ${keyList(c, list)[i].label}? Its checks go with it.`, "Delete", () => { if (!ensureDraft()) return; const cc = state.draft.config; const keys = keyList(cc, list); const id = keys[i].id; keys.splice(i, 1); for (const t of cc.tiles) if (t.members) t.members = t.members.filter((m) => m !== id); saveDraft(); location.hash = list === "tiles" ? "#keys" : "#keys/ready"; });
|
||
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; mine().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 = mine(); t.checks = t.checks || []; t.checks.push(add.value === "ready" ? { type: "ready", fix: "ready_recheck" } : { 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 = mine(), 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));
|
||
}
|
||
// ---- The READY page: what each checklist key looks at, and why it is amber or red right now ----
|
||
const READY_WORDS = {
|
||
tracks: "This weekend's Ableton set exists, has a song group for every Planning Center song, and is the set Live last opened.",
|
||
mix_foh: "Every song is mixed (a song whose tracks are all still at 0 dB is not) and its stems go out to the console, not to Master.",
|
||
lights: "The lighting cue track has a clip under every song.",
|
||
prop: "The ProPresenter playlist for the plan's date has every Planning Center item; MC announcements have a slide each and this week's notes; every person listed for baptisms or dedications has a slide.",
|
||
timecode: "Every song's presentation has a timeline, and Planning Center's LTC note for every song is the timecode the tracks play at that song's start.",
|
||
backgrounds: "Every song has at least one slide with a background.",
|
||
led_cross: "Every Resolume column Sunday's slides call up (slide → macro → Companion → column) holds content, and Resolume is on the Service deck.",
|
||
lineup: "The Band Lineup TV shows this plan, nobody who is not on it or declined, and everyone on the band and communication teams.",
|
||
track_bundle: "Every location has this weekend's Ableton project folder on the shared drive.",
|
||
prop_bundle: "A ProPresenter bundle was saved for the locations since Monday.",
|
||
};
|
||
async function readyKeyPanel(i, hold) {
|
||
const c = cfg();
|
||
const snap = await api("/api/snapshot").catch(() => ({}));
|
||
const r = snap.ready || {};
|
||
const plan = r.plan ? `<p class="small muted">Plan: ${esc(r.plan.title || "")} ${esc(r.plan.dates || "")}${r.time ? ` · looked ${esc(new Date(r.time * 1000).toLocaleTimeString())}` : ""}</p>` : `<p class="small muted">Not looked yet.</p>`;
|
||
if (i < 0) return `<div class="card"><h2 style="margin-top:4px">The weekly checklist</h2>${plan}<p class="small">Amber: still to do. Blinking red: still missing once it is due (${esc(((c.ready || {}).due || []).map((w) => w.days.join("/") + " " + w.start + "-" + w.end).join(", "))}). BOH MIX is tapped done on the deck and reset every ${esc((c.ready || {}).reset_day || "mon")}. Hold any key ${esc(hold)} to fix it or look again; hold READY to fix everything.</p><p class="small muted">Click a key to see what it checks and why it is amber or red.</p></div>`;
|
||
const k = c.ready_page.keys[i], chk = (k.checks || [])[0] || {};
|
||
const item = r.items && chk.item ? r.items[chk.item] : null;
|
||
const fix = chk.fix && c.fixes[chk.fix];
|
||
let what;
|
||
if (k.kind === "ready") what = `Sums up the page: how many keys still have something to do, and which to look at first. <b>Hold ${esc(hold)}</b>: fix everything on the page that can be fixed.`;
|
||
else if (chk.type === "ready_manual") what = `Only a person can check this. <b>Tap</b> on the deck when it is done; tap again to take it back. It resets every ${esc((c.ready || {}).reset_day || "mon")}.`;
|
||
else if (chk.type === "ready") what = `${esc(READY_WORDS[chk.item] || chk.item)} <b>Hold ${esc(hold)}</b>: ${esc(fix && fix.action !== "recheck" ? fix.label : "look again now")}.`;
|
||
else what = `Watches ${esc((k.checks || []).map((x) => x.tag || x.msg || x.type).join(", "))}. <b>Hold ${esc(hold)}</b>: fix the one that dropped.`;
|
||
const faults = item && item.faults && item.faults.length
|
||
? `<ul class="small">${item.details.map((d) => `<li>${esc(d)}</li>`).join("")}</ul>`
|
||
: item ? `<p class="small">Nothing to do${item.info ? ` (${esc(item.info)})` : ""}.</p>` : "";
|
||
const edit = editing() ? inputHtml(`ready_page.keys.${i}.label`, F("tiles[].label"), k.label) : "";
|
||
return `<div class="card"><h2 style="margin-top:4px">${esc(k.label)} <span class="muted small">row ${k.pos[0]}, col ${k.pos[1]}</span></h2>${plan}<p class="small">${what}</p>${faults}${edit}
|
||
${chk.fix && canRunNow(chk.fix) && editing() ? `<button class="small" data-run="${esc(k.id)}|${esc(chk.fix)}|0">Run the fix</button>` : ""}<details><summary>more</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(k.id)}</dd>${chk.item ? `<dt>checklist line</dt><dd class="mono">${esc(chk.item)}</dd>` : ""}</dl></details></div>`;
|
||
}
|
||
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"><span class="fl">hold runs</span><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"><span class="fl">flash while down${hint("Keeps flashing HOLD TO FIX while these are down; hold cmd/ctrl to pick several")}</span><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 style="margin-top:4px">${onOff(`restart_page.keys.${i}`, r, "this key")} ${esc(r.label)} <span class="muted small">row ${r.pos[0]}, col ${r.pos[1]}</span></h2><p class="small"><b>Hold ${esc(hold)}</b> ${esc(f.label || r.fix)}, even when the Status page says it is fine.</p>${edit}
|
||
${canRunNow(r.fix) && editing() ? `<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run the fix</button>` : ""}<details><summary>more</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd>${r.watch && r.watch.length ? `<dt>flashes while down</dt><dd>${esc(r.watch.join(", "))}</dd>` : ""}</dl></details>${editing() ? `<p style="margin-top:12px"><button class="small danger" id="delrkey">Delete 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'" data-hint="Devices that are not computers: switcher, decks, wireless, LED">Gear</button><button class="${compTab === "setup" ? "on" : ""}" onclick="location.hash='#computers/setup'" data-hint="The one-time set-up per computer, with the scripts">Setup</button>
|
||
${editing() ? `<button class="small" data-job="computer" style="margin-left:auto">+ Computer</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" : ""}${(cfg().hosts[c.alias] || {}).disabled ? " off" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${c.self ? "" : onOff(`hosts.${c.alias}`, cfg().hosts[c.alias], "this computer")}</td><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("");
|
||
const looked = Math.max(0, ...m.computers.map((c) => c.looked || 0));
|
||
body = `<div class="row" style="justify-content:flex-end;margin-bottom:6px"><span class="muted small">${looked ? "looked " + esc(ago(Date.now() / 1000 - looked)) : "not looked yet"}</span><button id="probe" class="small" ${m.probing ? "disabled" : ""} data-hint="Log in to every computer now and refresh the lights (read-only)">${m.probing ? "Looking…" : "Check now"}</button></div>
|
||
<div class="tablewrap"><table><tr><th></th><th>status</th><th>computer</th><th>does</th><th>keys${hint("The 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${hint("What runs where. Click a row for everything that runs there, with a light each.")}</h1>${tabs}${body}`;
|
||
for (const b of main.querySelectorAll("[data-copy]")) b.onclick = async () => { const ta = document.getElementById(b.dataset.copy); try { await navigator.clipboard.writeText(ta.value); b.textContent = "Copied"; } catch (e) { ta.select(); document.execCommand("copy"); b.textContent = "Copied"; } };
|
||
const rm = main.querySelector("#removehost"); if (rm) rm.onclick = () => removeComputer(selected);
|
||
for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job);
|
||
const pb = document.getElementById("probe");
|
||
if (pb) pb.onclick = async () => { pb.disabled = true; pb.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => computers(rest, true).then(() => wireInputs(main)), 4000); };
|
||
const ag = main.querySelector("#addgear"); if (ag) ag.onclick = () => gearDialog(null);
|
||
for (const a of main.querySelectorAll("[data-gearedit]")) a.onclick = (e) => { e.preventDefault(); gearDialog(a.dataset.gearedit); };
|
||
for (const b of main.querySelectorAll("[data-geardel]")) b.onclick = () => confirmBox(`Remove ${b.dataset.geardel} from the gear list?`, "Remove", async () => { try { const r = await post("/api/gear", { name: b.dataset.geardel, remove: true }); state.draft = r.draft; badge(); route(); } catch (e) { alertBox(e.message); } });
|
||
for (const b of main.querySelectorAll("[data-gear]")) b.onclick = async () => { const row = b.closest("tr"); try { const prev = (cfg().gear || {})[b.dataset.gear] || {}; await post("/api/gear", { name: b.dataset.gear, location: row.querySelector("[data-loc]").value, notes: row.querySelector("[data-notes]").value, kind: prev.kind || "", address: prev.address || "" }); const d = await api("/api/draft"); state.draft = d.draft; badge(); b.textContent = "Saved"; } catch (e) { alertBox(e.message); } };
|
||
const runs = main.querySelector("#runs"); if (runs) runs.onchange = () => { if (!ensureDraft()) return; try { state.draft.config.hosts[selected].runs = runs.value.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l)); saveDraft(); } catch (err) { alertBox("Each line must be a valid {...} entry."); } };
|
||
}
|
||
function computerCard(c) {
|
||
const items = (c.items || []).map((i) => `<tr><td>${lightDot(i.light, lightWord[i.light])}${esc(lightWord[i.light] || "")}</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 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</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 block"><span class="fl">what runs here${hint('One per line, as {"kind": ..., "name": ...}. Kinds: sshd, app (process), launchagent (label), daemon (process), task (name, exe), service (name), web (url), helper_dvs, helper_solo (label), midi_session (name, peer_of), artnet (source), folder (path), disabled (label). "watched": false = listed only.')}</span><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>
|
||
${c.os === "windows" ? `<button class="small" data-job="pcprogram">+ Program this PC can start</button>` : ""}</details>` : "";
|
||
const remove = editing() && !c.self ? `<p style="margin-top:12px"><button class="small danger" id="removehost" data-hint="Drops the fixes and checks that used it, and says which">Remove computer</button></p>` : "";
|
||
return `<div class="card" id="card"><h2 style="margin-top:4px">${c.self ? "" : onOff(`hosts.${c.alias}`, cfg().hosts[c.alias], "this computer") + " "}${esc(c.name)} ${wordOf(c.word)}${(cfg().hosts[c.alias] || {}).disabled ? ' <span class="muted small">off: nothing logs in to it; its checks and fixes are skipped</span>' : ""}</h2><p class="muted small">${esc(c.role)}${c.ssh ? ` · <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>` : ""}
|
||
<div class="tablewrap"><table>${items}</table></div>
|
||
${done ? `<ul class="small" style="list-style:none;padding:0">${done}</ul>` : ""}
|
||
<details><summary>everything found</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 rkeys = (c.restart_page ? c.restart_page.keys : []).filter((k) => fixes.includes(k.fix)).map((k) => k.label);
|
||
const what = [`the computer ${h.name || alias}`].concat(fixes.length ? [`${fixes.length} fix${fixes.length > 1 ? "es" : ""} that ran on it (${fixes.join(", ")})`] : [], checks.length ? [`${checks.length} check${checks.length > 1 ? "s" : ""} on the keys ${[...new Set(checks.map((x) => x[2]))].join(", ")}`] : [], rkeys.length ? [`the restart keys ${rkeys.join(", ")}`] : []);
|
||
confirmBox(`Remove ${what.join(", ")}? The keys stay; a key left with nothing to check will say OK.`, "Remove", () => {
|
||
if (!ensureDraft()) return;
|
||
const d = state.draft.config;
|
||
delete d.hosts[alias];
|
||
for (const id of fixes) { delete d.fixes[id]; if (d.auto_fix && d.auto_fix.fixes) d.auto_fix.fixes = d.auto_fix.fixes.filter((x) => x !== id); }
|
||
for (const t of d.tiles) {
|
||
t.checks = (t.checks || []).filter((ch) => !(ch.host === alias || (ch.params || {}).host === alias));
|
||
for (const ch of t.checks) { if (typeof ch.fix === "string" && fixes.includes(ch.fix)) delete ch.fix; else if (ch.fix && typeof ch.fix === "object") for (const k of Object.keys(ch.fix)) if (fixes.includes(ch.fix[k])) delete ch.fix[k]; }
|
||
if (fixes.includes(t.hold_always)) delete t.hold_always;
|
||
if (!t.checks.length && !t.members) delete t.checks;
|
||
}
|
||
if (d.restart_page) d.restart_page.keys = d.restart_page.keys.filter((k) => !fixes.includes(k.fix));
|
||
saveDraft(); location.hash = "#computers";
|
||
});
|
||
}
|
||
const GEAR_KINDS = ["recorder", "switcher", "Dante device", "Dante switch", "wireless receiver", "in-ear transmitter", "LED processor", "camera", "network switch", "display", "device"];
|
||
async function gearHtml() {
|
||
const g = await api("/api/gear");
|
||
return `<div class="tablewrap"><table><tr><th></th><th>device</th><th>kind</th><th>address</th><th>where${hint("Where it is in the building, and notes")}</th><th>watched by</th></tr>
|
||
${g.map((r) => `<tr><td>${lightDot(r.light, lightWord[r.light])}</td><td>${editing() ? `<a href="#" data-gearedit="${esc(r.name)}" data-hint="Change its kind, address or how it is watched">${esc(r.name)}</a>` : esc(r.name)}</td><td class="small">${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" style="width:11em"> <input data-notes value="${esc(r.notes || "")}" placeholder="notes" style="width:11em"> <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 || []).length ? r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</a>`).join(", ") : editing() && r.own ? `<button class="small danger" data-geardel="${esc(r.name)}">Remove</button>` : ""}</td></tr>`).join("")}</table></div>
|
||
${editing() ? `<div class="row" style="margin-top:8px"><button class="small" id="addgear">+ Gear</button></div>` : ""}`;
|
||
}
|
||
async function gearDialog(existing) {
|
||
const c = cfg();
|
||
const cur = existing ? Object.assign({ name: existing }, (c.gear || {})[existing] || {}) : {};
|
||
let conns = [];
|
||
try { const cc = await api("/api/companion"); conns = (cc.connections || []).filter((r) => !(r.watched_by || []).length).map((r) => r.label); } catch (e) {}
|
||
const plainKeys = c.tiles.filter((t) => !t.members && t.kind !== "master");
|
||
dialog(`<h3>${existing ? "Gear" : "New gear"}${hint("A device that is not a computer. Give it a kind and an address; choose whether a key watches it.")}</h3>
|
||
<input id="gname" placeholder="name (Tascam, LED wall left, …)" value="${esc(cur.name || "")}" ${existing ? "readonly" : ""}>
|
||
<select id="gkind">${GEAR_KINDS.map((k) => `<option ${cur.kind === k ? "selected" : ""}>${k}</option>`).join("")}</select>
|
||
<input id="gaddr" placeholder="address (10.1.x.x), if it has one" value="${esc(cur.address || "")}">
|
||
<input id="gloc" placeholder="where it is" value="${esc(cur.location || "")}"><input id="gnotes" placeholder="notes" value="${esc(cur.notes || "")}">
|
||
<select id="gwatch"><option value="">not watched by a key</option><option value="ping">watch it: ping the address</option>${conns.map((n) => `<option value="conn:${esc(n)}">watch its Companion connection ${esc(n)}</option>`).join("")}</select>
|
||
<select id="gkey"><option value="__new">on a new key</option>${plainKeys.map((t) => `<option value="${esc(t.id)}">on the ${esc(t.label)} key</option>`).join("")}</select>
|
||
<div class="row"><button class="primary" id="dok">${existing ? "Save" : "Add"}</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#dok").onclick = async () => {
|
||
const name = b.querySelector("#gname").value.trim(), kind = b.querySelector("#gkind").value, address = b.querySelector("#gaddr").value.trim();
|
||
const where = b.querySelector("#gloc").value.trim(), notes = b.querySelector("#gnotes").value.trim(), watch = b.querySelector("#gwatch").value, keyId = b.querySelector("#gkey").value;
|
||
if (!name) return alertBox("A name, please.");
|
||
if (watch === "ping" && !/^[\w.:-]+$/.test(address)) return alertBox("To ping it, give it an address.");
|
||
if (!ensureDraft()) return;
|
||
try { const r = await post("/api/gear", { name, kind, address, location: where, notes }); state.draft = r.draft; } catch (e) { return alertBox(e.message); }
|
||
if (watch) {
|
||
const d = state.draft.config;
|
||
const chk = watch === "ping" ? { type: "ping", host: address, name: tagOf(name) } : { type: "conn", conn: watch.slice(5), fix: "restart-connection" };
|
||
let t = d.tiles.find((x) => x.id === keyId);
|
||
if (!t) { let id = slug(name) || "gear", n = 2; while (d.tiles.some((x) => x.id === id)) id = slug(name) + "_" + n++; t = { id, label: tagOf(name), checks: [] }; const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; d.tiles.push(t); }
|
||
t.checks = t.checks || []; t.checks.push(chk);
|
||
saveDraft(); closeDialog(); location.hash = "#keys/" + t.id; return;
|
||
}
|
||
badge(); closeDialog(); route();
|
||
};
|
||
});
|
||
}
|
||
function setupHtml(m, generated) {
|
||
return m.computers.map((c) => {
|
||
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
|
||
const gen = (generated || []).find((g) => g.alias === c.alias) || { steps: [], scripts: [] };
|
||
const steps = gen.steps.length ? gen.steps.map((x) => x.text) : ((m.setup_steps || {})[kind] || []);
|
||
const howOf = (t) => (gen.steps.find((x) => x.text === t) || {}).how;
|
||
const lightFor = (step) => {
|
||
const s = c.setup || {};
|
||
const item = (k) => (c.items || []).find((i) => i.kind === k);
|
||
if (/login key/.test(step)) return s.key_ok === true || s.login_key === true ? "ok" : s.key_ok === false || s.login_key === false ? "problem" : "unknown";
|
||
if (/panel's key is accepted on this Mac/.test(step)) return "unknown";
|
||
if (/python/.test(step)) return s.python_ok === true ? "ok" : s.python_ok === false ? "problem" : "unknown";
|
||
if (/SSH service/.test(step)) return c.reachable ? "ok" : "problem";
|
||
if (/firewall/.test(step)) return s.firewall_allow ? "ok" : "unknown";
|
||
if (/wake-on-LAN/.test(step)) return s.wake_on_lan === "Enabled" ? "ok" : s.wake_on_lan ? "problem" : "unknown";
|
||
if (/Public/.test(step)) return s.profile === "Public" ? "ok" : s.profile ? "problem" : "unknown";
|
||
if (/DVS helper/.test(step)) { const it = item("helper_dvs"); return it ? it.light : "not_needed"; }
|
||
if (/Pro Tools helper/.test(step)) { const it = item("helper_solo"); return it ? it.light : "not_needed"; }
|
||
if (/StatusPanel/.test(step)) { const ts = (c.items || []).filter((i) => i.kind === "task"); return ts.length ? (ts.every((i) => i.light === "ok") ? "ok" : "problem") : "unknown"; }
|
||
if (/services restart|rtpMIDI/.test(step)) { const it = item("service"); return it ? it.light : "not_needed"; }
|
||
if (/Netgear/.test(step)) return s.netgear_keychain === true ? "ok" : s.netgear_keychain === false ? "problem" : "not_needed";
|
||
if (/Google Drive/.test(step)) { const it = item("folder"); return it ? it.light : "not_needed"; }
|
||
if (/watchdog and the settings/.test(step)) { const ok = (c.items || []).filter((i) => i.kind === "launchagent" && /watchdog|settings/i.test(i.name)); return ok.length && ok.every((i) => i.light === "ok") ? "ok" : "unknown"; }
|
||
if (/Wi-Fi/.test(step)) return c.manual_done && c.manual_done.wifi_power ? "hand" : "unknown";
|
||
if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown";
|
||
return "unknown";
|
||
};
|
||
const scriptsHtml = gen.scripts.map((sc, i) => `<details class="script"><summary>${esc(sc.name)}${hint(sc.how)}</summary><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, lightWord[l])}${esc(s)}${hint(how)}</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: "This page" }[n]}</button>`).join("");
|
||
const filters = logTab === "watchdog" ? `<div class="tabs">${["", "faults", "holds", "fixes", "auto", "errors"].map((f) => `<button class="small ${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${hint("What the watchdog did and saw. Newest at the bottom.")}</h1><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"><h3>${esc(rest[0])}</h3>${d.changes_to_now.length ? `<ul class="small">${d.changes_to_now.map((c) => `<li>${esc(c.text)}</li>`).join("")}</ul>` : "<p class='muted small'>The same as now.</p>"}
|
||
${editing() ? `<button class="primary" id="goback" data-hint="It becomes your draft; Apply puts it live">Go back to this</button>` : ""}</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${hint("Every version ever applied: who, when, the note. Open one to see what differs from now.")}${editing() && h.entries.length ? `<button id="undo" class="small" style="margin-left:auto" data-hint="The version before the newest becomes your draft">Undo last change</button>` : ""}</h1>
|
||
<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; 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>Apply</h1><p class="muted">No changes. Change something 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) => c.before !== undefined || c.after !== undefined
|
||
? `<li><details><summary>${esc(c.text)}${c.command ? ' <span class="pill" data-hint="A command the watchdog will run; it never runs before Apply">command</span>' : ""}</summary>${c.before != null ? `<div class="small"><span class="muted">was</span> <span class="${c.command ? "mono" : ""}">${esc(c.before)}</span></div>` : ""}${c.after != null ? `<div class="small"><span class="muted">now</span> <span class="${c.command ? "mono" : ""}">${esc(c.after)}</span></div>` : ""}</details></li>`
|
||
: `<li>${esc(c.text)}</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; your draft is saved. ${editing() ? '<button id="emergency" class="danger small">Apply anyway</button>' : ""}</div>` : "";
|
||
main.innerHTML = `<h1>Apply${hint("Your changes in words, and what will happen on the deck. Nothing is live until you press APPLY.")}</h1>${state.draftStale ? "<p class='notice'>The live settings changed since this draft was started and could not be merged: look through the list before applying.</p>" : ""}
|
||
<div class="card">${changes ? `<ul class="changes">${changes}</ul>` : "<p class='muted'>No change yet.</p>"}
|
||
<p><b>Then:</b> ${esc(r.feel)}</p>
|
||
${errors ? `<div class="notice problem"><b>Not possible yet:</b><ul>${errors}</ul></div>` : ""}${warnings ? `<div class="notice grey"><ul>${warnings}</ul></div>` : ""}${live}
|
||
<div class="row"><input id="note" placeholder="note for History" maxlength="200" style="flex:1;min-width:160px;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);font:inherit"><button class="primary" id="apply" ${errors || (r.live && r.live.live) || !editing() || !changes ? "disabled" : ""}>APPLY</button><button id="discard" ${editing() ? "" : "disabled"} data-hint="Every change since the last Apply is lost">Throw 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="muted">${esc(st.note || "")}</p>${stepsHtml(st)}`;
|
||
setTimeout(async () => { try { const s2 = await api("/api/apply/status"); if (s2.state === "running") applyProgress(s2); else { await loadAll(); route(); } } catch (e) { setTimeout(() => applyProgress(st), 2000); } }, 1500);
|
||
}
|
||
function stepsHtml(st) { return `<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 = [
|
||
["Red key", "It names the problem. Press once to acknowledge (stays dark red). Hold 2 s: it runs the fix for what is failing."],
|
||
["Purple keys", "The watchdog on the Ingest Mac has been silent 15 s. Overview › Restart watchdog, unless Companion itself is down."],
|
||
["Which computer or device?", "Computers › Map: every computer and the keys that turn red if it dies. Computers › Gear: switcher, decks, wireless, LED."],
|
||
["Changing something", "Edit (top right, the PIN). Change it where it lives: a key on Keys, a computer on Computers, the rest on Settings. The badge counts your changes and opens Apply."],
|
||
].map(([t, b]) => `<div class="card"><h3>${t}</h3><p class="small">${b}</p></div>`).join("");
|
||
const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `<details><summary><b>${esc(g)}</b></summary><p class="mono small">${esc(words.join(" · "))}</p></details>`).join("");
|
||
main.innerHTML = `<h1>Help</h1><div class="cards">${cards}</div>
|
||
<p style="margin-top:12px"><a class="btn" href="/key-guide.html" target="_blank" data-hint="One entry per key: what it checks, what holding does, every word it can show">What each key does</a> <a class="btn" href="/cheat.html" target="_blank" data-hint="One printable page: both decks, the colours, press = acknowledge, hold = fix">Cheat sheet</a> <a class="btn" href="/where.html" target="_blank" data-hint="Every file, folder, log and port per computer">Where things are</a></p>
|
||
<details open><summary><b>How do I add…</b> (everything is done here; nothing needs a terminal)</summary><ul class="small howto"><li><b>Watch a new Companion connection.</b> Edit (top right, the PIN) › Settings › Companion. Find it marked “not watched” › <b>+ Key</b>. When the Status page is full it asks which group key to put it in (AUDIO, SHOW…): its problems show on that key, and holding the key reconnects it. Then the badge › Apply.</li><li><b>Add a connection to a key that is already there.</b> Keys › click the key › <b>+ check…</b> › “A Companion connection is good” › pick the connection from the list › fix: “reconnect its Companion connection” › Apply.</li><li><b>Watch that a device is on the network.</b> Keys › the key › <b>+ check…</b> › “An address answers ping” › type the address and the name the key should show (it says “NAME NO PING”).</li><li><b>Watch a program on another computer, and restart it.</b> Keys › the key › <b>+ check…</b> › “A command on another computer succeeds” › built as “A program is running on a Mac” › pick the computer and the program (on a Windows PC: Custom, e.g. <span class=\"mono\">tasklist | find /I \"OBS.exe\"</span>). Then fix › a fix that starts it (make one first: next line).</li><li><b>Make a new fix (something a hold can do).</b> Keys › Fixes › <b>+ Fix</b> › pick how it is built (start a Windows program, restart a Mac program, restart a helper, reopen Ableton…) and fill in the fields; the command is written for you. Then choose it as a check's “fix”, and use “Run the fix” to try it.</li><li><b>Let a fix run by itself on Sundays.</b> Settings › Auto-fix › tick it, and paint the hours on the calendar. A fix that closes a running program asks first.</li><li><b>A fallback button (the stream audio switches to FOH Backup by itself when Studio B dies).</b> Keys › Fixes › <b>+ Fix</b> › built as “Press a Companion button” › page, row, column of the button in Companion (page 4, row 0, column 5 is FOH Backup) › under more, tick failover (and pick the fix that switches back as undo). Then Keys › <b>+ Key</b> (or an existing key) › <b>+ check…</b> for what must be alive (an address answers ping, a program is running on a Mac, a Dante device) › fix: that press. Then Settings › <b>Auto switch</b>: tick it, paint the hours, and tune what counts as down (how often, how many misses). The watchdog presses once per fault, within about 5 s, even while ALARMS is muted, and never switches back by itself.</li><li><b>Add a line to the weekly checklist.</b> Keys › READY page › <b>+ READY key</b> › “Tap to confirm” (a person taps it done; resets Monday), “a checklist line the page checks by itself”, or “something else” (then + check… like any key: a connection, a ping, a program). Drag it where you want it.</li><li><b>Change where the READY page looks.</b> Settings › READY page: the Planning Center service type, the ProPresenter and Resolume addresses, the Tracks Mac and its projects folder, which locations get tracks, the lineup TV, the bundle folder, and when gaps turn red.</li><li><b>A new computer.</b> Computers › <b>+ Computer</b> (its log-in address), then Computers › Setup › that computer › copy its set-up script and run it there once.</li><li><b>Made a mistake?.</b> Nothing is live until Apply. After Apply: History › Undo, then Apply again.</li></ul></details>
|
||
<details><summary><b>Try it</b> (ten minutes with Editing on; nothing is live until APPLY)</summary><ol class="small">
|
||
<li>Keys › HD2: untick "may have no disk"; the badge counts it; Apply reads it back. Throw away.</li>
|
||
<li>Keys: drag LED onto the tray and back. + Key "HD7", add a check (Is a recorder OK?), delete it.</li>
|
||
<li>Settings › HyperDecks: untick 5 and 6, look at Keys (cells stay empty). Throw away.</li>
|
||
<li>Settings › Playlist: paste a link, Look it up; the name follows everywhere.</li>
|
||
<li>Settings › Auto-fix: paint Sunday morning on the calendar.</li>
|
||
<li>Settings › MIDI links: add one to the MIDI key; Run the fix on that check.</li>
|
||
<li>Keys › Fixes › VISTA: change the program file to Vista2.exe, watch the command rebuild; put it back.</li>
|
||
<li>Computers › Setup › Lighting PC: open the script, Copy. Computers › Lighting PC › Remove (read, then Cancel).</li>
|
||
<li>Settings › Switcher: Pick the newest file; Discard. Settings › Program & campus: Export bundle.</li>
|
||
<li>A real change (a note on Computers › Gear): APPLY, then History › Undo, APPLY again.</li></ol></details>
|
||
<details><summary><b>The words a key can show</b></summary>${glossary}</details>
|
||
<details><summary><b>Only a person can</b></summary><ul class="small"><li>Run the set-up script on a new computer, 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.</li><li>Save a new switcher file from ATEM Software Control when the show changes, then pick it under Settings › Switcher.</li></ul></details>
|
||
<details><summary><b>Companion facts</b> (found by trying)</summary><dl class="kv small">${(facts.facts || []).map(([q, a]) => `<dt>${esc(q)}</dt><dd>${esc(a)}</dd>`).join("")}</dl></details>`;
|
||
}
|
||
|
||
// ---- Settings: one page, every section ------------------------------------------
|
||
const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys", "site.editing_code",
|
||
"ready_page.keys", "ready_page.back_pos", "ready.prop_lists", "failover.windows", "failover.after_seconds", "failover.note_seconds"];
|
||
const SECTIONS = [
|
||
["playlist", "Playlist", "The Spotify playlist the SPOTIFY key and the play fix use"],
|
||
["autofix", "Auto-fix", "When the panel may fix things by itself, and which fixes"],
|
||
["failover", "Auto switch", "Press a Companion button by itself when something dies (Studio B -> FOH Backup): which button, when, and what counts as down"],
|
||
["ready", "READY page", "Where the weekly checklist looks: Planning Center, ProPresenter, the Tracks Mac, the lineup TV, Resolume, the bundles"],
|
||
["wait", "Changes wait", "When changes to the panel must wait (a recording deck always counts)"],
|
||
["hyperdecks", "HyperDecks", "Which decks are in use"],
|
||
["midi", "MIDI links", "Network MIDI sessions and the peers that must stay in them"],
|
||
["computers", "Computers", "Every computer the panel talks to"],
|
||
["dante", "Dante", "The devices the DANTE key expects"],
|
||
["switcher", "Switcher", "The saved ATEM file the panel keeps the switcher in line with"],
|
||
["outlets", "Outlets", "Smart plugs the panel may switch, for a device that only a power cycle brings back"],
|
||
["companion", "Companion", "Connections, and names the keys use that are not here"],
|
||
["network", "Network", "This Mac's network port and the Dante switches"],
|
||
["site", "Site & PIN", "The campus, contacts, this Mac, the PIN"],
|
||
["program", "Program & campus", "Update the code, export or start a campus"],
|
||
["advanced", "Advanced", "Every number and name, by screen"],
|
||
];
|
||
async function settings(rest) {
|
||
await flush();
|
||
const which = SECTIONS.some(([k]) => k === rest[0]) ? rest[0] : "playlist";
|
||
const c = cfg();
|
||
const nav = `<nav class="subnav">${SECTIONS.map(([k, n, h]) => `<a href="#settings/${k}" class="${k === which ? "active" : ""}" data-hint="${esc(h)}">${n}</a>`).join("")}</nav>
|
||
<select id="subsel">${SECTIONS.map(([k, n]) => `<option value="${k}" ${k === which ? "selected" : ""}>${n}</option>`).join("")}</select>`;
|
||
const sec = SECTIONS.find(([k]) => k === which);
|
||
let body = "";
|
||
if (which === "playlist") body = spotifyHtml(c);
|
||
else if (which === "autofix") body = autofixHtml(c);
|
||
else if (which === "failover") body = autoSwitchHtml(c);
|
||
else if (which === "ready") body = readySettingsHtml(c);
|
||
else if (which === "wait") body = `<div class="card">${calendarHtml("site.live_services", (c.site || {}).live_services)}<p class="help">A recording HyperDeck always counts, whatever the clock says.</p></div>`;
|
||
else if (which === "hyperdecks") body = await hyperdecksHtml(c);
|
||
else if (which === "midi") body = midiHtml(c);
|
||
else if (which === "computers") body = computersHtml(c);
|
||
else if (which === "dante") body = await danteHtml(c) || "<p class='muted'>No DANTE key.</p>";
|
||
else if (which === "switcher") body = await atemHtml(c) + fieldsHtml("ATEM");
|
||
else if (which === "outlets") body = await outletsHtml();
|
||
else if (which === "companion") body = (await namesHtml()) + (await companionHtml());
|
||
else if (which === "network") body = fieldsHtml("Network & Dante");
|
||
else if (which === "site") body = siteHtml(c);
|
||
else if (which === "program") body = await programHtml();
|
||
else if (which === "advanced") body = advancedHtml();
|
||
main.innerHTML = `<h1>Settings${editing() ? "" : hint("Switch Editing on (Edit, top right) to change anything")}</h1><div class="settings">${nav}<div><h2 style="margin-top:0">${esc(sec[1])}${hint(sec[2])}</h2>${body}</div></div>`;
|
||
main.querySelector("#subsel").onchange = (e) => { location.hash = "#settings/" + e.target.value; };
|
||
wireSettings(which);
|
||
}
|
||
function readySettingsHtml(c) {
|
||
if (!c.ready_page) return "<p class='muted'>This campus has no READY page.</p>";
|
||
const lists = JSON.stringify((c.ready || {}).prop_lists || [], null, 1);
|
||
return `<div class="card"><p style="margin:0">${onOff("ready", c.ready || {}, "the READY page")} ${(c.ready || {}).disabled ? "<b>The READY page is off:</b> its keys show OFF and nothing is looked at. Everything is kept." : "<b>The READY page</b> looks at this weekend every 2 minutes."}</p></div><div class="card"><h3 style="margin-top:0">When gaps turn red${hint("Before this, a READY key that is not done is amber; inside it, still missing is red and blinks")}</h3>${calendarHtml("ready.due", (c.ready || {}).due)}</div>`
|
||
+ fieldsHtml("Ready")
|
||
+ `<div class="card"><div class="field"><span class="fl">lists the slides must match${hint('PCO items whose description lists things: {"match": a word in the item title, "kind": "count" (one slide per - line) or "names" (every name has a slide), "short": what the key calls it}')}</span><textarea data-jsonpath="ready.prop_lists" rows="${Math.min(12, lists.split("\n").length + 1)}" class="mono" ${editing() ? "" : "disabled"}>${esc(lists)}</textarea></div>
|
||
<p class="help">The keys themselves (add, move, delete, what each checks) are on Keys › READY page.</p></div>`;
|
||
}
|
||
function fieldsHtml(screen, includeAdvanced) {
|
||
const own = Object.entries(state.schema.fields).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !HAND_MADE.includes(p) && !HAND_MADE.some((h) => h.startsWith(p + ".")) && !Object.keys(state.schema.fields).some((k) => k.startsWith(p + ".") || k.startsWith(p + "[]")));
|
||
const plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced);
|
||
if (!own.length) return "";
|
||
const fh = ([p, f]) => inputHtml(p, f, getPath(p));
|
||
return `<div class="card">${plain.map(fh).join("")}${adv.length ? (includeAdvanced ? adv.map(fh).join("") : `<details><summary>more</summary>${adv.map(fh).join("")}</details>`) : ""}</div>`;
|
||
}
|
||
function advancedHtml() {
|
||
const screens = [...new Set(Object.values(state.schema.fields).map((f) => f.screen))];
|
||
return screens.map((s) => { const h = fieldsHtml(s, true); return h ? `<h3>${esc(s)}</h3>${h}` : ""; }).join("") || "<p class='muted'>Nothing.</p>";
|
||
}
|
||
function siteHtml(c) {
|
||
const f = F("site.editing_code");
|
||
return `<div class="card">${inputHtml("site.editing_code", Object.assign({}, f, { help: "The PIN that switches Editing on. Digits are easiest. It lives in the settings file, so everyone with the repo has it." }), (c.site || {}).editing_code)}</div>` + fieldsHtml("Site");
|
||
}
|
||
function spotifyHtml(c) {
|
||
const s = c.spotify || {};
|
||
const sw = []; c.tiles.forEach((t, ti) => (t.checks || []).forEach((ch, j) => { if (ch.type === "spotify") sw.push(`<p style="margin-top:0">${onOff(`tiles.${ti}.checks.${j}`, ch, "the Spotify check")} Watch that Spotify plays this playlist</p>`); }));
|
||
return `<div class="card">${sw.join("")}<div class="field"><span class="fl">playlist link${hint("Paste the link from Spotify (Share › Copy link). Look it up fills the name.")}</span><span class="row"><input id="splink" value="${esc(s.playlist || "")}" placeholder="https://open.spotify.com/playlist/…" ${editing() ? "" : "disabled"} style="flex:1;min-width:200px"> ${editing() ? `<button class="small" id="splook">Look it up</button>` : ""}</span></div>
|
||
<div class="field"><span class="fl">name${hint("As the keys say it")}</span><input id="spname" value="${esc(s.name || "")}" ${editing() ? "" : "disabled"}></div><p id="spinfo" class="help"></p></div>` + fieldsHtml("Spotify");
|
||
}
|
||
function autofixHtml(c) {
|
||
const fixes = Object.entries(c.fixes).map(([id, f]) => { const cl = closes(f); const on = (c.auto_fix.fixes || []).includes(id); return `<label class="inline" data-hint="${esc(f.label || id)}${cl ? " · closes a program: ticking it allows that by itself" : f.never_auto ? " · marked never by itself: ticking it lifts that" : ""}"><input type="checkbox" data-autofix="${esc(id)}" ${on ? "checked" : ""} ${editing() ? "" : "disabled"}> ${esc(f.short || id)}${cl || f.restart_ok ? " ⚠" : ""}</label>`; }).join(" ");
|
||
return `<div class="card"><p style="margin-top:0">${onOff("auto_fix", c.auto_fix, "auto-fix")} <b>Auto-fix</b> ${c.auto_fix.disabled ? "is off: nothing is fixed by itself (holds still work)." : "fixes the ticked fixes by itself in the painted hours."}</p>${calendarHtml("auto_fix.windows", c.auto_fix.windows)}</div>
|
||
<div class="card"><h3 style="margin-top:0">Fixes it may run${hint("Ticked fixes run by themselves inside the painted hours. ⚠ = it closes a running program to do its job; ticking it allows that without a hold.")}</h3><div class="chips">${fixes}</div></div><p class="help">Pressing a Companion button by itself when something dies is under Settings › <a href="#settings/failover">Auto switch</a>.</p>` + fieldsHtml("Schedules");
|
||
}
|
||
// Which keys name each fix (on a check, as hold-also-when-green, or on a restart key).
|
||
function fixUsers(c) {
|
||
const used = {};
|
||
const use = (f, who) => { if (f) (used[f] = used[f] || new Set()).add(who); };
|
||
for (const t of c.tiles.concat((c.ready_page || { keys: [] }).keys)) { 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);
|
||
return used;
|
||
}
|
||
// ---- A Companion page as Companion draws it: pick a button by clicking it ----
|
||
const cssHex = (n) => (n == null || typeof n !== "number" ? null : "#" + (n & 0xffffff).toString(16).padStart(6, "0"));
|
||
async function companionPicker(box, init, onSelect) {
|
||
// init: {page, row, col}; onSelect({page, row, col, text}) on every click.
|
||
box.innerHTML = `<p class="muted small">Reading Companion's pages…</p>`;
|
||
const pages = (await api("/api/companion/pages").catch(() => ({ pages: [] }))).pages || [];
|
||
if (!pages.length) { box.innerHTML = `<p class="notice small">Companion's pages could not be read; type the numbers below instead.</p>`; return; }
|
||
let cur = Object.assign({}, init || {});
|
||
if (!cur.page) cur.page = pages[0].page;
|
||
const draw = async () => {
|
||
box.innerHTML = `<div class="field"><span class="fl">Companion page</span><select class="cp-page">${pages.map((p) => `<option value="${p.page}" ${p.page === +cur.page ? "selected" : ""}>${p.page}: ${esc(p.name)}</option>`).join("")}</select></div><div class="cp-grid"><p class="muted small">Loading page ${esc(cur.page)}…</p></div><p class="small cp-pick muted">Click the button.</p>`;
|
||
box.querySelector(".cp-page").onchange = (e) => { cur = { page: +e.target.value }; draw(); };
|
||
const pg = await api("/api/companion/page?n=" + cur.page).catch(() => ({ buttons: [] }));
|
||
const at = {}; let maxR = 3, maxC = 7;
|
||
for (const b of pg.buttons || []) { at[b.row + "," + b.col] = b; maxR = Math.max(maxR, Math.min(b.row, 5)); maxC = Math.max(maxC, Math.min(b.col, 8)); }
|
||
let html = `<div class="deck cp-deck" style="grid-template-columns: repeat(${maxC + 1}, minmax(0, 1fr))">`;
|
||
for (let r = 0; r <= maxR; r++) for (let c = 0; c <= maxC; c++) {
|
||
const b = at[r + "," + c];
|
||
const sel = +cur.row === r && +cur.col === c && cur.row !== undefined && cur.row !== "";
|
||
if (!b) { html += `<div class="key empty" title="row ${r}, column ${c}: empty"></div>`; continue; }
|
||
if (["pageup", "pagedown", "pagenum"].includes(b.type)) { html += `<div class="key nav">${{ pageup: "UP", pagedown: "DOWN", pagenum: String(cur.page) }[b.type]}</div>`; continue; }
|
||
const bg = cssHex(b.bg) || "#000", fg = cssHex(b.fg) || "#fff";
|
||
const img = b.img ? `background-image:url('${b.img}');background-size:contain;background-repeat:no-repeat;background-position:center;` : "";
|
||
html += `<div class="key cp-key${sel ? " sel" : ""}" data-r="${r}" data-c="${c}" data-t="${esc(b.text || "")}" title="row ${r}, column ${c}" style="background-color:${bg};color:${fg};${img}">${esc(b.text || "")}</div>`;
|
||
}
|
||
box.querySelector(".cp-grid").innerHTML = html + "</div>";
|
||
const say = () => { const p = box.querySelector(".cp-pick"); if (cur.row !== undefined && cur.row !== "") { p.classList.remove("muted"); p.innerHTML = `Picked: <b>${esc((cur.text || "").replace(/\n/g, " ") || "a button")}</b> (page ${esc(cur.page)}, row ${esc(cur.row)}, column ${esc(cur.col)})`; } };
|
||
say();
|
||
for (const k of box.querySelectorAll(".cp-key")) k.onclick = () => {
|
||
for (const o of box.querySelectorAll(".cp-key.sel")) o.classList.remove("sel");
|
||
k.classList.add("sel");
|
||
cur = { page: +cur.page, row: +k.dataset.r, col: +k.dataset.c, text: k.dataset.t };
|
||
say(); onSelect(Object.assign({}, cur));
|
||
};
|
||
};
|
||
await draw();
|
||
}
|
||
function pickButtonDialog(title, init, onDone) {
|
||
let chosen = null;
|
||
dialog(`<h3>${esc(title)}</h3><div class="cp-box"></div><div class="row"><button class="primary" id="dok">Use this button</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
b.classList.add("wide");
|
||
companionPicker(b.querySelector(".cp-box"), init, (p) => { chosen = p; });
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#dok").onclick = () => { if (!chosen) return alertBox("Click a button on the page first."); closeDialog(); onDone(chosen); };
|
||
});
|
||
}
|
||
// ---- Auto switch: add a button / add what counts as down ----
|
||
function addSwitchButton() {
|
||
const c = cfg(), presses = Object.entries(c.fixes).filter(([, f]) => f.kind === "companion_press");
|
||
dialog(`<h3>New Companion button</h3><p class="small">Pick the page, then click the button, just as it looks in Companion.</p>
|
||
<div class="cp-box"></div>
|
||
<input id="bname" placeholder="what to call it (filled from the button)">
|
||
<details class="small"><summary>type the numbers instead</summary><div class="row"><label class="inline small">page <input id="bpage" type="number" min="1" style="width:5em"></label><label class="inline small">row <input id="brow" type="number" min="0" style="width:5em"></label><label class="inline small">column <input id="bcol" type="number" min="0" style="width:5em"></label></div></details>
|
||
<label class="inline"><input type="checkbox" id="bauto" checked> press it by itself when something dies (you pick what next)</label>
|
||
<div class="field"><span class="fl">switch back with (by hand)</span><select id="bundo"><option value="">nothing</option>${presses.map(([u, g]) => `<option value="${esc(u)}">${esc((g.params || {}).button || g.short || u)} (page ${g.page}, row ${g.row}, col ${g.col})</option>`).join("")}</select></div>
|
||
<div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
b.classList.add("wide");
|
||
companionPicker(b.querySelector(".cp-box"), {}, (p) => {
|
||
b.querySelector("#bpage").value = p.page; b.querySelector("#brow").value = p.row; b.querySelector("#bcol").value = p.col;
|
||
const nm = b.querySelector("#bname"); if (!nm.value || nm.dataset.auto) { nm.value = (p.text || "").replace(/\n/g, " ").trim(); nm.dataset.auto = "1"; }
|
||
});
|
||
b.querySelector("#bname").oninput = (e) => { delete e.target.dataset.auto; };
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#dok").onclick = async () => {
|
||
if (b.querySelector("#brow").value === "") return alertBox("Click the button on the page (or type the numbers).");
|
||
const name = b.querySelector("#bname").value.trim();
|
||
if (!name) return alertBox("What does the button say?");
|
||
const params = { page: b.querySelector("#bpage").value, row: b.querySelector("#brow").value, col: b.querySelector("#bcol").value, button: name };
|
||
const r = await post("/api/build", { fix: { template: "companion_press", params, short: name.toUpperCase().slice(0, 14) } });
|
||
if (!r.ok) return alertBox(r.error);
|
||
if (!ensureDraft()) return;
|
||
const fixes = state.draft.config.fixes;
|
||
let id = "press_" + (slug(name) || "button"), n = 2; while (fixes[id]) id = "press_" + slug(name) + "_" + n++;
|
||
const fx = r.fix; if (r.label) fx.label = r.label;
|
||
if (b.querySelector("#bauto").checked) fx.failover = true;
|
||
if (b.querySelector("#bundo").value) fx.undo = b.querySelector("#bundo").value;
|
||
fixes[id] = fx; saveDraft(); closeDialog(); route();
|
||
};
|
||
});
|
||
}
|
||
function addSwitchTrigger() {
|
||
const c = cfg();
|
||
const switches = Object.entries(c.fixes).filter(([, f]) => f.kind === "companion_press" && f.failover);
|
||
if (!switches.length) return alertBox("First add a button that switches by itself (+ Button, with “press it by itself” ticked).");
|
||
const users = fixUsers(c);
|
||
const home = c.tiles.find((t) => (t.checks || []).some((ch) => switches.some(([id]) => (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix]).includes(id))));
|
||
const keys = c.tiles.filter((t) => t.checks && !t.members && !t.kind);
|
||
const groups = c.tiles.filter((t) => t.members);
|
||
const dante = (c.tiles.flatMap((t) => t.checks || []).find((ch) => ch.type === "dante") || {});
|
||
const devices = Object.keys(dante.devices || {});
|
||
const macs = Object.entries(c.hosts).filter(([, h]) => !h.self);
|
||
dialog(`<h3>What counts as down</h3>
|
||
<select id="tkind"><option value="ping">A computer or device stops answering ping</option><option value="program">A program stops running on a computer</option><option value="dante">A Dante device disappears</option><option value="conn">A Companion connection fails</option></select>
|
||
<div data-k="ping"><input id="taddr" placeholder="its address (10.1.13.175)"><input id="tname" placeholder="what the key calls it (STUDIO B)"></div>
|
||
<div data-k="program" hidden><select id="thost">${macs.map(([a, h]) => `<option value="${esc(a)}">${esc(h.name || a)} (${esc(h.os || "mac")})</option>`).join("")}</select><input id="tproc" placeholder="the program, as Activity Monitor / Task Manager names it (Pro Tools, OBS64.exe)"></div>
|
||
<div data-k="dante" hidden><input id="tdev" list="tdevs" placeholder="the Dante device name"><datalist id="tdevs">${devices.map((d) => `<option value="${esc(d)}">`).join("")}</datalist></div>
|
||
<div data-k="conn" hidden><input id="tconn" list="tconns" placeholder="the Companion connection"><datalist id="tconns"></datalist></div>
|
||
<div class="field"><span class="fl">then press</span><select id="tfix">${switches.map(([id, f]) => `<option value="${esc(id)}">${esc((f.params || {}).button || f.short || id)}</option>`).join("")}</select></div>
|
||
<div class="field"><span class="fl">show it on</span><select id="tkey">${keys.map((t) => `<option value="${esc(t.id)}" ${home && home.id === t.id ? "selected" : ""}>${esc(t.label)}</option>`).join("")}<option value="">a new key inside a group…</option></select></div>
|
||
<div class="field" id="tnewkey" hidden><input id="tklabel" placeholder="the new key's name (STA AUDIO)"><select id="tgroup">${groups.map((g) => `<option value="${esc(g.id)}">inside ${esc(g.label)}</option>`).join("")}</select></div>
|
||
<div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, async (b) => {
|
||
const kind = b.querySelector("#tkind"), keySel = b.querySelector("#tkey");
|
||
const show = () => { for (const el of b.querySelectorAll("[data-k]")) el.hidden = el.dataset.k !== kind.value; b.querySelector("#tnewkey").hidden = keySel.value !== ""; };
|
||
kind.onchange = show; keySel.onchange = show; show();
|
||
api("/api/companion").then((cc) => { b.querySelector("#tconns").innerHTML = (cc.connections || []).map((r) => `<option value="${esc(r.label)}">`).join(""); }).catch(() => {});
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
b.querySelector("#dok").onclick = async () => {
|
||
const fix = b.querySelector("#tfix").value;
|
||
let chk;
|
||
if (kind.value === "ping") {
|
||
const host = b.querySelector("#taddr").value.trim(), name = b.querySelector("#tname").value.trim().toUpperCase();
|
||
if (!/^[0-9A-Za-z.\-]+$/.test(host)) return alertBox("Type the address, like 10.1.13.175.");
|
||
chk = { type: "ping", host, name: name || host, interval_seconds: 1, failures: 2, debounce_seconds: 1, fix };
|
||
} else if (kind.value === "program") {
|
||
const host = b.querySelector("#thost").value, proc = b.querySelector("#tproc").value.trim();
|
||
if (!proc) return alertBox("Which program?");
|
||
const h = c.hosts[host] || {};
|
||
if (h.os === "windows") {
|
||
const exe = /\.exe$/i.test(proc) ? proc : proc + ".exe";
|
||
if (!/^[A-Za-z0-9 ._()\-]+$/.test(exe)) return alertBox("That program name has characters a Windows check cannot use.");
|
||
chk = { type: "remote", host, cmd: `tasklist /NH /FI "IMAGENAME eq ${exe}" | find /I "${exe}"`, msg: exe.replace(/\.exe$/i, "").toUpperCase() + " NOT RUNNING", interval_seconds: 2, debounce_seconds: 1, fix };
|
||
} else {
|
||
const r = await post("/api/build-check", { check: { type: "remote", template: "mac_program", params: { host, process: proc }, msg: proc.toUpperCase() + " NOT RUNNING", fix, debounce_seconds: 1 } });
|
||
if (!r.ok) return alertBox(r.error);
|
||
chk = Object.assign(r.check, { interval_seconds: 2 });
|
||
}
|
||
} else if (kind.value === "dante") {
|
||
const dev = b.querySelector("#tdev").value.trim();
|
||
if (!dev) return alertBox("Which Dante device?");
|
||
chk = { type: "dante", conn: dante.conn || "audinate-dantecontroller", devices: { [dev]: dev.replace(/^.*?-/, "").slice(0, 10).toUpperCase() }, fix: { [dev]: fix } };
|
||
} else {
|
||
const conn = b.querySelector("#tconn").value.trim();
|
||
if (!conn) return alertBox("Which Companion connection?");
|
||
chk = { type: "conn", conn, tag: conn.toUpperCase().replace(/[_-]+/g, " ").slice(0, 12), fix };
|
||
}
|
||
if (!ensureDraft()) return;
|
||
const d = state.draft.config;
|
||
let tile = keySel.value ? d.tiles.find((t) => t.id === keySel.value) : null;
|
||
if (!tile) {
|
||
const label = b.querySelector("#tklabel").value.trim().toUpperCase();
|
||
if (!label) return alertBox("Name the new key.");
|
||
let id = slug(label) || "key", n = 2; while (d.tiles.some((t) => t.id === id)) id = slug(label) + "_" + n++;
|
||
tile = { id, label, checks: [] }; d.tiles.push(tile);
|
||
const g = d.tiles.find((t) => t.id === b.querySelector("#tgroup").value); if (g) g.members.push(id);
|
||
}
|
||
tile.checks = tile.checks || []; tile.checks.push(chk); saveDraft(); closeDialog(); route();
|
||
};
|
||
});
|
||
}
|
||
// ---- Auto switch: everything about the failover on one screen ----
|
||
function autoSwitchHtml(c) {
|
||
const fo = c.failover || {};
|
||
const windows = "windows" in fo ? fo.windows : (F("failover.windows").default || []);
|
||
const dis = editing() ? "" : "disabled";
|
||
const presses = Object.entries(c.fixes).filter(([, f]) => f.kind === "companion_press");
|
||
const switches = presses.filter(([, f]) => f.failover);
|
||
const where = (id, f) => `<span class="small">page ${esc(f.page)} · row ${esc(f.row)} · col ${esc(f.col)}</span>${editing() ? ` <button class="small" data-changepress="${esc(id)}">Change…</button>` : ""}`;
|
||
const undoSel = (id, f) => `<select data-path="fixes.${esc(id)}.undo" ${dis}><option value="">nothing</option>${presses.filter(([u]) => u !== id).map(([u, g]) => `<option value="${esc(u)}" ${f.undo === u ? "selected" : ""}>${esc((g.params || {}).button || g.short || u)} (page ${g.page}, row ${g.row}, col ${g.col})</option>`).join("")}</select>`;
|
||
const buttons = presses.map(([id, f]) => `<tr class="${f.disabled ? "off" : ""}"><td>${onOff(`fixes.${id}`, f, "this button")} <label class="inline"><input type="checkbox" data-path="fixes.${esc(id)}.failover" ${f.failover ? "checked" : ""} ${dis}> <b>${esc((f.params || {}).button || f.short || id)}</b></label></td>
|
||
<td>${where(id, f)}</td><td class="small">${f.failover ? undoSel(id, f) : "<span class='muted'>–</span>"}</td>
|
||
<td>${canRunNow(id) && editing() ? `<button class="small" data-runpress="${esc(id)}">Press it now</button>` : ""}${editing() ? ` <button class="small danger" data-delpress="${esc(id)}" data-hint="Remove this button from the panel (Companion keeps it)">Remove</button>` : ""}</td></tr>`).join("");
|
||
// What counts as down: every check whose fix is a switch, on whichever key it sits.
|
||
const rows = [];
|
||
c.tiles.forEach((t, ti) => (t.checks || []).forEach((ch, j) => {
|
||
const fx = typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix];
|
||
const sw = fx.find((f) => switches.some(([id]) => id === f));
|
||
if (!sw) return;
|
||
const base = `tiles.${ti}.checks.${j}.`;
|
||
const host = (c.hosts[ch.host] || {}).name || ch.host;
|
||
const proc = (ch.params || {}).process || (/IMAGENAME eq ([^"]+)"/.exec(ch.cmd || "") || [])[1] || "";
|
||
const what = ch.type === "ping" ? `${esc(ch.name || ch.host)} stops answering (ping ${esc(ch.host)})`
|
||
: ch.type === "remote" && proc ? `${esc(proc)} closes, crashes or is being restarted on ${esc(host)}`
|
||
: ch.type === "remote" ? `${esc(ch.msg || "a command fails")} on ${esc(host)}`
|
||
: ch.type === "dante" ? `${esc(Object.keys(ch.devices || {}).join(", "))} disappears from Dante`
|
||
: ch.type === "conn" ? `Companion's ${esc(ch.conn)} connection fails` : esc(ch.type);
|
||
const dis = editing() ? "" : "disabled";
|
||
const fld = (label, html, help) => `<div class="field"><span class="fl">${label}${help ? hint(help) : ""}</span>${html}</div>`;
|
||
// What it watches, editable in place
|
||
let edit = "";
|
||
if (ch.type === "ping") edit = fld("address", `<input data-path="${base}host" value="${esc(ch.host || "")}" ${dis}>`, "The computer or device it pings")
|
||
+ fld("name on the key", `<input data-path="${base}name" value="${esc(ch.name || "")}" ${dis}>`, "The key says NAME NO PING");
|
||
else if (ch.type === "remote" && proc) edit = fld("program", `<input data-trigproc="${ti}|${j}" value="${esc(proc)}" ${dis}>`, "As Activity Monitor (Mac) or Task Manager (PC) names it")
|
||
+ fld("on the computer", `<select data-trighost="${ti}|${j}" ${dis}>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${a === ch.host ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select>`);
|
||
else if (ch.type === "dante") edit = fld("Dante device", `<input data-trigdev="${ti}|${j}" value="${esc(Object.keys(ch.devices || {})[0] || "")}" ${dis}>`);
|
||
else if (ch.type === "conn") edit = fld("Companion connection", `<input data-path="${base}conn" value="${esc(ch.conn || "")}" ${dis}>`);
|
||
const swSel = fld("then press", `<select data-trigfix="${ti}|${j}" ${dis}>${switches.map(([id, f]) => `<option value="${esc(id)}" ${id === sw ? "selected" : ""}>${esc((f.params || {}).button || f.short || id)}</option>`).join("")}</select>`);
|
||
const tune = ["ping", "remote"].includes(ch.type) ? inputHtml(base + "interval_seconds", F("tiles[].checks[].interval_seconds"), ch.interval_seconds) : "";
|
||
const fails = ch.type === "ping" ? inputHtml(base + "failures", F("tiles[].checks[].failures"), ch.failures) : "";
|
||
rows.push(`<div class="trig${ch.disabled ? " off" : ""}"><p class="small" style="margin:0 0 6px">${onOff(`tiles.${ti}.checks.${j}`, ch, "this trigger")} <b>${what}</b> <span class="muted">· shows on <a href="#keys/${esc(t.id)}">${esc(t.label)}</a></span>${editing() ? ` <button class="small danger" style="float:right" data-rmswitch="${ti}|${j}">Remove</button>` : ""}</p>${edit}${swSel}${tune}${fails}${inputHtml(base + "debounce_seconds", F("tiles[].checks[].debounce_seconds"), ch.debounce_seconds)}</div>`);
|
||
}));
|
||
const words = (windows || []).length ? windows.map((w) => `${(w.days || []).map((d) => d[0].toUpperCase() + d.slice(1)).join(", ")} ${h12(w.start)} – ${w.end === "24:00" ? "midnight" : h12(w.end)}`).join(" · ") : "never";
|
||
const wait = fo.after_seconds != null ? fo.after_seconds : F("failover.after_seconds").default;
|
||
const names = switches.map(([id, f]) => (f.params || {}).button || f.short || id);
|
||
const summary = switches.length
|
||
? `It presses <b>${esc(names.join(" / "))}</b> by itself when anything below goes down, once it has been down <b>${esc(wait)} s</b>, during <b>${esc(words)}</b>. It never switches back by itself.`
|
||
: "Nothing switches by itself yet: tick a button below.";
|
||
return `<div class="card"><p style="margin:0">${onOff("failover", fo, "the auto switch")} ${fo.disabled ? "<b>The auto switch is off:</b> nothing is pressed by itself. Everything below is kept." : summary}</p>${editing() ? "" : `<p class="help">Edit (top right, your PIN) to change anything on this screen; then Apply.</p>`}</div>
|
||
<div class="card"><h3 style="margin-top:0">What counts as down${hint("Any one of these switches. Faster checks switch sooner: ping every 1 s with 2 misses, a program every 2 s.")}</h3>
|
||
<p class="help" style="margin-top:0">How sure it must be before switching: <b>look every</b> = how often it checks; <b>misses before it counts</b> = pings that must fail in a row; <b>a fault must last</b> = seconds the fault must hold; then <b>wait before acting</b> (below) and it presses.</p>
|
||
${rows.join("") || "<p class='muted'>Nothing switches anything yet.</p>"}
|
||
${editing() ? `<div class="row" style="margin-top:8px"><button class="small" id="as_addcheck">+ What counts as down</button></div>` : ""}</div>
|
||
<div class="card"><h3 style="margin-top:0">When it may switch${hint("Outside these hours a failure only shows on the keys")}</h3>
|
||
${inputHtml("failover.after_seconds", F("failover.after_seconds"), fo.after_seconds)}${inputHtml("failover.note_seconds", F("failover.note_seconds"), fo.note_seconds)}
|
||
<details><summary>Hours: <b>${esc(words)}</b> <span class="muted small">(open to paint)</span></summary>${calendarHtml("failover.windows", windows)}</details></div>
|
||
<div class="card"><h3 style="margin-top:0">The buttons${hint("Companion buttons the panel may press. Ticked = it presses it by itself when something above fails, once per fault; it never switches back by itself.")}</h3>
|
||
${presses.length ? `<div class="tablewrap"><table><tr><th>switch by itself</th><th>Companion button</th><th>switch back with (by hand)</th><th></th></tr>${buttons}</table></div>` : "<p class='muted'>No Companion buttons yet.</p>"}
|
||
${editing() ? `<div class="row" style="margin-top:8px"><button class="small" id="as_addbtn">+ Button</button></div>` : ""}</div>
|
||
<p class="help">Changes take effect after Apply (the badge, top right).</p>`;
|
||
}
|
||
// --- the weekly calendar: half-hour cells; click or drag to paint ---
|
||
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
||
const toMin = (hhmm) => { const m = String(hhmm || "").match(/^(\d{1,2}):(\d{2})$/); return m ? +m[1] * 60 + +m[2] : null; };
|
||
const toHHMM = (min) => `${String(Math.floor(min / 60) % 24).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
|
||
function cellsOf(windows) {
|
||
const on = new Set();
|
||
for (const w of windows || []) {
|
||
const s = toMin(w.start), e = toMin(w.end); if (s == null || e == null) continue;
|
||
for (const d of w.days || []) {
|
||
const di = DAYS.indexOf(d); if (di < 0) continue;
|
||
if (e > s) { for (let m = s; m < e; m += 30) on.add(di + ":" + m); }
|
||
else { for (let m = s; m < 1440; m += 30) on.add(di + ":" + m); for (let m = 0; m < e; m += 30) on.add(((di + 1) % 7) + ":" + m); }
|
||
}
|
||
}
|
||
return on;
|
||
}
|
||
function windowsOf(on) {
|
||
const runs = [];
|
||
for (let d = 0; d < 7; d++) { let s = null; for (let m = 0; m <= 1440; m += 30) { const here = m < 1440 && on.has(d + ":" + m); if (here && s == null) s = m; if (!here && s != null) { runs.push([d, s, m]); s = null; } } }
|
||
for (let i = 0; i < runs.length; i++) { const r = runs[i]; if (r[2] !== 1440) continue; const j = runs.findIndex((x) => x[0] === (r[0] + 1) % 7 && x[1] === 0); if (j >= 0 && j !== i && runs[j][2] < 1440) { r[2] = runs[j][2] + 1440; runs.splice(j, 1); if (j < i) i--; } }
|
||
const byTime = {};
|
||
for (const [d, s, e] of runs) { const k = toHHMM(s) + "-" + toHHMM(e >= 1440 ? e - 1440 : e); (byTime[k] = byTime[k] || []).push(DAYS[d]); }
|
||
return Object.entries(byTime).map(([k, days]) => { const [start, end] = k.split("-"); return { days: days.sort((a, b) => DAYS.indexOf(a) - DAYS.indexOf(b)), start, end: end === "00:00" ? "24:00" : end }; });
|
||
}
|
||
const h12 = (hhmm) => { const m = toMin(hhmm); if (m == null) return hhmm || ""; const h = Math.floor(m / 60) % 24, mm = m % 60; const ap = h < 12 ? "am" : "pm"; return `${h % 12 || 12}${mm ? ":" + String(mm).padStart(2, "0") : ""} ${ap}`; };
|
||
function calendarHtml(path, windows) {
|
||
const on = cellsOf(windows);
|
||
const SHOW = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||
let grid = `<div class="h"></div>` + SHOW.map((d) => `<div class="h">${d[0].toUpperCase() + d.slice(1)}</div>`).join("");
|
||
for (let m = 0; m < 1440; m += 30) {
|
||
grid += `<div class="t">${m % 120 === 0 ? h12(toHHMM(m)).replace(" ", "") : ""}</div>`;
|
||
for (const name of SHOW) { const d = DAYS.indexOf(name); grid += `<div class="c${on.has(d + ":" + m) ? " on" : ""}${m % 60 === 0 ? " hour" : ""}" data-cell="${d}:${m}"></div>`; }
|
||
}
|
||
const words = (windows || []).length ? (windows || []).map((w) => `${(w.days || []).map((d) => d[0].toUpperCase() + d.slice(1)).join(", ")} ${esc(h12(w.start))} – ${esc(w.end === "24:00" ? "midnight" : h12(w.end))}`).join(" · ") : "never";
|
||
return `<div class="calwrap"><div class="cal ${editing() ? "" : "ro"}" data-calpath="${esc(path)}">${grid}</div><div class="small muted" style="max-width:220px"><b>${words}</b>${editing() ? "<br>Click or drag to paint; drag over painted cells to clear." : ""}</div></div>`;
|
||
}
|
||
function wireCalendars() {
|
||
for (const cal of main.querySelectorAll(".cal[data-calpath]")) {
|
||
if (!editing()) continue;
|
||
let painting = null;
|
||
const apply = (cell) => { if (!cell || !cell.dataset || !cell.dataset.cell) return; cell.classList.toggle("on", painting); };
|
||
cal.addEventListener("mousedown", (e) => { const cell = e.target.closest(".c"); if (!cell) return; e.preventDefault(); painting = !cell.classList.contains("on"); apply(cell); });
|
||
cal.addEventListener("mouseover", (e) => { if (painting == null) return; apply(e.target.closest(".c")); });
|
||
const finish = () => {
|
||
if (painting == null) return; painting = null;
|
||
const on = new Set([...cal.querySelectorAll(".c.on")].map((x) => x.dataset.cell));
|
||
setPath(cal.dataset.calpath, windowsOf(on)); route();
|
||
};
|
||
cal.addEventListener("mouseup", finish); cal.addEventListener("mouseleave", finish);
|
||
cal.addEventListener("touchstart", (e) => { const cell = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY); if (!cell || !cell.dataset.cell) return; painting = !cell.classList.contains("on"); apply(cell); }, { passive: true });
|
||
cal.addEventListener("touchmove", (e) => { if (painting == null) return; apply(document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY)); }, { passive: true });
|
||
cal.addEventListener("touchend", finish);
|
||
}
|
||
}
|
||
async function hyperdecksHtml(c) {
|
||
let cc = null;
|
||
try { cc = await api("/api/companion"); } catch (e) { cc = null; }
|
||
const decks = cc ? (cc.connections || []).filter((r) => r.module === "bmd-hyperdeck") : [];
|
||
const keyFor = (conn) => c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn));
|
||
const known = new Set(decks.map((d) => d.label));
|
||
for (const t of c.tiles) for (const ch of t.checks || []) if (ch.type === "hyperdeck" && !known.has(ch.conn)) { decks.push({ label: ch.conn, missing: true }); known.add(ch.conn); }
|
||
const rows = decks.map((d) => { const k = keyFor(d.label); const chk = k && k.checks.find((ch) => ch.conn === d.label); const ki = k ? c.tiles.indexOf(k) : -1; return `<tr class="${k && k.disabled ? "off" : ""}"><td>${k ? onOff(`tiles.${ki}`, k, "this deck's key") : ""}</td><td><label class="inline"><input type="checkbox" data-deck="${esc(d.label)}" ${k ? "checked" : ""} ${editing() ? "" : "disabled"}> ${esc(d.label)}</label>${d.missing ? ` <span class="muted small" data-hint="A key watches it but Companion has no such connection">not in Companion</span>` : ""}</td><td>${k ? `<span data-hint="${k.pos ? `row ${k.pos[0]}, column ${k.pos[1]}` : "in a group only"}">${esc(k.label)}</span>` : "<span class='muted'>no key</span>"}</td><td><label class="inline" data-hint="NO DISK is a warning, not an alarm"><input type="checkbox" data-nodisk="${esc(d.label)}" ${chk && chk.optional ? "checked" : ""} ${editing() && k ? "" : "disabled"}> no disk OK</label></td></tr>`; }).join("");
|
||
return `<div class="card">${!cc ? "<p class='notice grey'>Companion is not answering; the list is from the keys.</p>" : ""}<div class="tablewrap"><table><tr><th></th><th>in use${hint("Tick a deck and it gets an HD key in the REC group; untick and the key goes (its cell stays empty)")}</th><th>key</th><th></th></tr>${rows || "<tr><td colspan=3 class='muted'>No HyperDeck connections in Companion.</td></tr>"}</table></div>
|
||
${editing() && rows ? `<div class="row" style="margin-top:8px"><button class="primary small" id="decksave">Use these decks</button></div>` : ""}</div>`;
|
||
}
|
||
function midiHtml(c) {
|
||
const rows = [];
|
||
c.tiles.forEach((t, ti) => (t.checks || []).forEach((ch, j) => { if (ch.type === "midi_peer") rows.push(`<tr class="${ch.disabled || t.disabled ? "off" : ""}"><td>${onOff(`tiles.${ti}.checks.${j}`, ch, "this MIDI link")}</td><td>${esc(t.label)}</td><td>${esc(ch.host ? (c.hosts[ch.host] || {}).name || ch.host : (c.site || {}).ingest_name || "this Mac")}</td><td class="mono small">${esc(ch.session)}</td><td class="mono small">${esc(ch.peer)}</td><td class="small">${esc(ch.tag || "")}</td><td>${editing() ? `<button class="small danger" data-rmcheck="${esc(t.id)}|${j}">Remove</button>` : ""}</td></tr>`); }));
|
||
return `<div class="card"><div class="tablewrap"><table><tr><th></th><th>key</th><th>Mac</th><th>session</th><th>peer</th><th>key text</th><th></th></tr>${rows.join("") || "<tr><td colspan=7 class='muted'>none</td></tr>"}</table></div>
|
||
${editing() ? `<div class="row" style="margin-top:8px"><button class="small" data-job="midi">+ MIDI link</button></div>` : ""}</div>`;
|
||
}
|
||
function computersHtml(c) {
|
||
const rows = Object.entries(c.hosts).map(([a, h]) => `<tr class="click${h.disabled ? " off" : ""}" onclick="location.hash='#computers/${esc(a)}'"><td>${h.self ? "" : onOff(`hosts.${a}`, h, "this computer")}</td><td>${esc(h.name || a)}${h.self ? " <span class='muted small'>(this Mac)</span>" : ""}</td><td class="small">${esc(h.self ? "Mac" : h.os === "windows" ? "Windows" : "Mac")}</td><td class="mono small">${esc(h.ssh || "")}</td><td class="small">${esc(h.role || "")}</td><td class="small">${(h.runs || []).filter((r) => r.kind === "task").map((r) => esc(r.name.replace(/^Start /, ""))).join(", ")}</td></tr>`).join("");
|
||
return `<div class="card"><div class="tablewrap"><table><tr><th></th><th>computer</th><th>kind</th><th>log-in</th><th>does</th><th>programs it can start</th></tr>${rows}</table></div>
|
||
${editing() ? `<div class="row" style="margin-top:8px"><button class="small" data-job="computer">+ Computer</button><button class="small" data-job="pcprogram" data-hint="A program on a Windows PC the panel may start (a scheduled task)">+ PC program</button></div>` : ""}</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"><dl class="kv"><dt>running</dt><dd>code ${esc(p.running_version || "?")}</dd><dt>staged</dt><dd>${staged}${same ? " <span class='muted'>· the same</span>" : ""}</dd><dt>folder</dt><dd class="mono small">${esc(p.folder)}</dd></dl>
|
||
<div class="row" style="margin-top:8px"><button id="installbtn" ${editing() && p.present ? "" : "disabled"} data-hint="Copy the new folder here with scp first. install.sh runs by itself, the config stays, the watchdog restarts, this page comes back.">Install the staged folder</button>${p.job === "running" ? " <span class='muted'>running…</span>" : ""}</div>
|
||
${p.log && p.log.length ? `<details><summary>last install 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"><div class="row"><a class="btn" href="/api/site/bundle" download data-hint="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">Export campus bundle</a>${editing() ? `<label class="btn" data-hint="This campus's name, addresses and PIN stay; every copied computer is flagged until you touch it">New campus from a bundle <input type="file" id="bundlefile" accept=".zip,.json" hidden></label>` : ""}</div></div>`;
|
||
}
|
||
async function outletsHtml() {
|
||
const o = await api("/api/outlets");
|
||
const dis = editing() ? "" : "disabled";
|
||
const rows = Object.entries(o.outlets || {}).map(([n, v]) => `<tr><td><b>${esc(n)}</b></td><td class="small">${esc(v.name || "")}</td><td class="small" id="ost-${esc(n)}"><span class="muted">…</span></td><td>${editing() ? `<button class="small" data-oswitch="${esc(n)}|1" data-hint="Switch the outlet on">On</button> <button class="small danger" data-oswitch="${esc(n)}|0" data-hint="Switch the outlet off: whatever is on it loses power">Off</button> <button class="small" data-oremove="${esc(n)}">Remove</button>` : ""}</td></tr>`).join("");
|
||
const used = Object.entries(cfg().fixes).filter(([, f]) => f.template === "outlet_cycle").map(([id, f]) => `<span class="pill" data-hint="${esc(f.label || "")}">${onOff(`fixes.${id}`, f, "this power-cycle")} ${esc(f.short || id)} → ${esc((f.params || {}).outlet || "?")}</span>`).join(" ");
|
||
return `<div class="card"><div class="field"><span class="fl">SmartLife / Tuya keys${hint("Access ID and Access Secret of a Cloud Project on iot.tuya.com with your SmartLife app linked to it. They stay on this Mac in a private file, never in the settings file.")}</span><span class="row"><input id="oid" placeholder="Access ID" value="${esc(o.access_id || "")}" ${dis} style="width:14em"><input id="osecret" type="password" placeholder="${o.configured ? "Access Secret (kept)" : "Access Secret"}" ${dis} style="width:16em"><select id="oregion" ${dis}>${["us", "eu", "in", "cn"].map((r) => `<option ${o.region === r ? "selected" : ""}>${r}</option>`).join("")}</select>${editing() ? `<button class="small" id="okeys">Save & test</button>` : ""}</span></div>
|
||
${o.configured ? "" : `<details><summary>how to get the keys</summary><ol class="small"><li>iot.tuya.com, free account, Cloud › Create Cloud Project (Smart Home, your region's data center).</li><li>In the project: Devices › Link App Account › scan the code with the SmartLife app (Me › scan icon).</li><li>Overview tab: copy Access ID and Access Secret here.</li></ol></details>`}</div>
|
||
<div class="card"><div class="tablewrap"><table><tr><th>outlet</th><th>plug</th><th>now</th><th></th></tr>${rows || "<tr><td colspan=4 class='muted'>none yet</td></tr>"}</table></div>
|
||
${editing() && o.configured ? `<div class="row" style="margin-top:8px"><button class="small" id="oadd">+ Outlet</button></div>` : ""}
|
||
${used ? `<p class="small" style="margin-top:8px">Used by fixes: ${used}</p>` : ""}
|
||
<p class="help">A fix "Power-cycle a smart outlet" (Keys › Fixes) cuts the power only when the device does not answer, never while a HyperDeck is recording.</p></div>`;
|
||
}
|
||
async function outletDialog() {
|
||
let devs = [];
|
||
try { const r = await api("/api/outlets/devices"); if (!r.ok) return alertBox(r.error); devs = r.devices; } catch (e) { return alertBox(e.message); }
|
||
devs.sort((a, b) => (b.online - a.online) || String(a.name).localeCompare(String(b.name)));
|
||
dialog(`<h3>New outlet</h3><select id="odev">${devs.map((d) => `<option value="${esc(d.id)}" data-name="${esc(d.name)}">${esc(d.name)}${d.online ? "" : " (offline)"}</option>`).join("")}</select>
|
||
<input id="oname" placeholder="short name the fixes use (tascam)"><div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||
b.querySelector("#dcancel").onclick = closeDialog;
|
||
const sel = b.querySelector("#odev"); sel.onchange = () => { if (!b.querySelector("#oname").value) b.querySelector("#oname").value = slug(sel.selectedOptions[0].dataset.name); }; sel.onchange();
|
||
b.querySelector("#dok").onclick = async () => { const name = b.querySelector("#oname").value.trim(); if (!name) return; try { const r = await post("/api/outlets", { name, id: sel.value, device_name: sel.selectedOptions[0].dataset.name }); if (r.error) return alertBox(r.error); closeDialog(); route(); } catch (e) { alertBox(e.message); } };
|
||
});
|
||
}
|
||
async function namesHtml() {
|
||
const n = await api("/api/site/names").catch(() => null);
|
||
if (!n || !n.missing) return "";
|
||
const sel = (kind, name, cands) => editing() ? `<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 ${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</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 style="margin-top:0">Names the keys use that are not here (${n.missing})${hint("A copied campus, or something renamed. Map each name to what this campus has; everything that uses it follows.")}</h3>
|
||
<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]) => `<span class="chip ${v === true ? "ok" : v === false ? "bad" : ""}">${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</span>`).join("");
|
||
const conns = (cc.connections || []).map((r) => `<tr><td>${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed", r.enabled ? r.category || "" : "off")}${esc(r.label)}</td><td class="mono small">${esc(r.module || "")}</td><td class="small muted">${esc(r.message || "")}</td><td class="small">${(r.watched_by || []).length ? connSwitch(r.label) + " " + 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)}" data-hint="A new key that watches this connection">+ Key</button>` : ""}`}</td></tr>`).join("");
|
||
return `<div class="card"><div class="chips">${lights}${cc.build ? `<span class="chip">${esc(cc.build)}</span>` : ""}${cc.page_number_buttons ? `<span class="chip" data-hint="Page-number keys coloured, recorded in ${esc(cc.page_number_buttons.recorded_in)}">${cc.page_number_buttons.pages_coloured} pages coloured</span>` : ""}</div>
|
||
${(cc.missing || []).length ? `<p class="notice">Keys watch connections Companion does not have: ${esc(cc.missing.join(", "))}</p>` : ""}
|
||
<div class="tablewrap" style="margin-top:8px"><table><tr><th>connection</th><th>module</th><th></th><th>watched by</th></tr>${conns}</table></div></div>` + fieldsHtml("Companion");
|
||
}
|
||
// Every check that watches one Companion connection, switched on or off together.
|
||
function connChecks(c, label) { const out = []; c.tiles.forEach((t, ti) => (t.checks || []).forEach((ch, j) => { if (ch.conn === label) out.push([ti, j, ch]); })); return out; }
|
||
function connSwitch(label) {
|
||
const list = connChecks(cfg(), label), on = list.some(([, , ch]) => !ch.disabled);
|
||
return `<label class="onoff${on ? " on" : ""}" data-hint="${on ? "Watched. Switch off to stop watching it (the checks stay in the settings)." : "Not watched (switched off). Switch on to watch it again."}"><input type="checkbox" data-connoff="${esc(label)}" ${on ? "checked" : ""} ${editing() ? "" : "disabled"}><span>${on ? "ON" : "OFF"}</span></label>`;
|
||
}
|
||
// The DANTE key's own device list: the Dante check with the most devices (STB AUDIO also watches one Dante device).
|
||
function danteCheck(c) {
|
||
let best = null;
|
||
for (const t of c.tiles) for (const ch of t.checks || []) if (ch.type === "dante" && (!best || Object.keys(ch.devices || {}).length > Object.keys(best.devices || {}).length)) best = ch;
|
||
return best;
|
||
}
|
||
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 offs = chk.devices_off || [];
|
||
const rows = Object.entries(chk.devices || {}).map(([n, a]) => { const e = map[n] || {}; const off = offs.includes(n); return `<tr class="${off ? "off" : ""}"><td><label class="onoff${off ? "" : " on"}" data-hint="${off ? "Off: kept in the list, not watched" : "On: watched. Switch off to keep it in the list without watching it."}"><input type="checkbox" data-danteoff="${esc(n)}" ${off ? "" : "checked"} ${editing() ? "" : "disabled"}><span>${off ? "OFF" : "ON"}</span></label></td><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"><div class="tablewrap"><table><tr><th></th><th>Dante name</th><th>key text</th><th>address</th><th>switch / port</th><th>PoE</th><th>when 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="row"><input id="dantenew" placeholder="Dante name" list="dantefound" style="width:16em"><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">+ Device</button>${extra.length ? `<span class="help">seen, not listed: ${esc(extra.join(", "))}</span>` : ""}</div>` : ""}</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>` : "";
|
||
const atemChecks = []; c.tiles.forEach((t, ti) => (t.checks || []).forEach((ch, j) => { if (ch.type === "atem" || (ch.type === "pref" && /atem/i.test(ch.domain || ""))) atemChecks.push(`<label class="inline small" style="margin-right:14px">${onOff(`tiles.${ti}.checks.${j}`, ch, "this switcher check")} ${esc(ch.type === "atem" ? "switcher " + ch.part + " matches the file" : ch.msg || "ATEM Software Control setting")}</label>`); }));
|
||
return `<div class="card">${atemChecks.length ? `<p style="margin-top:0">${atemChecks.join("")}</p>` : ""}${pending}
|
||
${editing() ? `<div class="row"><label class="btn" data-hint="A file saved from ATEM Software Control">Upload a saved file <input type="file" id="atemfile" accept=".xml" hidden></label><button id="atemnewest" ${folder.mounted && (folder.files || []).length ? "" : "disabled"} data-hint="The newest .xml in the folder below">Pick the newest</button></div>` : ""}
|
||
<p class="small muted">${esc(folder.folder ? folder.folder.replace(/^.*\/Shared drives\//, "Shared drives/") : "(no folder set)")} ${folder.mounted ? `· newest: ${(folder.files || []).length ? esc(folder.files[0].name) + " (" + esc(when(folder.files[0].when)) + ")" : "none"}` : "· <b>not mounted</b>"}</p>
|
||
${folder.blocked ? `<div class="notice grey">The folder is there but this page cannot read it. Use Upload, or let the panel's key log in to this Mac (Computers › Setup).</div>` : ""}<p id="ateminfo" class="help"></p></div>`;
|
||
}
|
||
function wireSettings(which) {
|
||
const q = (s) => main.querySelector(s);
|
||
wireCalendars();
|
||
for (const b of main.querySelectorAll("[data-changepress]")) b.onclick = () => {
|
||
const id = b.dataset.changepress, f0 = cfg().fixes[id];
|
||
pickButtonDialog(`Which button is ${(f0.params || {}).button || id}?`, { page: f0.page, row: f0.row, col: f0.col }, async (p) => {
|
||
if (!ensureDraft()) return;
|
||
const f = state.draft.config.fixes[id];
|
||
const params = Object.assign({}, f.params || {}, { page: p.page, row: p.row, col: p.col }, p.text ? { button: p.text.replace(/\n/g, " ").trim() } : {});
|
||
const r = await post("/api/build", { fix: Object.assign({}, f, { template: "companion_press", params }) });
|
||
if (!r.ok) return alertBox(r.error);
|
||
const nf = r.fix; if (r.label) nf.label = r.label;
|
||
state.draft.config.fixes[id] = nf; saveDraft(); route();
|
||
});
|
||
};
|
||
for (const inp of main.querySelectorAll("[data-press]")) inp.onchange = async () => {
|
||
if (!ensureDraft()) return;
|
||
const [id, k] = inp.dataset.press.split("|"), f = state.draft.config.fixes[id];
|
||
const params = Object.assign({}, f.params || {}, { [k]: inp.value });
|
||
const r = await post("/api/build", { fix: Object.assign({}, f, { template: "companion_press", params }) });
|
||
if (!r.ok) return alertBox(r.error);
|
||
const nf = r.fix; if (r.label) nf.label = r.label;
|
||
state.draft.config.fixes[id] = nf; saveDraft(); route();
|
||
};
|
||
const ab = q("#as_addbtn"); if (ab) ab.onclick = () => addSwitchButton();
|
||
const ac = q("#as_addcheck"); if (ac) ac.onclick = () => addSwitchTrigger();
|
||
for (const b of main.querySelectorAll("[data-delpress]")) b.onclick = () => {
|
||
const id = b.dataset.delpress, d = state.draft ? state.draft.config : cfg();
|
||
const users = [...(fixUsers(d)[id] || [])];
|
||
const undoOf = Object.entries(d.fixes).filter(([, f]) => f.undo === id).map(([u]) => u);
|
||
confirmBox(`Remove the ${(d.fixes[id].params || {}).button || id} button from the panel?` + (users.length ? ` It is the switch for: ${users.join(", ")}; those checks lose their switch.` : "") + " Companion keeps the button itself.", "Remove", () => {
|
||
if (!ensureDraft()) return;
|
||
const c2 = state.draft.config;
|
||
for (const t of c2.tiles.concat((c2.ready_page || { keys: [] }).keys)) for (const ch of t.checks || []) {
|
||
if (ch.fix === id) delete ch.fix;
|
||
else if (ch.fix && typeof ch.fix === "object") for (const [k, v] of Object.entries(ch.fix)) if (v === id) delete ch.fix[k];
|
||
if (t.hold_always === id) delete t.hold_always;
|
||
}
|
||
if (c2.restart_page) c2.restart_page.keys = c2.restart_page.keys.filter((k) => k.fix !== id);
|
||
for (const u of undoOf) delete c2.fixes[u].undo;
|
||
c2.auto_fix.fixes = (c2.auto_fix.fixes || []).filter((f) => f !== id);
|
||
delete c2.fixes[id]; saveDraft(); route();
|
||
});
|
||
};
|
||
for (const el of main.querySelectorAll("[data-trigfix]")) el.onchange = () => {
|
||
if (!ensureDraft()) return;
|
||
const [ti, j] = el.dataset.trigfix.split("|").map(Number), ch = state.draft.config.tiles[ti].checks[j];
|
||
if (ch.fix && typeof ch.fix === "object") for (const k of Object.keys(ch.fix)) ch.fix[k] = el.value; else ch.fix = el.value;
|
||
saveDraft(); route();
|
||
};
|
||
const rebuildProc = async (ti, j, host, proc) => {
|
||
if (!ensureDraft()) return;
|
||
const ch = state.draft.config.tiles[ti].checks[j];
|
||
if (!proc) return alertBox("Which program?");
|
||
const h = state.draft.config.hosts[host] || {};
|
||
const keep = { fix: ch.fix, interval_seconds: ch.interval_seconds, debounce_seconds: ch.debounce_seconds };
|
||
let nc;
|
||
if (h.os === "windows") {
|
||
const exe = /\.exe$/i.test(proc) ? proc : proc + ".exe";
|
||
if (!/^[A-Za-z0-9 ._()\-]+$/.test(exe)) return alertBox("That program name has characters a Windows check cannot use.");
|
||
nc = { type: "remote", host, cmd: `tasklist /NH /FI "IMAGENAME eq ${exe}" | find /I "${exe}"`, msg: exe.replace(/\.exe$/i, "").toUpperCase() + " NOT RUNNING" };
|
||
} else {
|
||
const r = await post("/api/build-check", { check: { type: "remote", template: "mac_program", params: { host, process: proc }, msg: proc.toUpperCase() + " NOT RUNNING" } });
|
||
if (!r.ok) return alertBox(r.error);
|
||
nc = r.check;
|
||
}
|
||
for (const [k, v] of Object.entries(keep)) if (v !== undefined) nc[k] = v;
|
||
state.draft.config.tiles[ti].checks[j] = nc; saveDraft(); route();
|
||
};
|
||
for (const el of main.querySelectorAll("[data-trigproc]")) el.onchange = () => { const [ti, j] = el.dataset.trigproc.split("|").map(Number); rebuildProc(ti, j, cfg().tiles[ti].checks[j].host, el.value.trim()); };
|
||
for (const el of main.querySelectorAll("[data-trighost]")) el.onchange = () => { const [ti, j] = el.dataset.trighost.split("|").map(Number); const inp = main.querySelector(`[data-trigproc="${ti}|${j}"]`); rebuildProc(ti, j, el.value, inp ? inp.value.trim() : ""); };
|
||
for (const el of main.querySelectorAll("[data-trigdev]")) el.onchange = () => {
|
||
if (!ensureDraft()) return;
|
||
const [ti, j] = el.dataset.trigdev.split("|").map(Number), ch = state.draft.config.tiles[ti].checks[j], dev = el.value.trim();
|
||
if (!dev) return alertBox("Which Dante device?");
|
||
const fx = ch.fix && typeof ch.fix === "object" ? Object.values(ch.fix)[0] : ch.fix;
|
||
ch.devices = { [dev]: dev.replace(/^.*?-/, "").slice(0, 10).toUpperCase() }; ch.fix = { [dev]: fx }; saveDraft(); route();
|
||
};
|
||
for (const b of main.querySelectorAll("[data-rmswitch]")) b.onclick = () => confirmBox("Remove this from what counts as down?", "Remove", () => {
|
||
if (!ensureDraft()) return;
|
||
const [ti, j] = b.dataset.rmswitch.split("|").map(Number);
|
||
const d = state.draft.config, t = d.tiles[ti];
|
||
t.checks.splice(j, 1);
|
||
if (!t.checks.length && !t.pos && !t.kind && !t.members) { // an empty hidden key: gone with its last check
|
||
d.tiles.splice(ti, 1);
|
||
for (const g of d.tiles) if (g.members) g.members = g.members.filter((m) => m !== t.id);
|
||
}
|
||
saveDraft(); route();
|
||
});
|
||
for (const b of main.querySelectorAll("[data-runpress]")) b.onclick = () => {
|
||
const id = b.dataset.runpress, users = fixUsers(cfg())[id];
|
||
const key = cfg().tiles.find((t) => (t.checks || []).some((ch) => (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix]).includes(id)));
|
||
if (!key) return alertBox("No key uses this button yet, so it cannot be pressed from here; press it in Companion.");
|
||
const j = key.checks.findIndex((ch) => (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix]).includes(id));
|
||
runNow(key.id, id, j);
|
||
};
|
||
for (const ta of main.querySelectorAll("[data-jsonpath]")) ta.onchange = () => { try { setPath(ta.dataset.jsonpath, JSON.parse(ta.value)); } catch (e) { alertBox("That is not valid: " + e.message); } };
|
||
for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job);
|
||
const ok = q("#okeys"); if (ok) ok.onclick = async () => { ok.disabled = true; ok.textContent = "Testing…"; try { const r = await post("/api/outlets/keys", { access_id: q("#oid").value, access_secret: q("#osecret").value, region: q("#oregion").value }); alertBox(r.ok ? `Works: ${r.devices} devices on the account.` : r.error); route(); } catch (e) { alertBox(e.message); route(); } };
|
||
const oa = q("#oadd"); if (oa) oa.onclick = outletDialog;
|
||
for (const b of main.querySelectorAll("[data-oswitch]")) b.onclick = () => { const [n, on] = b.dataset.oswitch.split("|"); confirmBox(`Switch the ${n} outlet ${on === "1" ? "on" : "OFF"}?${on === "1" ? "" : " Whatever is plugged into it loses power."}`, on === "1" ? "On" : "Off", async () => { try { const r = await post("/api/outlets/switch", { name: n, on: on === "1" }); if (!r.ok) alertBox(r.error); setTimeout(route, 1500); } catch (e) { alertBox(e.message); } }); };
|
||
for (const b of main.querySelectorAll("[data-oremove]")) b.onclick = () => confirmBox(`Remove the ${b.dataset.oremove} outlet from the list? Fixes that use it stop working.`, "Remove", async () => { try { await post("/api/outlets", { name: b.dataset.oremove, remove: true }); route(); } catch (e) { alertBox(e.message); } });
|
||
for (const td of main.querySelectorAll("[id^='ost-']")) api("/api/outlets/status?name=" + encodeURIComponent(td.id.slice(4))).then((r) => { td.innerHTML = r.ok ? `<span class="chip ${r.on ? "ok" : ""}">${r.on ? "on" : "off"}</span>` : `<span class="muted" data-hint="${esc(r.error)}">?</span>`; }).catch(() => {});
|
||
for (const m of main.querySelectorAll("[data-map]")) m.onchange = () => { if (!m.value || !ensureDraft()) return; const [kind, name] = m.dataset.map.split("|"); (kind === "conn" ? renameConn : renameDante)(name, m.value); saveDraft(); route(); };
|
||
for (const b of main.querySelectorAll("[data-addconn]")) b.onclick = () => { const conn = b.dataset.addconn; if (!ensureDraft()) return; const d = state.draft.config; let id = slug(conn) || "key", n = 2; while (d.tiles.some((t) => t.id === id)) id = slug(conn) + "_" + n++; const t = { id, label: conn.toUpperCase().replace(/[_-]+/g, " ").slice(0, 10).trim(), checks: [{ type: "conn", conn, fix: "restart-connection" }] }; const pos = firstEmpty(d, "status");
|
||
if (pos) { t.pos = pos; d.tiles.push(t); saveDraft(); location.hash = "#keys/" + id; return; }
|
||
// The Status page is full: put it inside a group key, where it shows and a hold on the group fixes it.
|
||
const groups = d.tiles.filter((g) => g.members);
|
||
dialog(`<h3>No free spot on the Status page</h3><p class="small">Put ${esc(conn)} inside a group key: its problems show on that key, and holding the key reconnects it.</p><select id="ngroup">${groups.map((g) => `<option value="${esc(g.id)}">${esc(g.label)} (${esc(g.members.join(", "))})</option>`).join("")}</select><div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, (bx) => {
|
||
bx.querySelector("#dcancel").onclick = closeDialog;
|
||
bx.querySelector("#dok").onclick = () => { const g = d.tiles.find((x) => x.id === bx.querySelector("#ngroup").value); d.tiles.push(t); if (g) g.members.push(id); saveDraft(); closeDialog(); location.hash = "#keys/" + id; };
|
||
}); };
|
||
for (const cb of main.querySelectorAll("[data-autofix]")) cb.onchange = () => { if (!ensureDraft()) return; const d = state.draft.config, id = cb.dataset.autofix, f = d.fixes[id]; const set = new Set(d.auto_fix.fixes || []); if (cb.checked) { set.add(id); if (closes(f)) f.restart_ok = true; if (f.never_auto) delete f.never_auto; } else set.delete(id); setPath("auto_fix.fixes", [...set]); };
|
||
for (const b of main.querySelectorAll("[data-rmcheck]")) b.onclick = () => confirmBox("Remove this MIDI link from its key?", "Remove", () => { if (!ensureDraft()) return; const [tid, j] = b.dataset.rmcheck.split("|"); const t = state.draft.config.tiles.find((x) => x.id === tid); t.checks.splice(+j, 1); saveDraft(); route(); });
|
||
const ds = q("#decksave"); if (ds) ds.onclick = () => applyDecks(main);
|
||
const dc = () => danteCheck(state.draft.config);
|
||
for (const el of main.querySelectorAll("[data-dantekey]")) el.onchange = () => { if (!ensureDraft()) return; dc().devices[el.dataset.dantekey] = el.value.trim(); saveDraft(); };
|
||
for (const el of main.querySelectorAll("[data-dantefix]")) el.onchange = () => { if (!ensureDraft()) return; const ch = dc(); if (typeof ch.fix !== "object" || !ch.fix) ch.fix = { "*": ch.fix || "dante-auto" }; if (el.value) ch.fix[el.dataset.dantefix] = el.value; else delete ch.fix[el.dataset.dantefix]; saveDraft(); };
|
||
for (const b of main.querySelectorAll("[data-dantedel]")) b.onclick = () => { if (!ensureDraft()) return; const ch = dc(); delete ch.devices[b.dataset.dantedel]; if (typeof ch.fix === "object" && ch.fix) delete ch.fix[b.dataset.dantedel]; saveDraft(); route(); };
|
||
const da = q("#danteadd"); if (da) da.onclick = () => { if (!ensureDraft()) return; const n = q("#dantenew").value.trim(), k = q("#dantenewkey").value.trim(); if (!n) return; const ch = dc(); ch.devices = ch.devices || {}; ch.devices[n] = k || n.slice(0, 10).toUpperCase(); saveDraft(); route(); };
|
||
const rs = q("#resurvey"); if (rs) rs.onclick = async () => { try { await post("/api/command", { text: "resurvey" }); alertBox("Asked the watchdog to look at the switches; the map updates within a minute."); } catch (e) { alertBox(e.message); } };
|
||
const look = q("#splook"); if (look) look.onclick = async () => { const info = q("#spinfo"); info.textContent = "Looking…"; try { const r = await api("/api/live/spotify?url=" + encodeURIComponent(q("#splink").value)); if (!r.ok) { info.textContent = r.error; return; } if (!ensureDraft()) return; const c = state.draft.config; c.spotify = Object.assign({}, c.spotify, { playlist: r.uri }); if (r.name) { c.spotify.name = r.name; q("#spname").value = r.name; } q("#splink").value = r.uri; await saveDraft(); for (const f of Object.values(c.fixes)) if (f.kind === "spotify") { const bb = await post("/api/build", { fix: f }); if (bb.ok && bb.label) f.label = bb.label; } saveDraft(); info.textContent = r.name ? `${r.name}: ${r.songs} songs.` : (r.warning || ""); } catch (e) { info.textContent = e.message; } };
|
||
const spname = q("#spname"); if (spname) spname.onchange = () => setPath("spotify.name", spname.value.trim());
|
||
const splink = q("#splink"); if (splink) splink.onchange = () => { const m = splink.value.match(/(?:playlist[/:])([A-Za-z0-9]{22})/); if (m) setPath("spotify.playlist", "spotify:playlist:" + m[1]); else if (splink.value.trim()) alertBox("That is not a playlist link."); };
|
||
const ib = q("#installbtn"); if (ib) ib.onclick = () => confirmBox("Install the staged folder? The watchdog restarts (the deck goes purple for a few seconds) and this page restarts too.", "Install", async () => { try { const r = await post("/api/program/install", {}); alertBox(r.ok ? r.detail : "Not started: " + r.error); setTimeout(() => location.reload(), 25000); } catch (e) { alertBox(e.message); } });
|
||
const bf = q("#bundlefile"); if (bf) bf.onchange = async () => { const f = bf.files[0]; if (!f) return; confirmBox(`Start a new campus from ${f.name}? Your current draft is replaced.`, "Start", async () => { try { const r = await fetch("/api/site/import", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/octet-stream" }, body: await f.arrayBuffer() }).then((x) => x.json()); if (r.error) return alertBox(r.error); state.draft = r.draft; state.atemPending = true; badge(); alertBox(`Copied from ${r.from}. ${r.errors.length ? "Not applicable yet: " + r.errors.slice(0, 3).join("; ") : "Look through Computers, Keys and the Companion names, then Apply."}`); location.hash = "#settings/companion"; } catch (e) { alertBox(e.message); } }); };
|
||
const af = q("#atemfile"); if (af) af.onchange = async () => { const f = af.files[0]; if (!f) return; try { const r = await fetch("/api/atem-reference", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/xml" }, body: await f.arrayBuffer() }).then((x) => x.json()); showAtem(r); } catch (e) { alertBox(e.message); } };
|
||
const an = q("#atemnewest"); if (an) an.onclick = async () => { an.disabled = true; an.textContent = "Reading…"; try { showAtem(await post("/api/atem-reference/pick-newest")); } catch (e) { alertBox(e.message); route(); } };
|
||
const ad = q("#atemdiscard"); if (ad) ad.onclick = async () => { try { await post("/api/atem-reference/discard"); state.atemPending = false; badge(); route(); } catch (e) { alertBox(e.message); } };
|
||
function showAtem(r) {
|
||
if (!r.ok) { alertBox(r.error || "Not taken."); route(); return; }
|
||
state.atemPending = true; if (!state.draft && ensureDraft()) saveDraft(); badge();
|
||
const diffs = (r.diffs || []).slice(0, 12).map((d) => d.part + " " + d.key + ": " + JSON.stringify(d.before) + " → " + JSON.stringify(d.after)).join("\n");
|
||
alertBox(`${r.file ? r.file + ": " : ""}${r.product || ""}, ${r.audio} audio and ${r.camera} camera values; ${r.diff_count} differ from the current file${diffs ? ":\n" + diffs : "."}\nApply replaces the file.`);
|
||
route();
|
||
}
|
||
}
|
||
function applyDecks(root) {
|
||
if (!ensureDraft()) return;
|
||
const d = state.draft.config;
|
||
const rec = d.tiles.find((t) => (t.members || []).some((m) => d.tiles.some((x) => x.id === m && (x.checks || []).some((ch) => ch.type === "hyperdeck")))) || d.tiles.find((t) => t.id === "rec");
|
||
const fixId = Object.keys(d.fixes).find((id) => d.fixes[id].kind === "hyperdeck");
|
||
for (const cb of root.querySelectorAll("[data-deck]")) {
|
||
const conn = cb.dataset.deck, nodisk = root.querySelector(`[data-nodisk="${CSS.escape(conn)}"]`).checked;
|
||
const existing = d.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn));
|
||
if (cb.checked && existing) { const ch = existing.checks.find((x) => x.conn === conn); if (nodisk) ch.optional = true; else delete ch.optional; }
|
||
else if (cb.checked) {
|
||
const n = (conn.match(/(\d+)\s*$/) || [])[1]; let id = n ? "hd" + n : slug(conn), k = 2; while (d.tiles.some((t) => t.id === id)) id = (n ? "hd" + n : slug(conn)) + "_" + k++;
|
||
const t = { id, label: n ? "HD" + n : conn.toUpperCase().slice(0, 8), hold_always: fixId || undefined, checks: [Object.assign({ type: "hyperdeck", conn }, nodisk ? { optional: true } : {}, fixId ? { fix: { conn: fixId } } : {})] };
|
||
const pos = firstEmpty(d, "status"); if (pos) t.pos = pos;
|
||
d.tiles.push(t); if (rec) rec.members.push(id);
|
||
} else if (existing) { d.tiles.splice(d.tiles.indexOf(existing), 1); for (const t of d.tiles) if (t.members) t.members = t.members.filter((m) => m !== existing.id); }
|
||
}
|
||
saveDraft(); route();
|
||
}
|
||
|
||
// ---- Jobs: the dialogs that add something ----------------------------------------
|
||
async function job(which) {
|
||
if (!needEditing()) return;
|
||
clearInterval(timer); timer = null;
|
||
if (which === "midi") return jobMidi();
|
||
if (which === "computer") return jobComputer();
|
||
if (which === "pcprogram") return jobPcProgram();
|
||
}
|
||
async function jobMidi() {
|
||
const live = await api("/api/live/midi").catch(() => ({ local: {}, remote: {} }));
|
||
const c = cfg();
|
||
const macs = Object.entries(c.hosts).filter(([, h]) => h.self || (h.os || "mac") === "mac");
|
||
const midiKey = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "midi_peer"));
|
||
const fixId = Object.keys(c.fixes).find((id) => c.fixes[id].kind === "midi_connect");
|
||
const sessionsOf = (alias) => { const h = c.hosts[alias]; const src = h.self ? (live.local || {}) : ((live.remote || {})[alias] || {}); return Object.entries(src).map(([name, v]) => [name, (v.peers || []).map((p) => p.name)]); };
|
||
dialog(`<h3>MIDI link${hint("A network MIDI session on one of the Macs, and the peer that must stay in it. Pick from what the Macs report now, or type the names if that Mac is off.")}</h3>
|
||
<select id="jhost">${macs.map(([a, h]) => `<option value="${esc(a)}">${esc(h.name || a)}${h.self ? " (this Mac)" : ""}</option>`).join("")}</select>
|
||
<input id="jsession" list="jsessions" placeholder="session, as Audio MIDI Setup names it"><datalist id="jsessions"></datalist>
|
||
<input id="jpeer" list="jpeers" placeholder="peer (the other end)"><datalist id="jpeers"></datalist>
|
||
<input id="jtag" placeholder="key text when lost (LIGHT PC)" maxlength="10">
|
||
<input id="jaddr" placeholder="address, if it must be reconnected by address (optional)">
|
||
<select id="jkey">${midiKey ? `<option value="${esc(midiKey.id)}">on the ${esc(midiKey.label)} key</option>` : ""}<option value="__new">on a new key</option></select>
|
||
<div class="row"><button class="primary" id="dok">Add</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(); route();
|
||
};
|
||
});
|
||
}
|
||
function jobComputer() {
|
||
dialog(`<h3>New computer${hint("It appears on Computers; the Setup tab then shows what to do on it (login key, Remote Login). Fixes and checks for it come after.")}</h3>
|
||
<input id="jname" placeholder="name, as people say it (Lobby PC)"><input id="jalias" placeholder="short name – filled from the name">
|
||
<select id="jos"><option value="mac">Mac</option><option value="windows">Windows</option></select>
|
||
<input id="jssh" placeholder="log-in address: user@10.1.x.x"><input id="jrole" placeholder="what it does (runs the lobby screen)">
|
||
<div class="row"><button class="primary" id="dok">Add</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 the computer first.");
|
||
const cur = (location.hash.split("/")[1] || "");
|
||
dialog(`<h3>Program a PC can start${hint("The panel starts programs on a PC through a scheduled task (the Setup tab's script creates it). This lists the task and adds a 'start it if it is not running' fix.")}</h3>
|
||
<select id="jhost">${pcs.map(([a, h]) => `<option value="${esc(a)}" ${a === cur ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select>
|
||
<input id="jname" placeholder="program, as people say it (Vista)"><input id="jexe" placeholder="program file, as Task Manager shows it (Vista.exe)">
|
||
<div class="row"><button class="primary" id="dok">Add</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 = "#keys/fixes/" + id;
|
||
};
|
||
});
|
||
}
|
||
|
||
// ---- builders: a fix or a check is made from fields ---------------------------------
|
||
function paramsHtml(tpl, params, prefix) {
|
||
const c = cfg();
|
||
return tpl.params.map((p) => {
|
||
const v = params[p.name] != null ? params[p.name] : p.default;
|
||
const id = `${prefix}-${p.name}`;
|
||
let input;
|
||
if (p.type === "host") input = `<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"><span class="fl">${esc(p.words.toLowerCase())}${hint(p.help)}</span>${input}</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 (a tab of Keys) ----------------------------------------------------------
|
||
let tplMsg = ""; // a builder's complaint, shown once after the next paint
|
||
async function fixes(rest) {
|
||
const c = cfg(), auto = new Set(c.auto_fix.fixes || []);
|
||
const used = fixUsers(c);
|
||
const rows = Object.entries(c.fixes).map(([id, f]) => `<tr class="click${rest[0] === id ? " sel" : ""}${f.disabled ? " off" : ""}" onclick="location.hash='#keys/fixes/${esc(id)}'"><td>${onOff(`fixes.${id}`, f, "this fix")}</td><td><b>${esc(f.short || id)}</b></td><td class="small">${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">${f.failover ? "by itself (failover)" : auto.has(id) ? "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"><span class="fl">built as</span><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">${esc(tplMsg)}</p>`; tplMsg = "";
|
||
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] || []).filter((fl) => !tpl.params.some((p) => p.name === fl)).map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("");
|
||
if (tplName === "custom") {
|
||
inputs += `<div class="field"><span class="fl">runs on</span><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"><span class="fl">computer</span><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 block"><span class="fl">command${hint("Typed by hand: zsh on a Mac, cmd.exe on a PC. Shown as a command at Apply; never run before Apply.")}</span><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run || "")}</textarea></div>`;
|
||
} else if (f.run) inputs += `<details><summary>the command it makes${f.kind === "local" ? "" : " (on " + esc((c.hosts[f.host] || {}).name || f.host) + ")"}</summary><pre class="log small" style="max-height:160px">${esc(f.run)}</pre></details>`;
|
||
const undoSel = `<div class="field"><span class="fl">undo${hint(F("fixes.*.undo").help)}</span><select data-path="fixes.${esc(id)}.undo"><option value="">none</option>${Object.entries(c.fixes).filter(([o]) => o !== id).map(([o, fx]) => `<option value="${esc(o)}" ${f.undo === o ? "selected" : ""}>${esc(fx.short || o)} – ${esc(fx.label || "")}</option>`).join("")}</select></div>`;
|
||
inputs += `<details ${f.failover ? "open" : ""}><summary>more</summary>${["reconnect", "conn", "restart_ok", "never_auto", "timeout", "failover"].map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("")}${undoSel}</details>`;
|
||
} else inputs = `<p class="small muted">Built as: ${esc(tpl.words)}</p>${f.run ? `<details><summary>the command</summary><pre class="log small" style="max-height:160px">${esc(f.run)}</pre></details>` : ""}`;
|
||
detail = `<div class="card"><h3 style="margin-top:0">${onOff(`fixes.${id}`, f, "this fix")} ${esc(f.short || id)} <span class="muted small">${esc(f.label || "")}${f.disabled ? " · off: never runs" : ""}</span></h3>${inputs}${keys.length ? `<p class="small muted">Used by ${esc(keys.join(", "))}.</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 fix</button>` : ""}</div>`;
|
||
}
|
||
const add = editing() ? `<span class="row" style="margin-left:auto"><input id="newfix" placeholder="new fix id" style="width:10em;padding:5px 8px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);font:inherit"><button id="addfix" class="small">+ Fix</button></span>` : "";
|
||
main.innerHTML = `<h1>Keys${hint("Everything a hold can run. Click a fix to see how it is built.")}</h1><div class="tabs"><button onclick="location.hash='#keys'">Status page</button>${c.restart_page ? `<button onclick="location.hash='#keys/restart'">Restart page</button>` : ""}${c.ready_page ? `<button onclick="location.hash='#keys/ready'">READY page</button>` : ""}<button class="on">Fixes</button>${add}</div>
|
||
<div class="cols"><div class="tablewrap"><table><tr><th></th><th>key text</th><th>does</th><th>built as</th><th>used by</th><th>runs</th></tr>${rows}</table></div><div>${detail}</div></div>`;
|
||
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 }) });
|
||
// Not buildable yet (a field is still blank): keep the choice, repaint so the builder's own fields show, say why.
|
||
if (!r.ok) { tplMsg = r.error; fx.template = tplName; fx.params = params; saveDraft(); route(); 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;
|
||
saveDraft(); route();
|
||
};
|
||
const ts = main.querySelector("#tplsel"); if (ts) { ts.onchange = rebuild; for (const el of main.querySelectorAll("#tplform [data-param]")) el.onchange = rebuild; }
|
||
const del = main.querySelector("#delfix"); if (del) del.onclick = () => confirmBox(`Delete the fix ${id}? No key uses it.`, "Delete", () => { if (!ensureDraft()) return; delete state.draft.config.fixes[id]; const af2 = state.draft.config.auto_fix.fixes || []; state.draft.config.auto_fix.fixes = af2.filter((x) => x !== id); saveDraft(); location.hash = "#keys/fixes"; });
|
||
const af = main.querySelector("#addfix"); if (af) af.onclick = () => { const nid = main.querySelector("#newfix").value.trim(); if (!/^[A-Za-z0-9_]+$/.test(nid) || c.fixes[nid]) return alertBox("A new id: letters, digits and _ only, not in use."); if (!ensureDraft()) return; state.draft.config.fixes[nid] = { kind: "local", short: nid.toUpperCase().slice(0, 12), label: "", run: "" }; saveDraft(); location.hash = "#keys/fixes/" + nid; };
|
||
}
|
||
})();
|