Apollo Beach on its own: a standalone panel to send them

- tools/standalone.py builds dist/<Name> Status Panel.zip: program, install.command, READ ME FIRST.txt
- setup --standalone: campuses.json standalone, tells nobody, no hub, no NA key
- Standalone backs itself up nightly to ~/Documents/Status Panel Backups/<id>
- upgrade-companion --from <.dmg/.zip from bitfocus.io> (no NA needed)
- Only the hub lists campuses or shows other campuses' install lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 13:12:30 -04:00
co-authored by Claude Opus 5.5
parent bea587cfad
commit 2f8e3d47a3
9 changed files with 314 additions and 21 deletions
+53 -9
View File
@@ -153,7 +153,7 @@ def wait_applied(applier, limit=240):
return applier.status or {}
def companion_version_problem(cfg):
def companion_version_problem(cfg, standalone=False):
"""None when this Companion can take the panel (5.x), else what to tell the person."""
try:
discover.companion_get(cfg, "/api/connections", 5)
@@ -162,6 +162,11 @@ def companion_version_problem(cfg):
pass
try:
discover.companion_get(cfg, "/socket.io/?EIO=4&transport=polling", 5)
if standalone:
return ("This Companion is older than version 5. The panel needs Companion 5.0.7: download it for Mac from\n"
" https://bitfocus.io/companion (5.0.7, the .dmg), then on this Mac run\n"
" cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.campus upgrade-companion --from ~/Downloads/<the .dmg>\n"
"(its settings come along; the old version stays installed), then run the installer again.")
return ("This Companion is older than version 5 (Woonsocket ran 4.0.3). The panel needs 5.0.7, as North "
"Attleboro runs. Upgrade it from NA's copy (its settings come along; the old version stays installed):\n"
" cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.campus upgrade-companion\n"
@@ -170,12 +175,18 @@ def companion_version_problem(cfg):
return "Companion does not answer on port 8000: open Companion (and its HTTP API, Settings > Protocols), then paste the line again."
def setup(data_dir, cid, name=None, say=print, home=None):
"""The first setup of a campus (or, when it is set up already, a look for what is new)."""
def setup(data_dir, cid, name=None, say=print, home=None, standalone=False):
"""The first setup of a campus (or, when it is set up already, a look for what is new).
Standalone: it reports to nobody and nobody else sees it (Apollo Beach runs its own)."""
from settings import server, campuses
if home: # where it was installed from: the campus list lives there
if home or standalone: # where it was installed from: the campus list lives there
data = campuses.load(data_dir)
data["home"] = home
if standalone:
data.pop("home", None)
data["standalone"] = True
else:
data["home"] = home
data.pop("standalone", None)
campuses.save(data_dir, data)
site = server.Site(data_dir)
cfg = site.load()
@@ -183,7 +194,7 @@ def setup(data_dir, cid, name=None, say=print, home=None):
say("%s is set up already: looking for what is new." % (cfg.get("site") or {}).get("name", "This campus"))
return look_and_add(site, say=say, force=True)
name = name or CAMPUSES.get(cid) or cid.upper()
problem = companion_version_problem(cfg)
problem = companion_version_problem(cfg, standalone)
if problem:
raise RuntimeError(problem)
for line in companion_prepare(cfg):
@@ -271,7 +282,7 @@ def clear_old_page(cfg, page):
return "cleared"
def upgrade_companion(base, say=print):
def upgrade_companion(base, say=print, source=None):
"""Companion 5.0.7 from `base` (North Attleboro's settings page), as Woonsocket was upgraded on 2026-10-20:
the running Companion is quit, the newest settings folder is copied for 5.0 (and marked newest, or the
launcher stops on "Config version mismatch"), 5.0.7 is installed beside the old one and started."""
@@ -281,6 +292,8 @@ def upgrade_companion(base, say=print):
import urllib.request
root = os.path.expanduser("~/Library/Application Support/companion")
app = "/Applications/Companion 5.0.7.app"
if not os.path.exists(app) and source: # standalone: the .dmg (or .zip) downloaded from bitfocus.io
install_companion_from(source, app, say)
if not os.path.exists(app):
say("Downloading Companion 5.0.7 from %s ..." % base)
tmp = "/tmp/companion-5.0.7.zip"
@@ -316,6 +329,35 @@ def upgrade_companion(base, say=print):
raise RuntimeError("Companion 5.0.7 did not start within 90 s: look at its window on this Mac")
def install_companion_from(source, app, say=print):
"""Companion copied from a downloaded .dmg or .zip to `app` (beside any older one)."""
import shutil
import subprocess
import tempfile
source = os.path.expanduser(source)
tmp = tempfile.mkdtemp(prefix="companion-")
try:
if source.lower().endswith(".dmg"):
mnt = os.path.join(tmp, "mnt")
subprocess.run(["/usr/bin/hdiutil", "attach", "-nobrowse", "-readonly", "-mountpoint", mnt, source], check=True, capture_output=True)
try:
found = [n for n in os.listdir(mnt) if n.startswith("Companion") and n.endswith(".app")]
if not found:
raise RuntimeError("no Companion app inside %s" % source)
subprocess.run(["/usr/bin/ditto", os.path.join(mnt, found[0]), app], check=True)
finally:
subprocess.run(["/usr/bin/hdiutil", "detach", mnt], capture_output=True)
else:
subprocess.run(["/usr/bin/ditto", "-x", "-k", source, tmp], check=True)
found = [n for n in os.listdir(tmp) if n.startswith("Companion") and n.endswith(".app")]
if not found:
raise RuntimeError("no Companion app inside %s" % source)
subprocess.run(["/usr/bin/ditto", os.path.join(tmp, found[0]), app], check=True)
say("Companion installed as %s" % app)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def summary(inv):
known = set(autoconf.COMPANION)
return {"connections": len(inv.get("connections") or []),
@@ -564,13 +606,15 @@ def main(argv=None):
ap.add_argument("--data-dir", default=PARENT)
ap.add_argument("--home", help="the settings page this campus was installed from (the campus list lives there)")
ap.add_argument("--pages", help="rebuild onto these Status,Restart pages (19,20 on every campus)")
ap.add_argument("--standalone", action="store_true", help="a campus that runs on its own: reports to no other campus")
ap.add_argument("--from", dest="source", help="upgrade-companion: a Companion .dmg or .zip downloaded from bitfocus.io")
a = ap.parse_args(argv)
if a.what == "setup":
setup(a.data_dir, a.id, a.name, home=a.home)
setup(a.data_dir, a.id, a.name, home=a.home, standalone=a.standalone)
elif a.what == "rebuild":
rebuild(a.data_dir, pages=tuple(int(p) for p in a.pages.split(",")) if a.pages else None)
elif a.what == "upgrade-companion":
upgrade_companion(a.home or (load_campuses_home(a.data_dir) or "http://10.1.1.1:8020"))
upgrade_companion(a.home or (load_campuses_home(a.data_dir) or "http://10.1.1.1:8020"), source=a.source)
elif a.what == "look":
cfg = sp.load_config(os.path.join(a.data_dir, "config.json"))
inv = discover.look(cfg, a.data_dir)
+5 -1
View File
@@ -16,6 +16,10 @@ at 99): the CAMPUS page's keys are cells ("c_1_3"), and the hub fills them from
layout (sp_view says which). A campus that stops answering goes purple. Pages are only written
where nothing a person made sits (push_page.safe_target): an empty page, else one added at the
end. State: campuses.json in the data folder.
A standalone campus (Apollo Beach, 2026-10-21: "they want to take care of it on their own ... i dont
need to monitor AB from NA") has "standalone" set instead of a home: it tells nobody, is no hub, and
lists only itself.
"""
import gzip
import logging
@@ -271,7 +275,7 @@ class Mirror:
@staticmethod
def is_hub(data):
return not data.get("home")
return not data.get("home") and not data.get("standalone")
def sync_registry(self):
"""A campus: tell the hub where this settings page is (it learns nothing back). The hub: know itself."""
+6 -3
View File
@@ -722,8 +722,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
if path == "/api/campuses":
# The hub's own view (its settings page). A campus only ever lists itself.
data = campuses.load(site.data_dir)
return self.send(200, {"list": data.get("list") or {}, "pages": data.get("pages") or {} if not data.get("home") else {},
"error": data.get("error"), "hub": not data.get("home")})
hub = campuses.Mirror.is_hub(data)
return self.send(200, {"list": data.get("list") or {}, "pages": data.get("pages") or {} if hub else {},
"error": data.get("error"), "hub": hub, "standalone": bool(data.get("standalone"))})
if path == "/api/fleet":
return self.send(200, {"machines": list((site.read_json("fleet.json", {}) or {}).values())})
if path == "/api/discover":
@@ -732,7 +733,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(200, {"auto": bool((cfg.get("discover") or {}).get("auto")), "last": campus.load_last(site.data_dir),
"joined": discover.registered(site.data_dir),
"join": {"mac": "curl -fsSL %s/join | sh" % base, "pc": "irm %s/join.ps1 | iex" % base},
"base": base})
"base": base, "hub": campuses.Mirror.is_hub(campuses.load(site.data_dir))})
if path == "/api/config":
cfg = site.load()
return self.send(200, {"config": schema.mask_secrets(cfg), "sha": site.cfg_sha, "mtime": site.cfg_mtime})
@@ -911,6 +912,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
or not allowed_client(url[7:].split(":")[0]):
return self.send(400, {"error": "an id like ws and an address like http://10.5.1.1:8020"})
data = campuses.load(site.data_dir)
if not campuses.Mirror.is_hub(data):
return self.send(403, {"error": "this settings page keeps no list of campuses"})
lst = data.setdefault("list", {})
for other in [k for k, e in lst.items() if k != cid and (e or {}).get("url") == url]:
lst.pop(other) # the same Mac under its old name (before its first setup)
+2 -2
View File
@@ -949,8 +949,8 @@
<div class="card"><h3 style="margin-top:0">Computers${hint("Each computer runs the join line once: it lets this Mac log in with its key and says it is here. The next look gives its programs keys.")}</h3>
<div class="tablewrap"><table>${comps}${joined}${comps || joined ? "" : "<tr><td class='muted'>none yet</td></tr>"}</table></div>
${line(d.join.mac, "Mac: Terminal, paste")}${line(d.join.pc, "PC: PowerShell as administrator, paste")}</div>
<div class="card"><h3 style="margin-top:0">Set up another campus${hint("On that campus's Mac that runs Companion: Terminal, paste. It installs the panel from here, finds the gear and makes the pages. Then each computer there runs that Mac's join line.")}</h3>
${campuses.map(([k, n]) => line(`curl -fsSL "${d.base}/campus/install?id=${k}" | zsh`, n)).join("")}</div>`;
${d.hub ? `<div class="card"><h3 style="margin-top:0">Set up another campus${hint("On that campus's Mac that runs Companion: Terminal, paste. It installs the panel from here, finds the gear and makes the pages. Then each computer there runs that Mac's join line.")}</h3>
${campuses.map(([k, n]) => line(`curl -fsSL "${d.base}/campus/install?id=${k}" | zsh`, n)).join("")}</div>` : ""}`;
}
function siteHtml(c) {
const f = F("site.editing_code");