Settings page: ON/OFF switches everywhere, the Auto switch screen, a Companion button picker
- ON/OFF on every key, check, fix, Restart and READY key, computer, MIDI link, HyperDeck, Dante device, watched Companion connection, switcher and Spotify check, outlet fix, and one main switch each for auto-fix, the auto switch and the READY page. Switched off = kept in the settings, grey OFF on the deck, never checked or run (effective_config in the watchdog). - Settings › Auto switch: a plain summary, what counts as down (each trigger editable in place: address, program and computer, Dante device, the button it presses, timings), the hours folded away, the buttons; + Button, + What counts as down, Remove. - Pick a Companion button by clicking it on its page, drawn like Companion (read-only API: /api/companion/pages and /api/companion/page). - STREAM STB / STREAM FOH Restart keys removed (the Studio B page has its own); the alert names the switch-back button instead. - Dante screen: shows the DANTE key's device list again (it picked STB AUDIO's one device). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -349,6 +349,8 @@ def probe_all(cfg, data_dir=None, only=None):
|
||||
for alias, host in cfg.get("hosts", {}).items():
|
||||
if only and alias not in only:
|
||||
continue
|
||||
if host.get("disabled"): # switched off in the settings: not looked at
|
||||
continue
|
||||
|
||||
def work(alias=alias, host=host):
|
||||
try:
|
||||
|
||||
+79
-1
@@ -148,6 +148,69 @@ class Site:
|
||||
self.page_cache[name] = (now, value)
|
||||
return value
|
||||
|
||||
# --- Companion's own pages, for picking a button by looking at it -----------------
|
||||
def companion_pages(self):
|
||||
"""[{"page", "name"}] for every page Companion has (read-only), all asked at once."""
|
||||
def one(n):
|
||||
try:
|
||||
status, raw = self.companion_get("/api/variable/internal/page_number_%d_name/value" % n, timeout=3)
|
||||
except (OSError, urllib.error.URLError):
|
||||
return None
|
||||
if status != 200:
|
||||
return None
|
||||
name = " ".join(raw.decode("utf-8", "replace").replace("\\n", " ").split())
|
||||
return {"page": n, "name": re.sub(r"^%d\s+" % n, "", name) or name} # "4 Audio" -> "Audio"
|
||||
|
||||
def look():
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=16) as pool:
|
||||
found = list(pool.map(one, range(1, 100)))
|
||||
out = []
|
||||
for f in found: # up to the first page Companion does not have
|
||||
if f is None:
|
||||
break
|
||||
out.append(f)
|
||||
return out
|
||||
return self.cached("companion-pages", 60, look)
|
||||
|
||||
def companion_page(self, n):
|
||||
"""One Companion page as its buttons look: text, colours and image, by row and column (read-only)."""
|
||||
def look():
|
||||
status, raw = self.companion_get("/int/export/page/%d?includeSecrets=false" % n, timeout=6)
|
||||
try:
|
||||
raw = gzip.decompress(raw)
|
||||
except OSError:
|
||||
pass
|
||||
page = (json.loads(raw).get("page") or {})
|
||||
val = lambda p: p.get("value") if isinstance(p, dict) and not p.get("isExpression") else None
|
||||
buttons = []
|
||||
for r, row in (page.get("controls") or {}).items():
|
||||
for c, ctl in (row or {}).items():
|
||||
if not ctl:
|
||||
continue
|
||||
b = {"row": int(r), "col": int(c), "type": ctl.get("type")}
|
||||
st = ctl.get("style") or {}
|
||||
if ctl.get("type") == "button-layered":
|
||||
lay = {}
|
||||
for l in st.get("layers") or []:
|
||||
lay.setdefault(l.get("type"), l)
|
||||
text = (lay.get("text") or {}).get("text") or {}
|
||||
b["text"] = val(text) if val(text) is not None else ("…" if text.get("isExpression") else "")
|
||||
b["bg"] = val((lay.get("box") or {}).get("color") or {})
|
||||
b["fg"] = val((lay.get("text") or {}).get("color") or {})
|
||||
img = val((lay.get("image") or {}).get("base64Image") or {})
|
||||
if img and len(img) < 60000:
|
||||
b["img"] = img if img.startswith("data:") else "data:image/png;base64," + img
|
||||
elif ctl.get("type") == "button":
|
||||
b["text"], b["bg"], b["fg"] = st.get("text") or "", st.get("bgcolor"), st.get("color")
|
||||
if st.get("png64") and len(st["png64"]) < 60000:
|
||||
b["img"] = st["png64"] if st["png64"].startswith("data:") else "data:image/png;base64," + st["png64"]
|
||||
if isinstance(b.get("text"), str):
|
||||
b["text"] = b["text"].replace("\\n", "\n")
|
||||
buttons.append(b)
|
||||
return {"page": n, "name": (page.get("name") or "").replace("\\n", " "), "grid": page.get("gridSize"), "buttons": buttons}
|
||||
return self.cached("companion-page-%d" % n, 10, look)
|
||||
|
||||
# --- what the screens show ---------------------------------------------
|
||||
|
||||
def overview(self):
|
||||
@@ -286,7 +349,9 @@ class Site:
|
||||
for f in fs:
|
||||
if f["level"] == "err" and alias in owners.get((tid, f.get("check")), set()):
|
||||
faults.append("%s %s" % (labels.get(tid, tid), f["msg"]))
|
||||
if probe and not probe.get("reachable") and not h.get("self"):
|
||||
if h.get("disabled"):
|
||||
word, reason = "Off", "switched off in the settings: not looked at, its checks and fixes skipped"
|
||||
elif probe and not probe.get("reachable") and not h.get("self"):
|
||||
word, reason = "Problem", "cannot be reached (%s)" % probe.get("error", "no answer")
|
||||
elif faults:
|
||||
word, reason = "Problem", "; ".join(faults[:2])
|
||||
@@ -629,6 +694,19 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(200, site.overview())
|
||||
if path == "/api/companion":
|
||||
return self.send(200, site.companion_view())
|
||||
if path == "/api/companion/pages":
|
||||
try:
|
||||
return self.send(200, {"pages": site.companion_pages()})
|
||||
except (OSError, ValueError) as e:
|
||||
return self.send(200, {"pages": [], "error": "Companion did not answer: %s" % e})
|
||||
if path == "/api/companion/page":
|
||||
n = (urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("n") or ["0"])[0]
|
||||
if not n.isdigit() or not 1 <= int(n) <= 99:
|
||||
return self.send(400, {"error": "which page?"})
|
||||
try:
|
||||
return self.send(200, site.companion_page(int(n)))
|
||||
except (OSError, ValueError) as e:
|
||||
return self.send(200, {"page": int(n), "buttons": [], "error": "Companion did not answer: %s" % e})
|
||||
if path == "/api/machines":
|
||||
return self.send(200, site.machines_view())
|
||||
if path == "/api/gear":
|
||||
|
||||
+378
-39
@@ -141,7 +141,7 @@
|
||||
if (f.type === "dict") { try { return raw.trim() ? JSON.parse(raw) : undefined; } catch (e) { return undefined; } }
|
||||
return raw === "" ? undefined : raw;
|
||||
}
|
||||
const LABELS = { editing_code: "PIN", ssh: "log-in address", mac: "hardware address", ingest_ips: "this Mac's addresses", ingest_name: "this Mac's name", ingest_hostname: "network name", hold_always: "hold also when green", page_hold_ms: "hold time (ms)", optional: "may have no disk" };
|
||||
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, "-");
|
||||
@@ -156,6 +156,36 @@
|
||||
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;
|
||||
@@ -206,6 +236,7 @@
|
||||
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>`; }
|
||||
@@ -276,9 +307,9 @@
|
||||
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 }; 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 }; 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 }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
|
||||
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] || "";
|
||||
@@ -292,7 +323,7 @@
|
||||
const s = deck && deck[cell.id];
|
||||
const style = s ? `--b1:${hex(s.b)};--f1:${hex(s.f)};--b2:${hex(s.b2)};--f2:${hex(s.f2)};background:${hex(s.b)};color:${hex(s.f)}` : "background:#444;color:#ddd";
|
||||
const blink = s && s.b !== s.b2 ? " blink" : "";
|
||||
out += `<div class="key${blink}${selected === cell.id ? " sel" : ""}" style="${style}"${drag(prefix + cell.id)} onclick="location.hash='#keys/${route_}${esc(cell.id)}'">${esc(s ? s.x : cell.label)}</div>`;
|
||||
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>`;
|
||||
@@ -463,7 +494,7 @@
|
||||
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><b>${what}</b>${hint(sentence)}</summary>`;
|
||||
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"
|
||||
@@ -493,8 +524,8 @@
|
||||
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">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">${esc(t.label)} <span class="muted small">${t.pos ? `row ${t.pos[0]}, col ${t.pos[1]}` : "in a group"}</span></h2>
|
||||
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>` : ""}
|
||||
@@ -571,7 +602,7 @@
|
||||
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">${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}
|
||||
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>`;
|
||||
}
|
||||
|
||||
@@ -588,10 +619,10 @@
|
||||
if (compTab === "gear") body = await gearHtml();
|
||||
else if (compTab === "setup") body = setupHtml(m, (await api("/api/setup").catch(() => ({ computers: [] }))).computers);
|
||||
else {
|
||||
const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `<tr class="click${selected === c.alias ? " sel" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}${c.self ? ' <span class="muted small">(this Mac)</span>' : ""}</td><td class="small">${esc(c.role)}</td><td class="small">${(c.keys || []).map((k) => `<span class="pill">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</span>`).join(" ")}</td><td class="muted small">${esc(c.reason)}${c.looked ? `<br>looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}</td></tr>`).join("");
|
||||
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>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>`;
|
||||
<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);
|
||||
}
|
||||
@@ -617,7 +648,7 @@
|
||||
<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">${esc(c.name)} ${wordOf(c.word)}</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>
|
||||
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>` : ""}
|
||||
@@ -802,7 +833,7 @@
|
||||
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. Settings › Auto-fix › Failover: paint the hours. A Restart page key with the press switches by hand. 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 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>
|
||||
@@ -825,6 +856,7 @@
|
||||
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"],
|
||||
@@ -849,6 +881,7 @@
|
||||
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);
|
||||
@@ -869,7 +902,7 @@
|
||||
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"><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>`
|
||||
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>`;
|
||||
@@ -891,13 +924,14 @@
|
||||
}
|
||||
function spotifyHtml(c) {
|
||||
const s = c.spotify || {};
|
||||
return `<div class="card"><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>
|
||||
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">${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>` + failoverHtml(c) + fieldsHtml("Schedules");
|
||||
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) {
|
||||
@@ -907,15 +941,214 @@
|
||||
for (const k of (c.restart_page || { keys: [] }).keys) use(k.fix, "restart key " + k.label);
|
||||
return used;
|
||||
}
|
||||
function failoverHtml(c) {
|
||||
// ---- 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 || []); // absent = the watchdog's default hours
|
||||
const used = fixUsers(c);
|
||||
const list = Object.entries(c.fixes).filter(([, f]) => f.failover).map(([id, f]) => { const keys = [...(used[id] || [])].filter((k) => !k.startsWith("restart key ")); return `<span class="pill" data-hint="${esc(f.label || id)}">${esc(f.short || id)} - ${keys.length ? "used by " + esc(keys.join(", ")) : "no check runs it"}</span>`; }).join(" ");
|
||||
return `<div class="card"><h3 style="margin-top:0">Failover${hint("A fix marked failover runs by itself within seconds when its check fails, inside these hours, even while ALARMS is muted, once per fault; it never switches back by itself.")}</h3>
|
||||
${calendarHtml("failover.windows", windows)}
|
||||
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)}
|
||||
<p class="small">Fixes marked failover: ${list || "<span class='muted'>none yet (Keys › Fixes › a fix › more › failover)</span>"}</p></div>`;
|
||||
<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"];
|
||||
@@ -978,19 +1211,19 @@
|
||||
const keyFor = (conn) => c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn));
|
||||
const known = new Set(decks.map((d) => d.label));
|
||||
for (const t of c.tiles) for (const ch of t.checks || []) if (ch.type === "hyperdeck" && !known.has(ch.conn)) { decks.push({ label: ch.conn, missing: true }); known.add(ch.conn); }
|
||||
const rows = decks.map((d) => { const k = keyFor(d.label); const chk = k && k.checks.find((ch) => ch.conn === d.label); return `<tr><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>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>
|
||||
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) => (t.checks || []).forEach((ch, j) => { if (ch.type === "midi_peer") rows.push(`<tr><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>key</th><th>Mac</th><th>session</th><th>peer</th><th>key text</th><th></th></tr>${rows.join("") || "<tr><td colspan=6 class='muted'>none</td></tr>"}</table></div>
|
||||
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" onclick="location.hash='#computers/${esc(a)}'"><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>computer</th><th>kind</th><th>log-in</th><th>does</th><th>programs it can start</th></tr>${rows}</table></div>
|
||||
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() {
|
||||
@@ -1007,7 +1240,7 @@
|
||||
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 || "")}">${esc(f.short || id)} → ${esc((f.params || {}).outlet || "?")}</span>`).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>
|
||||
@@ -1048,12 +1281,23 @@
|
||||
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 ? 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("");
|
||||
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");
|
||||
}
|
||||
function danteCheck(c) { const t = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "dante")); return t ? t.checks.find((ch) => ch.type === "dante") : null; }
|
||||
// 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 "";
|
||||
@@ -1061,16 +1305,18 @@
|
||||
const map = snap.dante_map || {};
|
||||
const fixOf = (n) => typeof chk.fix === "object" && chk.fix ? (chk.fix[n] || "") : "";
|
||||
const fixSel = (n) => `<select data-dantefix="${esc(n)}" ${editing() ? "" : "disabled"}><option value="">(everything else)</option><option value="dante-auto" ${fixOf(n) === "dante-auto" ? "selected" : ""}>Dante auto</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${fixOf(n) === id ? "selected" : ""}>${esc(f.short || id)}</option>`).join("")}</select>`;
|
||||
const rows = Object.entries(chk.devices || {}).map(([n, a]) => { const e = map[n] || {}; return `<tr><td class="mono small">${esc(n)}</td><td>${editing() ? `<input data-dantekey="${esc(n)}" value="${esc(a)}" size="10">` : esc(a)}</td><td class="mono small">${esc(e.ip || "")}</td><td class="small">${e.switch ? esc(e.switch + " port " + e.port) : ""}</td><td>${e.poe ? "yes" : ""}</td><td>${fixSel(n)}</td><td>${editing() ? `<button class="small danger" data-dantedel="${esc(n)}">Remove</button>` : ""}</td></tr>`; }).join("");
|
||||
const 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>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>
|
||||
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>` : "";
|
||||
return `<div class="card">${pending}
|
||||
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>`;
|
||||
@@ -1078,6 +1324,99 @@
|
||||
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(); } };
|
||||
@@ -1256,7 +1595,7 @@
|
||||
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" : ""}" onclick="location.hash='#keys/fixes/${esc(id)}'"><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 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) {
|
||||
@@ -1278,11 +1617,11 @@
|
||||
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">${esc(f.short || id)} <span class="muted small">${esc(f.label || "")}</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>`;
|
||||
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>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>`;
|
||||
<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;
|
||||
|
||||
@@ -137,3 +137,18 @@ details.script textarea { width: 100%; font-size: .8rem; }
|
||||
.checks { margin-top: 8px; }
|
||||
.chkgroup { margin: 10px 0 2px; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
|
||||
.chk { margin: 3px 0; }
|
||||
|
||||
#dialog .box.wide { max-width: min(760px, 96vw); width: 760px; }
|
||||
.cp-deck { max-width: 100%; margin: 6px 0; }
|
||||
.cp-deck .key { cursor: pointer; font-size: .62rem; text-shadow: 0 0 2px #000; }
|
||||
.cp-deck .key.cp-key:hover { border-color: #9be7a6; }
|
||||
.cp-deck .key.sel { border-color: #fff; box-shadow: 0 0 0 2px #2ecc71; }
|
||||
.trig { border-top: 1px solid var(--line); padding: 10px 0 4px; }
|
||||
.trig:first-of-type { border-top: 0; }
|
||||
|
||||
.onoff { display: inline-flex; align-items: center; cursor: pointer; vertical-align: middle; user-select: none; }
|
||||
.onoff input { display: none; }
|
||||
.onoff span { font: 700 .62rem/1 var(--font); letter-spacing: .04em; padding: 4px 8px; border-radius: 999px; background: #4a4f4c; color: #cfd3d1; min-width: 2.6em; text-align: center; }
|
||||
.onoff.on span { background: #1f8f4e; color: #fff; }
|
||||
.onoff input:disabled + span { opacity: .75; cursor: default; }
|
||||
tr.off td, .trig.off, .key.off { opacity: .55; }
|
||||
|
||||
Reference in New Issue
Block a user