Wiring view: every machine and what connects it (Companion, MIDI, Dante, watchdog, web, Art-Net), hide types or single links, scripts per computer
RESI LIVE NOT REC follows the Resi auto-record service (custom autorec_status, not during its REC hold; stale heartbeat = no verdict); INGEST watches AUTO REC (running, heartbeat, Resi reachable), hold restarts it. Probes list each computer's scripts by file name only. HyperDeck addresses read from bonjourHost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -522,6 +522,134 @@ class Site:
|
||||
"the DVS helper and its sudo rule", "the Dante and switch-management ports are up", "Google Drive is signed in (saved switcher files)"],
|
||||
}
|
||||
|
||||
# The Wiring screen: every computer and device the panel knows, and what connects them (2026-10-27:
|
||||
# "like a wiring diagram ... hide different connections ... how many and what scripts are running on each machine").
|
||||
WIRE_TYPES = [
|
||||
{"id": "companion", "label": "Companion control", "color": "#4f8cff", "hint": "Companion's connections: what the deck's buttons drive"},
|
||||
{"id": "midi", "label": "Network MIDI", "color": "#b36bff", "hint": "rtpMIDI / Audio MIDI Setup sessions and their peers"},
|
||||
{"id": "watch", "label": "Watchdog", "color": "#8a94a6", "hint": "What the status panel logs in to or pings"},
|
||||
{"id": "web", "label": "Web checks", "color": "#2bb3a3", "hint": "Web pages the panel asks (apps' own pages, Resi's cloud)"},
|
||||
{"id": "dante", "label": "Dante", "color": "#f0a020", "hint": "Dante devices and the switch port each is plugged into"},
|
||||
{"id": "artnet", "label": "Art-Net", "color": "#e8618c", "hint": "Lighting data the panel hears"},
|
||||
]
|
||||
|
||||
def wiring_view(self):
|
||||
cfg = self.load()
|
||||
snap = self.read_json("snapshot.json", {}) or {}
|
||||
live = self.read_json("machines-live.json", {}) or {}
|
||||
hosts = cfg.get("hosts", {})
|
||||
me = next((a for a, h in hosts.items() if h.get("self")), None)
|
||||
lines = {l["alias"]: l for l in self.computer_lines(snap)}
|
||||
word_light = {"OK": "ok", "Problem": "problem", "Off": "not_needed"}
|
||||
nodes, edges, owner = {}, {}, {"127.0.0.1": me, "localhost": me}
|
||||
|
||||
def norm(x):
|
||||
return re.sub(r"[^a-z0-9]", "", str(x).lower())
|
||||
for alias, h in hosts.items():
|
||||
probe = live.get(alias) or {}
|
||||
ip = (h.get("ssh") or "").split("@")[-1]
|
||||
for a in [ip] + [n.get("ip") for n in h.get("networks", [])] + [i.get("ip") for i in (probe.get("found") or {}).get("interfaces", [])]:
|
||||
if a:
|
||||
owner[a] = alias
|
||||
line = lines.get(alias, {})
|
||||
found = probe.get("found") or {}
|
||||
nodes["h:" + alias] = {"id": "h:" + alias, "label": h.get("name", alias), "kind": "computer", "os": h.get("os", "mac"),
|
||||
"self": bool(h.get("self")), "address": ip or ("this Mac" if h.get("self") else ""),
|
||||
"role": h.get("role", ""), "light": word_light.get(line.get("word"), "unknown"),
|
||||
"reason": line.get("reason", ""), "scripts": found.get("scripts"), "looked": probe.get("time")}
|
||||
for m in (self.read_json("fleet.json", {}) or {}).values():
|
||||
alias = next((a for a, h in hosts.items() if norm(h.get("name")) == norm(m.get("name"))), None)
|
||||
for a in m.get("ips") or [] if alias else []:
|
||||
owner.setdefault(a, alias)
|
||||
|
||||
def target(address, label, kind="device"):
|
||||
"""The node at an address: a computer when it is one of them, else a device (one per address)."""
|
||||
host = (address or "").split("//")[-1].split("/")[0].split(":")[0] if "://" in (address or "") else (address or "").split(":")[0]
|
||||
if host in owner and owner[host]:
|
||||
return "h:" + owner[host]
|
||||
nid = "d:" + (host or norm(label))
|
||||
node = nodes.setdefault(nid, {"id": nid, "label": label, "kind": kind, "address": host, "light": "unknown", "names": []})
|
||||
if label not in node["names"]:
|
||||
node["names"].append(label)
|
||||
return nid
|
||||
|
||||
def edge(kind, a, b, label, light):
|
||||
if not a or not b or a == b:
|
||||
if a and a == b and a in nodes:
|
||||
nodes[a].setdefault("local", []).append({"type": kind, "label": label, "light": light})
|
||||
return
|
||||
key = "|".join([kind] + sorted([a, b]) + [label])
|
||||
edges[key] = {"id": key, "type": kind, "from": a, "to": b, "label": label, "light": light}
|
||||
conns, conn_hosts = snap.get("conns") or {}, snap.get("conn_hosts") or {}
|
||||
gear = cfg.get("gear") or {}
|
||||
kinds = {"hyperdeck": "recorder", "tascam": "recorder", "shure": "wireless", "psm": "in-ears", "atem": "switcher"}
|
||||
conn_kind = {c["conn"]: kinds.get(c["type"], "device") for t in cfg["tiles"] for c in t.get("checks", []) if c.get("conn")}
|
||||
me_id = "h:" + me if me else None
|
||||
for label, st in sorted(conns.items()):
|
||||
address = conn_hosts.get(label) or (gear.get(label) or {}).get("address")
|
||||
light = "ok" if st.get("category") == "good" else ("not_needed" if not st.get("enabled") else "problem")
|
||||
if label == "audinate-dantecontroller":
|
||||
continue # the Dante type draws the Dante network itself
|
||||
node = target(address, label, conn_kind.get(label, "device")) if address else target(None, label, "service")
|
||||
if node.startswith("d:"):
|
||||
nodes[node]["light"] = light if nodes[node]["light"] in ("unknown", "ok") else nodes[node]["light"]
|
||||
edge("companion", me_id, node, label, light)
|
||||
sessions = {me: snap.get("midi") or {}} if me else {}
|
||||
sessions.update(snap.get("remote_midi") or {})
|
||||
for alias, sess in sessions.items():
|
||||
for name, s in (sess or {}).items():
|
||||
for peer in s.get("peers", []):
|
||||
other = target(peer.get("address"), peer.get("name") or peer.get("address"), "MIDI")
|
||||
edge("midi", "h:" + alias, other, "%s <-> %s" % (name, peer.get("name")),
|
||||
"ok" if peer.get("state") in ("running", "connected") else "problem")
|
||||
pings = snap.get("ping") or {}
|
||||
for alias, h in hosts.items():
|
||||
if h.get("self") or h.get("disabled"):
|
||||
continue
|
||||
probe = live.get(alias) or {}
|
||||
ip = (h.get("ssh") or "").split("@")[-1]
|
||||
ok = probe.get("reachable") if probe else (pings.get(ip) == 0 if ip in pings else None)
|
||||
edge("watch", me_id, "h:" + alias, "logs in (SSH)", "unknown" if ok is None else ("ok" if ok else "problem"))
|
||||
http = snap.get("http") or {}
|
||||
for t in cfg["tiles"]:
|
||||
for c in t.get("checks", []):
|
||||
if c["type"] == "ping" and c.get("host"):
|
||||
node = target(c["host"], c.get("name") or c["host"])
|
||||
fails = pings.get(c["host"])
|
||||
edge("watch", me_id, node, "ping", "unknown" if fails is None else ("ok" if fails == 0 else "problem"))
|
||||
elif c["type"] == "http" and c.get("url"):
|
||||
ok = http.get(c["url"])
|
||||
node = target(c["url"], c.get("name") or c.get("msg") or c["url"].split("//")[-1].split("/")[0],
|
||||
"device" if re.search(r"//(10|192\.168|172|127)\.", c["url"]) else "cloud")
|
||||
edge("web", me_id, node, c.get("name") or c.get("msg") or "web page", "unknown" if ok is None else ("ok" if ok else "problem"))
|
||||
for dev, d in sorted((snap.get("dante_map") or {}).items()):
|
||||
sw = "sw:" + str(d.get("switch"))
|
||||
nodes.setdefault(sw, {"id": sw, "label": "Dante switch %s" % d.get("switch"), "kind": "switch", "address": d.get("switch"),
|
||||
"light": "ok", "names": []})
|
||||
nd = norm(dev)
|
||||
# A computer's Dante name ends with its own (NA-Tracks: tracks; NA-ProPresenter-Mac: ProPresenter Mac);
|
||||
# STUDIO-B-MON is a monitor, not the Studio B Mac. A device already drawn joins up by name (SuperRack-LiveBox).
|
||||
alias = next((a for a, h in hosts.items() if nd.endswith(norm(a)) or nd.endswith(norm(h.get("name", a)))), None)
|
||||
same = next((n["id"] for n in nodes.values() if n["kind"] != "computer" and n["id"] != sw
|
||||
and any(len(norm(x)) >= 6 and nd.endswith(norm(x)) for x in n.get("names", []))), None)
|
||||
node = "h:" + alias if alias else same or target(None, dev, "Dante")
|
||||
if node.startswith("d:") and dev not in nodes[node]["names"]:
|
||||
nodes[node]["names"].append(dev)
|
||||
fresh = time.time() - (d.get("seen") or 0) < 3600
|
||||
edge("dante", node, sw, "port %s%s" % (d.get("port"), " (PoE)" if d.get("poe") else ""), "ok" if fresh else "unknown")
|
||||
for src, age in (snap.get("artnet") or {}).items():
|
||||
edge("artnet", target(src, "Art-Net from %s" % src), me_id, "Art-Net", "ok" if age < 10 else "problem")
|
||||
rank = {"problem": 3, "ok": 1, "not_needed": 0, "unknown": -1}
|
||||
for e in edges.values(): # a device is as good as what reaches it
|
||||
for end in (e["from"], e["to"]):
|
||||
n = nodes.get(end)
|
||||
if n and n["kind"] != "computer" and rank.get(e["light"], -1) > rank.get(n["light"], -1):
|
||||
n["light"] = e["light"]
|
||||
used = {e["from"] for e in edges.values()} | {e["to"] for e in edges.values()}
|
||||
out = [n for n in nodes.values() if n["kind"] == "computer" or n["id"] in used]
|
||||
return {"nodes": out, "edges": list(edges.values()), "types": self.WIRE_TYPES, "me": me_id, "time": snap.get("time"),
|
||||
"probing": bool(self.probing and self.probing.is_alive())}
|
||||
|
||||
def gear_view(self):
|
||||
cfg = self.load()
|
||||
snap = self.read_json("snapshot.json", {}) or {}
|
||||
@@ -769,6 +897,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(200, site.companion_page(int(n)))
|
||||
except (OSError, ValueError) as e:
|
||||
return self.send(200, {"page": int(n), "buttons": [], "error": "Companion did not answer: %s" % e})
|
||||
if path == "/api/wiring":
|
||||
return self.send(200, site.wiring_view())
|
||||
if path == "/api/machines":
|
||||
return self.send(200, site.machines_view())
|
||||
if path == "/api/gear":
|
||||
|
||||
Reference in New Issue
Block a user