Hold a failing button to fix what it is failing on.
Fixes come from config.json only and run over SSH with NA Ingest's status-panel key (ProPresenter restart, Ableton / Church Translate start, reopen DVS), locally (Resi bridge, captions, ProdDash) or as a HyperDeck reboot. A green button does nothing, each fix waits 60 s before it can run again, and a recording deck is never rebooted. New remote checks ask the Macs every 10 s whether their apps are running. Group buttons and INGEST now have a hold too; tests never run a real fix or open an SSH connection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+150
-7
@@ -22,6 +22,7 @@ import logging.handlers
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -137,6 +138,7 @@ class Snapshot:
|
||||
self.http = {} # url -> reachable
|
||||
self.disk = {} # path -> free GB
|
||||
self.listeners = {} # MIDI source name -> coremidi.Listener
|
||||
self.remote = {} # (host, command) -> True / False / None (could not ask)
|
||||
|
||||
def var(self, label, name):
|
||||
return self.vars.get((label, name))
|
||||
@@ -424,11 +426,22 @@ def check_midi_rx(chk, snap, ctx):
|
||||
return [("rx", Result(OK, info="%s %s" % (name, fmt_age(age))))]
|
||||
|
||||
|
||||
def remote_key(chk):
|
||||
return "%s\0%s" % (chk["host"], chk["cmd"])
|
||||
|
||||
|
||||
def check_remote(chk, snap, ctx):
|
||||
"""A command run over SSH on another machine, e.g. "is ProPresenter running?"."""
|
||||
if snap.remote.get(remote_key(chk)) is False:
|
||||
return [("remote", Result(ERR, chk["msg"], named=True))]
|
||||
return [("remote", Result(OK))]
|
||||
|
||||
|
||||
CHECKS = {
|
||||
"conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure,
|
||||
"psm": check_psm, "tascam": check_tascam, "ping": check_ping, "launchd": check_launchd,
|
||||
"process": check_process, "http": check_http, "disk": check_disk, "midi_peer": check_midi_peer,
|
||||
"mdns": check_mdns, "midi_rx": check_midi_rx,
|
||||
"mdns": check_mdns, "midi_rx": check_midi_rx, "remote": check_remote,
|
||||
}
|
||||
|
||||
# Checks whose connection a long press may restart (Dante is shared, so not that one).
|
||||
@@ -574,6 +587,9 @@ class Engine:
|
||||
self.conn_drops = deque() # (time, conn label, group)
|
||||
self.burst = None # {"start", "conns", "cleared"}
|
||||
self.events = []
|
||||
self.meta = {} # key -> (tile id, check, sub key)
|
||||
self.fix_last = {} # fix identity -> last run time
|
||||
self.executor = execute_fix
|
||||
|
||||
# raw results for every check key
|
||||
def evaluate(self, snap, now, service_on):
|
||||
@@ -600,6 +616,7 @@ class Engine:
|
||||
if chk.get("tag") and res.level in (WARN, ERR) and not res.named:
|
||||
res.msg, res.named = "%s %s" % (chk["tag"], res.msg), True
|
||||
raw["%s:%d:%s" % (t["id"], i, sub)] = (t["id"], res, chk)
|
||||
self.meta["%s:%d:%s" % (t["id"], i, sub)] = (t["id"], chk, sub)
|
||||
for sub, res in self.network_results(snap, now):
|
||||
raw["net:%s" % sub] = ("net", res, {"type": "network"})
|
||||
return raw
|
||||
@@ -698,6 +715,59 @@ class Engine:
|
||||
tile = self.tiles.get(tile_id, {})
|
||||
return [c["conn"] for c in tile.get("checks", []) if c["type"] in RESTARTABLE and c.get("conn")]
|
||||
|
||||
def fix_plan(self, tile_id, snap):
|
||||
"""What a hold on this button would do: fix only what is failing right now.
|
||||
|
||||
[(identity, "fix", fix id, check) or (identity, "restart", connection label, check)]
|
||||
A check's own "fix" wins; otherwise a failing Companion connection is restarted.
|
||||
Nothing is done for a healthy button, and a recording HyperDeck is never touched.
|
||||
"""
|
||||
tile = self.tiles.get(tile_id, {})
|
||||
ids = tile.get("members") or [tile_id]
|
||||
plan, seen = [], set()
|
||||
for key in sorted(self.meta):
|
||||
tid, chk, sub = self.meta[key]
|
||||
tr = self.trackers.get(key)
|
||||
if tid not in ids or not tr or not tr.confirmed or tr.result.level not in (WARN, ERR):
|
||||
continue
|
||||
fix_id = chk.get("fix")
|
||||
if isinstance(fix_id, dict):
|
||||
fix_id = fix_id.get(sub)
|
||||
if fix_id:
|
||||
if chk["type"] == "hyperdeck" and hyperdeck_recording(snap, chk["conn"]):
|
||||
continue
|
||||
# One run per fix, except a HyperDeck reboot, which is per deck.
|
||||
per_deck = self.cfg["fixes"][fix_id].get("kind") == "hyperdeck_reboot"
|
||||
ident = ("fix", fix_id, chk.get("ip", "") if per_deck else "")
|
||||
if ident not in seen:
|
||||
seen.add(ident)
|
||||
plan.append((ident, "fix", fix_id, chk))
|
||||
elif chk["type"] in RESTARTABLE and sub == "conn" and chk.get("conn"):
|
||||
ident = ("restart", chk["conn"])
|
||||
if ident not in seen:
|
||||
seen.add(ident)
|
||||
plan.append((ident, "restart", chk["conn"], chk))
|
||||
return plan
|
||||
|
||||
def run_fixes(self, tile_id, snap, comp, now, cooldown=60):
|
||||
plan = self.fix_plan(tile_id, snap)
|
||||
if not plan:
|
||||
log.info("HOLD %s: nothing failing, nothing to fix", tile_id)
|
||||
for ident, kind, what, chk in plan:
|
||||
if now - self.fix_last.get(ident, 0) < cooldown:
|
||||
log.info("HOLD %s: %s %s ran less than %ds ago, skipped", tile_id, kind, what, cooldown)
|
||||
continue
|
||||
self.fix_last[ident] = now
|
||||
if kind == "restart":
|
||||
c = snap.conns.get(what)
|
||||
if c and c.get("enabled"):
|
||||
ok = comp.restart_connection(c["id"])
|
||||
log.info("RESTART %s -> %s", what, "ok" if ok else "failed")
|
||||
else:
|
||||
log.info("FIX %s: %s", tile_id, self.cfg["fixes"][what].get("label", what))
|
||||
threading.Thread(target=self.executor, args=(self.cfg, what, chk), daemon=True).start()
|
||||
return plan
|
||||
|
||||
|
||||
def display_level(res, acked, service_on):
|
||||
if res.level == OFF:
|
||||
@@ -937,6 +1007,72 @@ def poll_midi(snap, cfg):
|
||||
lis.poll()
|
||||
|
||||
|
||||
# --- Other machines: SSH checks and fixes ------------------------------------
|
||||
|
||||
def ssh_cmd(cfg, host):
|
||||
"""ssh to a machine in config "hosts", with NA Ingest's status-panel key; one shared connection per host."""
|
||||
return ["/usr/bin/ssh", "-i", os.path.expanduser(cfg.get("ssh_key", "~/.ssh/id_status_panel")),
|
||||
"-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new", "-o", "ControlMaster=auto",
|
||||
"-o", "ControlPath=/tmp/status-panel-ssh-%C", "-o", "ControlPersist=120", cfg["hosts"][host]["ssh"]]
|
||||
|
||||
|
||||
def remote_script(cmds):
|
||||
"""One shell line that runs every check and prints SP<n>=<exit code>."""
|
||||
return "; ".join("(%s) >/dev/null 2>&1; echo SP%d=$?" % (cmd, i) for i, cmd in enumerate(cmds))
|
||||
|
||||
|
||||
def parse_remote(out, n):
|
||||
codes = dict(re.findall(r"SP(\d+)=(\d+)", out or ""))
|
||||
return [None if str(i) not in codes else codes[str(i)] == "0" for i in range(n)]
|
||||
|
||||
|
||||
def poll_remote(snap, cfg):
|
||||
by_host = {}
|
||||
for t in cfg["tiles"]:
|
||||
for c in t.get("checks", []):
|
||||
if c["type"] == "remote":
|
||||
by_host.setdefault(c["host"], [])
|
||||
if c["cmd"] not in by_host[c["host"]]:
|
||||
by_host[c["host"]].append(c["cmd"])
|
||||
result = dict(snap.remote)
|
||||
for host, cmds in by_host.items():
|
||||
try:
|
||||
out = subprocess.run(ssh_cmd(cfg, host) + [remote_script(cmds)], capture_output=True,
|
||||
text=True, timeout=20).stdout
|
||||
except subprocess.TimeoutExpired:
|
||||
out = ""
|
||||
for cmd, ok in zip(cmds, parse_remote(out, len(cmds))):
|
||||
result["%s\0%s" % (host, cmd)] = ok # None = could not reach it: no verdict
|
||||
snap.remote = result
|
||||
|
||||
|
||||
def execute_fix(cfg, fix_id, chk):
|
||||
"""Run one entry from config "fixes" (in its own thread). Only listed commands ever run."""
|
||||
fix = cfg["fixes"][fix_id]
|
||||
kind, label = fix.get("kind", "ssh"), fix.get("label", fix_id)
|
||||
try:
|
||||
if kind in ("ssh", "local"):
|
||||
argv = ssh_cmd(cfg, fix["host"]) + [fix["run"]] if kind == "ssh" else ["/bin/zsh", "-c", fix["run"]]
|
||||
r = subprocess.run(argv, capture_output=True, text=True, timeout=fix.get("timeout", 60))
|
||||
detail = (r.stdout + r.stderr).strip().replace("\n", " ")[-200:]
|
||||
log.info("FIX %s -> %s%s", label, "ok" if r.returncode == 0 else "exit %d" % r.returncode,
|
||||
(": " + detail) if detail else "")
|
||||
elif kind == "hyperdeck_reboot":
|
||||
with socket.create_connection((chk["ip"], 9993), timeout=3) as s:
|
||||
s.settimeout(3)
|
||||
try:
|
||||
s.recv(1024) # the deck's greeting
|
||||
except socket.timeout:
|
||||
pass
|
||||
s.sendall(b"reboot\n")
|
||||
log.info("FIX %s %s -> reboot sent", label, chk["conn"])
|
||||
else:
|
||||
log.warning("FIX %s: unknown kind %r", label, kind)
|
||||
except Exception as e:
|
||||
log.warning("FIX %s failed: %s", label, e)
|
||||
|
||||
|
||||
class Periodic(threading.Thread):
|
||||
def __init__(self, name, interval, fn, stop):
|
||||
super().__init__(name=name, daemon=True)
|
||||
@@ -975,6 +1111,15 @@ def load_config(path):
|
||||
for m in t.get("members", []):
|
||||
if m not in ids:
|
||||
raise ValueError("tile %s: unknown member %s" % (t["id"], m))
|
||||
for c in t.get("checks", []):
|
||||
fixes = c.get("fix") if isinstance(c.get("fix"), dict) else {"": c.get("fix")}
|
||||
for f in fixes.values():
|
||||
if f and f not in cfg.get("fixes", {}):
|
||||
raise ValueError("tile %s: unknown fix %s" % (t["id"], f))
|
||||
if f and cfg["fixes"][f].get("kind", "ssh") == "ssh" and cfg["fixes"][f]["host"] not in cfg.get("hosts", {}):
|
||||
raise ValueError("fix %s: unknown host" % f)
|
||||
if c["type"] == "remote" and c["host"] not in cfg.get("hosts", {}):
|
||||
raise ValueError("tile %s: unknown host %s" % (t["id"], c["host"]))
|
||||
return cfg
|
||||
|
||||
|
||||
@@ -1011,6 +1156,7 @@ def start_pollers(cfg, snap, stop):
|
||||
Periodic("system", 10.0, lambda: poll_system(snap, cfg), stop),
|
||||
Periodic("bonjour", 15.0, lambda: poll_mdns(snap), stop),
|
||||
Periodic("http", 5.0, lambda: poll_http(snap, http_checks(cfg)), stop),
|
||||
Periodic("remote", 10.0, lambda: poll_remote(snap, cfg), stop),
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
@@ -1042,12 +1188,8 @@ def handle_commands(text, engine, comp, snap, now):
|
||||
target = {"on": True, "off": False}.get(arg, not engine.service.on(now))
|
||||
engine.service.set(target, now)
|
||||
log.info("SERVICE %s (button)", "ON" if target else "OFF")
|
||||
elif verb == "restart":
|
||||
for label in engine.restart_targets(arg):
|
||||
c = snap.conns.get(label)
|
||||
if c and c.get("enabled"):
|
||||
ok = comp.restart_connection(c["id"])
|
||||
log.info("RESTART %s -> %s", label, "ok" if ok else "failed")
|
||||
elif verb == "restart": # sent by a 2 second hold
|
||||
engine.run_fixes(arg, snap, comp, now)
|
||||
else:
|
||||
log.warning("unknown command %r", part)
|
||||
|
||||
@@ -1123,6 +1265,7 @@ def run_once(cfg):
|
||||
poll_system(snap, cfg)
|
||||
poll_mdns(snap)
|
||||
poll_http(snap, http_checks(cfg))
|
||||
poll_remote(snap, cfg)
|
||||
try:
|
||||
poll_midi(snap, cfg)
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user