Steps 4-6: the jobs, the set-up tab with generated scripts, Install the staged folder, site bundles and the campus template

- Overview jobs: Change the playlist, Add or remove a HyperDeck, Add a MIDI link, Add a computer,
  Make a PC program startable; 'Add a key for it' on unwatched Companion connections.
- Computers > Setup: steps with a [how] each and the set-up scripts generated per computer by
  settings/scripts.py from the templates in remote-access/ (no site-shaped value left in them);
  Remove this computer drops the fixes and checks that used it.
- Site setup > Site: the running and staged code, Install the staged folder (a one-shot launchd job
  running install.sh --keep-config, refused while live), Export site bundle (zip), New campus from a
  bundle (this campus's site block stays; every copied computer is flagged until touched).
- sites/: na -> the repo config, _template = the empty campus; install.sh --site <id>.
- Review reads appended checks as 'added'; tests for all of it.
- Deployed page-only to NA Ingest. (config.json, status_panel.py and readiness.py are left to the
  READY-page work in progress in the same tree.)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 14:53:08 -04:00
co-authored by Claude Fable 5.1
parent dafa14a512
commit a33e344cae
19 changed files with 804 additions and 59 deletions
+4
View File
@@ -124,6 +124,10 @@ def changed_paths(a, b, prefix=""):
# lists of plain records of the same length (checks, windows): by position
for i, (x, y) in enumerate(zip(a, b)):
out += changed_paths(x, y, "%s.%d" % (prefix, i))
elif isinstance(a, list) and isinstance(b, list) and all(isinstance(x, dict) for x in a + b) and a[:min(len(a), len(b))] == b[:min(len(a), len(b))]:
# the same records with some added at the end, or some removed from the end
for i in range(min(len(a), len(b)), max(len(a), len(b))):
out.append(("%s.%d" % (prefix, i), a[i] if i < len(a) else None, b[i] if i < len(b) else None))
elif a != b:
out.append((prefix, a, b))
return out
+156
View File
@@ -0,0 +1,156 @@
"""The one-time set-up of each computer, generated from its record: the steps with a [how] each and the
scripts with the right names in them (the templates in remote-access/ hold no site-shaped value)."""
import os
import re
import shlex
from settings import PARENT
TEMPLATES = os.path.join(PARENT, "remote-access")
WIN_SAFE = re.compile(r"^[A-Za-z0-9 ._()\\:*-]+$")
def template(name):
with open(os.path.join(TEMPLATES, name), encoding="utf-8") as f:
return f.read()
def fill(text, values):
for k, v in values.items():
text = text.replace("{{%s}}" % k, v)
left = re.findall(r"{{[A-Z_]+}}", text)
if left:
raise ValueError("unfilled placeholders: %s" % ", ".join(sorted(set(left))))
return text
def public_key(cfg):
"""(the key line, its comment) from this Mac's login key, or (None, None)."""
path = os.path.expanduser(cfg.get("ssh_key", "~/.ssh/id_status_panel")) + ".pub"
try:
line = open(path, encoding="utf-8").read().strip().splitlines()[0]
except (OSError, IndexError):
return None, None
parts = line.split()
if len(parts) < 2 or "'" in line:
return None, None
return line, (parts[2] if len(parts) > 2 else parts[1][-16:])
def ps_quote(value, what):
value = str(value)
if not WIN_SAFE.match(value):
raise ValueError("%s: on a PC only letters, digits, space and . _ ( ) \\ : * - are allowed, not %r" % (what, value))
return "'" + value + "'"
def site_values(cfg):
site = cfg.get("site") or {}
key, mark = public_key(cfg)
return {"INGEST_NAME": site.get("ingest_name") or "the Ingest Mac",
"INGEST_IPS": ", ".join("'%s'" % ip for ip in site.get("ingest_ips") or []) or "'127.0.0.1'",
"KEY": key or "<< this Mac has no login key yet: run install.sh, then come back >>",
"KEY_COMMENT": mark or "status-panel", "DVS_HELPER": site.get("dvs_helper") or "/usr/local/sbin/status-panel-dvs-start"}
def windows_setup(cfg, alias):
host = cfg["hosts"][alias]
tasks = [r for r in host.get("runs") or [] if r.get("kind") == "task"]
apps = []
for r in tasks:
name = re.sub(r"^Start\s+", "", str(r.get("name", "")), flags=re.I).strip()
exe = str(r.get("exe", "")).strip()
if not name or not exe:
continue
line = " @{ Name = %s; Exe = (Find-Exe %s); Args = %s" % (ps_quote(name, "task name"), ps_quote(exe, "program file"), ps_quote(r.get("args", ""), "arguments") if r.get("args") else "''")
if r.get("at_logon"):
line += "; AtLogon = $true"
apps.append(line + " }")
services = [r for r in host.get("runs") or [] if r.get("kind") == "service"]
values = dict(site_values(cfg), PC_NAME=host.get("name") or alias, APPS=",\n".join(apps) or " # no programs listed for this PC yet",
PROGRAMS=", ".join(re.sub(r"^Start\s+", "", r.get("name", ""), flags=re.I) for r in tasks) or "none yet",
SERVICES=", ".join(ps_quote(r.get("name", ""), "service") for r in services))
return fill(template("windows-setup.ps1.template"), values)
def mac_authorize(cfg):
return fill(template("mac-authorize.sh.template"), site_values(cfg))
def mac_dvs_helper(cfg):
macs = [h.get("name", a) for a, h in cfg.get("hosts", {}).items() if any(r.get("kind") == "helper_dvs" for r in h.get("runs") or [])]
return fill(template("mac-dvs-helper.sh.template"), dict(site_values(cfg), DVS_MACS=", ".join(macs) or "the Macs that run Dante Virtual Soundcard"))
def solo_install(cfg, alias):
host = cfg["hosts"][alias]
ssh = host.get("ssh", "user@address")
return ("# From %s, in Terminal (the swift source and the installer travel with the panel's code):\n"
"cd ~/status-panel-install && scp remote-access/solo-mode-agent.swift remote-access/install-solo-mode.sh %s:/tmp/ && ssh %s 'zsh /tmp/install-solo-mode.sh'\n"
"# Then, on %s itself: System Settings > Privacy & Security > Accessibility > allow solo-mode-agent.\n"
% ((cfg.get("site") or {}).get("ingest_name") or "the Ingest Mac", shlex.quote(ssh), shlex.quote(ssh), host.get("name", alias)))
def setup_for(cfg, alias):
"""The checklist for one computer: steps (text + how) and scripts (name + how + text)."""
host = cfg["hosts"][alias]
runs = host.get("runs") or []
kinds = {r.get("kind") for r in runs}
site = cfg.get("site") or {}
ingest = site.get("ingest_name") or "the Ingest Mac"
steps, scripts = [], []
if host.get("self"):
steps = [
("Command Line Tools / python 3.9-3.12", "Terminal: xcode-select --install. The watchdog runs on the Mac's own python3."),
("the watchdog and the settings page are loaded", "Copy the folder to ~/status-panel-install and run install.sh once; both LaunchAgents are written by it."),
("Companion's custom variables and blink clock exist", "In Companion: Variables > Custom: %s; a trigger every second that adds 1 to flash_tick (Companion only allows this in its own UI)."
% ", ".join((cfg.get("variables") or {}).values())),
("the login key pair exists", "install.sh makes ~/.ssh/id_status_panel; the other computers get its public half through the scripts below."),
("the Netgear login is in the keychain", "Terminal: security add-generic-password -s netgear-status-panel -a <user> -w (it asks for the password; only needed with Dante switches)."),
("the Spotify permission was allowed once", "Hold the SPOTIFY key once while sitting at this Mac and click Allow on the Automation prompt."),
("the DVS helper and its sudo rule", "Run the DVS helper script below on this Mac too (as root, through ARD or sudo)."),
("Google Drive is signed in (saved switcher files)", "Finder: Google Drive is mounted and the switcher folder on Site setup > ATEM exists."),
]
if "helper_dvs" in kinds:
scripts.append({"name": "DVS helper (run as root)", "how": "Apple Remote Desktop > Send UNIX Command as root, or sudo zsh in Terminal here.", "text": mac_dvs_helper(cfg)})
elif host.get("os") == "windows":
steps = [
("the SSH service runs and starts with Windows", "The set-up script below installs and starts OpenSSH Server."),
("the firewall lets this Mac in", "The script opens port 22 for %s's addresses only." % ingest),
("the panel's login key is accepted", "The script writes %s's key into the administrators' and the user's authorized_keys." % ingest),
("a \\StatusPanel\\ task per program", "The script creates one scheduled task per program listed under 'What runs here' (Make a PC program startable adds one)."),
]
if "service" in kinds:
steps.append(("the services restart by themselves", "The script sets each listed service to restart when it stops."))
steps += [
("wake-on-LAN is on in Windows and in the BIOS", "The script turns it on for the wired adapter; the BIOS setting (Wake on LAN / Power On By PCI-E) is yours to set once."),
("the network profile is Public where rtpMIDI runs", "Settings > Network > the adapter > Public (rtpMIDI's Bonjour rules only allow Public)."),
("Wi-Fi power saving is off", "Device Manager > the Wi-Fi adapter > Power Management > untick 'allow the computer to turn off this device'; Ethernet is better."),
]
try:
text = windows_setup(cfg, alias)
except ValueError as e:
text = "# cannot generate: %s" % e
scripts.append({"name": "Set-up script (PowerShell, as administrator)", "how": "Save it as windows-setup.ps1, run PowerShell as administrator, Set-ExecutionPolicy RemoteSigned -Scope CurrentUser, then run the file. Paste the RESULT block below.", "text": text})
else:
steps = [
("Remote Login is on", "System Settings > General > Sharing > Remote Login on, for the user that is logged in."),
("the panel's login key is accepted", "Paste the authorise line below through Apple Remote Desktop (Send UNIX Command as root) or run it in Terminal on that Mac."),
("python3 is present (for MIDI reads)", "Terminal on that Mac: python3 --version (Command Line Tools install it)."),
]
if "helper_dvs" in kinds:
steps.append(("the DVS helper and its sudo rule", "Paste the DVS helper script below as root (ARD)."))
if "midi_session" in kinds:
steps.append(("Audio MIDI Setup sessions named as the keys expect", "Audio MIDI Setup > Window > Show MIDI Studio > Network: the session names on this computer's card."))
if "helper_solo" in kinds:
steps.append(("the Pro Tools helper runs and Accessibility is allowed", "Run the solo-mode install below from %s, then allow solo-mode-agent under Accessibility on that Mac." % ingest))
scripts.append({"name": "Authorise %s's login (run as root)" % ingest, "how": "ARD > Send UNIX Command > as root, or Terminal there with sudo.", "text": mac_authorize(cfg)})
if "helper_dvs" in kinds:
scripts.append({"name": "DVS helper (run as root)", "how": "ARD > Send UNIX Command > as root.", "text": mac_dvs_helper(cfg)})
if "helper_solo" in kinds:
scripts.append({"name": "Pro Tools solo-mode helper", "how": "Two lines from %s's Terminal; then one click under Accessibility on that Mac." % ingest, "text": solo_install(cfg, alias)})
return {"alias": alias, "name": host.get("name", alias), "steps": [{"text": t, "how": h} for t, h in steps], "scripts": scripts}
def setup_all(cfg):
return [setup_for(cfg, a) for a in cfg.get("hosts", {})]
+120
View File
@@ -20,18 +20,23 @@ import logging.handlers
import os
import re
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
import io
import plistlib
from settings import PARENT, HERE # noqa: F401
import schema
import status_panel as sp
from settings import apply as applying
from settings import builders
from settings import scripts
from settings import keyguide, probes
log = logging.getLogger("status-settings")
@@ -645,6 +650,28 @@ class Handler(http.server.BaseHTTPRequestHandler):
if path == "/api/live/midi":
snap = site.read_json("snapshot.json", {}) or {}
return self.send(200, {"local": snap.get("midi"), "remote": snap.get("remote_midi")})
if path == "/api/setup":
return self.send(200, {"computers": scripts.setup_all(site.load())})
if path == "/api/program/staged":
return self.send(200, self.staged())
if path == "/api/site/bundle":
cfg = site.load()
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("config.json", applying.config_text(cfg))
for name in ("atem-reference.xml", "nav_locations.json"):
fp = os.path.join(site.data_dir, name)
if os.path.exists(fp):
z.write(fp, name)
z.writestr("README.txt", "Site bundle of %s, exported %s from the settings page.\nDrop it in the repo as sites/%s/ or feed it to "
"'New campus from a bundle' on another campus's page.\n" % ((cfg.get("site") or {}).get("name", "?"), time.strftime("%Y-%m-%d %H:%M"), (cfg.get("site") or {}).get("id", "site")))
data = buf.getvalue()
self.send_response(200)
self.send_header("Content-Type", "application/zip")
self.send_header("Content-Disposition", "attachment; filename=\"site-%s-%s.zip\"" % ((cfg.get("site") or {}).get("id", "site"), time.strftime("%Y%m%d")))
self.send_header("Content-Length", str(len(data)))
self.end_headers()
return self.wfile.write(data)
if path == "/api/builders":
return self.send(200, {"fixes": builders.template_list(site.cfg), "checks": builders.check_template_list()})
if path == "/api/facts":
@@ -801,6 +828,32 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(200 if ok else 502, {"ok": ok, "sent": text})
if path == "/api/atem-reference":
return self.send(200, self.take_atem(raw))
if path == "/api/program/install":
live, reason = site.applier.live()
if live:
return self.send(409, {"error": "live now (%s): the program is not updated during a service" % reason})
return self.send(200, self.install_staged(who, keep_config=body.get("keep_config", True)))
if path == "/api/site/import":
try:
new_cfg = self.bundle_config(raw)
except (ValueError, KeyError, zipfile.BadZipFile) as e:
return self.send(400, {"error": "not a site bundle or settings file: %s" % e})
cfg = site.load()
mine = cfg.get("site") or {}
src = (new_cfg.get("site") or {}).get("name") or "the other campus"
new_cfg["site"] = dict(new_cfg.get("site") or {}, **{k: mine[k] for k in ("id", "name", "ingest_name", "ingest_hostname", "ingest_ips", "editing_code", "settings_port") if k in mine})
flag = "copied from %s - check it" % src
for alias, h in (new_cfg.get("hosts") or {}).items():
if h.get("self"):
for k in ("ssh", "mac", "wake_broadcast"):
h.pop(k, None)
h["notes"] = (flag + (". " + h["notes"] if h.get("notes") else ""))
for f in (new_cfg.get("fixes") or {}).values():
f["label"] = f.get("label", "")
errors, warnings = schema.validate(new_cfg)
draft, _ = site.draft.update(new_cfg, who, site.cfg_sha)
log.info("%s started a new campus from a bundle of %s", who, src)
return self.send(200, {"draft": self.masked_draft(draft), "from": src, "errors": errors, "warnings": warnings})
if path == "/api/atem-reference/pick-newest":
folder = os.path.expanduser((site.cfg.get("atem") or {}).get("source_folder") or "")
files = sorted(glob.glob(os.path.join(folder, "*.xml")), key=os.path.getmtime) if folder and os.path.isdir(folder) else []
@@ -825,6 +878,73 @@ class Handler(http.server.BaseHTTPRequestHandler):
log.exception("%s %s", self.command, path)
return self.send(500, {"error": str(e)})
def staged(self):
"""The staged folder (~/status-panel-install) against the running code."""
src = os.path.expanduser("~/status-panel-install")
out = {"folder": src, "present": os.path.isfile(os.path.join(src, "install.sh")), "staged_version": None, "staged_when": None,
"running_version": (self.site.read_json("started.json", {}) or {}).get("version"), "job": None, "log": []}
sp_path = os.path.join(src, "status_panel.py")
if os.path.isfile(sp_path):
m = re.search(r'^VERSION\s*=\s*"([^"]+)"', open(sp_path, encoding="utf-8").read(), re.M)
out["staged_version"] = m.group(1) if m else "?"
out["staged_when"] = os.path.getmtime(sp_path)
logp = os.path.expanduser("~/Library/Logs/status-install.log")
if os.path.exists(logp):
try:
out["log"] = open(logp, encoding="utf-8", errors="replace").read().splitlines()[-25:]
out["log_when"] = os.path.getmtime(logp)
except OSError:
pass
try:
r = subprocess.run(["/bin/launchctl", "print", "gui/%d/org.waterschurch.status-install" % os.getuid()], capture_output=True, text=True, timeout=5)
out["job"] = "running" if "state = running" in r.stdout else ("loaded" if r.returncode == 0 else None)
except (OSError, subprocess.TimeoutExpired):
pass
return out
def install_staged(self, who, keep_config=True):
"""Run install.sh from the staged folder as a one-shot launchd job (it survives this agent's own restart)."""
src = os.path.expanduser("~/status-panel-install")
if not os.path.isfile(os.path.join(src, "install.sh")):
return {"ok": False, "error": "nothing is staged in %s (copy the folder there with scp first)" % src}
label = "org.waterschurch.status-install"
plist = os.path.expanduser("~/Library/LaunchAgents/%s.plist" % label)
args = ["/bin/zsh", os.path.join(src, "install.sh")] + (["--keep-config"] if keep_config else [])
with open(plist, "wb") as f:
plistlib.dump({"Label": label, "ProgramArguments": args, "RunAtLoad": True, "AbandonProcessGroup": True,
"StandardOutPath": os.path.expanduser("~/Library/Logs/status-install.log"),
"StandardErrorPath": os.path.expanduser("~/Library/Logs/status-install.log"),
"EnvironmentVariables": {"HOME": os.path.expanduser("~"), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}}, f)
domain = "gui/%d" % os.getuid()
subprocess.run(["/bin/launchctl", "bootout", "%s/%s" % (domain, label)], capture_output=True, text=True, timeout=10)
try:
open(os.path.expanduser("~/Library/Logs/status-install.log"), "a").write("\n==== %s install started by %s from the settings page ====\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), who))
except OSError:
pass
r = subprocess.run(["/bin/launchctl", "bootstrap", domain, plist], capture_output=True, text=True, timeout=10)
log.info("%s started the install job (%s): rc=%s %s", who, " ".join(args), r.returncode, (r.stderr or r.stdout).strip())
if r.returncode != 0:
return {"ok": False, "error": "launchctl: %s" % (r.stderr or r.stdout).strip()[-200:]}
return {"ok": True, "detail": "install.sh is running; this page comes back in about 20 seconds"}
def bundle_config(self, raw):
"""config.json out of a site bundle (zip) or a bare settings file."""
if raw[:2] == b"PK":
with zipfile.ZipFile(io.BytesIO(raw)) as z:
names = [n for n in z.namelist() if n.endswith("config.json")]
if not names:
raise ValueError("no config.json in the bundle")
cfg = json.loads(z.read(names[0]).decode("utf-8"))
xml = [n for n in z.namelist() if n.endswith("atem-reference.xml")]
if xml:
with open(os.path.join(self.site.data_dir, "draft-atem.xml"), "wb") as f:
f.write(z.read(xml[0]))
else:
cfg = json.loads(raw.decode("utf-8"))
if not isinstance(cfg, dict) or "tiles" not in cfg:
raise ValueError("no keys in it")
return cfg
def masked_draft(self, draft):
return dict(draft, config=schema.mask_secrets(draft["config"]))
+199 -7
View File
@@ -229,7 +229,8 @@
<ul class="problems">${prob}</ul>${needs}${failed}${live}
<div class="cols">
<div><h2>Live deck <span class="muted small">${o.deck ? "as the Stream Deck looks now · click a key" : "(Companion is not answering)"}</span></h2>${deckHtml(o.deck, cfg(), "status")}
<div class="row" style="margin-top:10px"><button disabled title="arrives with step 4">Change the playlist</button><button disabled title="arrives with step 4">Add or remove a HyperDeck</button><button disabled title="arrives with step 4">Add a MIDI link</button></div></div>
<div class="row" style="margin-top:10px"><button data-job="playlist">Change the playlist</button><button data-job="hyperdecks">Add or remove a HyperDeck</button><button data-job="midi">Add a MIDI link</button>
<select id="morejobs"><option value="">More jobs…</option><option value="computer">Add a computer</option><option value="pcprogram">Make a PC program startable</option></select></div></div>
<div><h2>Computers</h2><div class="tablewrap"><table>${comps}</table></div></div>
</div>
<div class="card">
@@ -251,8 +252,154 @@
const stop = main.querySelector("#stopwd"); if (stop) stop.onclick = () => confirmBox("Stop the watchdog? The deck goes purple and every alarm is off until it is started again.", "Stop", async () => { try { const r = await post("/api/watchdog/stop", { word: "STOP" }); alertBox(r.ok ? "Stopped." : "Not stopped: " + r.detail); } catch (e) { alertBox(e.message); } }, "STOP");
main.querySelector("#alarms").onclick = async () => { try { await post("/api/command", { text: o.service_on === false ? "service on" : "service off" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } };
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job);
const mj = main.querySelector("#morejobs"); if (mj) mj.onchange = () => { const j = mj.value; mj.value = ""; if (j) job(j); };
if (!quiet) window.scrollTo(0, 0);
}
// ---- Jobs: the changes people actually make, one dialog each --------------------
const slug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
const tagOf = (t) => t.toUpperCase().replace(/[^A-Z0-9 ]+/g, " ").trim().slice(0, 10).trim();
async function job(which) {
if (!editing()) return alertBox("Switch Editing on first (top right).");
clearInterval(timer); timer = null;
if (which === "playlist") return jobPlaylist();
if (which === "hyperdecks") return jobHyperdecks();
if (which === "midi") return jobMidi();
if (which === "computer") return jobComputer();
if (which === "pcprogram") return jobPcProgram();
}
function jobPlaylist() {
const sp = cfg().spotify || {};
dialog(`<h3>Change the playlist</h3><p class="help">Paste the playlist link from Spotify (Share › Copy link). The SPOTIFY key, the play fix and the key guide follow it.</p>
<input id="jlink" placeholder="https://open.spotify.com/playlist/..." value="${esc(sp.playlist || "")}"><p id="jinfo" class="help">Now: ${esc(sp.name || "?")}</p>
<div class="row"><button class="primary" id="dok">Look it up and use it</button><button id="dcancel">Cancel</button></div>`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = async () => {
const info = b.querySelector("#jinfo"); info.textContent = "Looking…";
try {
const r = await api("/api/live/spotify?url=" + encodeURIComponent(b.querySelector("#jlink").value));
if (!r.ok) { info.textContent = r.error; return; }
if (!ensureDraft()) return;
const c = state.draft.config; c.spotify = Object.assign({}, c.spotify, { playlist: r.uri }); if (r.name) c.spotify.name = r.name;
await saveDraft();
for (const [id, f] of Object.entries(c.fixes)) if (f.kind === "spotify") { const bb = await post("/api/build", { fix: f }); if (bb.ok && bb.label) f.label = bb.label; }
await saveDraft(); closeDialog(); location.hash = "#review";
} catch (e) { info.textContent = e.message; }
};
});
}
async function jobHyperdecks() {
let cc;
try { cc = await api("/api/companion"); } catch (e) { return alertBox("Companion is not answering right now (" + e.message + "); try again in a moment."); }
const c = cfg();
const decks = (cc.connections || []).filter((r) => r.module === "bmd-hyperdeck");
const keyFor = (conn) => c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn));
const rows = decks.map((d) => { const k = keyFor(d.label); const chk = k && k.checks.find((ch) => ch.conn === d.label); return `<tr><td><label class="inline"><input type="checkbox" data-deck="${esc(d.label)}" ${k ? "checked" : ""}> ${esc(d.label)}</label></td><td>${k ? esc(k.label) + (k.pos ? ` (row ${k.pos[0]}, col ${k.pos[1]})` : "") : "<span class='muted'>no key</span>"}</td><td><label class="inline"><input type="checkbox" data-nodisk="${esc(d.label)}" ${chk && chk.optional ? "checked" : ""}> may have no disk</label></td></tr>`; }).join("");
if (!decks.length) return alertBox("Companion has no HyperDeck connections (module bmd-hyperdeck). Add the connection in Companion first.");
dialog(`<h3>Add or remove a HyperDeck</h3><p class="help">Tick the decks in use. A ticked deck gets an HD key in the REC group; an unticked one loses its key (its cell stays empty; drag another key there if you like). "May have no disk" means NO DISK is a warning, not an alarm.</p>
<div class="tablewrap"><table><tr><th>Companion connection</th><th>key</th><th></th></tr>${rows}</table></div>
<div class="row"><button class="primary" id="dok">Use these decks</button><button id="dcancel">Cancel</button></div>`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = () => {
if (!ensureDraft()) return;
const d = state.draft.config;
const rec = d.tiles.find((t) => (t.members || []).some((m) => d.tiles.some((x) => x.id === m && (x.checks || []).some((ch) => ch.type === "hyperdeck")))) || d.tiles.find((t) => t.id === "rec");
const fixId = Object.keys(d.fixes).find((id) => d.fixes[id].kind === "hyperdeck");
for (const cb of b.querySelectorAll("[data-deck]")) {
const conn = cb.dataset.deck, nodisk = b.querySelector(`[data-nodisk="${CSS.escape(conn)}"]`).checked;
const existing = d.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn));
if (cb.checked && existing) { const ch = existing.checks.find((x) => x.conn === conn); if (nodisk) ch.optional = true; else delete ch.optional; }
else if (cb.checked) {
const n = (conn.match(/(\d+)\s*$/) || [])[1]; let id = n ? "hd" + n : slug(conn), k = 2; while (d.tiles.some((t) => t.id === id)) id = (n ? "hd" + n : slug(conn)) + "_" + k++;
const t = { id, label: n ? "HD" + n : conn.toUpperCase().slice(0, 8), hold_always: fixId || undefined, checks: [Object.assign({ type: "hyperdeck", conn }, nodisk ? { optional: true } : {}, fixId ? { fix: { conn: fixId } } : {})] };
const pos = firstEmpty(d, "status"); if (pos) t.pos = pos;
d.tiles.push(t); if (rec) rec.members.push(id);
} else if (existing) { d.tiles.splice(d.tiles.indexOf(existing), 1); for (const t of d.tiles) if (t.members) t.members = t.members.filter((m) => m !== existing.id); }
}
saveDraft(); closeDialog(); location.hash = "#keys";
};
});
}
async function jobMidi() {
const live = await api("/api/live/midi").catch(() => ({ local: {}, remote: {} }));
const c = cfg();
const macs = Object.entries(c.hosts).filter(([, h]) => h.self || (h.os || "mac") === "mac");
const midiKey = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "midi_peer"));
const fixId = Object.keys(c.fixes).find((id) => c.fixes[id].kind === "midi_connect");
const sessionsOf = (alias) => { const h = c.hosts[alias]; const src = h.self ? (live.local || {}) : ((live.remote || {})[alias] || {}); return Object.entries(src).map(([name, v]) => [name, (v.peers || []).map((p) => p.name)]); };
dialog(`<h3>Add a MIDI link</h3><p class="help">A network MIDI session on one of the Macs, and the peer that must stay in it. Pick from what the Macs report now, or type the names if that Mac is off.</p>
<div class="field"><label>The Mac that holds the session</label><select id="jhost">${macs.map(([a, h]) => `<option value="${esc(a)}">${esc(h.name || a)}${h.self ? " (this Mac)" : ""}</option>`).join("")}</select></div>
<div class="field"><label>Session</label><input id="jsession" list="jsessions" placeholder="as Audio MIDI Setup names it"><datalist id="jsessions"></datalist></div>
<div class="field"><label>Peer</label><input id="jpeer" list="jpeers" placeholder="the other end's name"><datalist id="jpeers"></datalist></div>
<div class="field"><label>Key text when it is lost (short)</label><input id="jtag" placeholder="LIGHT PC" maxlength="10"></div>
<div class="field"><label>Address if the peer must be reconnected by address (optional)</label><input id="jaddr" placeholder="10.1.10.206:5004"></div>
<div class="field"><label>Which key</label><select id="jkey">${midiKey ? `<option value="${esc(midiKey.id)}">${esc(midiKey.label)} (the MIDI key)</option>` : ""}<option value="__new">a new key</option></select></div>
<div class="row"><button class="primary" id="dok">Add the link</button><button id="dcancel">Cancel</button></div>`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
const fill = () => { const ss = sessionsOf(b.querySelector("#jhost").value); b.querySelector("#jsessions").innerHTML = ss.map(([n]) => `<option value="${esc(n)}">`).join(""); const cur = ss.find(([n]) => n === b.querySelector("#jsession").value) || ss[0]; if (cur && !b.querySelector("#jsession").value) b.querySelector("#jsession").value = cur[0]; b.querySelector("#jpeers").innerHTML = ((cur || [])[1] || []).map((n) => `<option value="${esc(n)}">`).join(""); };
b.querySelector("#jhost").onchange = () => { b.querySelector("#jsession").value = ""; fill(); };
b.querySelector("#jsession").oninput = fill;
b.querySelector("#jpeer").oninput = () => { if (!b.querySelector("#jtag").value) b.querySelector("#jtag").placeholder = tagOf(b.querySelector("#jpeer").value) || "LIGHT PC"; };
fill();
b.querySelector("#dok").onclick = () => {
const host = b.querySelector("#jhost").value, session = b.querySelector("#jsession").value.trim(), peer = b.querySelector("#jpeer").value.trim();
const tag = b.querySelector("#jtag").value.trim() || tagOf(peer), addr = b.querySelector("#jaddr").value.trim(), keyId = b.querySelector("#jkey").value;
if (!session || !peer) return alertBox("A session and a peer, please.");
if (!ensureDraft()) return;
const d = state.draft.config;
const chk = Object.assign({ type: "midi_peer", session, peer, tag }, d.hosts[host].self ? {} : { host }, fixId ? { fix: fixId } : {}, addr ? { address: addr } : {});
let target = d.tiles.find((t) => t.id === keyId);
if (!target) { let id = "midi", n = 2; while (d.tiles.some((t) => t.id === id)) id = "midi_" + n++; target = { id, label: "MIDI", checks: [] }; const pos = firstEmpty(d, "status"); if (pos) target.pos = pos; d.tiles.push(target); }
target.checks = target.checks || []; target.checks.push(chk);
saveDraft(); closeDialog(); location.hash = "#keys/" + target.id;
};
});
}
function jobComputer() {
dialog(`<h3>Add a computer</h3><p class="help">It appears on Computers with what runs on it; the Setup tab then shows what to do on it (login key, Remote Login). Fixes and checks for it come after.</p>
<div class="field"><label>Name, as people say it</label><input id="jname" placeholder="Lobby PC"></div>
<div class="field"><label>Short name (letters, digits, _)</label><input id="jalias" placeholder="lobby"></div>
<div class="field"><label>Mac or Windows</label><select id="jos"><option value="mac">Mac</option><option value="windows">Windows</option></select></div>
<div class="field"><label>Log-in address</label><input id="jssh" placeholder="user@10.1.x.x"></div>
<div class="field"><label>What it does for the panel</label><input id="jrole" placeholder="runs the lobby screen"></div>
<div class="row"><button class="primary" id="dok">Add it</button><button id="dcancel">Cancel</button></div>`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#jname").oninput = () => { b.querySelector("#jalias").value = slug(b.querySelector("#jname").value); };
b.querySelector("#dok").onclick = () => {
const name = b.querySelector("#jname").value.trim(), alias = b.querySelector("#jalias").value.trim(), os = b.querySelector("#jos").value, ssh = b.querySelector("#jssh").value.trim(), role = b.querySelector("#jrole").value.trim();
if (!name || !/^[a-z0-9_]+$/.test(alias)) return alertBox("A name, and a short name of lowercase letters, digits and _.");
if (!/^[^@\s]+@[^@\s]+$/.test(ssh)) return alertBox("The log-in address is user@address.");
if (cfg().hosts[alias]) return alertBox("That short name is taken.");
if (!ensureDraft()) return;
state.draft.config.hosts[alias] = { name, os, ssh, role, runs: [{ kind: "sshd" }] };
saveDraft(); closeDialog(); location.hash = "#computers/" + alias;
};
});
}
function jobPcProgram() {
const pcs = Object.entries(cfg().hosts).filter(([, h]) => h.os === "windows");
if (!pcs.length) return alertBox("No Windows PC is listed yet: Add a computer first.");
dialog(`<h3>Make a PC program startable</h3><p class="help">The panel starts programs on a PC through a scheduled task named Start &lt;program&gt; under \\StatusPanel\\ (the Setup tab shows how to create it). This adds the task to the PC's list and a "start it if it is not running" fix.</p>
<div class="field"><label>PC</label><select id="jhost">${pcs.map(([a, h]) => `<option value="${esc(a)}">${esc(h.name || a)}</option>`).join("")}</select></div>
<div class="field"><label>Program, as people say it</label><input id="jname" placeholder="Vista"></div>
<div class="field"><label>Program file, as Task Manager shows it</label><input id="jexe" placeholder="Vista.exe"></div>
<div class="row"><button class="primary" id="dok">Add it</button><button id="dcancel">Cancel</button></div>`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = async () => {
const host = b.querySelector("#jhost").value, name = b.querySelector("#jname").value.trim(), exe = b.querySelector("#jexe").value.trim();
if (!name || !exe) return;
if (!ensureDraft()) return;
const d = state.draft.config, task = "Start " + name;
const h = d.hosts[host]; h.runs = h.runs || []; if (!h.runs.some((r) => r.kind === "task" && r.name === task)) h.runs.push({ kind: "task", name: task, exe });
let id = slug(name), n = 2; while (d.fixes[id]) id = slug(name) + "_" + n++;
const r = await post("/api/build", { fix: { template: "win_start", params: { host, task, exe }, short: name.toUpperCase().slice(0, 12) } });
if (!r.ok) return alertBox(r.error);
d.fixes[id] = Object.assign(r.fix, { label: r.label || ("start " + name + " if it is not running") });
saveDraft(); closeDialog(); location.hash = "#fixes/" + id;
};
});
}
function canRunNow(fixId) {
const f = cfg().fixes[fixId]; if (!f) return false;
if (["reconnect", "hyperdeck", "midi_connect", "spotify"].includes(f.kind)) return true;
@@ -487,7 +634,7 @@
<button id="probe" ${m.probing ? "disabled" : ""}>${m.probing ? "Looking…" : "Check computers now"}</button></div>`;
let body = "";
if (compTab === "gear") body = await gearHtml();
else if (compTab === "setup") body = setupHtml(m);
else if (compTab === "setup") body = setupHtml(m, (await api("/api/setup").catch(() => ({ computers: [] }))).computers);
else {
const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `<tr class="click${selected === c.alias ? " sel" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}${c.self ? ' <span class="muted small">(this Mac)</span>' : ""}</td><td class="small">${esc(c.role)}</td><td class="small">${(c.keys || []).map((k) => `<span class="pill">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</span>`).join(" ")}</td><td class="muted small">${esc(c.reason)}${c.looked ? `<br>looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}</td></tr>`).join("");
body = `<div class="tablewrap"><table><tr><th>status</th><th>computer</th><th>what it does for the panel</th><th>keys that turn red if it dies</th><th></th></tr>${rows}</table></div>`;
@@ -495,6 +642,8 @@
if (c) body += computerCard(c);
}
main.innerHTML = `<h1>Computers</h1><p class="what">What runs where. One row per computer; click it for everything that runs there, with a light each.</p>${tabs}${body}`;
for (const b of main.querySelectorAll("[data-copy]")) b.onclick = async () => { const ta = document.getElementById(b.dataset.copy); try { await navigator.clipboard.writeText(ta.value); b.textContent = "Copied"; } catch (e) { ta.select(); document.execCommand("copy"); b.textContent = "Copied"; } };
const rm = main.querySelector("#removehost"); if (rm) rm.onclick = () => removeComputer(selected);
const pb = document.getElementById("probe");
if (pb) pb.onclick = async () => { pb.disabled = true; pb.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => computers(rest, true).then(() => wireInputs(main)), 4000); };
for (const b of main.querySelectorAll("[data-gear]")) b.onclick = async () => { const row = b.closest("tr"); try { await post("/api/gear", { name: b.dataset.gear, location: row.querySelector("[data-loc]").value, notes: row.querySelector("[data-notes]").value }); const d = await api("/api/draft"); state.draft = d.draft; badge(); b.textContent = "Saved"; } catch (e) { alertBox(e.message); } };
@@ -508,23 +657,48 @@
const base = `hosts.${c.alias}.`;
const edit = editing() ? `<details open><summary>Change this computer</summary>${["name", "role", "notes", "os"].concat(c.self ? [] : ["ssh", "mac", "wake_broadcast", "pingable"]).map((f) => inputHtml(base + f, F("hosts.*." + f), getPath(base + f))).join("")}
<div class="field"><label>What runs here (one per line, as {"kind": ..., "name": ...})</label><textarea id="runs" rows="${Math.max(4, (getPath(base + "runs") || []).length + 1)}" class="mono">${esc((getPath(base + "runs") || []).map((r) => JSON.stringify(r)).join("\n"))}</textarea><div class="help">Kinds: sshd, app (process), launchagent (label), daemon (process), task (name, exe), service (name), web (url), helper_dvs, helper_solo (label), midi_session (name, peer_of), artnet (source), folder (path), disabled (label). "watched": false for things the panel only lists.</div></div></details>` : "";
const remove = editing() && !c.self ? `<p style="margin-top:12px"><button class="small danger" id="removehost">Remove this computer</button></p>` : "";
return `<div class="card" id="card"><h2>${esc(c.name)} ${wordOf(c.word)}</h2><p class="muted">${esc(c.role)}${c.ssh ? ` · logs in as <span class="mono">${esc(c.ssh)}</span>` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "<b>no answer</b>"}` : ""}${c.error ? ` · <b>${esc(c.error)}</b>` : ""}</p>
${c.notes ? `<p class="notice grey">${esc(c.notes)}</p>` : ""}
<h3>What runs here</h3><div class="tablewrap"><table>${items}</table></div>
${done ? `<ul class="small" style="list-style:none;padding:0">${done}</ul>` : ""}
<details><summary>Everything found on this computer</summary>${foundHtml}</details>
${c.self ? `<details><summary>This Mac's set-up lights</summary><dl class="kv small">${Object.entries(c.setup || {}).map(([k, v]) => `<dt>${esc(k.replace(/_/g, " "))}</dt><dd>${typeof v === "boolean" ? lightDot(v ? "ok" : "problem") + (v ? "OK" : "Problem") : esc(JSON.stringify(v))}</dd>`).join("")}</dl></details>` : ""}
${edit}</div>`;
${edit}${remove}</div>`;
}
function removeComputer(alias) {
const c = cfg(), h = c.hosts[alias]; if (!h) return;
const fixes = Object.entries(c.fixes).filter(([, f]) => f.host === alias || (f.params || {}).host === alias).map(([id]) => id);
const checks = []; for (const t of c.tiles) (t.checks || []).forEach((ch, j) => { if (ch.host === alias || (ch.params || {}).host === alias) checks.push([t.id, j, t.label]); });
const usesFix = []; for (const t of c.tiles) { for (const ch of t.checks || []) for (const f of (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix])) if (fixes.includes(f)) usesFix.push(t.label); if (fixes.includes(t.hold_always)) usesFix.push(t.label); }
const rkeys = (c.restart_page ? c.restart_page.keys : []).filter((k) => fixes.includes(k.fix)).map((k) => k.label);
const what = [`the computer ${h.name || alias}`].concat(fixes.length ? [`${fixes.length} fix${fixes.length > 1 ? "es" : ""} that ran on it (${fixes.join(", ")})`] : [], checks.length ? [`${checks.length} check${checks.length > 1 ? "s" : ""} on the keys ${[...new Set(checks.map((x) => x[2]))].join(", ")}`] : [], rkeys.length ? [`the restart keys ${rkeys.join(", ")}`] : []);
confirmBox(`Remove ${what.join(", ")}? The keys stay; a key left with nothing to check will say OK.`, "Remove", () => {
if (!ensureDraft()) return;
const d = state.draft.config;
delete d.hosts[alias];
for (const id of fixes) { delete d.fixes[id]; if (d.auto_fix && d.auto_fix.fixes) d.auto_fix.fixes = d.auto_fix.fixes.filter((x) => x !== id); }
for (const t of d.tiles) {
t.checks = (t.checks || []).filter((ch) => !(ch.host === alias || (ch.params || {}).host === alias));
for (const ch of t.checks) { if (typeof ch.fix === "string" && fixes.includes(ch.fix)) delete ch.fix; else if (ch.fix && typeof ch.fix === "object") for (const k of Object.keys(ch.fix)) if (fixes.includes(ch.fix[k])) delete ch.fix[k]; }
if (fixes.includes(t.hold_always)) delete t.hold_always;
if (!t.checks.length && !t.members) delete t.checks;
}
if (d.restart_page) d.restart_page.keys = d.restart_page.keys.filter((k) => !fixes.includes(k.fix));
saveDraft(); location.hash = "#computers";
});
}
async function gearHtml() {
const g = await api("/api/gear");
return `<p class="small muted">Devices that are not computers, from the keys that watch them.${editing() ? " Type where each one lives and press Save." : ""}</p><div class="tablewrap"><table><tr><th>light</th><th>device</th><th>kind</th><th>address</th><th>where / notes</th><th>watched by</th></tr>
${g.map((r) => `<tr><td>${lightDot(r.light)}${esc(r.light === "ok" ? "OK" : r.light === "problem" ? "Problem" : "–")}</td><td>${esc(r.name)}</td><td>${esc(r.kind)}</td><td class="mono small">${esc(r.address || "")}${r.detail ? `<br><span class="muted">${esc(r.detail)}</span>` : ""}</td><td class="small">${editing() ? `<input data-loc value="${esc(r.location || "")}" placeholder="where it is"> <input data-notes value="${esc(r.notes || "")}" placeholder="notes"> <button class="small" data-gear="${esc(r.name)}">Save</button>` : `${esc(r.location || "")} ${esc(r.notes || "")}`} <span class="muted">${esc(r.reason || "")}</span></td><td class="small">${(r.watched_by || []).map((k) => `<a href="#keys/${esc(k)}">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</a>`).join(", ")}</td></tr>`).join("")}</table></div>`;
}
function setupHtml(m) {
function setupHtml(m, generated) {
return m.computers.map((c) => {
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
const steps = (m.setup_steps || {})[kind] || [];
const gen = (generated || []).find((g) => g.alias === c.alias) || { steps: [], scripts: [] };
const steps = gen.steps.length ? gen.steps.map((x) => x.text) : ((m.setup_steps || {})[kind] || []);
const howOf = (t) => (gen.steps.find((x) => x.text === t) || {}).how;
const lightFor = (step) => {
const s = c.setup || {};
const item = (k) => (c.items || []).find((i) => i.kind === k);
@@ -545,7 +719,8 @@
if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown";
return "unknown";
};
return `<div class="card"><h3>${esc(c.name)}</h3><ul style="list-style:none;padding:0;margin:0">${steps.map((s) => { const l = lightFor(s); return `<li>${lightDot(l)}${esc(s)} <span class="muted small">${l === "hand" ? "(done by hand)" : l === "unknown" ? "– can't see from here" : ""}</span></li>`; }).join("")}</ul><p class="help">Generated set-up scripts and the [how] steps arrive with step 5.</p></div>`;
const scriptsHtml = gen.scripts.map((sc, i) => `<details class="script"><summary>${esc(sc.name)}</summary><p class="help">${esc(sc.how)}</p><textarea readonly rows="${Math.min(14, sc.text.split("\n").length + 1)}" class="mono" id="sc-${esc(c.alias)}-${i}">${esc(sc.text)}</textarea><div class="row"><button class="small" data-copy="sc-${esc(c.alias)}-${i}">Copy</button><a class="small" download="${esc(sc.name.replace(/[^A-Za-z0-9]+/g, "-").toLowerCase())}${/PowerShell/.test(sc.name) ? ".ps1" : ".sh"}" href="data:text/plain;charset=utf-8,${encodeURIComponent(sc.text)}">Save as a file</a></div></details>`).join("");
return `<div class="card"><h3>${esc(c.name)}</h3><ul style="list-style:none;padding:0;margin:0">${steps.map((s) => { const l = lightFor(s); const how = howOf(s); return `<li>${lightDot(l)}${esc(s)} <span class="muted small">${l === "hand" ? "(done by hand)" : l === "unknown" ? "– can't see from here" : ""}</span>${how ? `<details class="how"><summary class="small">how</summary><p class="help">${esc(how)}</p></details>` : ""}</li>`; }).join("")}</ul>${scriptsHtml}</div>`;
}).join("");
}
@@ -651,6 +826,7 @@
const fieldHtml = ([path, f]) => inputHtml(path, f, getPath(path));
const plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced);
let body = own.length ? `<div class="card"><h3>Settings</h3>${plain.map(fieldHtml).join("") || "<p class='muted small'>Only numbers here; they sit under Advanced.</p>"}${adv.length ? `<details><summary>Advanced</summary>${adv.map(fieldHtml).join("")}</details>` : ""}</div>` : "";
if (which === "site") body += await programHtml();
if (which === "companion") body = (await companionHtml()) + body;
if (which === "schedules") body = schedulesHtml(c) + body;
if (which === "network-dante") body += await danteHtml(c);
@@ -671,10 +847,23 @@
<div class="card"><h3>Auto-fix</h3>${windowsHtml("auto_fix.windows", c.auto_fix.windows, "When auto-fix may act by itself", "An end earlier than the start runs past midnight.")}<div class="field"><label>Fixes it may run (gentle ones only)</label>${fixes}</div></div>
<div class="card"><h3>Changes wait</h3>${windowsHtml("site.live_services", (c.site || {}).live_services, "Clock windows during which changes to the panel wait", "A recording HyperDeck always counts, whatever the clock says. NA has no clock windows.")}</div>`;
}
async function programHtml() {
const p = await api("/api/program/staged").catch(() => null);
if (!p) return "";
const staged = p.present ? `${esc(p.staged_version || "?")}${p.staged_when ? " (staged " + esc(when(p.staged_when)) + ")" : ""}` : "nothing staged";
const same = p.present && p.staged_version === p.running_version;
return `<div class="card"><h3>The program</h3><dl class="kv"><dt>Running</dt><dd>code ${esc(p.running_version || "?")}</dd><dt>Staged</dt><dd>${staged}${same ? " <span class='muted'>· the same code</span>" : ""}</dd><dt>Folder</dt><dd class="mono small">${esc(p.folder)}</dd></dl>
<p class="help">Copy the new folder to this Mac with scp as before, then press the button: install.sh runs by itself (the config stays as it is), the watchdog restarts, this page comes back.</p>
<div class="row"><button id="installbtn" ${editing() && p.present ? "" : "disabled"}>Install the staged folder</button>${p.job === "running" ? " <span class='muted'>install.sh is running…</span>" : ""}</div>
${p.log && p.log.length ? `<details><summary>The last install's log${p.log_when ? " (" + esc(when(p.log_when)) + ")" : ""}</summary><pre class="log small">${esc(p.log.join("\n"))}</pre></details>` : ""}</div>
<div class="card"><h3>This campus as a bundle</h3><p class="help">A zip of the settings, the saved switcher file and the page-key record: drop it in the repo as sites/${esc((cfg().site || {}).id || "site")}/, or feed it to another campus's page.</p>
<div class="row"><a class="btn" href="/api/site/bundle" download>Export site bundle</a>${editing() ? `<label class="btn">New campus from a bundle <input type="file" id="bundlefile" accept=".zip,.json" hidden></label>` : ""}</div>
<p class="help">New campus from a bundle: this campus's name, addresses and editing code stay; every computer from the bundle is flagged "copied from … - check it" until you touch it; keys, checks and fixes come along. Then Review & Apply.</p></div>`;
}
async function companionHtml() {
const cc = await api("/api/companion");
const lights = Object.entries(cc.lights || {}).map(([k, v]) => `<li>${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</li>`).join("");
const conns = (cc.connections || []).map((r) => `<tr><td>${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed")}${esc(r.label)}</td><td class="mono small">${esc(r.module || "")}</td><td class="small">${esc(r.enabled ? r.category || "" : "off")}${r.message ? ` <span class="muted">${esc(r.message)}</span>` : ""}</td><td class="small">${(r.watched_by || []).length ? r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc(k.toUpperCase())}</a>`).join(", ") : '<span class="muted">not watched</span>'}</td></tr>`).join("");
const conns = (cc.connections || []).map((r) => `<tr><td>${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed")}${esc(r.label)}</td><td class="mono small">${esc(r.module || "")}</td><td class="small">${esc(r.enabled ? r.category || "" : "off")}${r.message ? ` <span class="muted">${esc(r.message)}</span>` : ""}</td><td class="small">${(r.watched_by || []).length ? r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc(k.toUpperCase())}</a>`).join(", ") : `<span class="muted">not watched</span>${editing() ? ` <button class="small" data-addconn="${esc(r.label)}">Add a key for it</button>` : ""}`}</td></tr>`).join("");
return `<div class="card"><h3>Companion ${cc.ok ? "OK" : "<b>not answering</b>"}${cc.build ? ` <span class="pill">${esc(cc.build)}</span>` : ""}</h3><p class="small muted">Set up once by the person who installed Companion. You normally only come here to see which connection a key watches.</p><ul style="list-style:none;padding:0">${lights}</ul>
${(cc.missing || []).length ? `<p class="notice">Keys watch connections Companion does not have: ${esc(cc.missing.join(", "))}</p>` : ""}
${cc.page_number_buttons ? `<p class="small">Page-number buttons coloured on ${cc.page_number_buttons.pages_coloured} pages (${esc(cc.page_number_buttons.recorded_in)}).</p>` : ""}
@@ -708,6 +897,7 @@
}
function wireSite() {
const q = (s) => main.querySelector(s);
for (const b of main.querySelectorAll("[data-addconn]")) b.onclick = () => { const conn = b.dataset.addconn; if (!ensureDraft()) return; const d = state.draft.config; let id = slug(conn) || "key", n = 2; while (d.tiles.some((t) => t.id === id)) id = slug(conn) + "_" + n++; const t = { id, label: conn.toUpperCase().replace(/[_-]+/g, " ").slice(0, 10).trim(), checks: [{ type: "conn", conn, fix: "restart-connection" }] }; const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; d.tiles.push(t); saveDraft(); location.hash = "#keys/" + id; };
for (const b of main.querySelectorAll("[data-addwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.addwin; const list = (getPath(path, state.draft.config) || []).slice(); list.push({ days: ["sun"], start: "09:00", end: "12:00" }); setPath(path, list); route(); };
for (const b of main.querySelectorAll("[data-delwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.delwin.replace(/\.\d+$/, ""), i = +b.dataset.delwin.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list.splice(i, 1); setPath(path, list); route(); };
for (const w of main.querySelectorAll("[data-win]")) for (const el of w.querySelectorAll("input")) el.onchange = () => { if (!ensureDraft()) return; const path = w.dataset.win.replace(/\.\d+$/, ""), i = +w.dataset.win.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list[i] = { days: [...w.querySelectorAll("[data-day]")].filter((x) => x.checked).map((x) => x.dataset.day), start: w.querySelector("[data-start]").value.trim(), end: w.querySelector("[data-end]").value.trim() }; setPath(path, list); };
@@ -721,6 +911,8 @@
const look = q("#splook"); if (look) look.onclick = async () => { const info = q("#spinfo"); info.textContent = "Looking…"; try { const r = await api("/api/live/spotify?url=" + encodeURIComponent(q("#splink").value)); if (!r.ok) { info.textContent = r.error; return; } if (!ensureDraft()) return; setPath("spotify.playlist", r.uri); if (r.name) { setPath("spotify.name", r.name); q("#spname").value = r.name; } q("#splink").value = r.uri; info.textContent = r.name ? `${r.name}: ${r.songs} songs. The play fix's words follow the name.` : (r.warning || ""); } catch (e) { info.textContent = e.message; } };
const spname = q("#spname"); if (spname) spname.onchange = () => setPath("spotify.name", spname.value.trim());
const splink = q("#splink"); if (splink) splink.onchange = () => { const m = splink.value.match(/(?:playlist[/:])([A-Za-z0-9]{22})/); if (m) setPath("spotify.playlist", "spotify:playlist:" + m[1]); else if (splink.value.trim()) alertBox("That is not a playlist link or URI."); };
const ib = q("#installbtn"); if (ib) ib.onclick = () => confirmBox("Install the staged folder? The watchdog restarts (the deck goes purple for a few seconds) and this page restarts too.", "Install", async () => { try { const r = await post("/api/program/install", {}); alertBox(r.ok ? r.detail : "Not started: " + r.error); setTimeout(() => location.reload(), 25000); } catch (e) { alertBox(e.message); } });
const bf = q("#bundlefile"); if (bf) bf.onchange = async () => { const f = bf.files[0]; if (!f) return; confirmBox(`Start a new campus from ${f.name}? Your current draft is replaced.`, "Start", async () => { try { const r = await fetch("/api/site/import", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/octet-stream" }, body: await f.arrayBuffer() }).then((x) => x.json()); if (r.error) return alertBox(r.error); state.draft = r.draft; state.atemPending = true; badge(); alertBox(`Copied from ${r.from}. ${r.errors.length ? "Not applicable yet: " + r.errors.slice(0, 3).join("; ") : "Look through Computers, Keys and Fixes, then Review & Apply."}`); location.hash = "#computers"; } catch (e) { alertBox(e.message); } }); };
const af = q("#atemfile"); if (af) af.onchange = async () => { const f = af.files[0]; if (!f) return; try { const r = await fetch("/api/atem-reference", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/xml" }, body: await f.arrayBuffer() }).then((x) => x.json()); showAtem(r); } catch (e) { alertBox(e.message); } };
const an = q("#atemnewest"); if (an) an.onclick = async () => { try { showAtem(await post("/api/atem-reference/pick-newest")); } catch (e) { alertBox(e.message); } };
const ad = q("#atemdiscard"); if (ad) ad.onclick = async () => { try { await post("/api/atem-reference/discard"); state.atemPending = false; badge(); route(); } catch (e) { alertBox(e.message); } };
+3
View File
@@ -95,3 +95,6 @@ tr.sel { background: var(--accent-soft); }
.key[draggable="true"] { cursor: grab; } .key.empty[data-drop] { outline: 1px dashed #555; opacity: .7; } .key.empty.over { outline: 2px solid var(--accent); opacity: 1; }
.tray.over { outline: 2px solid var(--accent); border-radius: 8px; } .tray button[draggable="true"] { cursor: grab; }
input.pagenum { width: 4.5em; padding: 2px 4px; font: inherit; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); }
/* step 5: setup */
a.btn { display: inline-block; border: 1px solid var(--line); background: var(--surface); color: var(--fg); border-radius: 8px; padding: 6px 12px; text-decoration: none; }
details.script textarea { width: 100%; font-size: .8rem; } details.how { display: inline-block; margin: 0 0 0 8px; } details.how summary { display: inline; color: var(--accent); cursor: pointer; }