elsebox.querySelector("#dmsg").textContent=r.locked_for?`Locked for ${Math.ceil(r.locked_for/60)} minutes after too many tries.`:r.no_code?"No PIN is set yet (Settings › Site).":!name?"Your first name, please.":`Not the PIN (${r.tries_left} tries left).`;
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();}
constLABELS={editing_code:"PIN",ssh:"log-in address",mac:"hardware address",ingest_ips:"this Mac's addresses",ingest_name:"this Mac's name",ingest_hostname:"network name",hold_always:"hold also when green",page_hold_ms:"hold time (ms)",optional:"may have no disk"};
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>`:"";
<h1>${o.problems.length?o.problems.length+" problem"+(o.problems.length>1?"s":""):"Everything is fine"}${hint("The red keys, in words. Hold the key on the deck, or open it.")}</h1>
${chip(o.companion_ok,o.companion_ok?"Companion ✓":"Companion down",o.pages&&o.pages.status?(o.pages.status.ok?"Pages OK":"Page "+o.pages.status.page+" is called "+(o.pages.status.live||"?")):"")}
${chip(null,"Auto-fix "+(o.auto_fix.state&&o.auto_fix.state.on?"active":"waiting"),o.auto_fix.phrase+(o.auto_fix.state&&o.auto_fix.state.paused?" (waiting out a network blip)":""))}
<button id="restartwd" ${dis} data-hint="${w.running?"Restart the watchdog: the deck goes purple for about 5 s; a running fix finishes first":"Start the watchdog"}">${w.running?"Restart watchdog":"Start watchdog"}</button>
<button id="alarms" ${dis} data-hint="${o.service_on===false?"Alarms back on now":"Same as the ALARMS key: quiet until midnight"}">${o.service_on===false?"Alarms on":"Silence alarms"}</button>
${w.running?`<button id="stopwd" ${dis} data-hint="Stop the watchdog: every key purple, no alarms until it is started again">Stop</button>`:""}
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");
${editing()?`<span class="small muted" style="margin-left:auto">page <input class="pagenum" data-which="status" type="number" min="1" value="${c.page}" data-hint="The Status page's number in Companion">${c.restart_page?` · restart page <input class="pagenum" data-which="restart" type="number" min="1" value="${c.restart_page.page}">`:""} <label class="inline"><input type="checkbox" id="hasrestart" ${c.restart_page?"checked":""}> Restart page${hint("A second page where each key restarts one thing, even when green")}</label></span>`:""}</div>`;
letpanel=`<p class="muted small">Click a key.${editing()?" Drag to move; drop on the tray to hide.":""}</p>`;
if(state.draft){try{constv=awaitpost("/api/validate",{});if(v.plan&&v.plan.pages.length)note=`<div class="notice">Layout changed: Apply re-imports the ${v.plan.pages.filter((p)=>p==="status"||p==="restart"||p==="ready").map((p)=>p[0].toUpperCase()+p.slice(1)).join(" and ")} page.</div>`;}catch(e){}}
${!restart&&!ready?`<p class="small muted tray" id="tray" style="margin-top:8px">In a group only${hint("Keys with no spot of their own: they show inside their group key. Drop a key here to hide it.")}: ${tray||"<span class='muted'>none</span>"}</p>`:""}
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();}
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 – filled from the label"><input id="npos" placeholder="row, column (blank = in a group only)" value="${(firstEmpty(cfg(),"status")||[]).join(", ")}"><div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`,(b)=>{
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("")}`;
constfixSel=typeofchk.fix==="object"&&chk.fix?`<div class="field"><span class="fl">fix per device${hint('A table: device → fix, "*" for everything else')}</span><textarea data-path="${base}fix" rows="2" class="fixtable mono">${esc(JSON.stringify(chk.fix))}</textarea></div>`
:`<div class="field"><span class="fl">fix${hint("What holding the key runs for this check")}</span><select data-path="${base}fix" class="fixsel">${fixOptions(chk.fix)}</select></div>`;
construn=typeofchk.fix==="string"&&chk.fix&&canRunNow(chk.fix)?`<button class="small" data-run="${esc(t.id)}|${esc(chk.fix)}|${j}" data-hint="Through the watchdog, exactly like a hold">Run the fix</button>`:"";
constholdAlways=editing()&&plain?`<div class="field"><span class="fl">hold also when green${hint("A fix that runs on every hold, even with nothing failing")}</span><select data-path="tiles.${i}.hold_always" class="holdsel"><option value="">no</option>${Object.entries(c.fixes).map(([id,f])=>`<option value="${esc(id)}" ${t.hold_always===id?"selected":""}>${esc(f.short||id)}–${esc(f.label||"")}</option>`).join("")}</select></div>`:"";
${kt.says&&kt.says.length?`<details class="fold"><summary>Words it can show <span class="muted">${kt.says.length}</span></summary><p class="mono small">${esc(kt.says.join(" · "))}</p></details>`:""}
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.");}};
// ---- The READY page: what each checklist key looks at, and why it is amber or red right now ----
constREADY_WORDS={
tracks:"This weekend's Ableton set exists, has a song group for every Planning Center song, and is the set Live last opened.",
mix_foh:"Every song is mixed (a song whose tracks are all still at 0 dB is not) and its stems go out to the console, not to Master.",
lights:"The lighting cue track has a clip under every song.",
prop:"The ProPresenter playlist for the plan's date has every Planning Center item; MC announcements have a slide each and this week's notes; every person listed for baptisms or dedications has a slide.",
timecode:"Every song's presentation has a timeline, and Planning Center's LTC note for every song is the timecode the tracks play at that song's start.",
backgrounds:"Every song has at least one slide with a background.",
led_cross:"Resolume is on the Service deck and a clip is playing on the cross.",
lineup:"The Band Lineup TV shows this plan, nobody who is not on it or declined, and everyone on the band and communication teams.",
track_bundle:"Every location has this weekend's Ableton project folder on the shared drive.",
prop_bundle:"A ProPresenter bundle was saved for the locations since Monday.",
if(i<0)return`<div class="card"><h2 style="margin-top:4px">The weekly checklist</h2>${plan}<p class="small">Amber: still to do. Blinking red: still missing once it is due (${esc(((c.ready||{}).due || []).map((w) => w.days.join("/") + " " + w.start + "-" + w.end).join(", "))}). Tap keys (BOH MIX, MICS, STAGE) are tapped done on the deck and reset every ${esc((c.ready||{}).reset_day || "mon")}. Hold any key ${esc(hold)} to fix it or look again; hold READY to fix everything.</p><p class="small muted">Click a key to see what it checks and why it is amber or red.</p></div>`;
if(k.kind==="ready")what=`Sums up the page: how many keys still have something to do, and which to look at first. <b>Hold ${esc(hold)}</b>: fix everything on the page that can be fixed.`;
elseif(chk.type==="ready_manual")what=`Only a person can check this. <b>Tap</b> on the deck when it is done; tap again to take it back. It resets every ${esc((c.ready||{}).reset_day || "mon")}.`;
elseif(chk.type==="ready")what=`${esc(READY_WORDS[chk.item]||chk.item)} <b>Hold ${esc(hold)}</b>: ${esc(fix&&fix.action!=="recheck"?fix.label:"look again now")}.`;
elsewhat=`Watches ${esc((k.checks||[]).map((x)=>x.tag||x.msg||x.type).join(", "))}. <b>Hold ${esc(hold)}</b>: fix the one that dropped.`;
<div class="field"><span class="fl">flash while down${hint("Keeps flashing HOLD TO FIX while these are down; hold cmd/ctrl to pick several")}</span><select multiple size="5" class="watchsel" data-path="restart_page.keys.${i}.watch">${Object.keys(c.fixes).map((id)=>`<option value="${esc(id)}" ${(r.watch||[]).includes(id)?"selected":""}>fix ${esc(id)}</option>`).join("")}${[...conns].sort().map((cn)=>`<option value="conn:${esc(cn)}" ${(r.watch||[]).includes("conn:"+cn)?"selected":""}>connection ${esc(cn)}</option>`).join("")}</select></div>`:"";
return`<div class="card"><h2 style="margin-top:4px">${esc(r.label)} <span class="muted small">row ${r.pos[0]}, col ${r.pos[1]}</span></h2><p class="small"><b>Hold ${esc(hold)}</b> ${esc(f.label||r.fix)}, even when the Status page says it is fine.</p>${edit}
${canRunNow(r.fix)&&editing()?`<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run the fix</button>`:""}<details><summary>more</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd>${r.watch&&r.watch.length?`<dt>flashes while down</dt><dd>${esc(r.watch.join(", "))}</dd>`:""}</dl></details>${editing()?`<p style="margin-top:12px"><button class="small danger" id="delrkey">Delete key</button></p>`:""}</div>`;
consttabs=`<div class="tabs"><button class="${compTab==="map"?"on":""}" onclick="location.hash='#computers'">Map</button><button class="${compTab==="gear"?"on":""}" onclick="location.hash='#computers/gear'" data-hint="Devices that are not computers: switcher, decks, wireless, LED">Gear</button><button class="${compTab==="setup"?"on":""}" onclick="location.hash='#computers/setup'" data-hint="The one-time set-up per computer, with the scripts">Setup</button>
body=`<div class="row" style="justify-content:flex-end;margin-bottom:6px"><span class="muted small">${looked?"looked "+esc(ago(Date.now()/1000-looked)):"not looked yet"}</span><button id="probe" class="small" ${m.probing?"disabled":""} data-hint="Log in to every computer now and refresh the lights (read-only)">${m.probing?"Looking…":"Check now"}</button></div>
<div class="tablewrap"><table><tr><th>status</th><th>computer</th><th>does</th><th>keys${hint("The keys that turn red if it dies")}</th><th></th></tr>${rows}</table></div>`;
for(constbofmain.querySelectorAll("[data-geardel]"))b.onclick=()=>confirmBox(`Remove ${b.dataset.geardel} from the gear list?`,"Remove",async()=>{try{constr=awaitpost("/api/gear",{name:b.dataset.geardel,remove:true});state.draft=r.draft;badge();route();}catch(e){alertBox(e.message);}});
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("");
${c.os==="windows"?`<button class="small" data-job="pcprogram">+ Program this PC can start</button>`:""}</details>`:"";
constremove=editing()&&!c.self?`<p style="margin-top:12px"><button class="small danger" id="removehost" data-hint="Drops the fixes and checks that used it, and says which">Remove computer</button></p>`:"";
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`<div class="tablewrap"><table><tr><th></th><th>device</th><th>kind</th><th>address</th><th>where${hint("Where it is in the building, and notes")}</th><th>watched by</th></tr>
dialog(`<h3>${existing?"Gear":"New gear"}${hint("A device that is not a computer. Give it a kind and an address; choose whether a key watches it.")}</h3>
<input id="gname" placeholder="name (Tascam, LED wall left, …)" value="${esc(cur.name||"")}" ${existing?"readonly":""}>
<input id="gaddr" placeholder="address (10.1.x.x), if it has one" value="${esc(cur.address||"")}">
<input id="gloc" placeholder="where it is" value="${esc(cur.location||"")}"><input id="gnotes" placeholder="notes" value="${esc(cur.notes||"")}">
<select id="gwatch"><option value="">not watched by a key</option><option value="ping">watch it: ping the address</option>${conns.map((n)=>`<option value="conn:${esc(n)}">watch its Companion connection ${esc(n)}</option>`).join("")}</select>
<select id="gkey"><option value="__new">on a new key</option>${plainKeys.map((t)=>`<option value="${esc(t.id)}">on the ${esc(t.label)} key</option>`).join("")}</select>
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";}
main.innerHTML=`<h1>Logs${hint("What the watchdog did and saw. Newest at the bottom.")}</h1><div class="tabs">${tabs}</div>${filters}${files}<pre class="log">${esc((data.lines||[]).join("\n"))||"(empty)"}</pre>`;
detail=`<div class="card"><h3>${esc(rest[0])}</h3>${d.changes_to_now.length?`<ul class="small">${d.changes_to_now.map((c)=>`<li>${esc(c.text)}</li>`).join("")}</ul>`:"<p class='muted small'>The same as now.</p>"}
${editing()?`<button class="primary" id="goback" data-hint="It becomes your draft; Apply puts it live">Go back to this</button>`:""}</div>`;
main.innerHTML=`<h1>History${hint("Every version ever applied: who, when, the note. Open one to see what differs from now.")}${editing()&&h.entries.length?`<button id="undo" class="small" style="margin-left:auto" data-hint="The version before the newest becomes your draft">Undo last change</button>`:""}</h1>
constun=main.querySelector("#undo");if(un)un.onclick=()=>confirmBox("Undo the last change? The version before it becomes your draft; Apply puts it live.","Undo",async()=>{try{constr=awaitpost("/api/undo");state.draft=r.draft;badge();location.hash="#review";}catch(e){alertBox(e.message);}});
?`<li><details><summary>${esc(c.text)}${c.command?' <span class="pill" data-hint="A command the watchdog will run; it never runs before Apply">command</span>':""}</summary>${c.before!=null?`<div class="small"><span class="muted">was</span> <span class="${c.command?"mono":""}">${esc(c.before)}</span></div>`:""}${c.after!=null?`<div class="small"><span class="muted">now</span> <span class="${c.command?"mono":""}">${esc(c.after)}</span></div>`:""}</details></li>`
:`<li>${esc(c.text)}</li>`).join("")+(r.atem_pending?"<li>A new saved switcher file replaces the current one.</li>":"");
main.innerHTML=`<h1>Apply${hint("Your changes in words, and what will happen on the deck. Nothing is live until you press APPLY.")}</h1>${state.draftStale?"<p class='notice'>The live settings changed since this draft was started and could not be merged: look through the list before applying.</p>":""}
${errors?`<div class="notice problem"><b>Not possible yet:</b><ul>${errors}</ul></div>`:""}${warnings?`<div class="notice grey"><ul>${warnings}</ul></div>`:""}${live}
<div class="row"><input id="note" placeholder="note for History" maxlength="200" style="flex:1;min-width:160px;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);font:inherit"><button class="primary" id="apply" ${errors||(r.live&&r.live.live)||!editing()||!changes?"disabled":""}>APPLY</button><button id="discard" ${editing()?"":"disabled"} data-hint="Every change since the last Apply is lost">Throw away</button></div></div>
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);}});
["Red key","It names the problem. Press once to acknowledge (stays dark red). Hold 2 s: it runs the fix for what is failing."],
["Purple keys","The watchdog on the Ingest Mac has been silent 15 s. Overview › Restart watchdog, unless Companion itself is down."],
["Which computer or device?","Computers › Map: every computer and the keys that turn red if it dies. Computers › Gear: switcher, decks, wireless, LED."],
["Changing something","Edit (top right, the PIN). Change it where it lives: a key on Keys, a computer on Computers, the rest on Settings. The badge counts your changes and opens Apply."],
<p style="margin-top:12px"><a class="btn" href="/key-guide.html" target="_blank" data-hint="One entry per key: what it checks, what holding does, every word it can show">What each key does</a> <a class="btn" href="/cheat.html" target="_blank" data-hint="One printable page: both decks, the colours, press = acknowledge, hold = fix">Cheat sheet</a> <a class="btn" href="/where.html" target="_blank" data-hint="Every file, folder, log and port per computer">Where things are</a></p>
<details><summary><b>Try it</b> (ten minutes with Editing on; nothing is live until APPLY)</summary><ol class="small">
<li>Keys › HD2: untick "may have no disk"; the badge counts it; Apply reads it back. Throw away.</li>
<li>Keys: drag LED onto the tray and back. + Key "HD7", add a check (Is a recorder OK?), delete it.</li>
<li>Settings › HyperDecks: untick 5 and 6, look at Keys (cells stay empty). Throw away.</li>
<li>Settings › Playlist: paste a link, Look it up; the name follows everywhere.</li>
<li>Settings › Auto-fix: paint Sunday morning on the calendar.</li>
<li>Settings › MIDI links: add one to the MIDI key; Run the fix on that check.</li>
<li>Keys › Fixes › VISTA: change the program file to Vista2.exe, watch the command rebuild; put it back.</li>
<li>Computers › Setup › Lighting PC: open the script, Copy. Computers › Lighting PC › Remove (read, then Cancel).</li>
<li>Settings › Switcher: Pick the newest file; Discard. Settings › Program & campus: Export bundle.</li>
<li>A real change (a note on Computers › Gear): APPLY, then History › Undo, APPLY again.</li></ol></details>
<details><summary><b>The words a key can show</b></summary>${glossary}</details>
<details><summary><b>Only a person can</b></summary><ul class="small"><li>Run the set-up script on a new computer, allow Accessibility on the Studio B Mac, click Allow on the Spotify prompt once.</li><li>Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.</li><li>Type the Netgear login into the keychain; keep Google Drive signed in.</li><li>Save a new switcher file from ATEM Software Control when the show changes, then pick it under Settings › Switcher.</li></ul></details>
<details><summary><b>Companion facts</b> (found by trying)</summary><dl class="kv small">${(facts.facts||[]).map(([q,a])=>`<dt>${esc(q)}</dt><dd>${esc(a)}</dd>`).join("")}</dl></details>`;
main.innerHTML=`<h1>Settings${editing()?"":hint("Switch Editing on (Edit, top right) to change anything")}</h1><div class="settings">${nav}<div><h2 style="margin-top:0">${esc(sec[1])}${hint(sec[2])}</h2>${body}</div></div>`;
return`<div class="card">${inputHtml("site.editing_code",Object.assign({}, f, { help: "The PIN that switches Editing on. Digits are easiest. It lives in the settings file, so everyone with the repo has it." }), (c.site || {}).editing_code)}</div>`+fieldsHtml("Site");
}
functionspotifyHtml(c){
consts=c.spotify||{};
return`<div class="card"><div class="field"><span class="fl">playlist link${hint("Paste the link from Spotify (Share › Copy link). Look it up fills the name.")}</span><span class="row"><input id="splink" value="${esc(s.playlist||"")}" placeholder="https://open.spotify.com/playlist/…" ${editing()?"":"disabled"} style="flex:1;min-width:200px"> ${editing()?`<button class="small" id="splook">Look it up</button>`:""}</span></div>
<div class="field"><span class="fl">name${hint("As the keys say it")}</span><input id="spname" value="${esc(s.name||"")}" ${editing()?"":"disabled"}></div><p id="spinfo" class="help"></p></div>`+fieldsHtml("Spotify");
constfixes=Object.entries(c.fixes).map(([id,f])=>{constcl=closes(f);conston=(c.auto_fix.fixes||[]).includes(id);return`<label class="inline" data-hint="${esc(f.label||id)}${cl?" · closes a program: ticking it allows that by itself":f.never_auto?" · marked never by itself: ticking it lifts that":""}"><input type="checkbox" data-autofix="${esc(id)}" ${on?"checked":""}${editing()?"":"disabled"}> ${esc(f.short||id)}${cl||f.restart_ok?" ⚠":""}</label>`;}).join(" ");
<div class="card"><h3 style="margin-top:0">Fixes it may run${hint("Ticked fixes run by themselves inside the painted hours. ⚠ = it closes a running program to do its job; ticking it allows that without a hold.")}</h3><div class="chips">${fixes}</div></div>`+fieldsHtml("Schedules");
return`<div class="calwrap"><div class="cal ${editing()?"":"ro"}" data-calpath="${esc(path)}">${grid}</div><div class="small muted" style="max-width:220px"><b>${words}</b>${editing()?"<br>Click or drag to paint; drag over painted cells to clear.":""}</div></div>`;
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":""}${editing()?"":"disabled"}> ${esc(d.label)}</label>${d.missing?` <span class="muted small" data-hint="A key watches it but Companion has no such connection">not in Companion</span>`:""}</td><td>${k?`<span data-hint="${k.pos?`row ${k.pos[0]}, column ${k.pos[1]}`:"in a group only"}">${esc(k.label)}</span>`:"<span class='muted'>no key</span>"}</td><td><label class="inline" data-hint="NO DISK is a warning, not an alarm"><input type="checkbox" data-nodisk="${esc(d.label)}" ${chk&&chk.optional?"checked":""}${editing()&&k?"":"disabled"}> no disk OK</label></td></tr>`;}).join("");
return`<div class="card">${!cc?"<p class='notice grey'>Companion is not answering; the list is from the keys.</p>":""}<div class="tablewrap"><table><tr><th>in use${hint("Tick a deck and it gets an HD key in the REC group; untick and the key goes (its cell stays empty)")}</th><th>key</th><th></th></tr>${rows||"<tr><td colspan=3 class='muted'>No HyperDeck connections in Companion.</td></tr>"}</table></div>
${editing()&&rows?`<div class="row" style="margin-top:8px"><button class="primary small" id="decksave">Use these decks</button></div>`:""}</div>`;
return`<div class="card"><div class="tablewrap"><table><tr><th>computer</th><th>kind</th><th>log-in</th><th>does</th><th>programs it can start</th></tr>${rows}</table></div>
${editing()?`<div class="row" style="margin-top:8px"><button class="small" data-job="computer">+ Computer</button><button class="small" data-job="pcprogram" data-hint="A program on a Windows PC the panel may start (a scheduled task)">+ PC program</button></div>`:""}</div>`;
return`<div class="card"><dl class="kv"><dt>running</dt><dd>code ${esc(p.running_version||"?")}</dd><dt>staged</dt><dd>${staged}${same?" <span class='muted'>· the same</span>":""}</dd><dt>folder</dt><dd class="mono small">${esc(p.folder)}</dd></dl>
<div class="row" style="margin-top:8px"><button id="installbtn" ${editing()&&p.present?"":"disabled"} data-hint="Copy the new folder here with scp first. install.sh runs by itself, the config stays, the watchdog restarts, this page comes back.">Install the staged folder</button>${p.job==="running"?" <span class='muted'>running…</span>":""}</div>
<div class="card"><div class="row"><a class="btn" href="/api/site/bundle" download data-hint="A zip of the settings, the saved switcher file and the page-key record: drop it in the repo as sites/${esc((cfg().site||{}).id || "site")}/ or feed it to another campus">Export campus bundle</a>${editing()?`<label class="btn" data-hint="This campus's name, addresses and PIN stay; every copied computer is flagged until you touch it">New campus from a bundle <input type="file" id="bundlefile" accept=".zip,.json" hidden></label>`:""}</div></div>`;
return`<div class="card"><div class="field"><span class="fl">SmartLife / Tuya keys${hint("Access ID and Access Secret of a Cloud Project on iot.tuya.com with your SmartLife app linked to it. They stay on this Mac in a private file, never in the settings file.")}</span><span class="row"><input id="oid" placeholder="Access ID" value="${esc(o.access_id||"")}" ${dis} style="width:14em"><input id="osecret" type="password" placeholder="${o.configured?"Access Secret (kept)":"Access Secret"}" ${dis} style="width:16em"><select id="oregion" ${dis}>${["us","eu","in","cn"].map((r)=>`<option ${o.region===r?"selected":""}>${r}</option>`).join("")}</select>${editing()?`<button class="small" id="okeys">Save & test</button>`:""}</span></div>
${o.configured?"":`<details><summary>how to get the keys</summary><ol class="small"><li>iot.tuya.com, free account, Cloud › Create Cloud Project (Smart Home, your region's data center).</li><li>In the project: Devices › Link App Account › scan the code with the SmartLife app (Me › scan icon).</li><li>Overview tab: copy Access ID and Access Secret here.</li></ol></details>`}</div>
${used?`<p class="small" style="margin-top:8px">Used by fixes: ${used}</p>`:""}
<p class="help">A fix "Power-cycle a smart outlet" (Keys › Fixes) cuts the power only when the device does not answer, never while a HyperDeck is recording.</p></div>`;
<input id="oname" placeholder="short name the fixes use (tascam)"><div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`,(b)=>{
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 ${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</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 style="margin-top:0">Names the keys use that are not here (${n.missing})${hint("A copied campus, or something renamed. Map each name to what this campus has; everything that uses it follows.")}</h3>
<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>
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>`:"";
${folder.blocked?`<div class="notice grey">The folder is there but this page cannot read it. Use Upload, or let the panel's key log in to this Mac (Computers › Setup).</div>`:""}<p id="ateminfo" class="help"></p></div>`;
constok=q("#okeys");if(ok)ok.onclick=async()=>{ok.disabled=true;ok.textContent="Testing…";try{constr=awaitpost("/api/outlets/keys",{access_id:q("#oid").value,access_secret:q("#osecret").value,region:q("#oregion").value});alertBox(r.ok?`Works: ${r.devices} devices on the account.`:r.error);route();}catch(e){alertBox(e.message);route();}};
constoa=q("#oadd");if(oa)oa.onclick=outletDialog;
for(constbofmain.querySelectorAll("[data-oswitch]"))b.onclick=()=>{const[n,on]=b.dataset.oswitch.split("|");confirmBox(`Switch the ${n} outlet ${on==="1"?"on":"OFF"}?${on==="1"?"":" Whatever is plugged into it loses power."}`,on==="1"?"On":"Off",async()=>{try{constr=awaitpost("/api/outlets/switch",{name:n,on:on==="1"});if(!r.ok)alertBox(r.error);setTimeout(route,1500);}catch(e){alertBox(e.message);}});};
for(constbofmain.querySelectorAll("[data-oremove]"))b.onclick=()=>confirmBox(`Remove the ${b.dataset.oremove} outlet from the list? Fixes that use it stop working.`,"Remove",async()=>{try{awaitpost("/api/outlets",{name:b.dataset.oremove,remove:true});route();}catch(e){alertBox(e.message);}});
for(constbofmain.querySelectorAll("[data-rmcheck]"))b.onclick=()=>confirmBox("Remove this MIDI link from its key?","Remove",()=>{if(!ensureDraft())return;const[tid,j]=b.dataset.rmcheck.split("|");constt=state.draft.config.tiles.find((x)=>x.id===tid);t.checks.splice(+j,1);saveDraft();route();});
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);}};
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.");};
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 the Companion names, then Apply."}`);location.hash="#settings/companion";}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.`);
dialog(`<h3>MIDI link${hint("A network MIDI session on one of the Macs, and the peer that must stay in it. Pick from what the Macs report now, or type the names if that Mac is off.")}</h3>
<input id="jpeer" list="jpeers" placeholder="peer (the other end)"><datalist id="jpeers"></datalist>
<input id="jtag" placeholder="key text when lost (LIGHT PC)" maxlength="10">
<input id="jaddr" placeholder="address, if it must be reconnected by address (optional)">
<select id="jkey">${midiKey?`<option value="${esc(midiKey.id)}">on the ${esc(midiKey.label)} key</option>`:""}<option value="__new">on a new key</option></select>
dialog(`<h3>New computer${hint("It appears on Computers; the Setup tab then shows what to do on it (login key, Remote Login). Fixes and checks for it come after.")}</h3>
<input id="jname" placeholder="name, as people say it (Lobby PC)"><input id="jalias" placeholder="short name – filled from the name">
if(!pcs.length)returnalertBox("No Windows PC is listed yet: add the computer first.");
constcur=(location.hash.split("/")[1]||"");
dialog(`<h3>Program a PC can start${hint("The panel starts programs on a PC through a scheduled task (the Setup tab's script creates it). This lists the task and adds a 'start it if it is not running' fix.")}</h3>
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 block"><span class="fl">command${hint("Typed by hand: zsh on a Mac, cmd.exe on a PC. Shown as a command at Apply; never run before Apply.")}</span><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run||"")}</textarea></div>`;
}elseif(f.run)inputs+=`<details><summary>the command it makes${f.kind==="local"?"":" (on "+esc((c.hosts[f.host]||{}).name || f.host) + ")"}</summary><pre class="log small" style="max-height:160px">${esc(f.run)}</pre></details>`;
main.innerHTML=`<h1>Keys${hint("Everything a hold can run. Click a fix to see how it is built.")}</h1><div class="tabs"><button onclick="location.hash='#keys'">Status page</button>${c.restart_page?`<button onclick="location.hash='#keys/restart'">Restart page</button>`:""}<button class="on">Fixes</button>${add}</div>
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="#keys/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="#keys/fixes/"+nid;};