elsebox.querySelector("#dmsg").textContent=r.locked_for?`Locked for ${Math.ceil(r.locked_for/60)} minutes after too many tries.`:r.no_code?"No editing code is set in the config yet.":!name?"Your first name, please.":`Not the code (${r.tries_left} tries left).`;
if(e.status===409){alertBox(`${e.body.error}: ${(e.body.changed||[]).join(", ")||"the draft"}. The page now shows their version.`);state.draft=e.body.draft;route();}
constfailed=o.failed_last?`<div class="notice grey">The last Apply was put back: ${esc(o.failed_last.why||"")} <a href="#history">History</a></div>`:"";
constlive=isLive?`<div class="notice">Live now: ${esc(o.live.reason)}. Changes to the panel wait.</div>`:"";
<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 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>
<dt>Companion</dt><dd>${o.companion_ok?"OK":"<b>not answering</b>"}${o.pages&&o.pages.status?(o.pages.status.ok?", pages OK":", page "+o.pages.status.page+" is called "+esc(o.pages.status.live||"?")):""}</dd>
<dt>Auto-fix</dt><dd>${esc(o.auto_fix.phrase)}${o.auto_fix.state&&o.auto_fix.state.on?" · <b>may act now</b>":""}${o.auto_fix.state&&o.auto_fix.state.paused?" (waiting out a network blip)":""}</dd>
<dt>Running now</dt><dd>${Object.keys(o.running||{}).length ? Object.values(o.running).map((r) => esc(r.what + " on " + r.key)).join(", ") : "no fix is running"}</dd>
main.querySelector("#restartwd").onclick=()=>confirmBox(w.running?"Restart the watchdog? The deck goes purple for about 5 seconds; a fix that is running finishes first.":"Start the watchdog?",w.running?"Restart":"Start",async()=>{try{constr=awaitpost(w.running?"/api/watchdog/restart":"/api/watchdog/start");alertBox(r.ok?"Done: "+r.detail:"Not done: "+r.detail);}catch(e){alertBox(e.message);}});
conststop=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{constr=awaitpost("/api/watchdog/stop",{word:"STOP"});alertBox(r.ok?"Stopped.":"Not stopped: "+r.detail);}catch(e){alertBox(e.message);}},"STOP");
if(!editing())returnalertBox("Switch Editing on first (top right).");
clearInterval(timer);timer=null;
if(which==="playlist")returnjobPlaylist();
if(which==="hyperdecks")returnjobHyperdecks();
if(which==="midi")returnjobMidi();
if(which==="computer")returnjobComputer();
if(which==="pcprogram")returnjobPcProgram();
}
functionjobPlaylist(){
constsp=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>
constrows=decks.map((d)=>{constk=keyFor(d.label);constchk=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)returnalertBox("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>
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>
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>
if(!pcs.length)returnalertBox("No Windows PC is listed yet: Add a computer first.");
dialog(`<h3>Make a PC program startable</h3><p class="help">The panel starts programs on a PC through a scheduled task named Start <program> under \\StatusPanel\\ (the Setup tab shows how to create it). This adds the task to the PC's list and a "start it if it is not running" fix.</p>
confirmBox(`Run "${f.label||fixId}" now? It runs through the watchdog exactly like a hold on the ${key.toUpperCase()} key.`,"Run it",async()=>{
try{awaitpost("/api/command",{text:`test ${key}${fixId}${which}`});alertBox("Sent. Watch the key, or Logs › Watchdog (TEST).");}catch(e){alertBox(e.message);}
${editing()?`<p class="small row">In Companion the Status page is page <input class="pagenum" data-which="status" type="number" min="1" value="${c.page}">${c.restart_page?` and the Restart page is page <input class="pagenum" data-which="restart" type="number" min="1" value="${c.restart_page.page}">`:""} <label class="inline"><input type="checkbox" id="hasrestart" ${c.restart_page?"checked":""}> this campus has a Restart page</label></p>`:""}`;
letpanel=`<p class="muted">Click a key to see what it watches and what holding it does.${editing()?" With Editing on you can change it here, drag a key to move it, drag it onto the tray to hide it.":""}</p>`;
if(state.draft){try{constv=awaitpost("/api/validate",{});if(v.plan&&v.plan.pages.length)note=`<div class="notice">Layout changed: Apply will re-import the ${v.plan.pages.filter((p)=>p==="status"||p==="restart").map((p)=>p[0].toUpperCase()+p.slice(1)).join(" and ")} page.</div>`;}catch(e){}}
constadders=editing()?(restart?`<button id="newrkey" class="small">+ New restart key</button>`:`<button id="newkey" class="small">+ New key</button>`):"";
main.innerHTML=`<h1>Keys</h1><p class="what">The deck as people hold it. Click a key; with Editing on, change what it watches and what holding it does, drag it to move it.</p>${tabs}${note}
<div class="cols"><div>${restart&&!c.restart_page?"<p class='muted'>This campus has no Restart page.</p>":deckHtml(o.deck,c,restart?"restart":"status",sel,true)}
${!restart?`<p class="small muted tray" id="tray" style="margin-top:8px">Shown inside their group, no key of their own: ${tray||"<span class='muted'>(none)</span>"}${editing()?" <span class='muted'>· drop a key here to hide it</span>":""}</p>`:""}
if(!m){if(allowBlank&&!text.trim())returnnull;alertBox("Row, column: two numbers, like 2, 5.");returnfalse;}
constpos=[+m[1],+m[2]];
if(pos[0]>3||pos[1]>7){alertBox("The deck has rows 0-3 and columns 0-7.");returnfalse;}
if(takenCells(c,which).has(String(pos))){alertBox(`Row ${pos[0]} column ${pos[1]} is taken. Hide or move a key first`+(allowBlank?", or leave the position blank.":"."));returnfalse;}
tray.addEventListener("drop",(e)=>{e.preventDefault();constwhat=e.dataTransfer.getData("text/plain")||dragging;if(!what||!what.startsWith("tile:"))return;constt=cfg().tiles.find((x)=>x.id===what.slice(5));if(t&&t.kind)returnalertBox("That key always has a spot on the deck.");move(what,null);});
if(!hr.checked)confirmBox("Switch the Restart page off? Its keys are removed from the settings; page "+c.restart_page.page+" stays in Companion until someone clears it.","Switch it off",()=>{deletec.restart_page;saveDraft();location.hash="#keys";route();},null);
else{constpos=firstEmpty(c,"status");if(!pos)returnalertBox("No free spot on the Status page for the RESTART PAGE key.");c.restart_page={page:c.page+1,page_name:`${c.page+1}\nRestart`,jump_pos:pos,back_pos:pos,keys:[]};saveDraft();location.hash="#keys/restart";route();}
if(!hr.checked)hr.checked=true;
};
constnk=main.querySelector("#newkey");if(nk)nk.onclick=()=>dialog(`<h3>New key</h3><input id="nlabel" placeholder="label, as the key shows it (HD7)"><input id="nid" placeholder="id (letters, digits, _) – filled from the label"><input id="npos" placeholder="row, column" value="${(firstEmpty(cfg(),"status")||[]).join(", ")}"><div class="row"><button class="primary" id="dok">Add the key</button><button id="dcancel">Cancel</button></div><p class="help">It starts with no checks: add them on the key, then Apply.</p>`,(b)=>{
constfixOptions=(cur)=>`<option value="">none</option><option value="restart-connection" ${cur==="restart-connection"?"selected":""}>reconnect its Companion connection</option><option value="dante-auto" ${cur==="dante-auto"?"selected":""}>Dante auto (PoE port or the Dante module)</option>${Object.entries(c.fixes).map(([id,f])=>`<option value="${esc(id)}" ${cur===id?"selected":""}>${esc(f.short||id)}–${esc(f.label||"")}</option>`).join("")}`;
+(ctName==="custom"?fields:inputHtml(base+"msg",F("tiles[].checks[].msg"),chk.msg)+(chk.cmd?`<div class="field"><label>The command it makes (on ${esc((c.hosts[chk.host]||{}).name || chk.host)})</label><pre class="log small">${esc(chk.cmd)}</pre></div>`:""));
:`<div class="field"><label>Fix: what holding runs for this check</label><select data-path="${base}fix" class="fixsel">${fixOptions(chk.fix)}</select></div>`;
construn=typeofchk.fix==="string"&&chk.fix&&canRunNow(chk.fix)?`<button class="small" data-run="${esc(t.id)}|${esc(chk.fix)}|${j}">Run this fix now</button>`:"";
constholdAlways=editing()&&plain?`<div class="field"><label>Hold runs even when the key is green</label><select data-path="tiles.${i}.hold_always" class="holdsel"><option value="">no – only what is failing</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>`:"";
constpos=`<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(t.id)}</dd></dl>${editing()&&!t.kind?`<div class="field"><label>Position (row, column; blank = inside its group only)</label><input id="pos" value="${esc(t.pos?t.pos.join(", "):"")}" placeholder="row, column"></div>`:""}</details>`;
constdk=main.querySelector("#delkey");if(dk)dk.onclick=()=>confirmBox(`Delete the key ${c.tiles[i].label}? Its checks go with it.`,"Delete",()=>{if(!ensureDraft())return;constcc=state.draft.config;constid=cc.tiles[i].id;cc.tiles.splice(i,1);for(consttofcc.tiles)if(t.members)t.members=t.members.filter((m)=>m!==id);saveDraft();location.hash="#keys";});
constdr=main.querySelector("#delrkey");if(dr)dr.onclick=()=>confirmBox("Delete this restart key?","Delete",()=>{if(!ensureDraft())return;constks=state.draft.config.restart_page.keys;ks.splice(ks.findIndex((x)=>x.id===sel),1);saveDraft();location.hash="#keys/restart";});
for(constbofmain.querySelectorAll("[data-removecheck]"))b.onclick=()=>confirmBox("Remove this check from the key?","Remove",()=>{if(!ensureDraft())return;state.draft.config.tiles[i].checks.splice(+b.dataset.removecheck,1);saveDraft();route();});
for(consttaofmain.querySelectorAll("textarea.fixtable"))ta.onchange=()=>{try{setPath(ta.dataset.path,JSON.parse(ta.value));}catch(e){alertBox("Not a valid table.");}};
<div class="field"><label>Keep flashing HOLD TO FIX while these are down (hold cmd/ctrl for several)</label><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>${esc(r.label)}</h2><p class="muted">row ${r.pos[0]}, column ${r.pos[1]}</p><p><b>HOLDING ${esc(hold)} WILL:</b> ${esc(f.label||r.fix)}, even when the Status page says it is fine.</p>${r.watch&&r.watch.length?`<p class="small muted">Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}</p>`:""}${edit}
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>`;
constc=m.computers.find((x)=>x.alias===selected);
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}`;
construns=main.querySelector("#runs");if(runs)runs.onchange=()=>{if(!ensureDraft())return;try{state.draft.config.hosts[selected].runs=runs.value.split("\n").map((l)=>l.trim()).filter(Boolean).map((l)=>JSON.parse(l));saveDraft();}catch(err){alertBox("Each line must be a valid {...} entry.");}};
constdone=Object.entries(c.manual_done||{}).map(([k,v])=>`<li>${lightDot("hand")}${esc(k.replace(/_/g," "))}: done by hand (${esc(v.by)}, ${esc(v.on)})</li>`).join("");
constedit=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>`:"";
constwhat=[`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 ${[...newSet(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",()=>{
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>
if(/watchdog and the settings/.test(step)){constok=(c.items||[]).filter((i)=>i.kind==="launchagent"&&/watchdog|settings/i.test(i.name));returnok.length&&ok.every((i)=>i.light==="ok")?"ok":"unknown";}
constscriptsHtml=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)=>{constl=lightFor(s);consthow=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>`;
constfiles=logTab==="autofix"?`<p class="small">${(data.files||[]).slice().reverse().map((f)=>`<a href="#logs/autofix/${encodeURIComponent(f)}" class="${data.file===f?"pill":""}">${esc(f.replace(" Auto-Fix.log",""))}</a>`).join(" · ")||"no auto-fix days yet"}</p>`:"";
main.innerHTML=`<h1>Logs</h1><p class="what">What the watchdog did and saw. Newest lines at the bottom.</p><div class="tabs">${tabs}</div>${filters}${files}<pre class="log">${esc((data.lines||[]).join("\n"))||"(empty)"}</pre>`;
detail=`<div class="card"><h2>${esc(rest[0])}</h2><p class="muted">What is different between that version and now:</p>${d.changes_to_now.length?`<ul>${d.changes_to_now.map((c)=>`<li>${esc(c.text)}</li>`).join("")}</ul>`:"<p>Nothing: it is the same as now.</p>"}
${editing()?`<button class="primary" id="goback">Go back to this version</button> <span class="help">It becomes your draft; Review & Apply puts it live.</span>`:`<p class="help">Switch Editing on to go back to it.</p>`}</div>`;
constun=main.querySelector("#undo");if(un)un.onclick=()=>confirmBox("Undo the last change? The version before it becomes your draft; Review & Apply puts it live.","Undo",async()=>{try{constr=awaitpost("/api/undo");state.draft=r.draft;badge();location.hash="#review";}catch(e){alertBox(e.message);}});
main.innerHTML=`<h1>Review & Apply</h1><p class="what">Nothing to apply: there is no draft. Change something on any screen with Editing on.</p>${st.state==="done"||st.state==="failed"?applyResult(st):""}`;
return;
}
constr=awaitpost("/api/validate",{});
constchanges=r.changes.map((c)=>`<li class="${c.command?"command":""}">${esc(c.text)}${c.command?' <span class="pill">command</span>':""}</li>`).join("")+(r.atem_pending?"<li>A new saved switcher file replaces the current one.</li>":"");
constlive=r.live&&r.live.live?`<div class="notice">Live now: ${esc(r.live.reason)}. Apply waits until it is over; your draft is saved. ${editing()?'<button id="emergency" class="danger">Apply anyway (emergency)</button>':""}</div>`:"";
main.innerHTML=`<h1>Review & Apply</h1><p class="what">Started by ${esc(state.draft.started_by||state.draft.by||"?")}${state.draft.started?" at "+esc(when(state.draft.started)):""}. ${state.draftStale?"<b>The live settings changed since this draft was started: look again before applying.</b>":""}</p>
constem=main.querySelector("#emergency");if(em)em.onclick=()=>dialog(`<h3>Apply during a live service</h3><p>${esc(r.feel)} Say why, and type LIVE.</p><input id="why" placeholder="why it cannot wait"><input id="word" placeholder="type LIVE"><div class="row"><button class="primary danger" id="dok">Apply now</button><button id="dcancel">Cancel</button></div>`,(b)=>{b.querySelector("#dcancel").onclick=closeDialog;b.querySelector("#dok").onclick=()=>{constwhy=b.querySelector("#why").value.trim(),word=b.querySelector("#word").value.trim();if(word!=="LIVE"||!why)return;closeDialog();go({word,reason:why})();};});
main.querySelector("#discard").onclick=()=>confirmBox("Throw the draft away? Every change since the last Apply is lost.","Throw it away",async()=>{try{awaitpost("/api/draft",{},"DELETE");state.draft=null;state.atemPending=false;badge();review();}catch(e){alertBox(e.message);}});
}
functionapplyProgress(st){
main.innerHTML=`<h1>Applying…</h1><p class="what">${esc(st.note||"")}</p>${stepsHtml(st)}<p class="muted">This page updates by itself.</p>`;
["Read a red key","Green is fine. A flashing red key names the problem. Press it once to acknowledge (it stops flashing, stays dark red). Hold it 2 s and it runs the fix for what is failing."],
["Purple means the watchdog is down","Every key turns purple when the watchdog on the Ingest Mac has been silent for 15 s. Open this page: if the Overview says Companion is not answering, the problem is Companion; otherwise press Restart the watchdog on the Overview."],
["Which computer or device is that?","Computers › Map shows every computer and the keys that turn red if it dies; Computers › Gear lists the switcher, decks, recorders, wireless and LED gear with their addresses."],
["What is a connection?","Companion's link to one device (a HyperDeck, the Tascam, the switcher). The keys watch those connections; Site setup › Companion lists them all and which key watches each."],
constglossary=Object.entries(gl).filter(([g])=>!/while you hold/.test(g)).map(([g,words])=>`<details open><summary><b>${esc(g)}</b></summary><p class="mono small">${esc(words.join(" · "))}</p></details>`).join("");
main.innerHTML=`<h1>Help</h1><p class="what">Start with the four cards. The pages below are made from the settings, so they are never out of date.</p><div class="cards">${cards}</div>
<h2>Pages</h2><p><a href="/key-guide.html" target="_blank">What each key does</a> · <a href="/cheat.html" target="_blank">Cheat sheet (one page)</a> · <a href="/where.html" target="_blank">Where things are</a></p>
<h2>Changing something</h2><ol><li>Switch Editing on (top right): the editing code and your first name.</li><li>Change it where it lives: a key on Keys, a computer on Computers, a fix on Fixes, the rest on Site setup.</li><li>The badge at the top counts your changes; it opens Review & Apply.</li><li>Review reads your changes back in plain words and says what will happen (the deck goes purple for a few seconds; pages re-import only when the layout changed). Press APPLY.</li><li>If the watchdog does not come back, the previous version is put back by itself. History keeps every version; Go back to any of them.</li></ol>
<h2>First-day walkthrough (10 minutes, no code needed)</h2><ol><li>Overview: the live deck. Green, red, purple.</li><li>Click a red key (or any key): read what it checks and what holding it will do.</li><li>Logs › Watchdog, filter "holds": find a hold and what it did.</li><li>Keys › Restart page: each key restarts one thing, even when green.</li><li>Overview: when alarms are on and when auto-fix may act by itself.</li><li>Computers › Map: which computer a key talks to, and what runs there.</li><li>Help › What each key does: the whole deck in words.</li></ol>
<h2>Try it (ten minutes with Editing on; nothing is live until you press APPLY)</h2><ol>
<li>Keys › HD2: tick "may have no disk" off and on; the badge counts the change; Review & Apply reads it back. Throw the draft away.</li>
<li>Keys: drag LED onto the tray, then back onto its cell. Keys › + New key "HD7", give it a check (Is a recorder OK?), delete it again.</li>
<li>Overview › Add or remove a HyperDeck: untick 5 and 6 and look at Keys (the cells stay empty); Review says the page re-imports. Throw it away.</li>
<li>Overview › Change the playlist: paste a playlist link, Look it up, see the name follow on Site setup › Spotify and the play fix. Apply if you mean it.</li>
<li>Overview › Add a MIDI link to the MIDI key for a peer that exists; Run this fix now on that check (it goes through the watchdog like a hold).</li>
<li>Fixes › VISTA: change the program file to Vista2.exe and watch the command rebuild; put it back.</li>
<li>Computers › Setup › Lighting PC: open the script, Copy; Computers › Lighting PC › Remove this computer (read what would go), Cancel.</li>
<li>Site setup › ATEM › Pick the newest from the folder (it says what differs), Discard. Site setup › Site › Export site bundle.</li>
<li>History: open the last version, Go back to this version, look at Review, Throw it away. Undo the last change does the same for the newest.</li>
<li>Make a small real change (a note on Computers › Gear), APPLY, watch the steps, then Undo it and APPLY again.</li></ol>
<h2>Things only a person can do</h2><ul><li>Run the set-up script on a new computer (ARD on a Mac, PowerShell as administrator on a PC), allow Accessibility on the Studio B Mac, click Allow on the Spotify prompt once.</li><li>Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.</li><li>Type the Netgear login into the keychain; keep Google Drive signed in for the saved switcher files.</li><li>Save a new switcher file from ATEM Software Control when the show changes, then pick it on Site setup › ATEM.</li></ul>
<h2>Companion facts (found by trying)</h2><dl class="kv">${(facts.facts||[]).map(([q,a])=>`<dt>${esc(q)}</dt><dd>${esc(a)}</dd>`).join("")}</dl>`;
letbody=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>`:"";
main.innerHTML=`<h1>Site setup</h1><p class="what">Set up once. Each section opens on the few things a person changes; the numbers sit under Advanced.${editing()?"":" Switch Editing on to change anything."}</p><div class="tabs">${tabs}</div>${body}`;
constfixes=Object.entries(c.fixes).map(([id,f])=>{constcl=closes(f);conston=(c.auto_fix.fixes||[]).includes(id);return`<label class="inline"><input type="checkbox" data-autofix="${esc(id)}" ${on?"checked":""}${editing()&&!cl&&!f.never_auto?"":"disabled"}> ${esc(f.short||id)}${cl?' <span class="muted small">(closes a program: hold only)</span>':f.never_auto?' <span class="muted small">(never by itself)</span>':""}</label>`;}).join(" ");
return`<div class="notice">Alarms are on every day. The ALARMS key silences them until midnight, then they come back by themselves. Changes to the panel wait while a HyperDeck is recording.</div>
<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>`;
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>`;
constsel=(kind,name,cands)=>editing()?`<select data-map="${kind}|${esc(name)}"><option value="">– map it to –</option>${cands.map((c)=>`<option>${esc(c)}</option>`).join("")}</select>`:"";
constconns=n.connections.filter((c)=>!c.live).map((c)=>`<tr><td>${lightDot("problem")}<span class="mono">${esc(c.name)}</span></td><td class="small">${esc(c.keys.join(", "))}</td><td class="small">${n.companion_ok?"not in this Companion":"Companion not answering"}${c.module?` <span class="muted">(needs module ${esc(c.module)})</span>`:""}</td><td>${sel("conn",c.name,c.candidates)}</td></tr>`).join("");
constdante=n.dante.filter((d)=>!d.seen).map((d)=>`<tr><td>${lightDot("warn")}<span class="mono">${esc(d.name)}</span></td><td class="small">DANTE</td><td class="small">not seen on the Dante network (last survey)</td><td>${sel("dante",d.name,d.candidates)}</td></tr>`).join("");
constmidi=n.midi.filter((m)=>!m.session_seen||!m.peer_seen).map((m)=>`<tr><td>${lightDot("warn")}<span class="mono">${esc(m.session)}›${esc(m.peer)}</span></td><td class="small">${esc(m.key)}</td><td class="small">${!m.session_seen?`session not seen${m.host?" on "+esc((cfg().hosts[m.host]||{}).name || m.host) : ""}${m.sessions_seen.length?" (seen: "+esc(m.sessions_seen.join(", "))+")":""}`:`peer not in the session now${m.peers_seen.length?" (in it: "+esc(m.peers_seen.join(", "))+")":""}`}</td><td class="small muted">change it on the key</td></tr>`).join("");
return`<div class="card"><h3>Names the keys use that are not here (${n.missing})</h3><p class="small muted">A copied campus, or something renamed. Map each name to what this campus has; the keys, checks and restart keys that use it follow.</p>
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>`:""}
return`<div class="card"><h3>Dante device list (the DANTE key's devices)</h3><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 it is 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="field"><label>Add a device</label><input id="dantenew" placeholder="Dante name (as Dante Controller shows it)" list="dantefound"><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">Add</button>${extra.length?`<div class="help">Seen on the network but not listed: ${esc(extra.join(", "))}</div>`:""}</div>`:""}</div>`;
}
functionspotifyHtml(c){
consts=c.spotify||{};
return`<div class="card"><h3>The service playlist</h3><div class="field"><label>Playlist link</label><input id="splink" value="${esc(s.playlist||"")}" placeholder="paste the open.spotify.com/playlist/... link" ${editing()?"":"disabled"}> ${editing()?`<button class="small" id="splook">Look it up</button>`:""}<div class="help">The SPOTIFY key, the play fix and the key guide all use it.</div></div>
<div class="field"><label>Name</label><input id="spname" value="${esc(s.name||"")}" ${editing()?"":"disabled"}><div class="help">As the keys say it.</div></div><p id="spinfo" class="help"></p></div>`;
constpending=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"><h3>The saved switcher file</h3><p class="small muted">The panel keeps the switcher's audio and camera settings in line with this file (atem-reference.xml). Save a new one from ATEM Software Control when the show changes.</p>${pending}
${editing()?`<div class="row"><label class="btn">Upload a saved file <input type="file" id="atemfile" accept=".xml" hidden></label><button id="atemnewest" ${folder.mounted&&(folder.files||[]).length?"":"disabled"}>Pick the newest from the folder</button></div>`:""}
${folder.blocked?`<div class="notice grey">The folder is there but this page cannot read it (macOS keeps background programs out of cloud folders, and this Mac's own login key is not accepted on this Mac). Use Upload a saved file, or let the panel's key log in to this Mac: Computers › Setup ›${esc((cfg().site||{}).ingest_name || "the Ingest Mac")}.</div>`:""}<p id="ateminfo" class="help"></p></div>`;
constrs=q("#resurvey");if(rs)rs.onclick=async()=>{try{awaitpost("/api/command",{text:"resurvey"});alertBox("Asked the watchdog to look at the switches; the map updates within a minute.");}catch(e){alertBox(e.message);}};
constlook=q("#splook");if(look)look.onclick=async()=>{constinfo=q("#spinfo");info.textContent="Looking…";try{constr=awaitapi("/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;}};
constsplink=q("#splink");if(splink)splink.onchange=()=>{constm=splink.value.match(/(?:playlist[/:])([A-Za-z0-9]{22})/);if(m)setPath("spotify.playlist","spotify:playlist:"+m[1]);elseif(splink.value.trim())alertBox("That is not a playlist link or URI.");};
constib=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{constr=awaitpost("/api/program/install",{});alertBox(r.ok?r.detail:"Not started: "+r.error);setTimeout(()=>location.reload(),25000);}catch(e){alertBox(e.message);}});
constbf=q("#bundlefile");if(bf)bf.onchange=async()=>{constf=bf.files[0];if(!f)return;confirmBox(`Start a new campus from ${f.name}? Your current draft is replaced.`,"Start",async()=>{try{constr=awaitfetch("/api/site/import",{method:"POST",headers:{"X-Edit-Key":state.editing,"X-Editor":state.name,"Content-Type":"application/octet-stream"},body:awaitf.arrayBuffer()}).then((x)=>x.json());if(r.error)returnalertBox(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);}});};
alertBox(`${r.file?r.file+": ":""}${r.product||""}, ${r.audio} audio and ${r.camera} camera values; ${r.diff_count} differ from the current file${diffs?":\n"+diffs:"."}\nApply replaces the file.`);
elseif(p.type==="list")input=`<input id="${id}" data-param="${p.name}" value="${esc(Array.isArray(v)?v.join(", "):v||"")}" placeholder="one or more, with commas">`;
inputs+=`<div class="field"><label>Command (typed by hand; shown as a command at Review; never run before Apply)</label><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run||"")}</textarea><div class="help">zsh on a Mac, cmd.exe on a PC.</div></div>`;
}elseif(f.run)inputs+=`<div class="field"><label>The command it makes (${f.kind==="local"?"this Mac":"on "+esc((c.hosts[f.host]||{}).name || f.host)})</label><pre class="log small" style="max-height:160px">${esc(f.run)}</pre></div>`;
detail=`<div class="card"><h2>${esc(f.short||id)}</h2><p>${esc(f.label||"")}</p>${inputs}${canRunNow(id)&&editing()?`<p class="help">Run it from the key it belongs to (${esc(keys.join(", ")||"none yet")}).</p>`:""}<details><summary>Everything stored</summary><dl class="kv">${Object.entries(f).map(([k,v])=>`<dt>${esc(k)}</dt><dd class="mono small">${esc(typeofv==="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 this fix</button>`:""}</div>`;
constadd=editing()?`<div class="row"><input id="newfix" placeholder="new fix id (letters, digits, _)"><button id="addfix" class="small">+ New fix</button></div>`:"";
main.innerHTML=`<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(c.fixes).length} today.</p>${add}<div class="tablewrap"><table><tr><th>key text</th><th>plain words</th><th>built as</th><th>used by</th><th>by itself</th></tr>${rows}</table></div>${detail}`;
constdel=main.querySelector("#delfix");if(del)del.onclick=()=>confirmBox(`Delete the fix ${id}? No key uses it.`,"Delete",()=>{if(!ensureDraft())return;deletestate.draft.config.fixes[id];constaf2=state.draft.config.auto_fix.fixes||[];state.draft.config.auto_fix.fixes=af2.filter((x)=>x!==id);saveDraft();location.hash="#fixes";});
constaf=main.querySelector("#addfix");if(af)af.onclick=()=>{constnid=main.querySelector("#newfix").value.trim();if(!/^[A-Za-z0-9_]+$/.test(nid)||c.fixes[nid])returnalertBox("A new id: letters, digits and _ only, not in use.");if(!ensureDraft())return;state.draft.config.fixes[nid]={kind:"local",short:nid.toUpperCase().slice(0,12),label:"",run:""};saveDraft();location.hash="#fixes/"+nid;};