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:
@@ -278,7 +278,7 @@ The **page number key** on every page follows the ALL button. It's green when ev
|
|||||||
|
|
||||||
| Button | Checks |
|
| Button | Checks |
|
||||||
|---|---|
|
|---|---|
|
||||||
| HD1–HD6 | Companion connection; **NO DISK**; record time **LOW** (<30 min, amber) / **FULL** (<5 min); **NOT REC** when another deck is recording and this one isn't; **RESI LIVE NOT REC** when Resi has been playing for a minute (`rec_watch`: resi-studio-cues `playback_state` PLAYING) and no deck records. Holding HD1–6 or REC then **starts recording** on every deck with a disk that is not recording (over the deck's own protocol, port 9993; a recording deck is sent nothing), named like the Director page's REC key: `CAM3-YYYYMMDD_HHMMSS-` (`rec_name` per deck) |
|
| HD1–HD6 | Companion connection; **NO DISK**; record time **LOW** (<30 min, amber) / **FULL** (<5 min); **NOT REC** when another deck is recording and this one isn't; **RESI LIVE NOT REC** when Resi is live and no deck records (`rec_watch`: the Resi auto-record service's own verdict, Companion's `custom:autorec_status` saying LIVE, NOT RECORDING / RECORDING / STARTING for 20 s; not while it holds REC until 10 minutes before a service that Resi started early, and nothing while its heartbeat `autorec_hb` is stale). Holding HD1–6 or REC then **starts recording** on every deck with a disk that is not recording (over the deck's own protocol, port 9993; a recording deck is sent nothing), named like the Director page's REC key: `CAM3-YYYYMMDD_HHMMSS-` (`rec_name` per deck) |
|
||||||
| | HD2 and HD4 are `"optional": true` in `config.json`: with no disk they sit grey with no alarm, and once a disk is in they are watched like the others. Add or remove `"optional": true` on any deck to change that. |
|
| | HD2 and HD4 are `"optional": true` in `config.json`: with no disk they sit grey with no alarm, and once a disk is in they are watched like the others. Add or remove `"optional": true` on any deck to change that. |
|
||||||
| TASCAM | connection; the recorder's own Error / Caution. Use the **1.3.1-quiet** Tascam module (see below) so Companion's log isn't flooded.; pinged at the address on its Companion connection. |
|
| TASCAM | connection; the recorder's own Error / Caution. Use the **1.3.1-quiet** Tascam module (see below) so Companion's log isn't flooded.; pinged at the address on its Companion connection. |
|
||||||
| X32, ARENA, OBS | Companion connection |
|
| X32, ARENA, OBS | Companion connection |
|
||||||
@@ -292,7 +292,7 @@ The **page number key** on every page follows the ALL button. It's green when ev
|
|||||||
| VISTA | Vista on the Lighting PC: its Art-Net poll (sent every 0.5 s) must be heard within 5 s, otherwise **SILENT** (Vista closed or crashed, or the Lighting PC is off the network). Hold 2 s starts Vista if it is not running; Vista then opens **Prop Takeover** from its show folder (`E:\media\Documents\Vista 3\Shows`) by itself, through its own autoload preference (`<autoloadpath>` in `E:\media\Documents\Vista 3\.preferences`, set 2026-10-08). Resolume is the ARENA button. |
|
| VISTA | Vista on the Lighting PC: its Art-Net poll (sent every 0.5 s) must be heard within 5 s, otherwise **SILENT** (Vista closed or crashed, or the Lighting PC is off the network). Hold 2 s starts Vista if it is not running; Vista then opens **Prop Takeover** from its show folder (`E:\media\Documents\Vista 3\Shows`) by itself, through its own autoload preference (`<autoloadpath>` in `E:\media\Documents\Vista 3\.preferences`, set 2026-10-08). Resolume is the ARENA button. |
|
||||||
| RESI | decoder (RossTalk connection and ping, 10.1.10.203); **encoder** (ping and its web page, 10.1.10.202, LAOEncoder); resi-studio-cues; Resi cloud reachable. Only shows they are on the network, not that the encoder is streaming. |
|
| RESI | decoder (RossTalk connection and ping, 10.1.10.203); **encoder** (ping and its web page, 10.1.10.202, LAOEncoder); resi-studio-cues; Resi cloud reachable. Only shows they are on the network, not that the encoder is streaming. |
|
||||||
| NET | NA Ingest's link; gateway 10.1.0.1 pings; **4+ connections from 3+ groups dropping within 10 s** (what happened Sunday 10/5 at 10:21). A blip **flashes amber** for 5 minutes, then goes by itself; press NET to stop the flashing, hold NET 2 s to clear it now. |
|
| NET | NA Ingest's link; gateway 10.1.0.1 pings; **4+ connections from 3+ groups dropping within 10 s** (what happened Sunday 10/5 at 10:21). A blip **flashes amber** for 5 minutes, then goes by itself; press NET to stop the flashing, hold NET 2 s to clear it now. |
|
||||||
| INGEST | the bridge (makes ProPresenter's stage screen count down to the next Resi cue), lobby captions server and caption screen, **ProdCom** (transcription, answering on :24480), ProdDash (running and its page on :24500); free disk |
|
| INGEST | **AUTO REC** (`resi_autorec.py`, LaunchAgent `org.waterschurch.resi-autorec`: presses the Director page's REC key 3/2/3 when Resi's encoder goes live and STOP a minute after it goes off air; its own key is 3/3/3): **AUTO REC STOPPED / NOT LOADED**, **AUTO REC STUCK** (heartbeat `custom:autorec_hb` over 90 s old), amber **AUTO REC NO RESI** (it cannot see Resi); a hold restarts it. The bridge (makes ProPresenter's stage screen count down to the next Resi cue), lobby captions server and caption screen, **ProdCom** (transcription, answering on :24480), ProdDash (running and its page on :24500); free disk |
|
||||||
| SPOTIFY | Spotify on NA Ingest must be playing the **Christian EDM 2026** playlist (`spotify:playlist:2uTpJVYQLFnKF1WnSOuco8`): **SPOTIFY CLOSED**, **NOT PLAYING**, or **WRONG PLAYLIST** (it reads all of the playlist's songs from Spotify every hour, and again whenever a song plays that isn't on the list, since the playlist keeps getting new songs). Hold 2 s (and Sunday auto-fix) plays the playlist, opening Spotify first if needed. |
|
| SPOTIFY | Spotify on NA Ingest must be playing the **Christian EDM 2026** playlist (`spotify:playlist:2uTpJVYQLFnKF1WnSOuco8`): **SPOTIFY CLOSED**, **NOT PLAYING**, or **WRONG PLAYLIST** (it reads all of the playlist's songs from Spotify every hour, and again whenever a song plays that isn't on the list, since the playlist keeps getting new songs). Hold 2 s (and Sunday auto-fix) plays the playlist, opening Spotify first if needed. |
|
||||||
| RF (group) | Shure connections; battery ≤1 bar red, 2 bars amber; RF interference (only while that transmitter is on); PSM RF muted; each receiver pinged every 3 s ("AXIENT 1 NO PING" in about 6–9 s; Companion's Shure modules take 18–27 s to notice). The address is the one set on its Companion connection, read from Companion's settings every minute, so changing it there is enough; `"host"` in `config.json` is only the fallback. |
|
| RF (group) | Shure connections; battery ≤1 bar red, 2 bars amber; RF interference (only while that transmitter is on); PSM RF muted; each receiver pinged every 3 s ("AXIENT 1 NO PING" in about 6–9 s; Companion's Shure modules take 18–27 s to notice). The address is the one set on its Companion connection, read from Companion's settings every minute, so changing it there is enough; `"host"` in `config.json` is only the fallback. |
|
||||||
|
|
||||||
@@ -310,6 +310,21 @@ Fixes and the remote checks are built from fields (`settings/builders.py`): a bu
|
|||||||
|
|
||||||
The page reads what the watchdog writes next to its config (`snapshot.json` every 5 s, `started.json`, `live-now.json`, `fixes-running.json`) instead of asking the gear again. Its own look at the computers (`settings/probes.py`) runs fixed read-only commands over the same login key, fetches whole lists and matches them here, never while a fix runs.
|
The page reads what the watchdog writes next to its config (`snapshot.json` every 5 s, `started.json`, `live-now.json`, `fixes-running.json`) instead of asking the gear again. Its own look at the computers (`settings/probes.py`) runs fixed read-only commands over the same login key, fetches whole lists and matches them here, never while a fix runs.
|
||||||
|
|
||||||
|
## Wiring (settings page)
|
||||||
|
|
||||||
|
**Wiring** on the settings page draws every computer and device the panel knows and what connects them, from what
|
||||||
|
the watchdog already sees (`/api/wiring`, refreshed every 5 s): **Companion control** (each connection, to the computer
|
||||||
|
or device at its address), **Network MIDI** (each session's peers), **Watchdog** (its SSH logins and pings: hidden at
|
||||||
|
first), **Web checks**, **Dante** (each device to its switch port, from the Dante map; a computer's Dante name joins
|
||||||
|
its box) and **Art-Net**. Click a type to hide or show it, untick single connections in the side panel, or click a
|
||||||
|
machine and **Only this one's connections**. Red dashes = that connection has a problem, grey dashes = can't see it
|
||||||
|
from here. Drag machines to arrange them; the wheel or +/− zooms, dragging the background pans, Fit shows everything.
|
||||||
|
Positions and what is hidden stay in that browser.
|
||||||
|
Each computer shows how many **scripts** run on it and which (side panel): our LaunchAgents (`org.waterschurch.*`,
|
||||||
|
linked to their script), any running `.py/.js/.sh/...` script, and on PCs the `\StatusPanel\` tasks (running while
|
||||||
|
their program runs). Only file names are kept, never the rest of a command line. **Check scripts now** looks
|
||||||
|
(read-only; the same probe as Computers › Check now).
|
||||||
|
|
||||||
## Other locations: auto-setup
|
## Other locations: auto-setup
|
||||||
|
|
||||||
A location sets itself up. All a person does is let the panel in: one line on the location's Mac that runs Companion,
|
A location sets itself up. All a person does is let the panel in: one line on the location's Mac that runs Companion,
|
||||||
|
|||||||
+39
-2
@@ -553,6 +553,18 @@
|
|||||||
"run": "tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start Vista\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul)",
|
"run": "tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start Vista\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul)",
|
||||||
"short": "VISTA",
|
"short": "VISTA",
|
||||||
"template": "win_start"
|
"template": "win_start"
|
||||||
|
},
|
||||||
|
"autorec": {
|
||||||
|
"kind": "local",
|
||||||
|
"label": "restart the Resi auto-record",
|
||||||
|
"params": {
|
||||||
|
"labels": [
|
||||||
|
"org.waterschurch.resi-autorec"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"run": "launchctl kickstart -k gui/$(id -u)/org.waterschurch.resi-autorec || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.resi-autorec.plist",
|
||||||
|
"short": "AUTO REC",
|
||||||
|
"template": "helpers"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"flap": {
|
"flap": {
|
||||||
@@ -2618,6 +2630,24 @@
|
|||||||
"path": "/",
|
"path": "/",
|
||||||
"type": "disk",
|
"type": "disk",
|
||||||
"warn_gb": 25
|
"warn_gb": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fix": "autorec",
|
||||||
|
"label": "org.waterschurch.resi-autorec",
|
||||||
|
"name": "AUTO REC",
|
||||||
|
"type": "launchd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heartbeat",
|
||||||
|
"conn": "custom",
|
||||||
|
"var": "autorec_hb",
|
||||||
|
"max_age_seconds": 90,
|
||||||
|
"msg": "AUTO REC STUCK",
|
||||||
|
"status_var": "autorec_status",
|
||||||
|
"warn_if": "RESI ERROR",
|
||||||
|
"warn_msg": "AUTO REC NO RESI",
|
||||||
|
"debounce_seconds": 180,
|
||||||
|
"fix": "autorec"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"id": "ingest",
|
"id": "ingest",
|
||||||
@@ -2819,7 +2849,14 @@
|
|||||||
"state": "sp_state"
|
"state": "sp_state"
|
||||||
},
|
},
|
||||||
"rec_watch": {
|
"rec_watch": {
|
||||||
"conn": "resi-studio-cues",
|
"conn": "custom",
|
||||||
"after_seconds": 60
|
"var": "autorec_status",
|
||||||
|
"heartbeat": "autorec_hb",
|
||||||
|
"live": [
|
||||||
|
"LIVE, NOT",
|
||||||
|
"RECORDING",
|
||||||
|
"STARTING"
|
||||||
|
],
|
||||||
|
"after_seconds": 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,9 +104,10 @@ field("service_windows[].end", "hhmm", None, "", "Schedules", required=True)
|
|||||||
field("resi_events", "dict", None, "Resi's scheduled events (studio.resi.io), so the checks marked quiet \"resi_events\" keep quiet "
|
field("resi_events", "dict", None, "Resi's scheduled events (studio.resi.io), so the checks marked quiet \"resi_events\" keep quiet "
|
||||||
"while an event starts or stops: {\"weekly\": [{\"days\": [\"sun\"], \"start\": \"07:30\", \"end\": \"09:15\"}, ...], "
|
"while an event starts or stops: {\"weekly\": [{\"days\": [\"sun\"], \"start\": \"07:30\", \"end\": \"09:15\"}, ...], "
|
||||||
"\"margin_minutes\": 10, \"pco_specials\": true (special services' times from Planning Center)}.", "Site")
|
"\"margin_minutes\": 10, \"pco_specials\": true (special services' times from Planning Center)}.", "Site")
|
||||||
field("rec_watch", "dict", None, "Every deck with a disk must record while Resi plays: {\"conn\": \"resi-studio-cues\", "
|
field("rec_watch", "dict", None, "Every deck with a disk must record while Resi is live: {\"conn\": \"custom\", \"var\": "
|
||||||
"\"after_seconds\": 60} (its playback_state PLAYING that long). A deck that does not shows RESI LIVE NOT REC; "
|
"\"autorec_status\", \"heartbeat\": \"autorec_hb\", \"live\": [\"LIVE, NOT\", \"RECORDING\", \"STARTING\"], "
|
||||||
"a hold starts recording on the idle decks only.", "Site")
|
"\"after_seconds\": 20} (the Resi auto-record service's own verdict; without a heartbeat a module's playback_state "
|
||||||
|
"PLAYING). A deck that does not record shows RESI LIVE NOT REC; a hold starts recording on the idle decks only.", "Site")
|
||||||
field("debounce_seconds", "int", 3, "A fault must last this long before it shows (one bad reading is not an "
|
field("debounce_seconds", "int", 3, "A fault must last this long before it shows (one bad reading is not an "
|
||||||
"alarm).", "Schedules", advanced=True, lo=0, hi=60)
|
"alarm).", "Schedules", advanced=True, lo=0, hi=60)
|
||||||
field("flap", "dict", {}, "Repeated faults show amber 'xN'.", "Schedules", advanced=True)
|
field("flap", "dict", {}, "Repeated faults show amber 'xN'.", "Schedules", advanced=True)
|
||||||
@@ -325,6 +326,11 @@ field("tiles[].checks[].name", "str", None, "The name the key shows.", "Keys")
|
|||||||
field("tiles[].checks[].msg", "str", None, "The words the key shows when it fails.", "Keys")
|
field("tiles[].checks[].msg", "str", None, "The words the key shows when it fails.", "Keys")
|
||||||
field("tiles[].checks[].failures", "int", 2, "Pings missed before NO PING.", "Keys", advanced=True, lo=1, hi=10)
|
field("tiles[].checks[].failures", "int", 2, "Pings missed before NO PING.", "Keys", advanced=True, lo=1, hi=10)
|
||||||
field("tiles[].checks[].label", "str", None, "The launchd helper.", "Keys")
|
field("tiles[].checks[].label", "str", None, "The launchd helper.", "Keys")
|
||||||
|
field("tiles[].checks[].var", "str", None, "The Companion variable with the service's heartbeat (unix time).", "Keys")
|
||||||
|
field("tiles[].checks[].max_age_seconds", "int", 90, "A heartbeat older than this: the service is stuck.", "Keys", advanced=True, lo=10, hi=3600)
|
||||||
|
field("tiles[].checks[].status_var", "str", None, "The service's status text (Companion variable).", "Keys", advanced=True)
|
||||||
|
field("tiles[].checks[].warn_if", "str", None, "Amber while the status text contains this.", "Keys", advanced=True)
|
||||||
|
field("tiles[].checks[].warn_msg", "str", None, "What the key says then.", "Keys", advanced=True)
|
||||||
field("tiles[].checks[].match", "str", None, "The program (pgrep -f pattern).", "Keys")
|
field("tiles[].checks[].match", "str", None, "The program (pgrep -f pattern).", "Keys")
|
||||||
field("tiles[].checks[].url", "url", None, "The web address to fetch.", "Keys")
|
field("tiles[].checks[].url", "url", None, "The web address to fetch.", "Keys")
|
||||||
field("tiles[].checks[].interval_seconds", "int", 30, "Look this often, in seconds: a web address every 30, a command "
|
field("tiles[].checks[].interval_seconds", "int", 30, "Look this often, in seconds: a web address every 30, a command "
|
||||||
@@ -515,6 +521,9 @@ CHECKS = {
|
|||||||
"question": "Is a recorder OK?", "words": "The Tascam recorder reports no error or caution."},
|
"question": "Is a recorder OK?", "words": "The Tascam recorder reports no error or caution."},
|
||||||
"ping": {"fields": ["host", "conn", "name", "msg", "failures", "interval_seconds", "service_only"], "subs": ["ping"], "identity": ["conn", "host"],
|
"ping": {"fields": ["host", "conn", "name", "msg", "failures", "interval_seconds", "service_only"], "subs": ["ping"], "identity": ["conn", "host"],
|
||||||
"module": None, "question": "Is a device answering?", "words": "An address answers ping."},
|
"module": None, "question": "Is a device answering?", "words": "An address answers ping."},
|
||||||
|
"heartbeat": {"fields": ["conn", "var", "max_age_seconds", "msg", "status_var", "warn_if", "warn_msg"], "subs": ["hb"],
|
||||||
|
"identity": ["var"], "module": None, "question": "Is a service still writing its heartbeat?",
|
||||||
|
"words": "A service writes the time into a Companion variable (custom autorec_hb: the Resi auto-record); too old means it is stuck."},
|
||||||
"launchd": {"fields": ["label", "name"], "subs": ["svc"], "identity": ["label"], "module": None,
|
"launchd": {"fields": ["label", "name"], "subs": ["svc"], "identity": ["label"], "module": None,
|
||||||
"question": "Is a program running?", "words": "A helper (LaunchAgent) on this Mac is loaded and running."},
|
"question": "Is a program running?", "words": "A helper (LaunchAgent) on this Mac is loaded and running."},
|
||||||
"process": {"fields": ["match", "name", "template", "params"], "subs": ["proc"], "identity": ["match"], "module": None,
|
"process": {"fields": ["match", "name", "template", "params"], "subs": ["proc"], "identity": ["match"], "module": None,
|
||||||
@@ -565,12 +574,12 @@ GLOSSARY = {
|
|||||||
"DISABLED", "OFFLINE", "NO SESSION", "NOT RUNNING", "DOWN", "NOT REC", "SILENT", "WARNING", "STARTING",
|
"DISABLED", "OFFLINE", "NO SESSION", "NOT RUNNING", "DOWN", "NOT REC", "SILENT", "WARNING", "STARTING",
|
||||||
"ATEM AUDIO WRONG", "ATEM CAMERA WRONG", "ATEM SW MIDI ON", "PT SOLO NOT AFL", "PROPRES NOT RUNNING",
|
"ATEM AUDIO WRONG", "ATEM CAMERA WRONG", "ATEM SW MIDI ON", "PT SOLO NOT AFL", "PROPRES NOT RUNNING",
|
||||||
"ABLETON NOT RUNNING", "ABLESET NOT RUNNING", "TRANSLATE NOT RUNNING", "PROP TV DOWN", "BIBLE APP DOWN", "PRODCOM DOWN", "PRODDASH DOWN",
|
"ABLETON NOT RUNNING", "ABLESET NOT RUNNING", "TRANSLATE NOT RUNNING", "PROP TV DOWN", "BIBLE APP DOWN", "PRODCOM DOWN", "PRODDASH DOWN",
|
||||||
"ENCODER WEB DOWN", "PT SOLO CHECK OFF", "UNREACH", "DROPPED", "RESI LIVE NOT REC",
|
"ENCODER WEB DOWN", "PT SOLO CHECK OFF", "UNREACH", "DROPPED", "RESI LIVE NOT REC", "AUTO REC STOPPED", "AUTO REC STUCK",
|
||||||
],
|
],
|
||||||
"check the device or the cable": [
|
"check the device or the cable": [
|
||||||
"NO PING", "NO ANSWER", "UNREACHABLE", "LINK DOWN", "NO GATEWAY", "NO DISK", "FULL", "LOW", "BATT", "INTERF",
|
"NO PING", "NO ANSWER", "UNREACHABLE", "LINK DOWN", "NO GATEWAY", "NO DISK", "FULL", "LOW", "BATT", "INTERF",
|
||||||
"RF MUTE", "HUNG - POWER CYCLE IT", "NO NETWORK CHECK POWER", "STILL DOWN", "REFUSED", "LOGIN", "BLIP",
|
"RF MUTE", "HUNG - POWER CYCLE IT", "NO NETWORK CHECK POWER", "STILL DOWN", "REFUSED", "LOGIN", "BLIP",
|
||||||
"NOT REACHABLE", "DISK", "RATE LIMITED",
|
"NOT REACHABLE", "DISK", "RATE LIMITED", "AUTO REC NO RESI",
|
||||||
],
|
],
|
||||||
"the READY page: what is not ready yet (hold the key to fix it or look again)": [
|
"the READY page: what is not ready yet (hold the key to fix it or look again)": [
|
||||||
"TO DO", "ALL DONE", "TAP WHEN DONE", "TAP WHEN SET", "NOT DONE", "DONE", "NOT SET UP", "CHECKING",
|
"TO DO", "ALL DONE", "TAP WHEN DONE", "TAP WHEN SET", "NOT DONE", "DONE", "NOT SET UP", "CHECKING",
|
||||||
|
|||||||
+54
-8
@@ -27,7 +27,7 @@ MAC_PROBE = (
|
|||||||
'echo SP_AGENTS; launchctl list 2>/dev/null; echo SP_PROCS; ps -axo comm= 2>/dev/null; '
|
'echo SP_AGENTS; launchctl list 2>/dev/null; echo SP_PROCS; ps -axo comm= 2>/dev/null; '
|
||||||
'echo SP_APPS; ls /Applications 2>/dev/null; echo SP_HELPER; ls -l {helper} 2>&1; sudo -n -l {helper} 2>&1 | tail -1; '
|
'echo SP_APPS; ls /Applications 2>/dev/null; echo SP_HELPER; ls -l {helper} 2>&1; sudo -n -l {helper} 2>&1 | tail -1; '
|
||||||
'echo SP_SOLO; cat {solo}/solo-mode.json 2>/dev/null; echo; stat -f %m {solo}/solo-mode.json 2>/dev/null; '
|
'echo SP_SOLO; cat {solo}/solo-mode.json 2>/dev/null; echo; stat -f %m {solo}/solo-mode.json 2>/dev/null; '
|
||||||
'echo SP_NOW; date +%s; echo SP_END'
|
'echo SP_NOW; date +%s; echo SP_SCRIPTS; ps -axo pid=,args= 2>/dev/null | grep -E \'[.](py|js|mjs|sh|zsh|command|swift|rb|pl|scpt)( |$)\' | grep -v SP_SCRIPTS; echo SP_END'
|
||||||
)
|
)
|
||||||
WIN_PROBE = (
|
WIN_PROBE = (
|
||||||
'echo SP_NAME=%COMPUTERNAME% & echo SP_TASKS & schtasks /Query /FO CSV /NH 2>nul & echo SP_PROCS & tasklist /FO CSV /NH 2>nul & '
|
'echo SP_NAME=%COMPUTERNAME% & echo SP_TASKS & schtasks /Query /FO CSV /NH 2>nul & echo SP_PROCS & tasklist /FO CSV /NH 2>nul & '
|
||||||
@@ -35,8 +35,15 @@ WIN_PROBE = (
|
|||||||
'"(Get-NetConnectionProfile | Select -First 1 -ExpandProperty NetworkCategory); '
|
'"(Get-NetConnectionProfile | Select -First 1 -ExpandProperty NetworkCategory); '
|
||||||
'(Get-NetAdapterPowerManagement | Select -First 1 -ExpandProperty WakeOnMagicPacket); '
|
'(Get-NetAdapterPowerManagement | Select -First 1 -ExpandProperty WakeOnMagicPacket); '
|
||||||
'((Get-NetFirewallRule -Name OpenSSH-Server-In-TCP -ErrorAction SilentlyContinue | Get-NetFirewallAddressFilter | '
|
'((Get-NetFirewallRule -Name OpenSSH-Server-In-TCP -ErrorAction SilentlyContinue | Get-NetFirewallAddressFilter | '
|
||||||
'Select -ExpandProperty RemoteAddress) -join \',\')" 2>nul & echo SP_END'
|
'Select -ExpandProperty RemoteAddress) -join \',\')" 2>nul & echo SP_SCRIPTS & powershell -NoProfile -Command '
|
||||||
|
'"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match \'[.](ps1|py|js|bat|cmd|vbs)\' } | '
|
||||||
|
'ForEach-Object { [string]$_.ProcessId + \' \' + $_.CommandLine }" 2>nul & echo SP_END'
|
||||||
)
|
)
|
||||||
|
# A script: a file run by an interpreter (its name only is kept, never the rest of the command line).
|
||||||
|
SCRIPT_RE = r"[^ /\\]+[.](py|js|mjs|sh|zsh|command|swift|rb|pl|scpt|ps1|bat|cmd|vbs)( |\"|$)"
|
||||||
|
# Not the user's scripts: programs' own insides (Companion's modules, app bundles, the system).
|
||||||
|
NOT_SCRIPTS = (".app/Contents/", "/companion/modules/", "/System/", "/usr/libexec/", "/Library/Apple/", "\\Windows\\",
|
||||||
|
"/node_modules/", "Program Files")
|
||||||
|
|
||||||
|
|
||||||
def shell_path(path):
|
def shell_path(path):
|
||||||
@@ -113,6 +120,38 @@ def parse_sc(lines):
|
|||||||
return services
|
return services
|
||||||
|
|
||||||
|
|
||||||
|
def parse_scripts(lines, agents=None, tasks=None):
|
||||||
|
"""The scripts a computer runs: [{"name", "pid", "by", "running"}]. From `ps` (pid, command line) lines,
|
||||||
|
our own launchd jobs (org.waterschurch.*, linked to their script by pid) and the \\StatusPanel\\ tasks
|
||||||
|
({path: state}). Only a script's file name is kept, never the rest of its command line."""
|
||||||
|
agents, out, seen_pids = agents or {}, [], set()
|
||||||
|
by_pid = {pid: label for label, pid in agents.items() if pid}
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
m = re.match(r"^(\d+)\s+(.*)$", line)
|
||||||
|
if not m or "SP_SCRIPTS" in line:
|
||||||
|
continue
|
||||||
|
found = re.search(SCRIPT_RE, m.group(2))
|
||||||
|
if not found:
|
||||||
|
continue
|
||||||
|
# The script's own path (from the last " /", ' "' or " C:\" before its name): the interpreter may sit
|
||||||
|
# inside an app (Apple's python3 is in Python.app) without the script being a program's insides.
|
||||||
|
head = m.group(2)[:found.end()]
|
||||||
|
cut = max([head.rfind(" /"), head.rfind(' "')] + [x.start() for x in re.finditer(r" [A-Za-z]:\\", head)])
|
||||||
|
if any(x in head[cut + 1:] for x in NOT_SCRIPTS):
|
||||||
|
continue
|
||||||
|
name = re.split(r"[\\/]", found.group(0).strip().strip('"'))[-1]
|
||||||
|
pid = int(m.group(1))
|
||||||
|
out.append({"name": name, "pid": pid, "by": by_pid.get(pid, ""), "running": True})
|
||||||
|
seen_pids.add(pid)
|
||||||
|
for label, pid in sorted(agents.items()):
|
||||||
|
if label.startswith("org.waterschurch.") and pid not in seen_pids:
|
||||||
|
out.append({"name": label[len("org.waterschurch."):], "pid": pid, "by": label, "running": pid is not None})
|
||||||
|
for path, st in sorted((tasks or {}).items()):
|
||||||
|
out.append({"name": path.split("\\")[-1], "pid": None, "by": path, "running": st.lower() == "running", "state": st})
|
||||||
|
return sorted(out, key=lambda x: (not x["running"], x["name"].lower()))
|
||||||
|
|
||||||
|
|
||||||
def light(ok, reason=""):
|
def light(ok, reason=""):
|
||||||
return {"light": "ok" if ok else "problem", "reason": reason if not ok else ""}
|
return {"light": "ok" if ok else "problem", "reason": reason if not ok else ""}
|
||||||
|
|
||||||
@@ -192,7 +231,7 @@ def mac_items(host, parsed, cfg):
|
|||||||
item.update({"light": "unknown", "reason": ""})
|
item.update({"light": "unknown", "reason": ""})
|
||||||
items.append(item)
|
items.append(item)
|
||||||
return items, {"agents": sorted(a for a in agents if not a.startswith(("com.apple.", "application."))),
|
return items, {"agents": sorted(a for a in agents if not a.startswith(("com.apple.", "application."))),
|
||||||
"processes": sorted(proc_names)[:200]}
|
"processes": sorted(proc_names)[:200], "scripts": parse_scripts(parsed.get("SP_SCRIPTS", []), agents)}
|
||||||
|
|
||||||
|
|
||||||
def windows_items(host, parsed):
|
def windows_items(host, parsed):
|
||||||
@@ -228,8 +267,13 @@ def windows_items(host, parsed):
|
|||||||
item.update({"light": "unknown", "reason": ""})
|
item.update({"light": "unknown", "reason": ""})
|
||||||
items.append(item)
|
items.append(item)
|
||||||
setup = {"profile": profile, "wake_on_lan": wol, "firewall_allow": allow}
|
setup = {"profile": profile, "wake_on_lan": wol, "firewall_allow": allow}
|
||||||
return items, {"tasks": sorted(t for t in tasks if "StatusPanel" in t), "services": sorted(k for k, v in services.items()
|
# A task that starts a program is "Ready" once it did: it counts as running while that program runs.
|
||||||
if v == "RUNNING" and not k.lower().startswith(("w", "s", "d", "e", "n", "p", "t", "u", "c")))[:60]}, setup
|
up = {"\\StatusPanel\\" + run["name"] for run, item in zip(host.get("runs", []), items)
|
||||||
|
if run.get("kind") == "task" and run.get("exe") and item.get("light") == "ok"}
|
||||||
|
ours = {t: ("Running" if t in up else row[2] if len(row) > 2 else "") for t, row in tasks.items() if "StatusPanel" in t}
|
||||||
|
return items, {"tasks": sorted(ours), "services": sorted(k for k, v in services.items()
|
||||||
|
if v == "RUNNING" and not k.lower().startswith(("w", "s", "d", "e", "n", "p", "t", "u", "c")))[:60],
|
||||||
|
"scripts": parse_scripts(parsed.get("SP_SCRIPTS", []), tasks=ours)}, setup
|
||||||
|
|
||||||
|
|
||||||
def probe_host(cfg, alias, host):
|
def probe_host(cfg, alias, host):
|
||||||
@@ -245,7 +289,7 @@ def probe_host(cfg, alias, host):
|
|||||||
rec["error"] = err.strip() or "no answer"
|
rec["error"] = err.strip() or "no answer"
|
||||||
return rec
|
return rec
|
||||||
rec["reachable"] = True
|
rec["reachable"] = True
|
||||||
parsed = sections(out, ("SP_TASKS", "SP_PROCS", "SP_SVC", "SP_PS", "SP_END"))
|
parsed = sections(out, ("SP_TASKS", "SP_PROCS", "SP_SVC", "SP_PS", "SP_SCRIPTS", "SP_END"))
|
||||||
rec["computer_name"] = values(out).get("NAME", "").strip()
|
rec["computer_name"] = values(out).get("NAME", "").strip()
|
||||||
rec["items"], rec["found"], rec["setup"] = windows_items(host, parsed)
|
rec["items"], rec["found"], rec["setup"] = windows_items(host, parsed)
|
||||||
rec["setup"]["key_ok"] = True
|
rec["setup"]["key_ok"] = True
|
||||||
@@ -257,7 +301,7 @@ def probe_host(cfg, alias, host):
|
|||||||
return rec
|
return rec
|
||||||
rec["reachable"] = True
|
rec["reachable"] = True
|
||||||
v = values(out)
|
v = values(out)
|
||||||
parsed = sections(out, ("SP_AGENTS", "SP_PROCS", "SP_APPS", "SP_HELPER", "SP_SOLO", "SP_NOW", "SP_END"))
|
parsed = sections(out, ("SP_AGENTS", "SP_PROCS", "SP_APPS", "SP_HELPER", "SP_SOLO", "SP_NOW", "SP_SCRIPTS", "SP_END"))
|
||||||
rec["computer_name"], rec["os_version"], rec["python"] = v.get("NAME", ""), v.get("OSV", ""), v.get("PY", "")
|
rec["computer_name"], rec["os_version"], rec["python"] = v.get("NAME", ""), v.get("OSV", ""), v.get("PY", "")
|
||||||
rec["items"], rec["found"] = mac_items(host, parsed, cfg)
|
rec["items"], rec["found"] = mac_items(host, parsed, cfg)
|
||||||
rec["setup"] = {"key_ok": (v.get("KEY") or "0").strip() not in ("", "0"), "python_ok": "3." in v.get("PY", "")}
|
rec["setup"] = {"key_ok": (v.get("KEY") or "0").strip() not in ("", "0"), "python_ok": "3." in v.get("PY", "")}
|
||||||
@@ -301,6 +345,7 @@ def probe_self(cfg, alias, host):
|
|||||||
try:
|
try:
|
||||||
agents_out = subprocess.run(["/bin/launchctl", "list"], capture_output=True, text=True, timeout=5).stdout
|
agents_out = subprocess.run(["/bin/launchctl", "list"], capture_output=True, text=True, timeout=5).stdout
|
||||||
procs_out = subprocess.run(["/bin/ps", "-axo", "comm="], capture_output=True, text=True, timeout=5).stdout
|
procs_out = subprocess.run(["/bin/ps", "-axo", "comm="], capture_output=True, text=True, timeout=5).stdout
|
||||||
|
args_out = subprocess.run(["/bin/ps", "-axo", "pid=,args="], capture_output=True, text=True, timeout=5).stdout
|
||||||
except (OSError, subprocess.TimeoutExpired) as e:
|
except (OSError, subprocess.TimeoutExpired) as e:
|
||||||
rec["error"] = str(e)
|
rec["error"] = str(e)
|
||||||
return rec
|
return rec
|
||||||
@@ -308,7 +353,8 @@ def probe_self(cfg, alias, host):
|
|||||||
helper = subprocess.run(["/bin/zsh", "-c", "ls -l %s 2>&1; sudo -n -l %s 2>&1 | tail -1" % (paths["helper"], paths["helper"])],
|
helper = subprocess.run(["/bin/zsh", "-c", "ls -l %s 2>&1; sudo -n -l %s 2>&1 | tail -1" % (paths["helper"], paths["helper"])],
|
||||||
capture_output=True, text=True, timeout=6).stdout
|
capture_output=True, text=True, timeout=6).stdout
|
||||||
parsed = {"SP_AGENTS": agents_out.splitlines(), "SP_PROCS": procs_out.splitlines(), "SP_APPS": [],
|
parsed = {"SP_AGENTS": agents_out.splitlines(), "SP_PROCS": procs_out.splitlines(), "SP_APPS": [],
|
||||||
"SP_HELPER": helper.splitlines(), "SP_SOLO": [], "SP_NOW": [str(int(time.time()))]}
|
"SP_HELPER": helper.splitlines(), "SP_SOLO": [], "SP_NOW": [str(int(time.time()))],
|
||||||
|
"SP_SCRIPTS": [l for l in args_out.splitlines() if re.search(SCRIPT_RE, l)]}
|
||||||
rec["items"], rec["found"] = mac_items(host, parsed, cfg)
|
rec["items"], rec["found"] = mac_items(host, parsed, cfg)
|
||||||
for item, run in zip(rec["items"], host.get("runs", [])):
|
for item, run in zip(rec["items"], host.get("runs", [])):
|
||||||
if run.get("kind") == "folder":
|
if run.get("kind") == "folder":
|
||||||
|
|||||||
@@ -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 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):
|
def gear_view(self):
|
||||||
cfg = self.load()
|
cfg = self.load()
|
||||||
snap = self.read_json("snapshot.json", {}) or {}
|
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)))
|
return self.send(200, site.companion_page(int(n)))
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
return self.send(200, {"page": int(n), "buttons": [], "error": "Companion did not answer: %s" % 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":
|
if path == "/api/machines":
|
||||||
return self.send(200, site.machines_view())
|
return self.send(200, site.machines_view())
|
||||||
if path == "/api/gear":
|
if path == "/api/gear":
|
||||||
|
|||||||
+233
-2
@@ -233,7 +233,7 @@
|
|||||||
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
|
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
|
||||||
|
|
||||||
// ---- routing ---------------------------------------------------------------
|
// ---- routing ---------------------------------------------------------------
|
||||||
const screens = { overview, keys, computers, settings, logs, history, help, review };
|
const screens = { overview, keys, computers, wiring, settings, logs, history, help, review };
|
||||||
let timer = null;
|
let timer = null;
|
||||||
async function route() {
|
async function route() {
|
||||||
let [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
|
let [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
await fn(rest);
|
await fn(rest);
|
||||||
wireInputs(main);
|
wireInputs(main);
|
||||||
wireOnOff(main);
|
wireOnOff(main);
|
||||||
const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0]));
|
const refresh = name === "overview" || name === "wiring" || (name === "computers" && !["gear", "setup"].includes(rest[0]));
|
||||||
if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000);
|
if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000);
|
||||||
} catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
|
} catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
|
||||||
}
|
}
|
||||||
@@ -630,6 +630,237 @@
|
|||||||
${canRunNow(r.fix) && editing() ? `<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run the fix</button>` : ""}<details><summary>more</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd>${r.watch && r.watch.length ? `<dt>flashes while down</dt><dd>${esc(r.watch.join(", "))}</dd>` : ""}</dl></details>${editing() ? `<p style="margin-top:12px"><button class="small danger" id="delrkey">Delete key</button></p>` : ""}</div>`;
|
${canRunNow(r.fix) && editing() ? `<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run the fix</button>` : ""}<details><summary>more</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd>${r.watch && r.watch.length ? `<dt>flashes while down</dt><dd>${esc(r.watch.join(", "))}</dd>` : ""}</dl></details>${editing() ? `<p style="margin-top:12px"><button class="small danger" id="delrkey">Delete key</button></p>` : ""}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Wiring ------------------------------------------------------------------
|
||||||
|
// Every computer and device the panel knows and what connects them, like a wiring diagram (2026-10-27). Drag a
|
||||||
|
// machine to arrange it; click one to see its scripts and connections; hide connection types or single
|
||||||
|
// connections. Positions and what is hidden are kept in this browser only.
|
||||||
|
const W_W = 2000, W_H = 1300;
|
||||||
|
const wire = { data: null, pos: null, hidden: null, types: null, sel: null, focus: false, problems: false, drag: null, view: null };
|
||||||
|
const wireRead = (k, d) => { try { return JSON.parse(store.get(k) || "null") || d; } catch (e) { return d; } };
|
||||||
|
const wireSave = () => { store.set("sp-wire-pos", JSON.stringify(wire.pos)); store.set("sp-wire-hidden", JSON.stringify([...wire.hidden])); store.set("sp-wire-types", JSON.stringify([...wire.types])); };
|
||||||
|
const wireType = (id) => (wire.data.types.find((t) => t.id === id) || { label: id, color: "#888" });
|
||||||
|
const wireNode = (id) => wire.data.nodes.find((n) => n.id === id);
|
||||||
|
const wireName = (id) => (wireNode(id) || { label: id }).label;
|
||||||
|
async function wiring(rest, quiet) {
|
||||||
|
if (quiet && (wire.drag || document.getElementById("dialog").hidden === false)) return;
|
||||||
|
wire.data = await api("/api/wiring");
|
||||||
|
if (!wire.pos) { wire.pos = wireRead("sp-wire-pos", {}); wire.hidden = new Set(wireRead("sp-wire-hidden", [])); wire.types = new Set(wireRead("sp-wire-types", ["watch"])); } // the watchdog's own lines: hidden at first
|
||||||
|
if (wire.sel && !(wire.sel.edge ? wire.data.edges.some((e) => e.id === wire.sel.id) : wireNode(wire.sel.id))) wire.sel = null;
|
||||||
|
wireLayout();
|
||||||
|
if (!(quiet && document.getElementById("wiresvg"))) {
|
||||||
|
main.innerHTML = `<h1>Wiring${hint("What is connected to what. Drag a machine to move it, click it for its scripts and connections. Colours: the kind of connection; red = that connection has a problem; dashed = can't see it from here.")}</h1>
|
||||||
|
<div class="tabs" id="wiretools"></div><div class="wire"><div class="wirecanvas"><div class="wzoom"><button class="small" id="wzin" aria-label="zoom in">+</button><button class="small" id="wzout" aria-label="zoom out">−</button><button class="small" id="wfit" data-hint="Show everything that is shown">Fit</button></div><svg id="wiresvg" class="canvas" viewBox="0 0 ${W_W} ${W_H}" role="img" aria-label="wiring diagram"></svg></div><aside class="card wireside" id="wireside"></aside></div>`;
|
||||||
|
wireEvents();
|
||||||
|
}
|
||||||
|
wireTools(); wireDraw(); wireSide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireLayout(all) {
|
||||||
|
const d = wire.data, cx = W_W / 2, cy = W_H / 2;
|
||||||
|
const fresh = d.nodes.filter((n) => all || !wire.pos[n.id]).map((n) => n.id);
|
||||||
|
if (!fresh.length) return;
|
||||||
|
const nb = {};
|
||||||
|
for (const e of d.edges) if (e.type !== "watch") { (nb[e.from] = nb[e.from] || []).push(e.to); (nb[e.to] = nb[e.to] || []).push(e.from); }
|
||||||
|
const move = new Set(fresh), hubs = d.nodes.filter((n) => (n.kind === "computer" || n.kind === "switch") && n.id !== d.me);
|
||||||
|
hubs.sort((a, b) => (a.kind === "switch") - (b.kind === "switch") || a.label.localeCompare(b.label));
|
||||||
|
if (d.me && move.has(d.me)) wire.pos[d.me] = { x: cx, y: cy };
|
||||||
|
const angle = {};
|
||||||
|
hubs.forEach((n, i) => { angle[n.id] = 2 * Math.PI * i / hubs.length - Math.PI / 2; if (move.has(n.id)) wire.pos[n.id] = { x: cx + W_W * 0.24 * Math.cos(angle[n.id]), y: cy + W_H * 0.26 * Math.sin(angle[n.id]) }; });
|
||||||
|
// Each device beside the machine it belongs to: its switch or computer with the most links, else Companion's Mac.
|
||||||
|
const anchor = {}, groups = {};
|
||||||
|
for (const n of d.nodes) {
|
||||||
|
if (n.kind === "computer" || n.kind === "switch") continue;
|
||||||
|
const count = {};
|
||||||
|
for (const o of nb[n.id] || []) if (angle[o] !== undefined) count[o] = (count[o] || 0) + 1;
|
||||||
|
const best = Object.keys(count).sort((a, b) => count[b] - count[a])[0] || d.me;
|
||||||
|
anchor[n.id] = best; (groups[best] = groups[best] || []).push(n);
|
||||||
|
}
|
||||||
|
for (const [hub, list] of Object.entries(groups)) {
|
||||||
|
list.sort((a, b) => String(a.kind).localeCompare(String(b.kind)) || String(a.label).localeCompare(String(b.label)));
|
||||||
|
list.forEach((n, i) => {
|
||||||
|
if (!move.has(n.id)) return;
|
||||||
|
if (hub === d.me || angle[hub] === undefined) { // the outer ring, kinds together
|
||||||
|
const a = 2 * Math.PI * i / list.length - Math.PI / 2 + 0.2;
|
||||||
|
wire.pos[n.id] = { x: cx + W_W * 0.43 * Math.cos(a), y: cy + W_H * 0.44 * Math.sin(a) };
|
||||||
|
} else { // a fan outward from its machine
|
||||||
|
const a = angle[hub] + (i - (list.length - 1) / 2) * 0.12, r = 1.0 + 0.16 * (1 + (i % 2));
|
||||||
|
wire.pos[n.id] = { x: cx + W_W * 0.24 * r * Math.cos(a), y: cy + W_H * 0.26 * r * Math.sin(a) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Push apart whatever overlaps (boxes, with a margin), each device held near its machine.
|
||||||
|
const ids = d.nodes.map((n) => n.id), size = {};
|
||||||
|
for (const n of d.nodes) size[n.id] = nodeSize(n);
|
||||||
|
for (let it = 0; it < 260; it++) {
|
||||||
|
for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
|
||||||
|
const a = ids[i], b = ids[j];
|
||||||
|
if (!move.has(a) && !move.has(b)) continue;
|
||||||
|
const pa = wire.pos[a], pb = wire.pos[b];
|
||||||
|
const ox = (size[a].w + size[b].w) / 2 + 14 - Math.abs(pa.x - pb.x), oy = (size[a].h + size[b].h) / 2 + 12 - Math.abs(pa.y - pb.y);
|
||||||
|
if (ox <= 0 || oy <= 0) continue;
|
||||||
|
const ma = move.has(a) ? (move.has(b) ? 0.5 : 1) : 0, mb = 1 - ma;
|
||||||
|
if (ox < oy) { const sx = (pa.x < pb.x ? -1 : 1) * ox; pa.x += sx * ma; pb.x -= sx * mb; }
|
||||||
|
else { const sy = (pa.y < pb.y ? -1 : 1) * oy; pa.y += sy * ma; pb.y -= sy * mb; }
|
||||||
|
}
|
||||||
|
for (const id of move) {
|
||||||
|
const h = anchor[id], p = wire.pos[id];
|
||||||
|
if (h && h !== d.me && wire.pos[h]) { const q = wire.pos[h], dx = q.x - p.x, dy = q.y - p.y, dd = Math.hypot(dx, dy) || 1; if (dd > 230) { p.x += dx * 0.02; p.y += dy * 0.02; } }
|
||||||
|
p.x = Math.max(size[id].w / 2 + 6, Math.min(W_W - size[id].w / 2 - 6, p.x)); p.y = Math.max(20, Math.min(W_H - 20, p.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wireSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireFit() {
|
||||||
|
const { nodes } = wireShown();
|
||||||
|
if (!nodes.length) { wire.view = { x: 0, y: 0, w: W_W, h: W_H }; return; }
|
||||||
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||||
|
for (const n of nodes) { const p = wire.pos[n.id], { w, h } = nodeSize(n); x0 = Math.min(x0, p.x - w / 2); x1 = Math.max(x1, p.x + w / 2); y0 = Math.min(y0, p.y - h / 2); y1 = Math.max(y1, p.y + h / 2); }
|
||||||
|
const svg = document.getElementById("wiresvg"), box = svg && svg.getBoundingClientRect();
|
||||||
|
const pad = 30, w = x1 - x0 + 2 * pad, h = y1 - y0 + 2 * pad, ratio = box && box.height ? box.width / box.height : W_W / W_H;
|
||||||
|
const vw = Math.max(w, h * ratio), vh = vw / ratio;
|
||||||
|
wire.view = { x: (x0 + x1) / 2 - vw / 2, y: (y0 + y1) / 2 - vh / 2, w: vw, h: vh };
|
||||||
|
}
|
||||||
|
function wireZoom(f, px, py) {
|
||||||
|
const v = wire.view, nw = Math.max(250, Math.min(W_W * 2.5, v.w * f)), k = nw / v.w;
|
||||||
|
px = px == null ? v.x + v.w / 2 : px; py = py == null ? v.y + v.h / 2 : py;
|
||||||
|
wire.view = { x: px - (px - v.x) * k, y: py - (py - v.y) * k, w: nw, h: v.h * k };
|
||||||
|
const svg = document.getElementById("wiresvg"); if (svg) svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireShown() {
|
||||||
|
const d = wire.data, sel = wire.sel && !wire.sel.edge ? wire.sel.id : null;
|
||||||
|
const edges = d.edges.filter((e) => !wire.types.has(e.type) && !wire.hidden.has(e.id) && (!wire.problems || e.light === "problem")
|
||||||
|
&& (!wire.focus || !sel || e.from === sel || e.to === sel));
|
||||||
|
const ends = new Set(); for (const e of edges) { ends.add(e.from); ends.add(e.to); }
|
||||||
|
const nodes = d.nodes.filter((n) => ends.has(n.id) || (n.kind === "computer" && !wire.focus && !wire.problems) || n.id === sel);
|
||||||
|
return { edges, nodes };
|
||||||
|
}
|
||||||
|
const nodeSize = (n) => n.kind === "computer" ? { w: 168, h: 46 } : { w: Math.min(190, 26 + 6.6 * String(n.label).length), h: 26 };
|
||||||
|
|
||||||
|
function wireDraw() {
|
||||||
|
const svg = document.getElementById("wiresvg");
|
||||||
|
if (!svg) return;
|
||||||
|
if (!wire.view) wireFit();
|
||||||
|
svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`);
|
||||||
|
const { edges, nodes } = wireShown(), sel = wire.sel;
|
||||||
|
const near = new Set();
|
||||||
|
if (sel && !sel.edge) { near.add(sel.id); for (const e of edges) if (e.from === sel.id || e.to === sel.id) { near.add(e.from); near.add(e.to); } }
|
||||||
|
if (sel && sel.edge) { const e = wire.data.edges.find((x) => x.id === sel.id); if (e) { near.add(e.from); near.add(e.to); } }
|
||||||
|
const pairs = {};
|
||||||
|
for (const e of edges) { const k = [e.from, e.to].sort().join("|"); (pairs[k] = pairs[k] || []).push(e); }
|
||||||
|
let out = "";
|
||||||
|
for (const list of Object.values(pairs)) list.forEach((e, i) => {
|
||||||
|
const a = wire.pos[e.from], b = wire.pos[e.to];
|
||||||
|
if (!a || !b) return;
|
||||||
|
const off = (i - (list.length - 1) / 2) * 16, dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1;
|
||||||
|
const mx = (a.x + b.x) / 2 - dy / len * off, my = (a.y + b.y) / 2 + dx / len * off;
|
||||||
|
const t = wireType(e.type), on = sel ? (sel.edge ? sel.id === e.id : e.from === sel.id || e.to === sel.id) : true;
|
||||||
|
out += `<path class="wedge ${esc(e.light)}${on ? "" : " dim"}${sel && sel.edge && sel.id === e.id ? " sel" : ""}" data-edge="${esc(e.id)}" d="M${a.x},${a.y} Q${mx},${my} ${b.x},${b.y}" style="stroke:${e.light === "problem" ? "var(--problem)" : t.color}"><title>${esc(t.label)}: ${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))} · ${esc(e.label)} · ${esc(lightWord[e.light] || e.light)}</title></path>`;
|
||||||
|
});
|
||||||
|
for (const n of nodes) {
|
||||||
|
const p = wire.pos[n.id], { w, h } = nodeSize(n), dim = sel && !near.has(n.id);
|
||||||
|
const scripts = n.scripts ? n.scripts.filter((x) => x.running).length : null;
|
||||||
|
const shape = n.kind === "computer" ? `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="9"/>`
|
||||||
|
: n.kind === "switch" ? `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="2"/>`
|
||||||
|
: `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="${h / 2}"/>`;
|
||||||
|
const name = String(n.label).length > 26 ? String(n.label).slice(0, 25) + "…" : n.label;
|
||||||
|
out += `<g class="wnode ${esc(n.kind)} ${esc(n.light)}${dim ? " dim" : ""}${sel && !sel.edge && sel.id === n.id ? " sel" : ""}" data-node="${esc(n.id)}" transform="translate(${p.x},${p.y})"><title>${esc(n.label)}${n.address ? " · " + esc(n.address) : ""}${n.reason ? " · " + esc(n.reason) : ""}</title>${shape}
|
||||||
|
<circle class="wdot" cx="${-w / 2 + 12}" cy="0" r="5"/><text x="${-w / 2 + 22}" y="${n.kind === "computer" ? -3 : 4}">${esc(name)}</text>
|
||||||
|
${n.kind === "computer" ? `<text class="wsub" x="${-w / 2 + 22}" y="13">${scripts == null ? "scripts: not looked yet" : scripts + " script" + (scripts === 1 ? "" : "s") + " running"}${n.self ? " · this Mac" : ""}</text>` : ""}</g>`;
|
||||||
|
}
|
||||||
|
svg.innerHTML = out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireTools() {
|
||||||
|
const el = document.getElementById("wiretools");
|
||||||
|
if (!el) return;
|
||||||
|
const d = wire.data, count = {};
|
||||||
|
for (const e of d.edges) count[e.type] = (count[e.type] || 0) + 1;
|
||||||
|
const bad = d.edges.filter((e) => e.light === "problem").length;
|
||||||
|
el.innerHTML = d.types.filter((t) => count[t.id]).map((t) => `<button class="small wtype${wire.types.has(t.id) ? " off" : " on"}" data-wtype="${esc(t.id)}" data-hint="${esc(t.hint)} · click to ${wire.types.has(t.id) ? "show" : "hide"}"><i style="background:${t.color}"></i>${esc(t.label)} <span class="muted">${count[t.id]}</span></button>`).join("")
|
||||||
|
+ `<button class="small${wire.problems ? " on" : ""}" id="wprob" data-hint="Only the connections with a problem">Problems only <span class="muted">${bad}</span></button>`
|
||||||
|
+ `<button class="small" id="wall" data-hint="Show every connection again">Show all</button>`
|
||||||
|
+ `<button class="small" id="wre" data-hint="Forget where machines were dragged and lay them out again">Re-arrange</button>`
|
||||||
|
+ `<button class="small" id="wprobe" style="margin-left:auto" ${d.probing ? "disabled" : ""} data-hint="Log in to every computer now and list its scripts (read-only)">${d.probing ? "Looking…" : "Check scripts now"}</button>`;
|
||||||
|
for (const b of el.querySelectorAll("[data-wtype]")) b.onclick = () => { const t = b.dataset.wtype; wire.types.has(t) ? wire.types.delete(t) : wire.types.add(t); wireSave(); wireTools(); wireDraw(); wireSide(); };
|
||||||
|
el.querySelector("#wprob").onclick = () => { wire.problems = !wire.problems; wireTools(); wireDraw(); wireSide(); };
|
||||||
|
el.querySelector("#wall").onclick = () => { wire.types.clear(); wire.hidden.clear(); wire.focus = false; wire.problems = false; wireSave(); wireTools(); wireDraw(); wireSide(); };
|
||||||
|
el.querySelector("#wre").onclick = () => { wireLayout(true); wireFit(); wireDraw(); };
|
||||||
|
el.querySelector("#wprobe").onclick = async (ev) => { ev.target.disabled = true; ev.target.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => wiring([], true), 6000); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireSide() {
|
||||||
|
const el = document.getElementById("wireside");
|
||||||
|
if (!el) return;
|
||||||
|
const d = wire.data, sel = wire.sel;
|
||||||
|
const row = (e, other) => `<li><label class="wrow"><input type="checkbox" data-whide="${esc(e.id)}" ${wire.hidden.has(e.id) ? "" : "checked"}>${lightDot(e.light, lightWord[e.light])}<span><b>${esc(wireName(other))}</b> <span class="muted small">${esc(e.label)}</span></span></label></li>`;
|
||||||
|
let html;
|
||||||
|
if (sel && sel.edge) {
|
||||||
|
const e = d.edges.find((x) => x.id === sel.id), t = wireType(e.type);
|
||||||
|
html = `<h2><i class="wsw" style="background:${t.color}"></i>${esc(t.label)}</h2><p>${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))}</p><p class="small">${esc(e.label)} · ${lightDot(e.light)} ${esc(lightWord[e.light] || e.light)}</p>
|
||||||
|
<p><button class="small" data-whide-one="${esc(e.id)}">${wire.hidden.has(e.id) ? "Show" : "Hide"} this connection</button> <button class="small" data-wsel="${esc(e.from)}">${esc(wireName(e.from))}</button> <button class="small" data-wsel="${esc(e.to)}">${esc(wireName(e.to))}</button></p>`;
|
||||||
|
} else if (sel) {
|
||||||
|
const n = wireNode(sel.id), mine = d.edges.filter((e) => e.from === n.id || e.to === n.id);
|
||||||
|
const byType = {};
|
||||||
|
for (const e of mine) (byType[e.type] = byType[e.type] || []).push(e);
|
||||||
|
const scripts = n.kind !== "computer" ? "" : n.scripts == null
|
||||||
|
? `<h3>Scripts</h3><p class="small muted">Not looked yet: press Check scripts now.</p>`
|
||||||
|
: `<h3>Scripts <span class="muted small">${n.scripts.filter((x) => x.running).length} running${n.scripts.some((x) => !x.running) ? `, ${n.scripts.filter((x) => !x.running).length} stopped` : ""}</span></h3>${n.scripts.length ? `<ul class="wlist">${n.scripts.map((x) => `<li>${lightDot(x.running ? "ok" : "not_needed", x.running ? "running" : (x.state || "not running"))}<span class="mono small">${esc(x.name)}</span>${x.by && x.by !== x.name ? ` <span class="muted small">${esc(x.by.replace(/^org\.waterschurch\./, ""))}</span>` : ""}</li>`).join("")}</ul>` : `<p class="small muted">None.</p>`}${n.looked ? `<p class="small muted">looked ${esc(ago(Date.now() / 1000 - n.looked))}</p>` : ""}`;
|
||||||
|
html = `<h2>${lightDot(n.light, lightWord[n.light])} ${esc(n.label)}</h2><p class="small muted">${esc(n.kind === "computer" ? (n.os === "windows" ? "Windows PC" : "Mac") + (n.self ? " · this Mac (the panel)" : "") : n.kind)}${n.address ? " · " + esc(n.address) : ""}${n.role ? " · " + esc(n.role) : ""}</p>
|
||||||
|
${n.reason ? `<p class="small">${esc(n.reason)}</p>` : ""}${n.names && n.names.length > 1 ? `<p class="small muted">also called ${esc(n.names.filter((x) => x !== n.label).join(", "))}</p>` : ""}
|
||||||
|
<p><button class="small${wire.focus ? " on" : ""}" id="wfocus">${wire.focus ? "Show everything" : "Only this one's connections"}</button></p>${scripts}
|
||||||
|
<h3>Connections <span class="muted small">${mine.length}</span></h3>${Object.entries(byType).map(([t, list]) => `<p class="small wtyp"><i class="wsw" style="background:${wireType(t).color}"></i>${esc(wireType(t).label)}</p><ul class="wlist">${list.map((e) => row(e, e.from === n.id ? e.to : e.from)).join("")}</ul>`).join("") || `<p class="small muted">None.</p>`}
|
||||||
|
${(n.local || []).length ? `<p class="small muted">On this machine itself: ${esc(n.local.map((x) => x.label).join(", "))}</p>` : ""}`;
|
||||||
|
} else {
|
||||||
|
const comps = d.nodes.filter((n) => n.kind === "computer");
|
||||||
|
const bad = d.edges.filter((e) => e.light === "problem");
|
||||||
|
html = `<h2>${comps.length} computers, ${d.nodes.length - comps.length} devices</h2><p class="small">${d.edges.length} connections. Click a machine for its scripts and connections; untick a connection to hide it.</p>
|
||||||
|
<h3>Scripts per computer</h3><ul class="wlist">${comps.map((n) => `<li><a href="#" data-wsel="${esc(n.id)}">${esc(n.label)}</a> <span class="muted small">${n.scripts == null ? "not looked yet" : n.scripts.filter((x) => x.running).length + " running"}</span></li>`).join("")}</ul>
|
||||||
|
${bad.length ? `<h3>Problems <span class="muted small">${bad.length}</span></h3><ul class="wlist">${bad.map((e) => `<li><a href="#" data-wedge="${esc(e.id)}">${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))}</a> <span class="muted small">${esc(e.label)}</span></li>`).join("")}</ul>` : ""}
|
||||||
|
${wire.hidden.size ? `<h3>Hidden <span class="muted small">${wire.hidden.size}</span></h3><ul class="wlist">${[...wire.hidden].map((id) => d.edges.find((e) => e.id === id)).filter(Boolean).map((e) => `<li><label class="wrow"><input type="checkbox" data-whide="${esc(e.id)}"><span>${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))} <span class="muted small">${esc(e.label)}</span></span></label></li>`).join("")}</ul>` : ""}`;
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
for (const c of el.querySelectorAll("[data-whide]")) c.onchange = () => { c.checked ? wire.hidden.delete(c.dataset.whide) : wire.hidden.add(c.dataset.whide); wireSave(); wireDraw(); wireSide(); };
|
||||||
|
for (const b of el.querySelectorAll("[data-whide-one]")) b.onclick = () => { const id = b.dataset.whideOne; wire.hidden.has(id) ? wire.hidden.delete(id) : wire.hidden.add(id); wireSave(); wireDraw(); wireSide(); };
|
||||||
|
for (const a of el.querySelectorAll("[data-wsel]")) a.onclick = (ev) => { ev.preventDefault(); wire.sel = { id: a.dataset.wsel }; wireDraw(); wireSide(); };
|
||||||
|
for (const a of el.querySelectorAll("[data-wedge]")) a.onclick = (ev) => { ev.preventDefault(); wire.sel = { id: a.dataset.wedge, edge: true }; wireDraw(); wireSide(); };
|
||||||
|
const f = el.querySelector("#wfocus"); if (f) f.onclick = () => { wire.focus = !wire.focus; wireDraw(); wireSide(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireEvents() {
|
||||||
|
const svg = document.getElementById("wiresvg");
|
||||||
|
const at = (ev) => { const pt = svg.createSVGPoint(); pt.x = ev.clientX; pt.y = ev.clientY; return pt.matrixTransform(svg.getScreenCTM().inverse()); };
|
||||||
|
document.getElementById("wzin").onclick = () => wireZoom(0.8);
|
||||||
|
document.getElementById("wzout").onclick = () => wireZoom(1.25);
|
||||||
|
document.getElementById("wfit").onclick = () => { wireFit(); wireDraw(); };
|
||||||
|
svg.addEventListener("wheel", (ev) => { ev.preventDefault(); const p = at(ev); wireZoom(ev.deltaY > 0 ? 1.12 : 1 / 1.12, p.x, p.y); }, { passive: false });
|
||||||
|
svg.addEventListener("pointerdown", (ev) => {
|
||||||
|
const g = ev.target.closest("[data-node]"), path = ev.target.closest("[data-edge]");
|
||||||
|
const p = at(ev);
|
||||||
|
if (g) { const id = g.dataset.node; wire.drag = { id, dx: wire.pos[id].x - p.x, dy: wire.pos[id].y - p.y, x0: ev.clientX, y0: ev.clientY, moved: false }; }
|
||||||
|
else if (path) { wire.sel = { id: path.dataset.edge, edge: true }; wireDraw(); wireSide(); return; }
|
||||||
|
else wire.drag = { pan: true, x0: ev.clientX, y0: ev.clientY, vx: wire.view.x, vy: wire.view.y, k: wire.view.w / svg.getBoundingClientRect().width, moved: false };
|
||||||
|
svg.setPointerCapture(ev.pointerId); ev.preventDefault();
|
||||||
|
});
|
||||||
|
svg.addEventListener("pointermove", (ev) => {
|
||||||
|
const dr = wire.drag; if (!dr) return;
|
||||||
|
if (!dr.moved && Math.hypot(ev.clientX - dr.x0, ev.clientY - dr.y0) < 4) return;
|
||||||
|
dr.moved = true;
|
||||||
|
if (dr.pan) { wire.view.x = dr.vx - (ev.clientX - dr.x0) * dr.k; wire.view.y = dr.vy - (ev.clientY - dr.y0) * dr.k; svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`); return; }
|
||||||
|
const p = at(ev);
|
||||||
|
wire.pos[dr.id] = { x: p.x + dr.dx, y: p.y + dr.dy };
|
||||||
|
wireDraw();
|
||||||
|
});
|
||||||
|
const end = () => {
|
||||||
|
const dr = wire.drag; wire.drag = null; if (!dr) return;
|
||||||
|
if (dr.pan) { if (!dr.moved) { wire.sel = null; wire.focus = false; wireDraw(); wireSide(); } return; }
|
||||||
|
if (dr.moved) wireSave(); else { wire.sel = wire.sel && !wire.sel.edge && wire.sel.id === dr.id ? null : { id: dr.id }; if (!wire.sel) wire.focus = false; }
|
||||||
|
wireDraw(); wireSide();
|
||||||
|
};
|
||||||
|
svg.addEventListener("pointerup", end); svg.addEventListener("pointercancel", end);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Computers ---------------------------------------------------------------
|
// ---- Computers ---------------------------------------------------------------
|
||||||
let compTab = "map";
|
let compTab = "map";
|
||||||
async function computers(rest, quiet) {
|
async function computers(rest, quiet) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<a href="#overview" data-hint="Is the room OK"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/><rect x="13" y="13" width="8" height="8" rx="1.5"/></svg><span>Overview</span></a>
|
<a href="#overview" data-hint="Is the room OK"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/><rect x="13" y="13" width="8" height="8" rx="1.5"/></svg><span>Overview</span></a>
|
||||||
<a href="#keys" data-hint="The deck: what each key watches and does"><svg viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h2M10 10h2M14 10h2M18 10h0M6 14h12"/></svg><span>Keys</span></a>
|
<a href="#keys" data-hint="The deck: what each key watches and does"><svg viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h2M10 10h2M14 10h2M18 10h0M6 14h12"/></svg><span>Keys</span></a>
|
||||||
<a href="#computers" data-hint="What runs where"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg><span>Computers</span></a>
|
<a href="#computers" data-hint="What runs where"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg><span>Computers</span></a>
|
||||||
|
<a href="#wiring" data-hint="What is connected to what, and the scripts on each computer"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="7" height="5" rx="1"/><rect x="15" y="3" width="7" height="5" rx="1"/><rect x="8.5" y="16" width="7" height="5" rx="1"/><path d="M5.5 8v3.5h13V8M12 11.5V16"/></svg><span>Wiring</span></a>
|
||||||
<a href="#settings" data-hint="Everything you can change"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h0a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span>Settings</span></a>
|
<a href="#settings" data-hint="Everything you can change"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h0a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span>Settings</span></a>
|
||||||
<a href="#logs" data-hint="What the watchdog did and saw"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span>Logs</span></a>
|
<a href="#logs" data-hint="What the watchdog did and saw"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span>Logs</span></a>
|
||||||
<a href="#history" data-hint="Every version ever applied"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>History</span></a>
|
<a href="#history" data-hint="Every version ever applied"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>History</span></a>
|
||||||
|
|||||||
@@ -153,3 +153,35 @@ details.script textarea { width: 100%; font-size: .8rem; }
|
|||||||
.onoff.on span { background: #1f8f4e; color: #fff; }
|
.onoff.on span { background: #1f8f4e; color: #fff; }
|
||||||
.onoff input:disabled + span { opacity: .75; cursor: default; }
|
.onoff input:disabled + span { opacity: .75; cursor: default; }
|
||||||
tr.off td, .trig.off, .key.off { opacity: .55; }
|
tr.off td, .trig.off, .key.off { opacity: .55; }
|
||||||
|
|
||||||
|
/* Wiring: the diagram and the panel beside it (one column on a phone) */
|
||||||
|
.wire { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 12px; align-items: start; }
|
||||||
|
@media (max-width: 900px) { .wire { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
.wirecanvas { position: relative; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; touch-action: none; }
|
||||||
|
.wzoom { position: absolute; top: 8px; left: 8px; display: flex; gap: 4px; z-index: 1; } .wzoom button { min-width: 30px; justify-content: center; }
|
||||||
|
.wirecanvas svg.canvas { cursor: move; }
|
||||||
|
.wirecanvas svg.canvas { width: 100%; height: calc(100vh - 175px); min-height: 420px; display: block; fill: none; stroke: none; stroke-width: 1; user-select: none; -webkit-user-select: none; }
|
||||||
|
.wedge { fill: none; stroke-width: 2.2; opacity: .85; cursor: pointer; }
|
||||||
|
.wedge:hover, .wedge.sel { stroke-width: 4.5; opacity: 1; }
|
||||||
|
.wedge.unknown { stroke-dasharray: 5 5; } .wedge.not_needed { opacity: .3; }
|
||||||
|
.wedge.problem { stroke-width: 3.4; stroke-dasharray: 9 5; animation: wflow 1s linear infinite; }
|
||||||
|
@keyframes wflow { to { stroke-dashoffset: -14; } }
|
||||||
|
.wedge.dim, .wnode.dim { opacity: .12; }
|
||||||
|
.wnode { cursor: grab; } .wnode:active { cursor: grabbing; }
|
||||||
|
.wnode rect { fill: var(--surface); stroke: var(--line); stroke-width: 1.5; }
|
||||||
|
.wnode.computer rect { stroke: var(--fg); stroke-width: 1.8; }
|
||||||
|
.wnode.switch rect { fill: var(--bg); }
|
||||||
|
.wnode.problem rect { stroke: var(--problem); stroke-width: 2.5; }
|
||||||
|
.wnode.sel rect { stroke: var(--accent); stroke-width: 3.5; }
|
||||||
|
.wnode text { fill: var(--fg); font: 600 13px var(--font); stroke: none; }
|
||||||
|
.wnode:not(.computer) text { font-weight: 500; font-size: 12px; }
|
||||||
|
.wnode text.wsub { fill: var(--muted); font: 11px var(--font); }
|
||||||
|
.wnode .wdot { fill: var(--unknown); stroke: none; }
|
||||||
|
.wnode.ok .wdot { fill: var(--ok); } .wnode.problem .wdot { fill: var(--problem); } .wnode.not_needed .wdot { fill: var(--none); }
|
||||||
|
.wtype i, .wsw { display: inline-block; width: 12px; height: 4px; border-radius: 2px; margin-right: 2px; vertical-align: middle; }
|
||||||
|
.wtype.off { opacity: .45; text-decoration: line-through; } .wtype.on { border-color: var(--line); }
|
||||||
|
.wireside { position: sticky; top: 70px; max-height: calc(100vh - 90px); overflow: auto; margin: 0; }
|
||||||
|
.wireside h2 { margin: 4px 0 6px; font-size: 1.1rem; } .wireside h3 { margin: 14px 0 4px; font-size: .95rem; }
|
||||||
|
.wlist { list-style: none; margin: 0; padding: 0; } .wlist li { padding: 3px 0; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.wrow { display: flex; gap: 6px; align-items: center; cursor: pointer; } .wtyp { margin: 8px 0 2px; }
|
||||||
|
main:has(.wire) { max-width: none; }
|
||||||
|
|||||||
+46
-6
@@ -484,6 +484,25 @@ def check_launchd(chk, snap, ctx):
|
|||||||
return [("svc", Result(OK))]
|
return [("svc", Result(OK))]
|
||||||
|
|
||||||
|
|
||||||
|
def check_heartbeat(chk, snap, ctx):
|
||||||
|
"""A service that writes the time into a Companion variable (the Resi auto-record: custom autorec_hb):
|
||||||
|
older than max_age_seconds is stuck. With status_var, a status containing warn_if is amber."""
|
||||||
|
conn = chk.get("conn", "custom")
|
||||||
|
raw = snap.var(conn, chk["var"])
|
||||||
|
if raw in (None, ""):
|
||||||
|
return [("hb", Result(OK))] # not read yet
|
||||||
|
try:
|
||||||
|
age = ctx["now"] - float(raw)
|
||||||
|
except ValueError:
|
||||||
|
age = None
|
||||||
|
if age is None or age > chk.get("max_age_seconds", 90):
|
||||||
|
return [("hb", Result(ERR, chk.get("msg", "STUCK"), named=True))]
|
||||||
|
status = (snap.var(conn, chk["status_var"]) or "") if chk.get("status_var") else ""
|
||||||
|
if chk.get("warn_if") and chk["warn_if"].upper() in status.upper():
|
||||||
|
return [("hb", Result(WARN, chk.get("warn_msg", chk["warn_if"]), named=True))]
|
||||||
|
return [("hb", Result(OK))]
|
||||||
|
|
||||||
|
|
||||||
def check_process(chk, snap, ctx):
|
def check_process(chk, snap, ctx):
|
||||||
running = snap.procs.get(chk["match"])
|
running = snap.procs.get(chk["match"])
|
||||||
if running is False:
|
if running is False:
|
||||||
@@ -956,7 +975,7 @@ CHECKS = {
|
|||||||
"psm": check_psm, "tascam": check_tascam, "ping": check_ping, "launchd": check_launchd,
|
"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, "midi_wired": check_midi_wired, "pref": check_pref, "spotify": check_spotify,
|
"process": check_process, "http": check_http, "disk": check_disk, "midi_peer": check_midi_peer, "midi_wired": check_midi_wired, "pref": check_pref, "spotify": check_spotify,
|
||||||
"mdns": check_mdns, "midi_rx": check_midi_rx, "remote": check_remote, "artnet": check_artnet,
|
"mdns": check_mdns, "midi_rx": check_midi_rx, "remote": check_remote, "artnet": check_artnet,
|
||||||
"ready": check_ready, "ready_manual": check_ready_manual, "tune_rx": check_tune_rx,
|
"ready": check_ready, "ready_manual": check_ready_manual, "tune_rx": check_tune_rx, "heartbeat": check_heartbeat,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1040,6 +1059,14 @@ def needed_variables(cfg):
|
|||||||
rw = cfg.get("rec_watch") or {}
|
rw = cfg.get("rec_watch") or {}
|
||||||
if rw.get("conn"):
|
if rw.get("conn"):
|
||||||
fast.append((rw["conn"], rw.get("var", "playback_state")))
|
fast.append((rw["conn"], rw.get("var", "playback_state")))
|
||||||
|
if rw.get("heartbeat"):
|
||||||
|
fast.append((rw["conn"], rw["heartbeat"]))
|
||||||
|
for tile in all_tiles(cfg):
|
||||||
|
for chk in tile.get("checks", []):
|
||||||
|
if chk["type"] == "heartbeat":
|
||||||
|
fast.append((chk.get("conn", "custom"), chk["var"]))
|
||||||
|
if chk.get("status_var"):
|
||||||
|
fast.append((chk.get("conn", "custom"), chk["status_var"]))
|
||||||
dedupe = lambda xs: list(dict.fromkeys(xs))
|
dedupe = lambda xs: list(dict.fromkeys(xs))
|
||||||
return dedupe(fast), dedupe(slow)
|
return dedupe(fast), dedupe(slow)
|
||||||
|
|
||||||
@@ -1957,13 +1984,22 @@ class Engine:
|
|||||||
self.toggle_bypass(tid, comp, now)
|
self.toggle_bypass(tid, comp, now)
|
||||||
|
|
||||||
def resi_playing(self, snap, now):
|
def resi_playing(self, snap, now):
|
||||||
"""config "rec_watch": Resi's playback (resi-studio-cues "playback_state") has read PLAYING for
|
"""config "rec_watch": Resi is live and the decks should record, for after_seconds. NA reads the Resi
|
||||||
after_seconds, with the module itself connected. The decks must record then."""
|
auto-record service's own verdict (custom variable autorec_status: RECORDING / STARTING / LIVE, NOT
|
||||||
|
RECORDING; not while it holds REC until 10 minutes before a service), and nothing while its heartbeat
|
||||||
|
is stale. Without a heartbeat the connection must be up (a module's playback_state PLAYING)."""
|
||||||
rw = self.cfg.get("rec_watch") or {}
|
rw = self.cfg.get("rec_watch") or {}
|
||||||
if not rw.get("conn"):
|
if not rw.get("conn"):
|
||||||
return False
|
return False
|
||||||
value = (snap.var(rw["conn"], rw.get("var", "playback_state")) or "").strip().upper()
|
value = (snap.var(rw["conn"], rw.get("var", "playback_state")) or "").upper()
|
||||||
if value != rw.get("playing", "PLAYING").upper() or conn_result(snap, rw["conn"], now).level != OK:
|
if rw.get("heartbeat"):
|
||||||
|
try:
|
||||||
|
fresh = now - float(snap.var(rw["conn"], rw["heartbeat"]) or 0) < rw.get("stale_seconds", 90)
|
||||||
|
except ValueError:
|
||||||
|
fresh = False
|
||||||
|
else:
|
||||||
|
fresh = conn_result(snap, rw["conn"], now).level == OK
|
||||||
|
if not fresh or not any(w.upper() in value for w in rw.get("live", ["PLAYING"])):
|
||||||
self.resi_since = None
|
self.resi_since = None
|
||||||
return False
|
return False
|
||||||
self.resi_since = self.resi_since or now
|
self.resi_since = self.resi_since or now
|
||||||
@@ -2865,7 +2901,11 @@ def companion_conn_hosts(path):
|
|||||||
inst = json.loads(value)
|
inst = json.loads(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
host = (inst.get("config") or {}).get("host") if isinstance(inst, dict) else None
|
conf = (inst.get("config") or {}) if isinstance(inst, dict) else {}
|
||||||
|
host = conf.get("host")
|
||||||
|
if not (isinstance(host, str) and host.strip()) and isinstance(conf.get("bonjourHost"), str):
|
||||||
|
host = conf["bonjourHost"].rsplit(":", 1)[0] if re.match(r"^[\d.]+:\d+$", conf["bonjourHost"].strip()) \
|
||||||
|
else conf["bonjourHost"] # HyperDecks: "10.1.10.11:9993"
|
||||||
if inst.get("label") and isinstance(host, str) and host.strip():
|
if inst.get("label") and isinstance(host, str) and host.strip():
|
||||||
hosts[inst["label"]] = host.strip()
|
hosts[inst["label"]] = host.strip()
|
||||||
return hosts
|
return hosts
|
||||||
|
|||||||
@@ -102,6 +102,40 @@ class TestServer(unittest.TestCase):
|
|||||||
self.assertEqual(web["light"], "ok") # from snapshot.json, not a probe
|
self.assertEqual(web["light"], "ok") # from snapshot.json, not a probe
|
||||||
self.assertIn("mac", o["setup_steps"])
|
self.assertIn("mac", o["setup_steps"])
|
||||||
|
|
||||||
|
def test_wiring_shows_machines_their_connections_and_scripts(self):
|
||||||
|
# 2026-10-27: "like a wiring diagram ... how many and what scripts are running on each machine"
|
||||||
|
live = {"tracks": {"alias": "tracks", "reachable": True, "time": time.time(), "items": [],
|
||||||
|
"found": {"scripts": [{"name": "statuspanel.py", "pid": 5, "by": "", "running": True}]}}}
|
||||||
|
json.dump(live, open(os.path.join(self.data, "machines-live.json"), "w"))
|
||||||
|
snap = json.load(open(os.path.join(self.data, "snapshot.json")))
|
||||||
|
snap.update({"conns": {"ATEM": {"category": "good", "enabled": True}, "x32": {"category": "error", "enabled": True},
|
||||||
|
"HyperDeck_1": {"category": "good", "enabled": True}, "tcp-HyperDeck-1": {"category": "good", "enabled": True}},
|
||||||
|
"conn_hosts": {"ATEM": "10.1.10.2", "x32": "10.1.1.112", "HyperDeck_1": "10.1.10.11", "tcp-HyperDeck-1": "10.1.10.11"},
|
||||||
|
"remote_midi": {"tracks": {"NA Tracks": {"peers": [{"address": "10.1.13.140:5004", "name": "SuperRack-LiveBox", "state": "running"}]}}},
|
||||||
|
"dante_map": {"NA-Tracks": {"switch": "10.66.1.100", "port": "0/6", "seen": time.time()},
|
||||||
|
"FOH-SuperRack-LiveBox": {"switch": "10.66.1.102", "port": "0/9", "seen": time.time()},
|
||||||
|
"STUDIO-B-MON": {"switch": "10.66.1.100", "port": "0/2", "seen": time.time()}}})
|
||||||
|
path = os.path.join(self.data, "snapshot.json")
|
||||||
|
old = open(path).read()
|
||||||
|
try:
|
||||||
|
json.dump(snap, open(path, "w"))
|
||||||
|
w = json.loads(get(self.port, "/api/wiring")[1])
|
||||||
|
finally:
|
||||||
|
open(path, "w").write(old)
|
||||||
|
os.remove(os.path.join(self.data, "machines-live.json"))
|
||||||
|
nodes = {n["id"]: n for n in w["nodes"]}
|
||||||
|
edges = [(e["type"], e["from"], e["to"], e["light"]) for e in w["edges"]]
|
||||||
|
self.assertEqual(w["me"], "h:ingest")
|
||||||
|
self.assertEqual(nodes["h:tracks"]["scripts"][0]["name"], "statuspanel.py")
|
||||||
|
self.assertIsNone(nodes["h:prop"]["scripts"]) # not looked yet
|
||||||
|
self.assertIn(("companion", "h:ingest", "d:10.1.1.112", "problem"), edges)
|
||||||
|
self.assertEqual(sorted(nodes["d:10.1.10.11"]["names"]), ["HyperDeck_1", "tcp-HyperDeck-1"]) # one deck, one box
|
||||||
|
self.assertIn(("midi", "h:tracks", "d:10.1.13.140", "ok"), edges)
|
||||||
|
self.assertIn(("dante", "h:tracks", "sw:10.66.1.100", "ok"), edges) # NA-Tracks is the Tracks Mac
|
||||||
|
self.assertIn("FOH-SuperRack-LiveBox", nodes["d:10.1.13.140"]["names"]) # same box as its MIDI peer
|
||||||
|
self.assertIn("d:studiobmon", nodes) # a monitor, not the Studio B Mac
|
||||||
|
self.assertEqual({t["id"] for t in w["types"]} >= {"companion", "midi", "watch", "dante"}, True)
|
||||||
|
|
||||||
def test_gear_and_keys_and_pages(self):
|
def test_gear_and_keys_and_pages(self):
|
||||||
gear = json.loads(get(self.port, "/api/gear")[1])
|
gear = json.loads(get(self.port, "/api/gear")[1])
|
||||||
self.assertTrue(any(r["name"] == "HyperDeck_2" and "hd2" in r["watched_by"] for r in gear))
|
self.assertTrue(any(r["name"] == "HyperDeck_2" and "hd2" in r["watched_by"] for r in gear))
|
||||||
@@ -362,6 +396,25 @@ class TestBindingAndProbes(unittest.TestCase):
|
|||||||
self.assertEqual(by["sshd"]["light"], "ok")
|
self.assertEqual(by["sshd"]["light"], "ok")
|
||||||
self.assertEqual((setup["profile"], setup["wake_on_lan"], setup["firewall_allow"]), ("Public", "Enabled", "10.1.1.1,10.6.3.222"))
|
self.assertEqual((setup["profile"], setup["wake_on_lan"], setup["firewall_allow"]), ("Public", "Enabled", "10.1.1.1,10.6.3.222"))
|
||||||
self.assertIn("\\StatusPanel\\Start Arena", found["tasks"])
|
self.assertIn("\\StatusPanel\\Start Arena", found["tasks"])
|
||||||
|
scripts = {x["name"]: x["running"] for x in found["scripts"]}
|
||||||
|
self.assertEqual((scripts["Start Vista"], scripts["Start rtpMIDI"]), (True, False)) # Vista.exe runs; rtpMIDI.exe does not
|
||||||
|
|
||||||
|
def test_scripts_are_listed_by_file_name_only(self):
|
||||||
|
agents = {"org.waterschurch.prop-guard": 412, "org.waterschurch.status-panel": None, "com.apple.x": 9}
|
||||||
|
lines = ["412 /usr/bin/python3 /Users/m/Library/Application Support/status-panel/prop_guard.py /Users/m/cfg --token=SECRET",
|
||||||
|
"77 /bin/bash /Users/m/backup/backup-campuses.sh", "55 /Applications/Companion.app/Contents/Resources/node main.js",
|
||||||
|
"60 node /Users/m/Library/Application Support/companion/modules/x/main.js", "9 zsh -c echo SP_SCRIPTS",
|
||||||
|
"88 /Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/Resources/"
|
||||||
|
"Python.app/Contents/MacOS/Python /Users/m/Library/Application Support/resi-autorec/resi_autorec.py"]
|
||||||
|
got = probes.parse_scripts(lines, agents)
|
||||||
|
self.assertEqual([(x["name"], x["by"], x["running"]) for x in got],
|
||||||
|
[("backup-campuses.sh", "", True), ("prop_guard.py", "org.waterschurch.prop-guard", True),
|
||||||
|
("resi_autorec.py", "", True),
|
||||||
|
("status-panel", "org.waterschurch.status-panel", False)])
|
||||||
|
self.assertNotIn("SECRET", json.dumps(got)) # never the rest of the line
|
||||||
|
win = probes.parse_scripts(['812 powershell.exe -File "C:\\ProgramData\\StatusPanel\\start-vista.ps1"'],
|
||||||
|
tasks={"\\StatusPanel\\Start Vista": "Running", "\\StatusPanel\\Start rtpMIDI": "Ready"})
|
||||||
|
self.assertEqual([(x["name"], x["running"]) for x in win], [("Start Vista", True), ("start-vista.ps1", True), ("Start rtpMIDI", False)])
|
||||||
|
|
||||||
def test_probe_commands_never_carry_names_from_the_settings(self):
|
def test_probe_commands_never_carry_names_from_the_settings(self):
|
||||||
cfg = json.loads(json.dumps(CONFIG))
|
cfg = json.loads(json.dumps(CONFIG))
|
||||||
|
|||||||
+36
-18
@@ -76,7 +76,8 @@ def healthy_snapshot(now):
|
|||||||
snap.ping = {"10.1.3.227": 0, "10.6.7.13": 0, "10.1.0.1": 0}
|
snap.ping = {"10.1.3.227": 0, "10.6.7.13": 0, "10.1.0.1": 0}
|
||||||
snap.link = True
|
snap.link = True
|
||||||
snap.launchd = {"org.waterschurch.resi-propresenter": 884, "org.waterschurch.lobby-captions": 900,
|
snap.launchd = {"org.waterschurch.resi-propresenter": 884, "org.waterschurch.lobby-captions": 900,
|
||||||
"org.waterschurch.lobby-captions-display": 901, "org.waterschurch.digico-relay": 902}
|
"org.waterschurch.lobby-captions-display": 901, "org.waterschurch.digico-relay": 902,
|
||||||
|
"org.waterschurch.resi-autorec": 903}
|
||||||
snap.launchd_time = now
|
snap.launchd_time = now
|
||||||
snap.procs = {"/Applications/ProdDash.app/Contents/MacOS/ProdDash": True}
|
snap.procs = {"/Applications/ProdDash.app/Contents/MacOS/ProdDash": True}
|
||||||
snap.remote = {"studiob\0pgrep -xq 'Pro Tools'": True} # the failover's check has a verdict
|
snap.remote = {"studiob\0pgrep -xq 'Pro Tools'": True} # the failover's check has a verdict
|
||||||
@@ -488,12 +489,14 @@ class TestChecks(unittest.TestCase):
|
|||||||
db.execute("CREATE TABLE instances (id STRING, value STRING)")
|
db.execute("CREATE TABLE instances (id STRING, value STRING)")
|
||||||
rows = [("a", {"label": "Axient_1", "config": {"host": "10.1.1.101", "port": 2202}}),
|
rows = [("a", {"label": "Axient_1", "config": {"host": "10.1.1.101", "port": 2202}}),
|
||||||
("b", {"label": "OBS-Youtube", "config": {"host": " 10.1.9.126 "}}),
|
("b", {"label": "OBS-Youtube", "config": {"host": " 10.1.9.126 "}}),
|
||||||
("c", {"label": "no-host", "config": {}}), ("d", "not json")]
|
("c", {"label": "no-host", "config": {}}), ("d", "not json"),
|
||||||
|
("e", {"label": "HyperDeck_1", "config": {"bonjourHost": "10.1.10.11:9993"}})]
|
||||||
db.executemany("INSERT INTO instances VALUES (?, ?)",
|
db.executemany("INSERT INTO instances VALUES (?, ?)",
|
||||||
[(i, v if isinstance(v, str) else json.dumps(v)) for i, v in rows])
|
[(i, v if isinstance(v, str) else json.dumps(v)) for i, v in rows])
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
db.close()
|
||||||
self.assertEqual(sp.companion_conn_hosts(path), {"Axient_1": "10.1.1.101", "OBS-Youtube": "10.1.9.126"})
|
self.assertEqual(sp.companion_conn_hosts(path), {"Axient_1": "10.1.1.101", "OBS-Youtube": "10.1.9.126",
|
||||||
|
"HyperDeck_1": "10.1.10.11"})
|
||||||
self.assertEqual(sp.companion_conn_hosts(os.path.join(d, "missing.sqlite")), {})
|
self.assertEqual(sp.companion_conn_hosts(os.path.join(d, "missing.sqlite")), {})
|
||||||
|
|
||||||
def test_fx_lights_midi_on_the_tracks_mac(self):
|
def test_fx_lights_midi_on_the_tracks_mac(self):
|
||||||
@@ -1517,43 +1520,58 @@ class TestFixes(unittest.TestCase):
|
|||||||
self.assertEqual(self.plan("rec"), [("enable", "HyperDeck_1")])
|
self.assertEqual(self.plan("rec"), [("enable", "HyperDeck_1")])
|
||||||
self.assertEqual(self.plan("x32"), [("enable", "x32")])
|
self.assertEqual(self.plan("x32"), [("enable", "x32")])
|
||||||
|
|
||||||
|
def autorec(self, status, hb=None):
|
||||||
|
"""What the Resi auto-record service publishes (custom variables)."""
|
||||||
|
self.snap.vars[("custom", "autorec_status")] = status
|
||||||
|
self.snap.vars[("custom", "autorec_hb")] = str(int(SUNDAY + 100000 if hb is None else hb))
|
||||||
|
|
||||||
def test_resi_live_and_no_deck_recording_is_red_and_a_hold_starts_them(self):
|
def test_resi_live_and_no_deck_recording_is_red_and_a_hold_starts_them(self):
|
||||||
# 2026-10-27: "notify me if we are not recording on the hyperdecks, but resi is playing"
|
# 2026-10-27: "notify me if we are not recording on the hyperdecks, but resi is playing"
|
||||||
self.snap.vars[("resi-studio-cues", "playback_state")] = "PLAYING"
|
self.autorec("AUTO REC\nLIVE, NOT\nRECORDING")
|
||||||
self.snap.vars[("HyperDeck_2", "slotId")] = "-" # optional deck, no disk: not in use
|
self.snap.vars[("HyperDeck_2", "slotId")] = "-" # optional deck, no disk: not in use
|
||||||
self.snap.vars[("HyperDeck_2", "recordingTime")] = "--:--:--"
|
self.snap.vars[("HyperDeck_2", "recordingTime")] = "--:--:--"
|
||||||
tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 30)
|
tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 10)
|
||||||
self.assertEqual(level(tiles["hd1"]), "ok") # not yet: Resi just started
|
self.assertEqual(level(tiles["hd1"]), "ok") # not yet: give it a moment
|
||||||
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 30, 40)
|
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 10, 30)
|
||||||
self.assertEqual(tiles["hd1"]["x"], "HD1\nRESI LIVE NOT REC")
|
self.assertEqual(tiles["hd1"]["x"], "HD1\nRESI LIVE NOT REC")
|
||||||
self.assertEqual(level(tiles["rec"]), "err")
|
self.assertEqual(level(tiles["rec"]), "err")
|
||||||
self.assertEqual(level(tiles["hd2"]), "off")
|
self.assertEqual(level(tiles["hd2"]), "off")
|
||||||
self.snap.vars[("HyperDeck_3", "status")] = "Record" # once one records, the others are NOT REC
|
self.snap.vars[("HyperDeck_3", "status")] = "Record" # once one records, the others are NOT REC
|
||||||
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 70, 4)
|
self.autorec("AUTO REC\nRECORDING\n9:30 AM")
|
||||||
|
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 40, 4)
|
||||||
self.assertEqual(tiles["hd1"]["x"], "HD1\nNOT REC")
|
self.assertEqual(tiles["hd1"]["x"], "HD1\nNOT REC")
|
||||||
started = []
|
started = []
|
||||||
self.engine.record_executor = lambda ip, name: started.append((ip, name)) or True
|
self.engine.record_executor = lambda ip, name: started.append((ip, name)) or True
|
||||||
self.engine.run_fixes("rec", self.snap, None, SUNDAY + 80)
|
self.engine.run_fixes("rec", self.snap, None, SUNDAY + 50)
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
self.assertEqual([ip for ip, _ in started], ["10.1.10.11", "10.1.10.14", "10.1.10.15", "10.1.10.16"])
|
self.assertEqual([ip for ip, _ in started], ["10.1.10.11", "10.1.10.14", "10.1.10.15", "10.1.10.16"])
|
||||||
self.assertTrue(started[0][1].startswith("CAM3-") and started[0][1].endswith("-"))
|
self.assertTrue(started[0][1].startswith("CAM3-") and started[0][1].endswith("-"))
|
||||||
self.assertTrue(started[3][1].startswith("PGM4-"))
|
self.assertTrue(started[3][1].startswith("PGM4-"))
|
||||||
self.assertEqual(self.engine.notes["rec"]["text"], "STARTING\nREC")
|
self.assertEqual(self.engine.notes["rec"]["text"], "STARTING\nREC")
|
||||||
|
|
||||||
def test_resi_paused_or_its_module_down_is_not_live(self):
|
def test_not_live_while_auto_rec_waits_or_is_stuck(self):
|
||||||
self.snap.vars[("resi-studio-cues", "playback_state")] = "PAUSED"
|
self.autorec("AUTO REC\nREC AT\n9:20 AM") # live early: REC held until 10 min before
|
||||||
tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 70)
|
tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 40)
|
||||||
self.assertEqual(level(tiles["hd1"]), "ok")
|
self.assertEqual(level(tiles["hd1"]), "ok")
|
||||||
self.snap.vars[("resi-studio-cues", "playback_state")] = "PLAYING"
|
self.autorec("AUTO REC\nLIVE, NOT\nRECORDING", hb=SUNDAY - 600) # a stale verdict counts for nothing...
|
||||||
self.snap.conns["resi-studio-cues"]["category"] = "error"
|
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 40, 200)
|
||||||
run_steps(self.engine, self.snap, SUNDAY + 70, 70)
|
|
||||||
self.assertFalse(self.engine.resi_live)
|
self.assertFalse(self.engine.resi_live)
|
||||||
|
self.assertEqual(tiles["ingest"]["x"], "INGEST\nAUTO REC STUCK") # ...and says the service is stuck
|
||||||
ran = []
|
ran = []
|
||||||
self.engine.executor = lambda cfg, fix_id, chk: ran.append(chk.get("conn")) or True
|
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
|
||||||
self.engine.record_executor = lambda ip, name: self.fail("must not record when Resi is not live")
|
self.engine.record_executor = lambda ip, name: self.fail("must not record when Resi is not live")
|
||||||
self.engine.run_fixes("hd1", self.snap, None, SUNDAY + 150) # the usual hold: reconnect
|
self.engine.run_fixes("ingest", self.snap, None, SUNDAY + 250) # the hold restarts the service
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
self.assertEqual(ran, ["HyperDeck_1"])
|
self.assertEqual(ran, ["autorec"])
|
||||||
|
|
||||||
|
def test_auto_rec_not_running_or_without_resi(self):
|
||||||
|
del self.snap.launchd["org.waterschurch.resi-autorec"]
|
||||||
|
tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 6)
|
||||||
|
self.assertEqual(tiles["ingest"]["x"], "INGEST\nAUTO REC NOT LOADED")
|
||||||
|
self.snap.launchd["org.waterschurch.resi-autorec"] = 903
|
||||||
|
self.autorec("AUTO REC\nRESI ERROR")
|
||||||
|
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 6, 200) # amber only once it lasts 3 minutes
|
||||||
|
self.assertEqual((tiles["ingest"]["x"], level(tiles["ingest"])), ("INGEST\nAUTO REC NO RESI", "warn"))
|
||||||
|
|
||||||
def test_not_recording_is_not_fixed_by_a_reboot(self):
|
def test_not_recording_is_not_fixed_by_a_reboot(self):
|
||||||
for i in (1, 3):
|
for i in (1, 3):
|
||||||
|
|||||||
Reference in New Issue
Block a user