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:
2026-10-15 14:30:41 -04:00
co-authored by Claude Opus 5.5
parent 14fce2cf9a
commit 5adab6c518
11 changed files with 663 additions and 82 deletions
+3 -5
View File
@@ -89,14 +89,14 @@ The stream's audio comes in on the Dante device STUDIO-A-Resi-AJA-SDI, channels
- **When:** inside the `"failover"` windows (Sunday 06:30–13:30 today), even while ALARMS is muted. Once per fault: it re-arms once the fault has been clear for 10 s (a check that cannot ask, say an SSH hiccup, does not count as clear).
- **How fast:** about 5–6 s from a crash to the press (2–3 s to notice, 1 s debounce, 2 s `after_seconds`). A vanished Dante device takes as long as Companion's Dante module needs to notice it.
- **Unlike auto-fix:** it does not wait 20 s, does not stop for a network blip, and is not limited to 2 tries per 10 minutes. The one guard: when several Dante devices vanish at once (that is NA Ingest's view of Dante, not Studio B) it holds back, like auto-fix.
- **What the keys show:** AUDIO (and the hidden STB AUDIO) carry an extra line **ON FOH BACKUP** while the fault lasts and for 15 minutes after (`note_seconds`), or until STREAM STB is held. Hold AUDIO right after and it says JUST TRIED.
- **What the keys show:** AUDIO (and the hidden STB AUDIO) carry an extra line **ON FOH BACKUP** while the fault lasts and for 15 minutes after (`note_seconds`). Hold AUDIO right after and it says JUST TRIED.
- **Log and alert:** every press goes to the daily auto-fix log as `FAILOVER:` lines, to the watchdog log, and to `alert_webhook` if set.
- **Switching back is yours.** It never switches back by itself: when Studio B is up again, hold **STREAM STB** on the Restart page for 2 s (the fix's `"undo"`). That presses Studio B on the Audio page and clears ON FOH BACKUP.
- **Switching back is yours.** It never switches back by itself: when Studio B is up again, press **Studio B** on Companion's Studio B page (page 7) or Audio page (page 4). The alert names that button (the fix's `"undo"`).
- **Never:** a press outside the window, a second press for the same fault, or a press back to Studio B.
In `config.json`: the `"failover"` block (`windows`, `after_seconds` 2, `note_seconds` 900); `"failover": true` and `"undo"` on the fix (`stream_foh_backup` undoes with `stream_studio_b`); `"interval_seconds"` on ping checks (default 3) and remote checks (default 10). The remote poll runs each computer's checks in its own thread, so a dead Studio B never delays the checks on another Mac.
**Another fallback button:** any Companion button can be a fix. On the settings page, Keys › Fixes › + Fix › "Press a Companion button" (page, row, column and what the button says), tick failover under more and pick its undo; Settings › Auto-fix › Failover calendar holds the window and `after_seconds`; Keys › + Key / + check… adds the checks that call it (a ping with interval seconds and failures, a program on a Mac, a Dante device) with fix = the press; Keys › Restart page › + Restart key adds the keys that press it by hand. The Help page "How do I add…" has an entry "A fallback button".
**Another fallback button:** any Companion button can be a fix. On the settings page, Keys › Fixes › + Fix › "Press a Companion button" (page, row, column and what the button says), tick failover under more and pick its undo; **Settings › Auto switch** has it all on one screen: which buttons switch by themselves (tick, page/row/column, the button to switch back with, Press it now), the hours (calendar), `after_seconds` and `note_seconds`, and what counts as down (how often each check looks, how many misses, how long a fault must last). Keys › + Key / + check… adds more checks that call it (a ping, a program on a Mac, a Dante device) with fix = the press. The Help page "How do I add…" has an entry "A fallback button".
## Restart page (page 20)
@@ -112,8 +112,6 @@ Tap **RESTART PAGE** on the status page (row 2). Each key restarts one thing **e
| PRODDASH / CAPTIONS / BRIDGE | that service on NA Ingest |
| TASCAM | reconnects Companion to the recorder (see TASCAM above) |
| SPOTIFY | plays the Christian EDM 2026 playlist on NA Ingest (opens Spotify first if it is closed) |
| STREAM STB | presses Studio B on Companion's Audio page: the stream audio back on Studio B (how you switch back after a failover, see Audio failover) |
| STREAM FOH | presses FOH Backup on Companion's Audio page: the stream audio from the FOH console. Flashes HOLD TO FIX while Studio B is down; once the failover has pressed it, it shows ON FOH BACKUP (green) instead. |
A pressing key says PRESSING, then DONE; a press that finds no button there says NO BUTTON THERE.
-18
View File
@@ -1049,24 +1049,6 @@
"watch": [
"conn:tascam"
]
},
{
"id": "rs_stream_stb",
"label": "STREAM STB",
"pos": [
3,
2
],
"fix": "stream_studio_b"
},
{
"id": "rs_stream_foh",
"label": "STREAM FOH",
"pos": [
3,
3
],
"fix": "stream_foh_backup"
}
]
},
Binary file not shown.
+20 -4
View File
@@ -113,6 +113,8 @@ field("auto_fix.windows[].start", "hhmm", None, "", "Schedules", required=True)
field("auto_fix.windows[].end", "hhmm", None, "", "Schedules", required=True)
field("auto_fix.while_muted", "list", [], "Auto-fixes that keep running inside the window even while ALARMS is "
"muted (harmless ones, like playing the Spotify playlist).", "Schedules")
field("auto_fix.disabled", "bool", False, "Auto-fix switched off as a whole: nothing is fixed by itself (holds "
"still work).", "Schedules")
field("auto_fix.after_seconds", "int", 20, "A fault must last this long before auto-fix acts.", "Schedules",
advanced=True, lo=5, hi=600)
field("auto_fix.max_tries", "int", 2, "Tries per fault, then it is yours to hold.", "Schedules", advanced=True,
@@ -130,6 +132,8 @@ field("auto_fix.keep_months", "int", 1, "Daily log files are deleted after this
advanced=True, lo=1, hi=12)
field("failover", "dict", {}, "Automatic fallback: when a fix marked failover may act (it acts even while ALARMS is "
"muted).", "Schedules")
field("failover.disabled", "bool", False, "The auto switch switched off as a whole: nothing is pressed by itself.",
"Schedules")
field("failover.windows", "list", [{"days": ["sun"], "start": "06:30", "end": "13:30"}], "When a failover may switch "
"by itself.", "Schedules")
field("failover.windows[].days", "days", None, "", "Schedules", required=True)
@@ -184,6 +188,8 @@ field("hosts.*.ssh", "str", None, "How this Mac logs in: user@address.", "Comput
field("hosts.*.mac", "mac", None, "The PC's hardware address, to wake it when it is off.", "Computers")
field("hosts.*.wake_broadcast", "ip", None, "The broadcast address the wake-up goes to.", "Computers", advanced=True)
field("hosts.*.name", "str", None, "The computer, as people say it.", "Computers", apply="none")
field("hosts.*.disabled", "bool", False, "Switched off: kept, but nothing logs in to it - its checks and fixes are "
"skipped (a computer that is away or being rebuilt).", "Computers")
field("hosts.*.os", "enum", "mac", "Mac or Windows (a PC's checks are written for cmd.exe).", "Computers",
options=["mac", "windows", "other"])
field("hosts.*.role", "str", None, "What it is for.", "Computers", apply="none")
@@ -229,6 +235,8 @@ field("fixes.*.mac", "mac", None, "The device expected on that port.", "Fixes")
field("fixes.*.playlist", "str", None, "A playlist other than the site's (rare).", "Fixes", advanced=True)
field("fixes.*.template", "str", None, "The builder this fix was made with.", "Fixes", advanced=True, apply="none")
field("fixes.*.params", "dict", None, "The builder's fields.", "Fixes", advanced=True, apply="none")
field("fixes.*.disabled", "bool", False, "Switched off: kept, but never runs (a hold says TURNED OFF; auto-fix "
"and the auto switch skip it).", "Fixes")
field("fixes.*.action", "enum", None, "What a READY page fix does.", "Fixes", options=list(sp.READY_ACTIONS))
field("fixes.*.page", "int", None, "The Companion page the button is on.", "Fixes", lo=1)
field("fixes.*.row", "int", None, "The button's row on that page (the top row is 0).", "Fixes", lo=0)
@@ -250,6 +258,10 @@ field("tiles[].kind", "enum", None, "A special key: master (ALL), service (ALARM
options=["master", "service", "network", "ready"], apply="pages")
field("tiles[].members", "list", None, "A group: the keys it sums up.", "Keys")
field("tiles[].checks", "list", [], "What the key watches.", "Keys")
field("tiles[].disabled", "bool", False, "Switched off: the key stays on the page (grey, OFF) but checks nothing "
"and fixes nothing, like a connection switched off in Companion.", "Keys")
field("tiles[].checks[].devices_off", "list", [], "Dante devices switched off: kept in the list, not watched.", "Keys")
field("tiles[].checks[].disabled", "bool", False, "Switched off: this check is kept but not looked at.", "Keys")
field("tiles[].hold_always", "str", None, "A fix a hold runs even when the key is green.", "Keys", apply="pages")
field("tiles[].checks[].type", "enum", None, "What kind of check.", "Keys", required=True)
field("tiles[].checks[].conn", "str", None, "The Companion connection.", "Keys")
@@ -316,6 +328,8 @@ field("restart_page.keys[].id", "id", None, "", "Keys", advanced=True, required=
field("restart_page.keys[].label", "str", None, "", "Keys", required=True, apply="pages")
field("restart_page.keys[].pos", "list", None, "[row, column].", "Keys", required=True, apply="pages")
field("restart_page.keys[].fix", "str", None, "What holding it runs.", "Keys", required=True)
field("restart_page.keys[].disabled", "bool", False, "Switched off: the key stays on the page (OFF) but a hold does "
"nothing.", "Keys", apply="pages")
field("restart_page.keys[].watch", "list", [], "Keep flashing HOLD TO FIX while these fixes' checks or "
"'conn:<label>' connections are down.", "Keys")
@@ -329,6 +343,8 @@ field("ready_page.back_pos", "list", None, "Where the 'back to Status' key sits
field("ready_page.keys", "list", [], "The checklist keys (each like a Status page key, placed on the READY page).",
"Ready", apply="pages")
field("ready", "dict", {}, "Where the READY page looks.", "Ready")
field("ready.disabled", "bool", False, "The READY page switched off as a whole: its keys show OFF and nothing is "
"looked at.", "Ready")
field("ready.interval_seconds", "int", 120, "Look this often (never while a HyperDeck is recording; a hold looks at "
"once).", "Ready", advanced=True, lo=30, hi=3600)
field("ready.due", "list", [], "From when gaps are red instead of amber.", "Ready")
@@ -382,7 +398,7 @@ CHECKS = {
"hyperdeck": {"fields": ["conn", "optional", "disk_service_only", "full_minutes", "low_minutes", "rec_sync", "ip"],
"subs": ["conn", "disk", "rec"], "identity": ["conn"], "module": "bmd-hyperdeck",
"question": "Is a recorder OK?", "words": "A HyperDeck is connected, has a disk, and records with the others."},
"dante": {"fields": ["conn", "devices", "count_all", "service_only"], "subs": ["conn", "<device name>"], "identity": ["conn"],
"dante": {"fields": ["conn", "devices", "count_all", "service_only", "devices_off"], "subs": ["conn", "<device name>"], "identity": ["conn"],
"module": "audinate-dantecontroller", "question": "Is Dante complete?", "words": "Every listed Dante device is present."},
"shure": {"fields": ["conn", "channels", "battery_err_bars", "battery_warn_bars"], "subs": ["conn", "ch<N>_batt", "ch<N>_rf"],
"identity": ["conn"], "module": "shure-wireless", "question": "Is a device answering?", "words": "A Shure receiver: batteries and interference."},
@@ -421,7 +437,7 @@ CHECKS = {
"ready_manual": {"fields": ["item", "msg"], "subs": ["done"], "identity": ["item"], "module": None,
"question": "Is the weekend ready?", "words": "A line of the weekly checklist a person taps done."},
}
COMMON_CHECK_FIELDS = ["type", "fix", "tag", "service_only", "debounce_seconds"]
COMMON_CHECK_FIELDS = ["type", "fix", "tag", "service_only", "debounce_seconds", "disabled"]
FIX_KINDS = sp.FIX_KINDS
FIX_KIND_FIELDS = {
"ssh": ["host", "run", "timeout", "reconnect", "conn"], "local": ["run", "timeout", "reconnect", "conn"],
@@ -451,7 +467,7 @@ GLOSSARY = {
"MISSING", "SLIDES OLD?", "SLIDES", "NO TC", "NO LTC", "NO BG", "NO PLAYLIST", "OLD PLAN", "SYNC ERROR",
"NOT ON PLAN", "DECLINED", "WRONG DECK", "NOTHING ON", "NOT SENT", "COPYING TO", "NO PCO LOGIN",
"PCO UNREACHABLE", "NO PLAN IN PCO", "PROP UNREACHABLE", "LINEUP UNREACHABLE", "RESOLUME UNREACHABLE",
"UNREACHABLE", "CHECK FAILED", "NO CLIP KNOWN", "LTC WRONG", "PCO WRITE FAILED", "IN PCO", "NO SET TO COPY", "SYNC SLOW", "NO PLAN YET", "VOX",
"UNREACHABLE", "CHECK FAILED", "NO CLIP KNOWN", "TURNED OFF", "LTC WRONG", "PCO WRITE FAILED", "IN PCO", "NO SET TO COPY", "SYNC SLOW", "NO PLAN YET", "VOX",
],
"ask Chris": ["NOT IN COMPANION", "CHECK BUG", "NO DATA", "WATCHDOG DOWN", "ERR", "PROCESS", "WEB"],
"what a key says while you hold it": [
@@ -650,7 +666,7 @@ def validate(cfg, live=None):
if fix.get("undo") and fix["undo"] not in fixes:
errors.append("%s: the fix that switches back (undo) is unknown: %s" % (where, fix["undo"]))
for k in fix:
if k not in FIX_KIND_FIELDS.get(kind, []) and k not in ("kind", "short", "label", "restart_ok", "never_auto",
if k not in FIX_KIND_FIELDS.get(kind, []) and k not in ("kind", "short", "label", "restart_ok", "never_auto", "disabled",
"template", "params", "timeout", "failover", "undo"):
warnings.append("%s: %s is not a field of a %s fix" % (where, k, kind))
named = set()
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+15
View File
@@ -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; }
+57 -4
View File
@@ -608,6 +608,41 @@ def ready_keys(cfg):
return (cfg.get("ready_page") or {}).get("keys", [])
def effective_config(cfg):
"""The config as the watchdog runs it: a key that is switched off ("disabled": true) keeps its
place on the page but checks nothing, and a check that is switched off is left out. Like a
connection switched off in Companion, everything stays in the settings, ready to switch on."""
out = json.loads(json.dumps(cfg))
# A computer switched off: nothing logs in to it, so its checks and fixes go too.
off_hosts = {a for a, h in cfg.get("hosts", {}).items() if h.get("disabled")}
off_ips = {str(h.get("ssh", "")).split("@")[-1] for a, h in cfg.get("hosts", {}).items() if a in off_hosts}
for fid, fix in (out.get("fixes") or {}).items():
if fix.get("host") in off_hosts:
fix["disabled"] = True
ready = out.get("ready") or {}
if (cfg.get("ready") or {}).get("disabled"): # the READY page switched off as a whole
for k in ready_keys(out):
k["disabled"] = True
for name in ("tracks_host", "prop_bundle_host"):
if ready.get(name) in off_hosts:
ready.pop(name)
for t in all_tiles(out):
if "checks" in t:
t["checks"] = [] if t.get("disabled") else [
c for c in t["checks"] if not c.get("disabled") and c.get("host") not in off_hosts
and not (c.get("type") == "ping" and c.get("host") in off_ips)
and not (c.get("type") == "http" and urllib.parse.urlparse(c.get("url", "")).hostname in off_ips)]
for c in t["checks"]:
if c.get("devices_off") and isinstance(c.get("devices"), dict): # Dante devices switched off
c["devices"] = {n: v for n, v in c["devices"].items() if n not in c["devices_off"]}
# Auto-fix or the auto switch switched off as a whole: their hours are emptied, so they never act.
if (cfg.get("auto_fix") or {}).get("disabled"):
out["auto_fix"]["windows"] = []
if (cfg.get("failover") or {}).get("disabled"):
out["failover"]["windows"] = []
return out
def all_tiles(cfg):
"""Every key the watchdog judges: the Status page's tiles and the READY page's keys. The READY
page's keys are not in ALL (nor in auto-fix): a weekday gap in the checklist is not an alarm."""
@@ -882,6 +917,7 @@ class ServiceMode:
class Engine:
def __init__(self, cfg, state_path=None, data_dir=None):
cfg = effective_config(cfg)
self.cfg = cfg
self.tiles = {t["id"]: t for t in all_tiles(cfg)}
# The READY page's checklist keys (a "ready" kind key sums them up, on either page).
@@ -1488,7 +1524,8 @@ class Engine:
if self.auto_paused and self.autolog:
self.autolog.write(now, "NET", "network settled: auto-fix is back on")
self.auto_paused = False
gentle = {f for f in a.get("fixes", []) if not self.cfg["fixes"].get(f, {}).get("never_auto")}
gentle = {f for f in a.get("fixes", []) if not self.cfg["fixes"].get(f, {}).get("never_auto")
and not self.cfg["fixes"].get(f, {}).get("disabled")}
done = []
for t in self.cfg["tiles"]:
if "checks" not in t:
@@ -1560,7 +1597,7 @@ class Engine:
if isinstance(fix_id, dict):
fix_id = fix_id.get(sub, fix_id.get("*"))
fix = self.cfg["fixes"].get(fix_id) if fix_id else None
return fix_id if fix and fix.get("failover") else None
return fix_id if fix and fix.get("failover") and not fix.get("disabled") else None
def dante_gone(self, conn):
"""How many devices on this Dante connection are gone right now, counted over every dante
@@ -1656,10 +1693,12 @@ class Engine:
log.info("FAILOVER %s: %s - %s", tile_id, ", ".join(msgs), what)
if self.autolog:
self.autolog.write(now, label, "FAILOVER: %s - %s" % (", ".join(msgs), what))
# How to switch back: a Restart key that runs the undo, else the undo fix's own words.
back = next((k["label"] for k in self.restart_keys.values() if k["fix"] == fix.get("undo")), None)
undo = self.cfg["fixes"].get(fix.get("undo") or "", {}).get("label")
text = "%s: %s %s - switched over by itself: %s. Switch back by hand%s once it is fixed." % (
self.site.get("alert_prefix") or self.site.get("ingest_name") or "Status panel", label, ", ".join(msgs),
what, (" (hold %s on the Restart page)" % back) if back else "")
what, (" (hold %s on the Restart page)" % back) if back else (" (%s)" % undo) if undo else "")
threading.Thread(target=self.notify, args=(self.cfg.get("alert_webhook"), text), daemon=True).start()
started = time.time()
self.run_plan(tile_id, [(("failover", fix_id), "fix", fix_id, chk)], snap, comp, now, cooldown=0, auto=True,
@@ -1692,6 +1731,9 @@ class Engine:
log.warning("FORCE %s: no such restart key", key_id)
return None
fix_id = key["fix"]
if key.get("disabled"):
self.note(key_id, "TURNED\nOFF", "idle", now, 4)
return None
busy = [w for w in key.get("watch", []) if not w.startswith("conn:")
and now - self.fix_last.get(("fix", w, ""), 0) < cooldown]
if busy: # e.g. auto-fix is starting Ableton right now
@@ -1847,6 +1889,9 @@ def render(engine, service_on):
down_fixes, down_conns = engine.failing_targets()
for kid, key in engine.restart_keys.items():
note = engine.notes.get(kid)
if key.get("disabled") or engine.cfg["fixes"].get(key["fix"], {}).get("disabled"):
out[kid] = colored(key["label"] + "\nOFF", "off") # switched off in the settings
continue
watch = [key["fix"]] + key.get("watch", [])
down = any(w[5:] in down_conns if w.startswith("conn:") else w in down_fixes for w in watch)
pressed = engine.failover_note_for(key["fix"])
@@ -1860,6 +1905,9 @@ def render(engine, service_on):
out[kid] = colored(key["label"] + "\n" + engine.hold_text, "idle")
for tile in all_tiles(engine.cfg):
tid, kind = tile["id"], tile.get("kind")
if tile.get("disabled"): # switched off in the settings: grey, checks nothing
out[tid] = colored(tile["label"] + "\nOFF", "off")
continue
note = engine.notes.get(tid)
if note and engine.now < note["until"]: # a hold is being acted on: say so
out[tid] = colored(tile["label"] + "\n" + note["text"], note["level"])
@@ -2469,6 +2517,9 @@ def execute_fix(cfg, fix_id, chk):
Returns True if the command ran and succeeded."""
fix = cfg["fixes"][fix_id]
kind, label = fix.get("kind", "ssh"), fix.get("label", fix_id)
if fix.get("disabled"): # switched off in the settings: never runs
log.info("FIX %s: switched off in the settings, not run", label)
return "TURNED\nOFF"
try:
if kind in ("ssh", "local"):
argv = ssh_cmd(cfg, fix["host"]) + [fix["run"]] if kind == "ssh" else ["/bin/zsh", "-c", fix["run"]]
@@ -3120,6 +3171,7 @@ def handle_commands(text, engine, comp, snap, now):
def run(cfg, data_dir, dry_run=False, stop=None, config_sha=None):
cfg = effective_config(cfg)
stop = stop or threading.Event()
names = cfg["variables"]
comp = make_companion(cfg)
@@ -3139,7 +3191,7 @@ def run(cfg, data_dir, dry_run=False, stop=None, config_sha=None):
except ValueError: # not the main thread (tests)
pass
threads = start_pollers(cfg, snap, stop, data_dir)
if cfg.get("ready_page"):
if cfg.get("ready_page") and not (cfg.get("ready") or {}).get("disabled"):
import readiness
poller = readiness.Poller(cfg, snap, stop, data_dir, is_live=lambda: engine.live_now(time.time())[0])
poller.start()
@@ -3238,6 +3290,7 @@ def run(cfg, data_dir, dry_run=False, stop=None, config_sha=None):
def run_once(cfg):
"""Poll everything synchronously, judge with no debounce, print the tiles."""
cfg = effective_config(cfg)
snap = Snapshot()
comp = make_companion(cfg)
fast, slow = needed_variables(cfg)
+2
View File
@@ -69,6 +69,8 @@ def collect(path):
return step(base, key)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in ("items", "values"):
return kind_of(node.func.value, local)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "effective_config":
return "top" # the config with switched-off keys and checks taken out
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ("all_tiles", "ready_keys"):
return "tiles" # the Status page's tiles and the READY page's keys
if isinstance(node, ast.Call) and dotted(node.func) == "json.load":
+107 -11
View File
@@ -1536,6 +1536,108 @@ def run_failover(engine, snap, start, seconds, step=0.5):
STB_PING, STB_PT = "10.1.13.175", "studiob\0pgrep -xq 'Pro Tools'"
class TestSwitchedOff(unittest.TestCase):
"""Anything can be switched off in the settings and stay there, like a connection in Companion:
a key goes grey OFF and checks nothing, a check is skipped, a fix never runs."""
def cfg(self, **changes):
cfg = json.loads(json.dumps(CONFIG))
for path, value in changes.items():
node = cfg
*parts, last = path.split("/")
for p in parts:
node = node[int(p)] if isinstance(node, list) else node[p]
node[last] = value
return cfg
def test_a_key_switched_off_is_grey_and_checks_nothing(self):
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "obs")
engine = sp.Engine(self.cfg(**{"tiles/%d/disabled" % i: True}))
snap = healthy_snapshot(SUNDAY)
snap.conns.pop("OBS-Youtube", None) # OBS gone: would be red
tiles, _ = run_steps(engine, snap, SUNDAY, 5)
self.assertEqual(tiles["obs"]["x"], "OBS\nOFF")
self.assertEqual(level(tiles["obs"]), "off")
self.assertEqual(tiles["all"]["x"], "ALL OK") # not part of ALL any more
self.assertEqual(tiles["stream"]["x"], "STREAM\nOK") # nor of its group
def test_a_check_switched_off_is_skipped(self):
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "stb_audio")
engine = sp.Engine(self.cfg(**{"tiles/%d/checks/0/disabled" % i: True}))
snap = healthy_snapshot(SUNDAY)
snap.ping[STB_PING] = 5 # the ping check would fail
tiles, _ = run_steps(engine, snap, SUNDAY, 5)
self.assertEqual(tiles["stb_audio"]["x"], "STB AUDIO\nOK")
self.assertEqual(len(engine.tiles["stb_audio"]["checks"]), 2)
self.assertEqual(len(CONFIG["tiles"][i]["checks"]), 3) # the settings keep all three
def test_a_fix_switched_off_never_runs(self):
cfg = self.cfg(**{"fixes/obs/disabled": True, "fixes/stream_foh_backup/disabled": True})
self.assertEqual(sp.execute_fix(cfg, "obs", {}), "TURNED\nOFF")
engine = sp.Engine(cfg)
engine.executor = lambda c, fix_id, chk: self.fail("ran " + fix_id)
engine.notify = lambda url, text: None
snap = healthy_snapshot(SUNDAY)
snap.ping[STB_PING] = 5 # Studio B dies on a Sunday
snap.conns.pop("OBS-Youtube", None)
run_failover(engine, snap, SUNDAY, 40)
self.assertEqual(engine.failed, {}) # no auto switch
self.assertEqual(engine.auto_fix(snap, mock_comp(), SUNDAY + 41), []) # no auto-fix
def test_a_restart_key_switched_off(self):
rp = self.cfg()["restart_page"]
j = next(n for n, k in enumerate(rp["keys"]) if k["id"] == "rs_tascam")
engine = sp.Engine(self.cfg(**{"restart_page/keys/%d/disabled" % j: True}))
tiles = engine.step(healthy_snapshot(SUNDAY), SUNDAY)
self.assertEqual(tiles["rs_tascam"]["x"], "TASCAM\nOFF")
self.assertIsNone(engine.run_force("rs_tascam", healthy_snapshot(SUNDAY), None, SUNDAY))
self.assertEqual(engine.notes["rs_tascam"]["text"], "TURNED\nOFF")
def test_a_computer_switched_off(self):
cfg = self.cfg(**{"hosts/studiob/disabled": True})
engine = sp.Engine(cfg)
stb = engine.tiles["stb_audio"]["checks"]
self.assertEqual([c["type"] for c in stb], ["dante"]) # its ping and Pro Tools checks are gone
self.assertTrue(engine.cfg["fixes"]["solo_afl"]["disabled"]) # and a fix that logs in to it
self.assertFalse(any(c.get("host") == "studiob" for t in sp.all_tiles(engine.cfg) for c in t.get("checks", [])))
self.assertNotIn("studiob", sp.remote_midi_hosts(engine.cfg))
engine = sp.Engine(self.cfg(**{"hosts/bibletv/disabled": True})) # its web page is not fetched either
self.assertNotIn("http://10.6.7.13:8080/", [u for u, _ in sp.http_checks(engine.cfg)])
self.assertTrue(engine.cfg["fixes"]["bibleapp"]["disabled"])
def test_a_dante_device_switched_off(self):
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "dante")
engine = sp.Engine(self.cfg(**{"tiles/%d/checks/0/devices_off" % i: ["STUDIO-B-Tascam"]}))
snap = healthy_snapshot(SUNDAY)
devices = json.loads(snap.vars[("audinate-dantecontroller", "devices")])
snap.vars[("audinate-dantecontroller", "devices")] = json.dumps([d for d in devices if d != "STUDIO-B-Tascam"])
tiles, _ = run_steps(engine, snap, SUNDAY, 5)
self.assertNotIn("GONE", tiles["dante"]["x"]) # gone, but switched off: no alarm
self.assertIn("STUDIO-B-Tascam", CONFIG["tiles"][i]["checks"][0]["devices"]) # still in the list
def test_whole_features_switched_off(self):
cfg = self.cfg(**{"auto_fix/disabled": True, "failover/disabled": True, "ready/disabled": True})
engine = sp.Engine(cfg)
self.assertEqual(engine.cfg["auto_fix"]["windows"], [])
self.assertEqual(engine.failover_windows, [])
self.assertTrue(all(k.get("disabled") for k in sp.ready_keys(engine.cfg)))
snap = healthy_snapshot(SUNDAY)
snap.ping[STB_PING] = 5
engine.executor = lambda c, fix_id, chk: self.fail("ran " + fix_id)
engine.notify = lambda url, text: None
tiles, _ = run_failover(engine, snap, SUNDAY, 20)
self.assertEqual(engine.failed, {})
self.assertEqual(tiles["rd_tracks"]["x"], "TRACKS\nOFF")
def test_the_rules_accept_switched_off_things(self):
import schema
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "obs")
cfg = self.cfg(**{"tiles/%d/disabled" % i: True, "tiles/%d/checks/0/disabled" % i: True, "fixes/obs/disabled": True})
errors, warnings = schema.validate(cfg)
self.assertEqual(errors, [])
self.assertFalse(any("disabled" in w for w in warnings), warnings)
class TestFailover(unittest.TestCase):
"""Studio B dies on a Sunday: the FOH Backup button is pressed by itself, once, and the keys say so.
Nothing here presses a real button, posts a real alert or writes a real log."""
@@ -1567,8 +1669,6 @@ class TestFailover(unittest.TestCase):
self.assertEqual(fix["undo"], "stream_studio_b")
self.assertFalse(CONFIG["fixes"]["stream_studio_b"].get("failover"))
self.assertNotIn("stream_foh_backup", CONFIG["auto_fix"]["fixes"])
keys = {k["id"]: k["fix"] for k in CONFIG["restart_page"]["keys"]}
self.assertEqual((keys["rs_stream_stb"], keys["rs_stream_foh"]), ("stream_studio_b", "stream_foh_backup"))
self.assertEqual(CONFIG["failover"]["after_seconds"], 2)
self.assertEqual(self.engine.failover_windows, [{"days": ["sun"], "start": "06:30", "end": "13:30"}])
@@ -1600,11 +1700,9 @@ class TestFailover(unittest.TestCase):
self.assertTrue(any("-> done, checking it comes back" in l for l in body), body)
self.assertEqual(len(self.sent), 1)
self.assertTrue(self.sent[0].startswith("NA Ingest: STB AUDIO STUDIO B NO PING - switched over by itself: press the FOH Backup"))
self.assertIn("hold STREAM STB on the Restart page", self.sent[0])
self.assertIn("Switch back by hand (press the Studio B button in Companion", self.sent[0])
self.engine.run_fixes("audio", self.snap, None, at + 10)
self.assertEqual(self.engine.notes["audio"]["text"], "JUST TRIED\nWAIT 1 MIN")
self.assertIsNone(self.engine.run_force("rs_stream_foh", self.snap, None, at + 11))
self.assertEqual(self.engine.notes["rs_stream_foh"]["text"], "JUST DONE\nWAIT 1 MIN")
out = sp.snapshot_summary(self.snap, self.engine, t)
json.dumps(out)
self.assertEqual(out["failover"]["stream_foh_backup"]["note"], "ON FOH BACKUP")
@@ -1664,9 +1762,6 @@ class TestFailover(unittest.TestCase):
self.snap.remote[STB_PT] = False # the answer is back: still gone
tiles, t = run_failover(self.engine, self.snap, t, 10)
self.assertEqual(self.ran, ["stream_foh_backup"]) # no second press
self.assertEqual(tiles["rs_stream_foh"]["x"], "STREAM FOH\nON FOH BACKUP") # pressed already: no HOLD TO FIX
self.assertEqual(level(tiles["rs_stream_foh"]), "ok")
self.assertEqual(tiles["rs_stream_stb"]["x"], "STREAM STB\nHOLD 2s")
def test_studio_b_rednet_is_never_power_cycled_by_itself(self):
"""Only STUDIO-B-RedNet-TNX vanishes: the failover presses FOH Backup; Sunday auto-fix on the DANTE
@@ -1697,11 +1792,12 @@ class TestFailover(unittest.TestCase):
self.snap.ping[STB_PING] = 2
tiles, t = run_failover(self.engine, self.snap, t0, 6)
self.assertIn("ON FOH BACKUP", tiles["audio"]["x"])
self.assertEqual(self.engine.run_force("rs_stream_stb", self.snap, None, t), "stream_studio_b")
self.assertEqual(self.engine.notes["rs_stream_stb"]["text"], "PRESSING")
# the undo, run through the watchdog (a Restart key or the settings page's Run the fix)
self.assertEqual(self.engine.run_force("stb_audio", self.snap, None, t, fix_id="stream_studio_b", chk={}), "stream_studio_b")
self.assertEqual(self.engine.notes["stb_audio"]["text"], "PRESSING")
gate.set()
time.sleep(0.2)
self.assertEqual(self.engine.notes["rs_stream_stb"]["text"], "DONE")
self.assertEqual(self.engine.notes["stb_audio"]["text"], "DONE")
self.assertEqual(self.engine.failed, {})
tiles = self.engine.step(self.snap, t + 0.5)
self.assertEqual(tiles["audio"]["x"], "AUDIO\nSTUDIO B NO PING")