- 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>
"""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")forrinruns}
site=cfg.get("site")or{}
ingest=site.get("ingest_name")or"the Ingest Mac"
steps,scripts=[],[]
ifhost.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"inkinds:
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)})
elifhost.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"inkinds:
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)
exceptValueErrorase:
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"inkinds:
steps.append(("the DVS helper and its sudo rule","Paste the DVS helper script below as root (ARD)."))
if"midi_session"inkinds:
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"inkinds:
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"inkinds:
scripts.append({"name":"DVS helper (run as root)","how":"ARD > Send UNIX Command > as root.","text":mac_dvs_helper(cfg)})
if"helper_solo"inkinds:
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)})
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")))
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))
<div><h2>Livedeck<spanclass="muted small">${o.deck?"as the Stream Deck looks now · click a key":"(Companion is not answering)"}</span></h2>${deckHtml(o.deck,cfg(),"status")}
<divclass="row"style="margin-top:10px"><buttondisabledtitle="arrives with step 4">Changetheplaylist</button><buttondisabledtitle="arriveswithstep4">AddorremoveaHyperDeck</button><buttondisabledtitle="arriveswithstep4">AddaMIDIlink</button></div></div>
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>
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>
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>
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}`;
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("")}
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>
return`<div class="card"><h3>${esc(c.name)}</h3><ul style="list-style:none;padding:0;margin:0">${steps.map((s)=>{constl=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>`;
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>`;
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>`:"";
${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>
<divclass="card"><h3>Thiscampusasabundle</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")}/,orfeedittoanothercampus'spage.</p>
constconns=(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>`:""}
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);}});};
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.