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
+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", {})]