From 6d1cfea2f7b65b8f92a3f50a8d4ab3cc7e210b28 Mon Sep 17 00:00:00 2001 From: christian Date: Tue, 20 Oct 2026 17:17:04 -0400 Subject: [PATCH] Every campus matches NA, tailored: Restart page, Companion-Mac programs, NA's auto-fix hours, nightly backups, restart from NA - Auto-setup: programs on the Companion Mac itself (ProPresenter at Woonsocket) get a local process check and local start/restart fixes; every campus gets a Restart page (keys arrive as computers join); NA's auto-fix hours (Tue, Wed, Sun); a program found after its Companion connection joins that key instead of making a second one. settings.campus rebuild remakes a campus from what is found now, keeping its name, PIN and pages; upgrade-companion brings Companion to 5.0.7 from NA's copy (/campus/companion.zip), as Woonsocket was; setup stops with that advice on an older one. - NA's CAMPUSES page has a RESTART key per campus; the CAMPUS page shows that campus's Restart page and a hold there restarts it at that campus. - Backups: backup/backup-campuses.sh, nightly 04:30 on NA (installed everywhere, acts on the hub), into Drive > Waters Creative > Apps > Status Panel Backups/, 30 kept, alert email via the checklist's alert.js. Campuses report the account to back up with. sites/ws/config.json in the repo. - Woonsocket rebuilt: 9 keys (PROP with ProPresenter on its Mac), Restart page 20 (SUPERRACK, VISTA, ABLETON, PROP), auto-fix on NA's hours; all OK. Both campuses backed up to Drive by the job. Co-Authored-By: Claude Opus 5.5 --- README.md | 26 +- backup/backup-campuses.sh | 80 ++++++ install.sh | 22 ++ settings/autoconf.py | 46 ++- settings/campus.py | 113 +++++++- settings/campuses.py | 59 ++-- settings/discover.py | 5 + settings/server.py | 25 +- sites/ws/config.json | 581 ++++++++++++++++++++++++++++++++++++++ tests/test_campus.py | 57 +++- 10 files changed, 985 insertions(+), 29 deletions(-) create mode 100755 backup/backup-campuses.sh create mode 100644 sites/ws/config.json diff --git a/README.md b/README.md index 78adfa7..d90b575 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,28 @@ device or MIDI link into its key). Never during the auto-fix hours or a recordin was there before and was taken off by someone is not added back (`discover-seen.json`). "Look now" adds what is new at once. Everything it makes is ordinary settings, changed on the page like North Attleboro's. +## The campuses at a glance + +| Campus | Settings page (bookmark these) | Companion Mac | Status / Restart pages | +|---|---|---|---| +| North Attleboro (hub) | http://10.1.1.1:8020/ | NA Ingest | 19 / 20, READY 24, CAMPUSES 98, CAMPUS 99 | +| Woonsocket | http://10.2.2.210:8020/ | WS ProPresenter Mac (wsprop) | 19 / 20 | + +Every campus: alarms armed all day; auto-fix on NA's hours (Tue 14:00–22:00, Wed 09:00–21:00, Sun 05:00–14:00); +a hold on any key fixes it at any time, on that campus's own deck or from NA's CAMPUS page; ALARMS stops auto-fix. +`python3 -m settings.campus rebuild` (on a campus's Companion Mac, in the panel folder) makes its setup again from +what is found now, keeping its name, PIN and pages. `python3 -m settings.campus upgrade-companion` brings an older +Companion to 5.0.7 from NA's copy (settings come along; the old version stays installed). + +## Backups + +Nightly at 04:30 NA's LaunchAgent `org.waterschurch.status-backup` (`backup/backup-campuses.sh`) copies every +campus's settings, Apply history and page-key record into Google Drive: Shared drives > Waters Creative > Apps > +Status Panel Backups > > -.tgz, newest 30 kept, the same pattern and alert email as the +checklist's backup-to-drive.sh. Other campuses are reached over SSH as the account they report. The repo keeps each +campus's settings too: `config.json` (NA) and `sites//config.json`; a reinstall with `--site ` starts from it. +Smart-plug keys (outlets.json) are never copied. + ## Every campus on North Attleboro's deck (NA only) Each campus set up from NA's install line tells NA's settings page where it is (`/api/campus/hello`) and learns @@ -267,8 +289,8 @@ nothing back: no campus ever shows another, and only NA (the hub: no "home" in ` (`settings/campuses.py`). NA's settings page reads each campus's key states every two seconds (`/api/state`) and keeps two pages in NA's Companion, added at the end when no page is empty (today 98 and 99): -- **CAMPUSES (98):** one key per campus, its ALL key with its name ("WS / ALL OK"); purple "WS ?" when it stops answering. Tap NA: NA's status page. Tap another: the CAMPUS page showing it. -- **CAMPUS (99):** the tapped campus's status page, same places, colours and words, live; view only (holds stay on that campus's own deck). Bottom-left goes back to CAMPUSES. +- **CAMPUSES (98):** row 0, one key per campus, its ALL key with its name ("WS / ALL OK"); purple "WS ?" when it stops answering. Row 1, its RESTART key. Tap NA's: NA's own pages. Tap another's: the CAMPUS page showing it. +- **CAMPUS (99):** the tapped campus's status (or Restart) page, same places, colours and words, live. Tap = acknowledge, hold = fix, at that campus, exactly as on its own deck (a Restart key's hold restarts it there). Bottom-left ("WS / ALL SITES") goes back. ## SSH from the admin laptop to every machine diff --git a/backup/backup-campuses.sh b/backup/backup-campuses.sh new file mode 100755 index 0000000..9f3e0d6 --- /dev/null +++ b/backup/backup-campuses.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# backup-campuses.sh - every campus's status panel settings, off-site in Google Drive, nightly. +# +# The same pattern as the checklist's backup-to-drive.sh on this Mac: a LaunchAgent runs it at 04:30, +# it copies into the shared Drive (Waters Creative > Apps > Status Panel Backups > ), keeps +# the newest 30 per campus, and emails an alert (through the checklist's alert.js) when something +# fails, because a backup nobody hears about failing is no backup. +# +# Only the hub (North Attleboro: no "home" in campuses.json) does anything; elsewhere it exits. +# Each campus is reached over SSH with the panel's own key, as the account it reported. What is +# copied: config.json (the settings), history/ (every Apply), the page-key record and the campus +# lists. Never outlets.json (the smart-plug keys stay on their Mac). + +set -uo pipefail +DEST="$HOME/Library/Application Support/status-panel" +LOG="$HOME/Library/Logs/status-panel-backup.log" +KEEP=30 +KEY="$HOME/.ssh/id_status_panel" +FILES="config.json history nav_locations.json campuses.json computers.json discover-seen.json" +log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"; echo "$*"; } +alert() { + log "ALERT: $1" + local js="$HOME/checklist-app/scripts/alert.js" + [ -f "$js" ] && node "$js" "Status panel backup: $1" "${2:-}$(printf '\n\nLog: %s' "$LOG")" >> "$LOG" 2>&1 || true +} + +/usr/bin/python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d.get("list") and not d.get("home") else 1)' \ + "$DEST/campuses.json" 2>/dev/null || { log "not the hub: nothing to back up from here"; exit 0; } + +DRIVE="" +for acct in "$HOME/Library/CloudStorage"/GoogleDrive-*; do + [ -d "$acct/Shared drives/Waters Creative/Apps" ] && { DRIVE="$acct/Shared drives/Waters Creative/Apps/Status Panel Backups"; break; } +done +if [ -z "$DRIVE" ]; then + alert "tonight's backup did not run - the Google Drive folder is missing" \ + "Shared drives > Waters Creative > Apps could not be found on this Mac. Usually Google Drive is not running or signed out." + exit 1 +fi +mkdir -p "$DRIVE" || { alert "could not create $DRIVE"; exit 1; } +log "── run start ── into $DRIVE" + +STAMP="$(date '+%Y-%m-%d')" +FAILED="" +while IFS=$'\t' read -r id user host; do + [ -z "$id" ] && continue + mkdir -p "$DRIVE/$id" + OUT="$DRIVE/$id/$id-$STAMP.tgz" + TMP="$(mktemp -t status-backup)" + if [ "$host" = "self" ]; then + (cd "$DEST" && tar -czf "$TMP" $(ls -d $FILES 2>/dev/null)) 2>>"$LOG" + else + ssh -n -i "$KEY" -o BatchMode=yes -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new "$user@$host" \ + "cd 'Library/Application Support/status-panel' && tar -czf - \$(ls -d $FILES 2>/dev/null)" > "$TMP" 2>>"$LOG" + fi + # Listing it proves it whole (not gzip -t: macOS tar pads a piped archive, which gzip calls "trailing garbage"). + if [ -s "$TMP" ] && tar -tzf "$TMP" 2>/dev/null | grep -qx 'config.json'; then + cp "$TMP" "$OUT.part" && mv "$OUT.part" "$OUT" && log "✓ $id -> $(basename "$OUT") ($(du -k "$OUT" | cut -f1) KB)" + ls -1t "$DRIVE/$id"/"$id"-*.tgz 2>/dev/null | tail -n +$((KEEP + 1)) | while read -r old; do rm -f "$old"; done + else + FAILED="$FAILED $id" + log "✗ $id: nothing usable came back from $user@$host" + fi + rm -f "$TMP" +done < <(/usr/bin/python3 - "$DEST/campuses.json" "$DEST/config.json" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])); me = (json.load(open(sys.argv[2])).get("site") or {}).get("id") +for cid, e in sorted((d.get("list") or {}).items()): + if cid == me: + print("%s\t-\tself" % cid) + elif e.get("user") and e.get("url"): + print("%s\t%s\t%s" % (cid, e["user"], e["url"].split("//")[1].split(":")[0])) + else: + print("%s\t?\t?" % cid) +PY +) +if [ -n "$FAILED" ]; then + alert "no backup tonight for:$FAILED" "The other campuses were backed up. A campus missing here is usually off, off the network, or no longer letting this Mac log in." + exit 1 +fi +log "── done ──" diff --git a/install.sh b/install.sh index f897d6a..6a1c82d 100644 --- a/install.sh +++ b/install.sh @@ -217,6 +217,28 @@ rm -rf "$DEST/kit" && mkdir -p "$DEST/kit/sites" cp "$SRC/install.sh" "$DEST/kit/" [[ -f "$SRC/README.md" ]] && cp "$SRC/README.md" "$DEST/kit/" [[ -d "$SRC/sites/_template" ]] && cp -R "$SRC/sites/_template" "$DEST/kit/sites/" +# The nightly off-site backup of every campus's settings (does anything only on the hub, NA). +if [[ -d "$SRC/backup" ]]; then + rm -rf "$DEST/backup" && cp -R "$SRC/backup" "$DEST/" && chmod +x "$DEST/backup/"*.sh + BACKUP_LABEL=org.waterschurch.status-backup + BACKUP_PLIST="$HOME/Library/LaunchAgents/$BACKUP_LABEL.plist" + cat > "$BACKUP_PLIST" < + + + + Label$BACKUP_LABEL + ProgramArguments/bin/bash$DEST/backup/backup-campuses.sh + EnvironmentVariablesPATH/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin + StartCalendarIntervalHour4Minute30 + StandardErrorPath$HOME/Library/Logs/status-panel-backup.err.log + StandardOutPath$HOME/Library/Logs/status-panel-backup.out.log + + +EOF2 + launchctl bootout "$DOMAIN/$BACKUP_LABEL" 2>/dev/null || true + launchctl bootstrap "$DOMAIN" "$BACKUP_PLIST" 2>/dev/null || true +fi # The panel's own login key for the other computers (made once, never replaced). if [[ ! -f "$HOME/.ssh/id_status_panel" ]]; then mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" diff --git a/settings/autoconf.py b/settings/autoconf.py index 5add958..cf30b63 100644 --- a/settings/autoconf.py +++ b/settings/autoconf.py @@ -86,8 +86,11 @@ SHARED_FIXES = { } STATUS_ROWS, COLS = 4, 8 +# North Attleboro's auto-fix hours, for every campus: Tuesday and Wednesday rehearsals, Sunday services. DEFAULT_AUTO_FIX = {"after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60, - "restart_connections": True, "windows": [{"days": ["sun"], "start": "05:00", "end": "14:00"}]} + "restart_connections": True, "windows": [{"days": ["tue"], "start": "14:00", "end": "22:00"}, + {"days": ["wed"], "start": "09:00", "end": "21:00"}, + {"days": ["sun"], "start": "05:00", "end": "14:00"}]} # --- small helpers --------------------------------------------------------------------- @@ -218,6 +221,27 @@ def items(inv, nets): apps_at[(a["app"], c.get("ip"))] = item for ip in c.get("ips") or []: apps_at.setdefault((a["app"], ip), item) + # Programs on this Mac itself (the Companion Mac): a local process check and local start/restart fixes. + ing = inv.get("ingest") or {} + for a in MAC_APPS: + exe = (ing.get("exes") or {}).get(a["process"]) + app_path = (ing.get("paths") or {}).get(a["process"]) + if not exe or not app_path: + continue + fid = slug("%s_ingest" % a["app"]) + pretty = a["label"].title() if a["app"] not in ("propresenter", "ableton") else {"propresenter": "ProPresenter", "ableton": "Ableton"}[a["app"]] + start = {"template": "mac_start", "params": {"host": "ingest", "process": a["process"], "app": app_path}, + "label": "start %s on this Mac if it is not running" % pretty, "short": a["label"]} + again = {"template": "mac_restart", "params": {"host": "ingest", "process": a["process"], "app": app_path, "wait_seconds": 10, "force": True}, + "label": "restart %s on this Mac (closes it first)" % pretty, "short": a["label"]} + item = {"ident": "app:ingest|%s" % a["process"], "group": a.get("group") or ("VIDEO" if a["app"] == "arena" else "STREAM" if a["app"] == "obs" else "SHOW"), + "label": a["label"], "checks": [{"type": "process", "match": exe, "name": a["label"], "fix": fid}], + "fixes": {fid: start, "restart_" + fid: again}, "restart": [{"label": a["label"], "fix": "restart_" + fid}] if a.get("restart") else [], + "also": [], "fold": a.get("fold")} + out.append(item) + for ip in [(ing.get("wired") or {}).get("ip")] + list(ing.get("ips") or []) + ["127.0.0.1"]: + if ip: + apps_at.setdefault((a["app"], ip), item) # Companion connections. counts = {} for conn in inv.get("connections") or []: @@ -343,7 +367,7 @@ def layout(cfg, new_tiles, restart_items): """Put the keys on the Status page: everything fits, so no group keys; otherwise group keys along the top row and the members after them (a member with no room left shows inside its group only).""" fixed = {tuple(t["pos"]) for t in cfg["tiles"] if t.get("pos")} - if restart_items: + if cfg.get("restart_page"): fixed.add(tuple(cfg["restart_page"]["jump_pos"])) cells = free_cells(fixed) groups = [g for g in GROUPS if any(t["_group"] == g for t in new_tiles)] @@ -386,10 +410,10 @@ def place_new(cfg, tile): def restart_layout(cfg, restart_items): - if not restart_items: - cfg.pop("restart_page", None) + """The Restart page always exists (a campus set up before its computers joined gets their keys later).""" + rp = cfg.get("restart_page") + if not rp: return - rp = cfg["restart_page"] taken = {tuple(rp["back_pos"])} | {tuple(k["pos"]) for k in rp.get("keys", [])} cells = free_cells(taken) ids = {k["id"] for k in rp.get("keys", [])} @@ -474,6 +498,18 @@ def extend(cfg, inv, seen): continue if any(c.get("params", {}).get("host") not in cfg["hosts"] for c in it["checks"] if c.get("template")): continue + # A program whose Companion connection already has a key (Woonsocket's PROP got a second key): + # its own checks join that key. + home = next((t for t in cfg["tiles"] if any(("conn:" + c.get("conn", "")) in it["also"] for c in t.get("checks", []) if c.get("conn"))), None) + if home is not None: + for fid, f in it["fixes"].items(): + cfg["fixes"].setdefault(fid, f) + home["checks"] += [c for c in it["checks"] if not c.get("conn")] + if it["restart"] and cfg.get("restart_page"): + restart_layout(cfg, it["restart"]) + added.append("%s checks on the %s key" % (key_text(it["label"]), home["label"])) + now_seen += [it["ident"]] + it["also"] + continue tid = slug(it["label"], taken) taken.add(tid) for fid, f in it["fixes"].items(): diff --git a/settings/campus.py b/settings/campus.py index 16527ab..c8ab0de 100644 --- a/settings/campus.py +++ b/settings/campus.py @@ -38,7 +38,7 @@ SEEN, LAST = "discover-seen.json", "discover-last.json" # What the program kit carries (no settings, keys or history of the campus that serves it). KIT_FILES = ("status_panel.py", "readiness.py", "coremidi.py", "netgear.py", "schema.py", "make_page.py", "push_page.py", "nav_keys.py", "atem_watch.js", "atem_state.js", "nav_templates.json") -KIT_DIRS = ("settings", "remote-access", "companion-objects") +KIT_DIRS = ("settings", "remote-access", "companion-objects", "backup") # --- Companion: the panel's variables, its blink clock, and two free pages --------------- @@ -153,6 +153,23 @@ def wait_applied(applier, limit=240): return applier.status or {} +def companion_version_problem(cfg): + """None when this Companion can take the panel (5.x), else what to tell the person.""" + try: + discover.companion_get(cfg, "/api/connections", 5) + return None + except OSError: + pass + try: + discover.companion_get(cfg, "/socket.io/?EIO=4&transport=polling", 5) + 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" + "then paste the install line again.") + except OSError: + 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).""" from settings import server, campuses @@ -166,6 +183,9 @@ 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) + if problem: + raise RuntimeError(problem) for line in companion_prepare(cfg): say("Companion: added %s" % line) pages = pick_pages(cfg) @@ -191,6 +211,83 @@ def setup(data_dir, cid, name=None, say=print, home=None): return new +def rebuild(data_dir, say=print): + """A campus's setup made again from what is found now (today's recipes), keeping its name, PIN, + pages and site details. Applied like any Apply (it waits for a recording; history keeps the old one).""" + from settings import server + site = server.Site(data_dir) + cfg = site.load() + s = cfg.get("site") or {} + inv = discover.look(cfg, data_dir) + if inv.get("companion_error"): + raise RuntimeError("Companion does not answer: %s" % inv["companion_error"]) + rp = (cfg.get("restart_page") or {}).get("page") + if not rp: + rp = next(p for p in pick_pages(cfg, 3) if p != cfg["page"]) + new, seen = autoconf.build(template(data_dir), inv, {"id": s.get("id"), "name": s.get("name")}, s.get("editing_code"), (cfg["page"], rp)) + for k in ("contacts", "live_services", "ingest_name", "settings_port"): + if k in s: + new["site"][k] = s[k] + new["discover"] = dict(new.get("discover") or {}, **(cfg.get("discover") or {})) + errors, _ = schema.validate(new) + if errors: + raise RuntimeError("the rebuilt config does not pass the rules: %s" % "; ".join(errors[:4])) + r = site.applier.start(new, "auto-setup: rebuilt with today's recipes", "auto-setup", "127.0.0.1") + if not r.get("ok"): + raise RuntimeError(r.get("error") or "the Apply did not start") + st = wait_applied(site.applier) + save_seen(data_dir, load_seen(data_dir) | set(seen)) + say("%s: %s" % (st.get("state"), st.get("outcome", ""))) + say("Keys: %s" % ", ".join(t["label"] for t in new["tiles"] if not t.get("kind"))) + say("Restart page %s: %s" % (rp, ", ".join(k["label"] for k in new.get("restart_page", {}).get("keys", [])) or "no keys yet")) + return new + + +def upgrade_companion(base, say=print): + """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.""" + import glob + import shutil + import subprocess + import urllib.request + root = os.path.expanduser("~/Library/Application Support/companion") + app = "/Applications/Companion 5.0.7.app" + if not os.path.exists(app): + say("Downloading Companion 5.0.7 from %s ..." % base) + tmp = "/tmp/companion-5.0.7.zip" + with urllib.request.urlopen(base.rstrip("/") + "/campus/companion.zip", timeout=600) as r, open(tmp, "wb") as f: + shutil.copyfileobj(r, f) + subprocess.run(["/usr/bin/ditto", "-x", "-k", tmp, "/Applications/"], check=True) + os.remove(tmp) + subprocess.run(["/usr/bin/codesign", "--verify", "--deep", "--strict", app], check=True) + dbs = sorted(glob.glob(os.path.join(root, "v*", "db.sqlite")), key=os.path.getmtime) + if not os.path.exists(os.path.join(root, "v5.0", "db.sqlite")): + if not dbs: + raise RuntimeError("no Companion settings found to bring along") + newest = os.path.dirname(dbs[-1]) + subprocess.run(["/usr/bin/pkill", "-TERM", "-f", "Companion.*\\.app/Contents/MacOS/Companion"]) + for _ in range(40): + if subprocess.run(["/usr/bin/pgrep", "-f", "Companion.*\\.app/Contents/MacOS/Companion"], capture_output=True).returncode: + break + time.sleep(0.5) + shutil.copytree(newest, os.path.join(root, "v5.0")) + say("Settings copied from %s to v5.0 (the old folder stays as it was)" % os.path.basename(newest)) + for f in ("db.sqlite", "cache.sqlite"): + p = os.path.join(root, "v5.0", f) + if os.path.exists(p): + os.utime(p) + subprocess.run(["/usr/bin/open", "-g", app], check=True) + for _ in range(90): + try: + discover.companion_get({"companion": {}}, "/api/connections", 2) + say("Companion 5.0.7 is running. In System Settings > General > Login Items, replace the old Companion with 5.0.7.") + return True + except OSError: + time.sleep(1) + raise RuntimeError("Companion 5.0.7 did not start within 90 s: look at its window on this Mac") + + def summary(inv): known = set(autoconf.COMPANION) return {"connections": len(inv.get("connections") or []), @@ -274,6 +371,14 @@ def loop(site, stop, poke): stop.wait(20) # a computer just joined: give its SSH a moment +def load_campuses_home(data_dir): + try: + with open(os.path.join(data_dir, "campuses.json")) as f: + return json.load(f).get("home") + except (OSError, ValueError): + return None + + def load_last(data_dir): try: with open(os.path.join(data_dir, LAST)) as f: @@ -411,7 +516,7 @@ try { def main(argv=None): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - ap.add_argument("what", choices=("setup", "look", "add")) + ap.add_argument("what", choices=("setup", "look", "add", "rebuild", "upgrade-companion")) ap.add_argument("--id", default="new") ap.add_argument("--name") ap.add_argument("--data-dir", default=PARENT) @@ -419,6 +524,10 @@ def main(argv=None): a = ap.parse_args(argv) if a.what == "setup": setup(a.data_dir, a.id, a.name, home=a.home) + elif a.what == "rebuild": + rebuild(a.data_dir) + elif a.what == "upgrade-companion": + upgrade_companion(a.home or (load_campuses_home(a.data_dir) or "http://10.1.1.1:8020")) elif a.what == "look": cfg = sp.load_config(os.path.join(a.data_dir, "config.json")) inv = discover.look(cfg, a.data_dir) diff --git a/settings/campuses.py b/settings/campuses.py index e4aa2a1..8e556b9 100644 --- a/settings/campuses.py +++ b/settings/campuses.py @@ -63,8 +63,11 @@ def my_url(cfg): def me(cfg, url=None): + import pwd site = cfg.get("site") or {} - return {"id": site.get("id") or "new", "name": site.get("name") or "This campus", "url": url or my_url(cfg)} + # The account this panel runs as: the hub logs in with it for the nightly backup. + return {"id": site.get("id") or "new", "name": site.get("name") or "This campus", "url": url or my_url(cfg), + "user": pwd.getpwuid(os.getuid()).pw_name} def layout_sha(cfg): @@ -94,6 +97,7 @@ def http_json(url, body=None, timeout=3): VIEW = {"state": "sp_state_view", "heartbeat": "sp_hb_view"} SELECT = "sp_view" VIEW_CMD = "sp_view_cmd" # a tap or hold on the CAMPUS page, passed on to that campus's own watchdog +MODE = "sp_view_mode" # "" = that campus's status page, "RESTART" = its Restart page CELLS = [(r, c) for r in range(4) for c in range(1, 8)] @@ -132,14 +136,16 @@ def build_view(local_cfg, page, overview_page): "Tap: back to the CAMPUSES page (page %d)." % overview_page, "STATUS jump key") for layer in back["style"]["layers"]: if layer["type"] == "text": # which campus this page shows, then the way back - layer["text"] = make_page.prop("`${$(custom:%s)}\\nALL SITES`" % SELECT, True) + layer["text"] = make_page.prop("`${$(custom:%s)} ${$(custom:%s)}\\nALL SITES`" % (SELECT, MODE), True) make_page.place(controls, (3, 0), back) return make_page.page_export("%d\\nCampus" % page, page, controls, local_cfg) -def select_action(cid): - return {"type": "action", "id": make_page.stable_id("campus_" + cid, "select"), "definitionId": "custom_variable_set_value", - "connectionId": "internal", "options": {"name": make_page.prop(SELECT), "value": make_page.prop(cid.upper())}, "children": {}} +def select_action(cid, mode=""): + return [{"type": "action", "id": make_page.stable_id("campus_" + cid + mode, "select"), "definitionId": "custom_variable_set_value", + "connectionId": "internal", "options": {"name": make_page.prop(SELECT), "value": make_page.prop(cid.upper())}, "children": {}}, + {"type": "action", "id": make_page.stable_id("campus_" + cid + mode, "mode"), "definitionId": "custom_variable_set_value", + "connectionId": "internal", "options": {"name": make_page.prop(MODE), "value": make_page.prop(mode)}, "children": {}}] def campus_key(cid, name, names, actions, note, local_cfg): @@ -160,18 +166,29 @@ def campus_key(cid, name, names, actions, note, local_cfg): def build_overview(local_cfg, campuses, page, view_page): + """Row 0: each campus's ALL key (tap: its status page). Row 1: its RESTART key (tap: its Restart page).""" controls = make_page.nav_controls(local_cfg) mine = (local_cfg.get("site") or {}).get("id") - for cid, cell in zip(sorted(campuses, key=lambda c: (c != mine, c)), CELLS): + rp = (local_cfg.get("restart_page") or {}).get("page") + for col, cid in enumerate(sorted(campuses, key=lambda c: (c != mine, c))[:7], start=1): name = campuses[cid].get("name", cid) + tag = short(cid, name) if cid == mine: key = campus_key(cid, name, local_cfg["variables"], [make_page.set_page_action("campus_" + cid, "go", local_cfg["page"])], "Tap: its status page (page %d)." % local_cfg["page"], local_cfg) + restart = make_page.static_button("campus_rs_" + cid, "%s\\nRESTART" % tag, 0x37474F, 0xFFFFFF, rp, + "Tap: this campus's Restart page (page %s)." % rp, "STATUS jump key") if rp else None else: key = campus_key(cid, name, {"state": "sp_state_" + cid, "heartbeat": "sp_hb_" + cid}, - [select_action(cid), make_page.set_page_action("campus_" + cid, "go", view_page)], + select_action(cid) + [make_page.set_page_action("campus_" + cid, "go", view_page)], "Tap: its status page, live (page %d)." % view_page, local_cfg) - make_page.place(controls, cell, key) + restart = make_page.static_button("campus_rs_" + cid, "%s\\nRESTART" % tag, 0x37474F, 0xFFFFFF, view_page, + "Tap: %s's Restart page, live (page %d); hold a key there to restart it at %s." % (name, view_page, name), + "STATUS jump key") + restart["steps"]["0"]["action_sets"]["up"] = select_action(cid, "RESTART") + restart["steps"]["0"]["action_sets"]["up"] + make_page.place(controls, (0, col), key) + if restart: + make_page.place(controls, (1, col), restart) return make_page.page_export("%d\\nCampuses" % page, page, controls, local_cfg) @@ -179,7 +196,7 @@ def ensure_variables(cfg, ids): from settings import campus have, _ = campus.companion_state(cfg) want = [n for cid in ids for n in ("sp_state_" + cid, "sp_hb_" + cid) if n not in have] - want += [n for n in (VIEW["state"], VIEW["heartbeat"], SELECT, VIEW_CMD) if n not in have] + want += [n for n in (VIEW["state"], VIEW["heartbeat"], SELECT, VIEW_CMD, MODE) if n not in have] if not want: return comp = cfg.get("companion") or {} @@ -250,6 +267,7 @@ class Mirror: self.site = site self.remote = {} # id -> last /api/state answer self.keys = {} # id -> [{"id", "pos"}] of its status page (from its /api/config) + self.rkeys = {} # id -> the same for its Restart page @staticmethod def is_hub(data): @@ -294,6 +312,7 @@ class Mirror: if st and (layouts.get(cid) != st.get("layout") or cid not in self.keys): remote_cfg = http_json(e["url"].rstrip("/") + "/api/config", timeout=8)["config"] self.keys[cid] = [{"id": t["id"], "pos": t["pos"]} for t in remote_cfg.get("tiles", []) if t.get("pos")] + self.rkeys[cid] = [{"id": k["id"], "pos": k["pos"]} for k in (remote_cfg.get("restart_page") or {}).get("keys", []) if k.get("pos")] layouts[cid] = st.get("layout") ensure_variables(cfg, list(others)) taken = self.taken_pages(cfg) | {p for p in pages.values() if p} @@ -313,13 +332,14 @@ class Mirror: save(self.site.data_dir, data) return data - def pass_on(self, pick, others): + def pass_on(self, pick, others, restart=False): """A tap or hold on the CAMPUS page goes to the shown campus's watchdog, through its Companion.""" text = (self.site.comp.get_custom(VIEW_CMD) or "").strip() if not text: return self.site.comp.set_custom(VIEW_CMD, "") - cmds = campus_command(self.keys.get(pick), text, time.time()) if pick else [] + keys = (self.rkeys if restart else self.keys).get(pick) + cmds = campus_command(keys, text, time.time(), restart) if pick else [] if not cmds: return host = urllib.parse.urlparse(others[pick]["url"]).hostname @@ -362,20 +382,23 @@ class Mirror: # Nothing tapped yet (or a campus that left): show the first one rather than a purple page. pick = sorted(others)[0] self.site.comp.set_custom(SELECT, pick.upper()) - self.pass_on(pick if pick in others else None, others) + restart = (self.site.comp.get_custom(MODE) or "").strip().upper() == "RESTART" + self.pass_on(pick if pick in others else None, others, restart) st = self.remote.get(pick) - if st and pick in self.keys and (data.get("pages") or {}).get("view"): + keys = (self.rkeys if restart else self.keys).get(pick) + if st and keys is not None and (data.get("pages") or {}).get("view"): try: state = json.loads(st.get("state") or "{}") except ValueError: state = {} - self.site.comp.set_custom(VIEW["state"], json.dumps(view_state(state, self.keys[pick]), separators=(",", ":"))) + self.site.comp.set_custom(VIEW["state"], json.dumps(view_state(state, keys), separators=(",", ":"))) self.site.comp.set_custom(VIEW["heartbeat"], str(st.get("hb") or "0")) -def campus_command(keys, text, now): +def campus_command(keys, text, now, restart=False): """The CAMPUS page's taps and holds ("tap 1 3