Step 1 in progress: the settings page server, probes, teaching pages and UI (not deployed yet)
- settings/server.py: read-only routes (overview, keys, computers, gear, companion, logs, history, help facts, key guide / cheat sheet / where things are), the editing-code check with a lockout, LAN-only binding with a per-minute rebind - settings/probes.py: fixed read-only probes per computer; whole lists fetched and matched locally - settings/keyguide.py: the generated teaching pages - settings/ui: the page, every screen read-only - status_panel.py: snapshot.json every 5 s for the page; the Companion database lookup prefers the running build's folder and skips a database that will not open (a stale v5.1 folder was breaking it) - config.json: every computer's record (what runs there, network ports, done-by-hand ticks), the editing code (kept in the repo, the user's choice), an empty gear list - install.sh: the settings page as a second LaunchAgent; settings/ and schema.py deployed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+348
-8
@@ -2,7 +2,8 @@
|
|||||||
"site": {
|
"site": {
|
||||||
"id": "na", "name": "North Attleboro", "ingest_name": "NA Ingest", "ingest_hostname": "na-ingest.local",
|
"id": "na", "name": "North Attleboro", "ingest_name": "NA Ingest", "ingest_hostname": "na-ingest.local",
|
||||||
"ingest_ips": ["10.1.1.1", "10.6.3.222"],
|
"ingest_ips": ["10.1.1.1", "10.6.3.222"],
|
||||||
"live_services": []
|
"live_services": [],
|
||||||
|
"editing_code": "stingo serried warp"
|
||||||
},
|
},
|
||||||
"companion": { "host": "127.0.0.1", "port": 8000 },
|
"companion": { "host": "127.0.0.1", "port": 8000 },
|
||||||
"page": 19,
|
"page": 19,
|
||||||
@@ -38,15 +39,354 @@
|
|||||||
|
|
||||||
"dante_switches": ["10.66.1.100", "10.66.1.102"],
|
"dante_switches": ["10.66.1.100", "10.66.1.102"],
|
||||||
"hosts": {
|
"hosts": {
|
||||||
"prop": { "ssh": "napropresenter@10.1.14.225" },
|
"ingest": {
|
||||||
"tracks": { "ssh": "waterschurchmedia@10.1.3.227" },
|
"self": true,
|
||||||
"translation": { "ssh": "studio@10.1.7.39" },
|
"name": "NA Ingest",
|
||||||
"bibletv": { "ssh": "media@10.6.7.13", "os": "windows" },
|
"os": "mac",
|
||||||
"lighting": { "ssh": "media@10.1.10.206", "os": "windows" },
|
"role": "the watchdog, Companion, the settings page, lobby captions, transcription",
|
||||||
"studiob": { "ssh": "studiob@10.1.13.175" },
|
"runs": [
|
||||||
"livestream": { "ssh": "media@10.1.9.126", "os": "windows", "mac": "6c:24:08:d7:fb:57", "wake_broadcast": "10.1.255.255" }
|
{
|
||||||
|
"kind": "launchagent",
|
||||||
|
"label": "org.waterschurch.status-panel",
|
||||||
|
"name": "the watchdog"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "launchagent",
|
||||||
|
"label": "org.waterschurch.status-settings",
|
||||||
|
"name": "the settings page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "app",
|
||||||
|
"process": "Companion",
|
||||||
|
"name": "Companion 5.0.7",
|
||||||
|
"http": "http://127.0.0.1:8000/api/connections"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "app",
|
||||||
|
"process": "Companion Satellite",
|
||||||
|
"name": "Companion Satellite",
|
||||||
|
"watched": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "launchagent",
|
||||||
|
"label": "org.waterschurch.resi-propresenter",
|
||||||
|
"name": "the Resi bridge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "launchagent",
|
||||||
|
"label": "org.waterschurch.lobby-captions",
|
||||||
|
"name": "lobby captions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "launchagent",
|
||||||
|
"label": "org.waterschurch.lobby-captions-display",
|
||||||
|
"name": "the caption screen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "app",
|
||||||
|
"process": "ProdCom",
|
||||||
|
"name": "ProdCom (transcription)",
|
||||||
|
"http": "http://127.0.0.1:24480/api/v1/ping"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "app",
|
||||||
|
"process": "ProdDash",
|
||||||
|
"name": "ProdDash",
|
||||||
|
"http": "http://127.0.0.1:24500/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "app",
|
||||||
|
"process": "Spotify",
|
||||||
|
"name": "Spotify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "app",
|
||||||
|
"process": "ATEM Software Control",
|
||||||
|
"name": "ATEM Software Control"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "daemon",
|
||||||
|
"process": "DanteVirtualSoundcard",
|
||||||
|
"name": "Dante Virtual Soundcard",
|
||||||
|
"dante_name": "STUDIO-A-Ingest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "helper_dvs",
|
||||||
|
"name": "the DVS helper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "folder",
|
||||||
|
"path": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Production Team/Backup/NA/ATEM",
|
||||||
|
"name": "Google Drive (the saved switcher files)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "launchagent",
|
||||||
|
"label": "org.waterschurch.checklist-build-runner",
|
||||||
|
"name": "Gitea runner (not the panel)",
|
||||||
|
"watched": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "launchagent",
|
||||||
|
"label": "org.waterschurch.checklist-drive-backup",
|
||||||
|
"name": "checklist backup (not the panel)",
|
||||||
|
"watched": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "launchagent",
|
||||||
|
"label": "org.waterschurch.checklist-weekly-health",
|
||||||
|
"name": "checklist health (not the panel)",
|
||||||
|
"watched": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "disabled",
|
||||||
|
"label": "org.waterschurch.translate",
|
||||||
|
"name": "Church Translate (must NOT run here)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"networks": [
|
||||||
|
{
|
||||||
|
"iface": "en0",
|
||||||
|
"use": "LAN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iface": "en1",
|
||||||
|
"use": "LAN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iface": "en17",
|
||||||
|
"use": "Dante"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iface": "en20",
|
||||||
|
"use": "Netgear management"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"prop": {
|
||||||
|
"ssh": "napropresenter@10.1.14.225",
|
||||||
|
"name": "ProPresenter Mac",
|
||||||
|
"os": "mac",
|
||||||
|
"role": "ProPresenter, its stage and Prop TV pages, Dante sound card",
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"kind": "sshd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "app",
|
||||||
|
"process": "ProPresenter",
|
||||||
|
"name": "ProPresenter (newest installed)",
|
||||||
|
"http": "http://10.1.14.225:1025/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "daemon",
|
||||||
|
"process": "DanteVirtualSoundcard",
|
||||||
|
"name": "Dante Virtual Soundcard",
|
||||||
|
"dante_name": "NA-ProPresenter-Mac"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "helper_dvs",
|
||||||
|
"name": "the DVS helper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "midi_session",
|
||||||
|
"name": "NA ProP"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tracks": {
|
||||||
|
"ssh": "waterschurchmedia@10.1.3.227",
|
||||||
|
"name": "Tracks Mac",
|
||||||
|
"os": "mac",
|
||||||
|
"role": "Ableton (tracks and FX lighting cues), Dante sound card",
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"kind": "sshd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "app",
|
||||||
|
"process": "Live",
|
||||||
|
"name": "Ableton Live 11 Suite"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "daemon",
|
||||||
|
"process": "DanteVirtualSoundcard",
|
||||||
|
"name": "Dante Virtual Soundcard",
|
||||||
|
"dante_name": "NA-Tracks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "helper_dvs",
|
||||||
|
"name": "the DVS helper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "midi_session",
|
||||||
|
"name": "NA Tracks"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"translation": {
|
||||||
|
"ssh": "studio@10.1.7.39",
|
||||||
|
"name": "NA Translation",
|
||||||
|
"os": "mac",
|
||||||
|
"role": "Church Translate, Dante sound card",
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"kind": "sshd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "app",
|
||||||
|
"process": "Church Translate",
|
||||||
|
"name": "Church Translate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "launchagent",
|
||||||
|
"label": "org.waterschurch.translate.engine",
|
||||||
|
"name": "the translation engine",
|
||||||
|
"watched": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "daemon",
|
||||||
|
"process": "DanteVirtualSoundcard",
|
||||||
|
"name": "Dante Virtual Soundcard",
|
||||||
|
"dante_name": "NA-Translation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "helper_dvs",
|
||||||
|
"name": "the DVS helper"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"studiob": {
|
||||||
|
"ssh": "studiob@10.1.13.175",
|
||||||
|
"name": "Studio B Mac Studio",
|
||||||
|
"os": "mac",
|
||||||
|
"role": "Pro Tools",
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"kind": "sshd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "app",
|
||||||
|
"process": "Pro Tools",
|
||||||
|
"name": "Pro Tools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "helper_solo",
|
||||||
|
"label": "org.waterschurch.status-panel.solo-mode",
|
||||||
|
"name": "the Pro Tools solo-mode helper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "midi_session",
|
||||||
|
"name": "Studio B Mac Studio",
|
||||||
|
"peer_of": "NA Tracks"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lighting": {
|
||||||
|
"ssh": "media@10.1.10.206",
|
||||||
|
"name": "Lighting PC",
|
||||||
|
"os": "windows",
|
||||||
|
"role": "Vista, Resolume Arena, rtpMIDI",
|
||||||
|
"pingable": false,
|
||||||
|
"notes": "Keep the network profile on Public: rtpMIDI's Bonjour rules only allow Public.",
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"kind": "sshd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "task",
|
||||||
|
"name": "Start Vista",
|
||||||
|
"exe": "Vista.exe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "task",
|
||||||
|
"name": "Start Arena",
|
||||||
|
"exe": "Arena.exe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "task",
|
||||||
|
"name": "Start rtpMIDI",
|
||||||
|
"exe": "rtpMIDI.exe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "service",
|
||||||
|
"name": "rtpMIDIService"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "artnet",
|
||||||
|
"name": "Vista Art-Net",
|
||||||
|
"source": "10.1.10.206"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"manual_done": {
|
||||||
|
"public_profile": {
|
||||||
|
"by": "Chris",
|
||||||
|
"on": "2026-10-08"
|
||||||
|
},
|
||||||
|
"vista_autoload": {
|
||||||
|
"by": "Chris",
|
||||||
|
"on": "2026-10-08"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"livestream": {
|
||||||
|
"ssh": "media@10.1.9.126",
|
||||||
|
"os": "windows",
|
||||||
|
"mac": "6c:24:08:d7:fb:57",
|
||||||
|
"wake_broadcast": "10.1.255.255",
|
||||||
|
"name": "LiveStream PC",
|
||||||
|
"role": "OBS (YouTube)",
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"kind": "sshd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "task",
|
||||||
|
"name": "Start OBS",
|
||||||
|
"exe": "obs64.exe",
|
||||||
|
"elevated": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"manual_done": {
|
||||||
|
"bios_wol": {
|
||||||
|
"by": "Chris",
|
||||||
|
"on": "2026-10-08"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bibletv": {
|
||||||
|
"ssh": "media@10.6.7.13",
|
||||||
|
"os": "windows",
|
||||||
|
"name": "Bible TV PC",
|
||||||
|
"role": "the Bible app on the sanctuary TV",
|
||||||
|
"pingable": false,
|
||||||
|
"notes": "Wi-Fi only; Ethernet recommended. Wi-Fi power saving is off.",
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"kind": "sshd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "task",
|
||||||
|
"name": "Start BibleApp",
|
||||||
|
"exe": "Bible App.exe",
|
||||||
|
"at_logon": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "web",
|
||||||
|
"url": "http://10.6.7.13:8080/",
|
||||||
|
"name": "the Bible app's web page"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"manual_done": {
|
||||||
|
"wifi_power": {
|
||||||
|
"by": "Chris",
|
||||||
|
"on": "2026-10-11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"gear": {},
|
||||||
|
|
||||||
"fixes": {
|
"fixes": {
|
||||||
"propresenter": { "host": "prop", "short": "PROPRES", "label": "restart ProPresenter (newest installed)",
|
"propresenter": { "host": "prop", "short": "PROPRES", "label": "restart ProPresenter (newest installed)",
|
||||||
"run": "APP=$(ls -d /Applications/ProPresenter*.app | sort -V | tail -1); if pgrep -xq ProPresenter; then pkill -TERM -x ProPresenter; for i in 1 2 3 4 5 6 7 8 9 10; do pgrep -xq ProPresenter || break; sleep 1; done; pkill -KILL -x ProPresenter; sleep 1; fi; open \"$APP\"" },
|
"run": "APP=$(ls -d /Applications/ProPresenter*.app | sort -V | tail -1); if pgrep -xq ProPresenter; then pkill -TERM -x ProPresenter; for i in 1 2 3 4 5 6 7 8 9 10; do pgrep -xq ProPresenter || break; sleep 1; done; pkill -KILL -x ProPresenter; sleep 1; fi; open \"$APP\"" },
|
||||||
|
|||||||
+49
-8
@@ -12,11 +12,12 @@
|
|||||||
set -e
|
set -e
|
||||||
SRC=${0:A:h}
|
SRC=${0:A:h}
|
||||||
LABEL=org.waterschurch.status-panel
|
LABEL=org.waterschurch.status-panel
|
||||||
|
SETTINGS_LABEL=org.waterschurch.status-settings
|
||||||
# App Support, not ~/Documents: launchd-run python3 cannot read ~/Documents.
|
# App Support, not ~/Documents: launchd-run python3 cannot read ~/Documents.
|
||||||
DEST="$HOME/Library/Application Support/status-panel"
|
DEST="$HOME/Library/Application Support/status-panel"
|
||||||
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
||||||
DOMAIN="gui/$(id -u)"
|
DOMAIN="gui/$(id -u)"
|
||||||
CODE=(status_panel.py coremidi.py netgear.py make_page.py push_page.py nav_keys.py atem_watch.js atem_state.js nav_templates.json)
|
CODE=(status_panel.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py atem_watch.js atem_state.js nav_templates.json)
|
||||||
KEEP_CONFIG=0
|
KEEP_CONFIG=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
@@ -26,17 +27,45 @@ for arg in "$@"; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
restart_agent() {
|
restart_agent() { # $1 = label, $2 = plist
|
||||||
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
|
local label=${1:-$LABEL} plist=${2:-$PLIST}
|
||||||
|
launchctl bootout "$DOMAIN/$label" 2>/dev/null || true
|
||||||
# bootout returns before the old job is gone; bootstrapping too early fails with "5: Input/output error".
|
# bootout returns before the old job is gone; bootstrapping too early fails with "5: Input/output error".
|
||||||
for i in {1..50}; do launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || break; sleep 0.2; done
|
for i in {1..50}; do launchctl print "$DOMAIN/$label" >/dev/null 2>&1 || break; sleep 0.2; done
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
launchctl bootstrap "$DOMAIN" "$PLIST" && break
|
launchctl bootstrap "$DOMAIN" "$plist" && break
|
||||||
[[ $attempt == 3 ]] && { echo "launchctl bootstrap failed" >&2; exit 1; }
|
[[ $attempt == 3 ]] && { echo "launchctl bootstrap failed for $label" >&2; exit 1; }
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SETTINGS_PLIST="$HOME/Library/LaunchAgents/$SETTINGS_LABEL.plist"
|
||||||
|
write_settings_plist() {
|
||||||
|
cat > "$SETTINGS_PLIST" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key><string>$SETTINGS_LABEL</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/bin/python3</string>
|
||||||
|
<string>-m</string>
|
||||||
|
<string>settings.server</string>
|
||||||
|
<string>--data-dir</string>
|
||||||
|
<string>$DEST</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key><string>$DEST</string>
|
||||||
|
<key>RunAtLoad</key><true/>
|
||||||
|
<key>KeepAlive</key><true/>
|
||||||
|
<key>ThrottleInterval</key><integer>10</integer>
|
||||||
|
<key>AbandonProcessGroup</key><true/>
|
||||||
|
<key>StandardErrorPath</key><string>$HOME/Library/Logs/status-settings.err.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
write_plist() { # $1 = exit timeout in seconds
|
write_plist() { # $1 = exit timeout in seconds
|
||||||
cat > "$PLIST" <<EOF
|
cat > "$PLIST" <<EOF
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -80,8 +109,9 @@ PY
|
|||||||
|
|
||||||
if [[ "$1" == "--remove" ]]; then
|
if [[ "$1" == "--remove" ]]; then
|
||||||
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
|
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
|
||||||
rm -f "$PLIST"
|
launchctl bootout "$DOMAIN/$SETTINGS_LABEL" 2>/dev/null || true
|
||||||
echo "removed $LABEL (files left in $DEST)"
|
rm -f "$PLIST" "$SETTINGS_PLIST"
|
||||||
|
echo "removed $LABEL and $SETTINGS_LABEL (files left in $DEST)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -121,6 +151,11 @@ if [[ $KEEP_CONFIG == 0 ]]; then
|
|||||||
fi
|
fi
|
||||||
for f in $CODE; do [[ -f "$SRC/$f" ]] && cp "$SRC/$f" "$DEST/"; done
|
for f in $CODE; do [[ -f "$SRC/$f" ]] && cp "$SRC/$f" "$DEST/"; done
|
||||||
[[ -f "$SRC/atem-reference.xml" ]] && cp "$SRC/atem-reference.xml" "$DEST/"
|
[[ -f "$SRC/atem-reference.xml" ]] && cp "$SRC/atem-reference.xml" "$DEST/"
|
||||||
|
# The settings page (its own LaunchAgent) and the record of coloured page-number keys (site-owned: never overwritten).
|
||||||
|
if [[ -d "$SRC/settings" ]]; then
|
||||||
|
rm -rf "$DEST/settings" && cp -R "$SRC/settings" "$DEST/" && find "$DEST/settings" -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null
|
||||||
|
fi
|
||||||
|
[[ -f "$SRC/nav_locations.json" && ! -f "$DEST/nav_locations.json" ]] && cp "$SRC/nav_locations.json" "$DEST/"
|
||||||
if [[ -d "$SRC/companion-objects" ]]; then
|
if [[ -d "$SRC/companion-objects" ]]; then
|
||||||
rm -rf "$DEST/companion-objects" && cp -R "$SRC/companion-objects" "$DEST/"
|
rm -rf "$DEST/companion-objects" && cp -R "$SRC/companion-objects" "$DEST/"
|
||||||
fi
|
fi
|
||||||
@@ -132,3 +167,9 @@ restart_agent
|
|||||||
echo "installed $LABEL -> $DEST (exit timeout ${EXIT_TIMEOUT}s)"
|
echo "installed $LABEL -> $DEST (exit timeout ${EXIT_TIMEOUT}s)"
|
||||||
echo "log: ~/Library/Logs/status-panel.log"
|
echo "log: ~/Library/Logs/status-panel.log"
|
||||||
wait_for_start "$T" || true
|
wait_for_start "$T" || true
|
||||||
|
if [[ -d "$DEST/settings" ]]; then
|
||||||
|
write_settings_plist
|
||||||
|
restart_agent "$SETTINGS_LABEL" "$SETTINGS_PLIST"
|
||||||
|
PORT=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("site",{}).get("settings_port",8020))' "$DEST/config.json")
|
||||||
|
echo "settings page: http://$(hostname -s).local:$PORT/ (log: ~/Library/Logs/status-settings.log)"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""The settings page: a small web server on the Ingest Mac beside the watchdog."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
PARENT = os.path.dirname(HERE)
|
||||||
|
if PARENT not in sys.path: # status_panel, schema, make_page live one folder up
|
||||||
|
sys.path.insert(0, PARENT)
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""The teaching pages, built from the config: "What each key does", the cheat sheet, "Where things are"."""
|
||||||
|
import html
|
||||||
|
import os
|
||||||
|
|
||||||
|
from settings import PARENT # noqa: F401
|
||||||
|
import schema
|
||||||
|
import status_panel as sp
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
body { font-family: -apple-system, 'Helvetica Neue', Arial, sans-serif; margin: 0; padding: 24px 16px; color: #1d2420; background: #fff; }
|
||||||
|
h1 { font-size: 1.7rem; margin: 0 0 6px; } h2 { font-size: 1.2rem; margin: 28px 0 8px; border-top: 2px solid #d6dcd6; padding-top: 10px; }
|
||||||
|
h3 { font-size: 1.05rem; margin: 18px 0 4px; }
|
||||||
|
p, li { line-height: 1.45; } .muted { color: #5a665f; } code { background: #eef2ee; padding: 1px 5px; border-radius: 4px; font-size: .9em; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px; max-width: 560px; margin: 10px 0; }
|
||||||
|
.cell { background: #1b1f1d; color: #e8ebe6; font: 600 .72rem/1.1 -apple-system, sans-serif; border-radius: 5px; aspect-ratio: 1; display: flex; align-items: center; justify-content: center; text-align: center; padding: 2px; }
|
||||||
|
.cell.nav { background: #37474f; } .cell.empty { background: #2a2e2c; opacity: .4; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: .95rem; } th, td { text-align: left; vertical-align: top; padding: 6px 10px; border-bottom: 1px solid #e3e7e3; }
|
||||||
|
.key { margin: 14px 0 18px; padding: 10px 14px; border: 1px solid #d6dcd6; border-radius: 8px; }
|
||||||
|
.key h3 { margin-top: 0; } .words { font-family: ui-monospace, Menlo, monospace; font-size: .85rem; }
|
||||||
|
.legend span { display: inline-block; margin-right: 14px; } .legend i { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
|
||||||
|
@media print { body { padding: 0; } .key { break-inside: avoid; } }
|
||||||
|
"""
|
||||||
|
|
||||||
|
FAULT_WORDS = {
|
||||||
|
"conn": ["NO ANSWER", "UNREACHABLE", "REFUSED", "LOGIN", "OFFLINE", "WARNING", "DISABLED", "NOT IN COMPANION", "NO DATA"],
|
||||||
|
"hyperdeck": ["NO DISK", "FULL <minutes>", "LOW <minutes>", "NOT REC", "OFFLINE", "NO ANSWER"],
|
||||||
|
"dante": ["<device> GONE"], "shure": ["<channel> BATT", "<channel> INTERF"], "psm": ["<channel> RF MUTE"],
|
||||||
|
"tascam": ["<the recorder's own error or caution>"], "ping": ["<name> NO PING"], "launchd": ["<name> NOT LOADED", "<name> STOPPED"],
|
||||||
|
"process": ["<name> STOPPED"], "http": ["<name> UNREACH"], "disk": ["DISK <free>GB"],
|
||||||
|
"midi_peer": ["MIDI LOST", "NO SESSION", "MIDI <state>"], "pref": [], "spotify": ["SPOTIFY CLOSED", "NOT PLAYING", "WRONG PLAYLIST"],
|
||||||
|
"mdns": ["<name> GONE"], "midi_rx": ["<name> SILENT"], "remote": [], "artnet": ["SILENT"], "atem": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def e(text):
|
||||||
|
return html.escape(str(text))
|
||||||
|
|
||||||
|
|
||||||
|
def check_sentence(chk, cfg):
|
||||||
|
t = chk["type"]
|
||||||
|
hosts = cfg.get("hosts", {})
|
||||||
|
who = lambda alias: hosts.get(alias, {}).get("name", alias)
|
||||||
|
if t == "conn":
|
||||||
|
return "Companion's connection %s is good" % e(chk["conn"])
|
||||||
|
if t == "hyperdeck":
|
||||||
|
s = "HyperDeck %s is connected" % e(chk["conn"])
|
||||||
|
s += ", may have no disk" if chk.get("optional") else ", has a disk with time left"
|
||||||
|
return s + (", and records whenever another deck records" if chk.get("rec_sync", True) else "")
|
||||||
|
if t == "dante":
|
||||||
|
devices = chk.get("devices", {})
|
||||||
|
return "all %d Dante devices are present (%s)" % (len(devices), ", ".join(sorted(devices.values() if isinstance(devices, dict) else devices)))
|
||||||
|
if t == "shure":
|
||||||
|
return "Shure receiver %s: %d channel%s, batteries and interference" % (e(chk["conn"]), chk.get("channels", 1), "" if chk.get("channels", 1) == 1 else "s")
|
||||||
|
if t == "psm":
|
||||||
|
return "PSM transmitter %s: %d channel%s not RF-muted" % (e(chk["conn"]), chk.get("channels", 1), "" if chk.get("channels", 1) == 1 else "s")
|
||||||
|
if t == "tascam":
|
||||||
|
return "the Tascam recorder (%s) reports no error or caution" % e(chk["conn"])
|
||||||
|
if t == "ping":
|
||||||
|
return "%s answers ping (%s)" % (e(chk.get("name") or chk.get("conn") or chk.get("host")), e(chk.get("conn") and "at Companion's address for %s" % chk["conn"] or chk.get("host")))
|
||||||
|
if t == "launchd":
|
||||||
|
return "%s (a helper on this Mac) is loaded and running" % e(chk.get("name") or chk["label"])
|
||||||
|
if t == "process":
|
||||||
|
return "%s is running on this Mac" % e(chk.get("name") or chk["match"])
|
||||||
|
if t == "http":
|
||||||
|
return "%s answers (%s)" % (e(chk.get("name") or chk.get("msg") or "a web address"), e(chk["url"]))
|
||||||
|
if t == "disk":
|
||||||
|
return "the disk %s has more than %d GB free" % (e(chk["path"]), chk.get("warn_gb", 20))
|
||||||
|
if t == "midi_peer":
|
||||||
|
where = ("on %s" % who(chk["host"])) if chk.get("host") else "on this Mac"
|
||||||
|
return "the network MIDI session %s %s has %s in it" % (e(chk["session"]), where, e(chk["peer"]))
|
||||||
|
if t == "pref":
|
||||||
|
return "the setting %s in %s is %s" % (e(chk["key"]), e(chk["domain"]), e(chk["want"]))
|
||||||
|
if t == "spotify":
|
||||||
|
return "Spotify on this Mac is playing %s" % e((cfg.get("spotify") or {}).get("name") or "the service playlist")
|
||||||
|
if t == "mdns":
|
||||||
|
return "%s is advertised on the network (Bonjour)" % e(chk.get("name") or chk["instance"])
|
||||||
|
if t == "midi_rx":
|
||||||
|
return "MIDI is arriving from %s" % e(chk.get("name") or chk["source"])
|
||||||
|
if t == "remote":
|
||||||
|
return "on %s: %s" % (who(chk["host"]), e(chk.get("msg", "a command succeeds").lower().replace(" not ", " is ")))
|
||||||
|
if t == "artnet":
|
||||||
|
return "the lighting program at %s keeps sending Art-Net" % e(chk["source"])
|
||||||
|
if t == "atem":
|
||||||
|
return "the switcher's %s settings match the saved file" % e(chk["part"])
|
||||||
|
return e(t)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_words(fix_id, cfg):
|
||||||
|
if fix_id == sp.RESTART_CONNECTION:
|
||||||
|
return "reconnects its Companion connection"
|
||||||
|
if fix_id == sp.DANTE_AUTO:
|
||||||
|
return "power-cycles that device's PoE port if it has one, otherwise reconnects Companion's Dante module"
|
||||||
|
fix = cfg.get("fixes", {}).get(fix_id)
|
||||||
|
return e(fix.get("label", fix_id)) if fix else e(fix_id)
|
||||||
|
|
||||||
|
|
||||||
|
def hold_words(tile, cfg):
|
||||||
|
fixes = []
|
||||||
|
for chk in tile.get("checks", []):
|
||||||
|
fx = chk.get("fix")
|
||||||
|
if isinstance(fx, dict):
|
||||||
|
for sub, f in fx.items():
|
||||||
|
fixes.append("%s: %s" % ("for anything else" if sub == "*" else "for " + e(sub), fix_words(f, cfg)))
|
||||||
|
elif fx:
|
||||||
|
fixes.append(fix_words(fx, cfg))
|
||||||
|
elif chk["type"] in sp.RESTARTABLE and chk.get("conn"):
|
||||||
|
fixes.append("reconnects Companion's %s connection" % e(chk["conn"]))
|
||||||
|
out = []
|
||||||
|
if tile.get("hold_always"):
|
||||||
|
out.append("<b>also when the key is green:</b> " + fix_words(tile["hold_always"], cfg))
|
||||||
|
seen = []
|
||||||
|
for f in fixes:
|
||||||
|
if f not in seen:
|
||||||
|
seen.append(f)
|
||||||
|
if seen:
|
||||||
|
out.append("fixes what is failing: " + "; ".join(seen))
|
||||||
|
if tile.get("kind") == "network":
|
||||||
|
out.append("clears a remembered network blip")
|
||||||
|
if tile.get("members"):
|
||||||
|
out.append("fixes whatever is failing on %s" % ", ".join(e(m.upper()) for m in tile["members"]))
|
||||||
|
return "; ".join(out) if out else "nothing (there is no automatic fix for this key)"
|
||||||
|
|
||||||
|
|
||||||
|
def key_guide(cfg):
|
||||||
|
"""What each key does: one entry per key, in deck order, plus the restart keys."""
|
||||||
|
ph = schema.phrases(cfg)
|
||||||
|
auto = set((cfg.get("auto_fix") or {}).get("fixes", []))
|
||||||
|
tiles = {t["id"]: t for t in cfg["tiles"]}
|
||||||
|
parts = ["<!doctype html><meta charset='utf-8'><title>What each key does</title><style>%s</style>" % CSS,
|
||||||
|
"<h1>What each key does</h1><p class='muted'>Made from the panel's settings; it changes whenever they do. "
|
||||||
|
"Press a key = acknowledge its alarm. Hold %s = run its fix.</p>" % e(ph["hold"].replace("HOLD ", "")),
|
||||||
|
grid_html(cfg)]
|
||||||
|
order = sorted((t for t in cfg["tiles"] if "pos" in t), key=lambda t: (t["pos"][0], t["pos"][1]))
|
||||||
|
order += [t for t in cfg["tiles"] if "pos" not in t]
|
||||||
|
for t in order:
|
||||||
|
parts.append("<div class='key' id='key-%s'><h3>%s</h3>" % (e(t["id"]), e(t["label"])))
|
||||||
|
pos = "row %d, column %d" % tuple(t["pos"]) if "pos" in t else "no key of its own (shown inside its group)"
|
||||||
|
parts.append("<p class='muted'>%s%s</p>" % (pos, "" if t.get("kind") != "master" else " - sums up every key"))
|
||||||
|
if t.get("members"):
|
||||||
|
parts.append("<p>Shows the worst of: %s. Hold: %s.</p>" % (", ".join(e(tiles[m]["label"]) for m in t["members"]), hold_words(t, cfg)))
|
||||||
|
if t.get("kind") == "service":
|
||||||
|
parts.append("<p>%s</p>" % e(ph["alarms"]))
|
||||||
|
if t.get("kind") == "network":
|
||||||
|
n = cfg.get("network", {})
|
||||||
|
parts.append("<p>Watches this Mac's network port%s, and calls it a blip when %d connections from %d groups drop within %d s "
|
||||||
|
"(shown for %d minutes). Hold: clears the blip.</p>" % (
|
||||||
|
(" and pings %s" % e(n["gateway"])) if n.get("gateway") else "", n.get("burst_connections", 4),
|
||||||
|
n.get("burst_groups", 3), n.get("burst_window_seconds", 10), n.get("remember_seconds", 300) // 60))
|
||||||
|
if t.get("checks"):
|
||||||
|
parts.append("<p><b>Checks:</b></p><ul>")
|
||||||
|
for chk in t["checks"]:
|
||||||
|
words = FAULT_WORDS.get(chk["type"], [])
|
||||||
|
if chk.get("msg"):
|
||||||
|
words = [chk["msg"]]
|
||||||
|
parts.append("<li>%s%s</li>" % (check_sentence(chk, cfg), (" <span class='words muted'>→ %s</span>" % e(", ".join(words))) if words else ""))
|
||||||
|
parts.append("</ul>")
|
||||||
|
parts.append("<p><b>Holding:</b> %s.</p>" % hold_words(t, cfg))
|
||||||
|
own = set()
|
||||||
|
for chk in t["checks"]:
|
||||||
|
fx = chk.get("fix")
|
||||||
|
own |= set(fx.values()) if isinstance(fx, dict) else ({fx} if fx else set())
|
||||||
|
if t.get("hold_always"):
|
||||||
|
own.add(t["hold_always"])
|
||||||
|
byself = sorted(f for f in own if f in auto)
|
||||||
|
if byself:
|
||||||
|
parts.append("<p class='muted'>Auto-fix %s: %s.</p>" % (e(ph["auto_fix"]), ", ".join(e(cfg["fixes"][f].get("short", f)) for f in byself)))
|
||||||
|
parts.append("</div>")
|
||||||
|
rp = cfg.get("restart_page")
|
||||||
|
if rp:
|
||||||
|
parts.append("<h2>Restart page (page %d)</h2><p>Each key restarts one thing even when the Status page says it is fine. "
|
||||||
|
"It flashes HOLD TO FIX while that thing is down.</p>" % rp["page"])
|
||||||
|
for k in sorted(rp.get("keys", []), key=lambda k: (k["pos"][0], k["pos"][1])):
|
||||||
|
parts.append("<div class='key'><h3>%s</h3><p class='muted'>row %d, column %d</p><p>Hold: %s.</p></div>"
|
||||||
|
% (e(k["label"]), k["pos"][0], k["pos"][1], fix_words(k["fix"], cfg)))
|
||||||
|
parts.append("<h2>Colours</h2><p class='legend'><span><i style='background:#007A2E'></i>green: fine</span>"
|
||||||
|
"<span><i style='background:#FF0000'></i>flashing red: the problem, named</span>"
|
||||||
|
"<span><i style='background:#8B0000'></i>dark red: acknowledged</span>"
|
||||||
|
"<span><i style='background:#FFA000'></i>amber: a warning, or a fault that keeps coming back (xN)</span>"
|
||||||
|
"<span><i style='background:#6A1B9A'></i>purple: the watchdog is down</span>"
|
||||||
|
"<span><i style='background:#2962FF'></i>blue: a fix is running</span></p>")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def grid_html(cfg, restart=False):
|
||||||
|
rp = cfg.get("restart_page")
|
||||||
|
cells = {}
|
||||||
|
if restart and rp:
|
||||||
|
for k in rp.get("keys", []):
|
||||||
|
cells[tuple(k["pos"])] = k["label"]
|
||||||
|
cells[tuple(rp["back_pos"])] = "STATUS PAGE"
|
||||||
|
else:
|
||||||
|
for t in cfg["tiles"]:
|
||||||
|
if "pos" in t:
|
||||||
|
cells[tuple(t["pos"])] = t["label"]
|
||||||
|
if rp:
|
||||||
|
cells[tuple(rp["jump_pos"])] = "RESTART PAGE"
|
||||||
|
nav = {(0, 0): "UP", (1, 0): str(rp["page"] if restart and rp else cfg["page"]), (2, 0): "DOWN"}
|
||||||
|
out = ["<div class='grid'>"]
|
||||||
|
for r in range(4):
|
||||||
|
for c in range(8):
|
||||||
|
if (r, c) in nav:
|
||||||
|
out.append("<div class='cell nav'>%s</div>" % e(nav[(r, c)]))
|
||||||
|
elif (r, c) in cells:
|
||||||
|
out.append("<div class='cell'>%s</div>" % e(cells[(r, c)]))
|
||||||
|
else:
|
||||||
|
out.append("<div class='cell empty'></div>")
|
||||||
|
out.append("</div>")
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def cheat_sheet(cfg, address):
|
||||||
|
ph = schema.phrases(cfg)
|
||||||
|
site = cfg.get("site", {})
|
||||||
|
hosts = cfg.get("hosts", {})
|
||||||
|
rows = "".join("<tr><td>%s</td><td>%s</td></tr>" % (e(h.get("name", a)), e(h.get("role", ""))) for a, h in hosts.items())
|
||||||
|
return "\n".join([
|
||||||
|
"<!doctype html><meta charset='utf-8'><title>Status panel cheat sheet</title><style>%s</style>" % CSS,
|
||||||
|
"<h1>Status panel - %s</h1>" % e(site.get("name", "")),
|
||||||
|
"<p><b>Green</b> = fine. <b>Flashing red</b> = the problem, named on the key. <b>Press</b> = acknowledge (it stops flashing). "
|
||||||
|
"<b>%s</b> = run the fix. <b>Purple</b> = the watchdog is down: open the settings page and press Restart the watchdog.</p>" % e(ph["hold"].replace("HOLD ", "Hold ")),
|
||||||
|
"<p>%s Auto-fix %s. %s.</p>" % (e(ph["alarms"]), e(ph["auto_fix"]), e(ph["live"][0].upper() + ph["live"][1:])),
|
||||||
|
"<h2>Status page (page %d)</h2>" % cfg["page"], grid_html(cfg),
|
||||||
|
("<h2>Restart page (page %d)</h2>" % cfg["restart_page"]["page"] + grid_html(cfg, restart=True)) if cfg.get("restart_page") else "",
|
||||||
|
"<h2>The computers</h2><table>%s</table>" % rows,
|
||||||
|
"<h2>Where to look</h2><p>The settings page: <b>%s</b> (every key, every computer, the logs, the history, help). "
|
||||||
|
"Contacts: %s</p>" % (e(address), e(site.get("contacts") or "-")),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def where_things_are(cfg, data_dir, address):
|
||||||
|
"""Every file, folder, log, helper and port, per computer. The one page allowed to show file names."""
|
||||||
|
site = cfg.get("site", {})
|
||||||
|
hosts = cfg.get("hosts", {})
|
||||||
|
parts = ["<!doctype html><meta charset='utf-8'><title>Where things are</title><style>%s</style>" % CSS,
|
||||||
|
"<h1>Where things are</h1><p class='muted'>For the person who maintains it.</p>",
|
||||||
|
"<h2>%s (this Mac)</h2><ul>" % e(site.get("ingest_name", "the Ingest Mac")),
|
||||||
|
"<li>The watchdog and the settings page: LaunchAgents <code>org.waterschurch.status-panel</code> and "
|
||||||
|
"<code>org.waterschurch.status-settings</code>; files in <code>%s</code></li>" % e(data_dir or "~/Library/Application Support/status-panel"),
|
||||||
|
"<li>Logs: <code>~/Library/Logs/status-panel.log</code>, <code>status-panel.err.log</code>, <code>status-settings.log</code>; "
|
||||||
|
"auto-fix daily logs in <code>%s</code></li>" % e((cfg.get("auto_fix") or {}).get("log_dir", "~/Desktop/Auto-Fix Logs")),
|
||||||
|
"<li>Working files: <code>config.json</code> (the settings), <code>state.json</code> (what survives a restart), "
|
||||||
|
"<code>started.json</code>, <code>live-now.json</code>, <code>fixes-running.json</code>, <code>snapshot.json</code> (what the watchdog sees), "
|
||||||
|
"<code>machines-live.json</code> (the last look at the computers), <code>dante-map.json</code>, <code>atem-reference.xml</code> (the saved switcher file), "
|
||||||
|
"<code>prev/</code> (the previous code, for <code>install.sh --rollback</code>)</li>",
|
||||||
|
"<li>Companion: <code>http://127.0.0.1:%d</code>, custom variables <code>%s</code>, <code>%s</code>, <code>%s</code>, blink clock <code>%s</code>; "
|
||||||
|
"pages %d (Status)%s</li>" % (cfg["companion"].get("port", 8000), e(cfg["variables"]["state"]), e(cfg["variables"]["heartbeat"]),
|
||||||
|
e(cfg["variables"]["command"]), e(cfg["variables"].get("flash", "flash_tick")), cfg["page"],
|
||||||
|
(" and %d (Restart)" % cfg["restart_page"]["page"]) if cfg.get("restart_page") else ""),
|
||||||
|
"<li>The login key for the other computers: <code>%s</code>; the Netgear login: keychain item <code>netgear-status-panel</code></li>" % e(cfg.get("ssh_key", "~/.ssh/id_status_panel")),
|
||||||
|
"<li>This page: <code>%s</code></li></ul>" % e(address)]
|
||||||
|
for alias, h in hosts.items():
|
||||||
|
if h.get("self"):
|
||||||
|
continue
|
||||||
|
parts.append("<h2>%s</h2><ul><li>Log in: <code>%s</code>%s</li>" % (e(h.get("name", alias)), e(h.get("ssh", "")),
|
||||||
|
(" (wake-on-LAN %s)" % e(h["mac"])) if h.get("mac") else ""))
|
||||||
|
for run in h.get("runs", []):
|
||||||
|
k = run.get("kind")
|
||||||
|
if k == "task":
|
||||||
|
parts.append("<li>Scheduled task <code>\\StatusPanel\\%s</code> starts <code>%s</code></li>" % (e(run["name"]), e(run.get("exe", ""))))
|
||||||
|
elif k == "service":
|
||||||
|
parts.append("<li>Windows service <code>%s</code></li>" % e(run["name"]))
|
||||||
|
elif k == "launchagent":
|
||||||
|
parts.append("<li>LaunchAgent <code>%s</code> (%s)</li>" % (e(run.get("label", "")), e(run.get("name", ""))))
|
||||||
|
elif k == "helper_dvs":
|
||||||
|
parts.append("<li>DVS helper <code>%s</code> with a sudoers rule in <code>/etc/sudoers.d/status-panel</code></li>" % e(site.get("dvs_helper", "/usr/local/sbin/status-panel-dvs-start")))
|
||||||
|
elif k == "helper_solo":
|
||||||
|
parts.append("<li>Pro Tools helper in <code>%s</code> (LaunchAgent <code>%s</code>; needs Accessibility)</li>" % (e(site.get("solo_dir", "")), e(run.get("label", ""))))
|
||||||
|
elif k == "app":
|
||||||
|
parts.append("<li>%s (process <code>%s</code>)</li>" % (e(run.get("name", "")), e(run.get("process", ""))))
|
||||||
|
parts.append("</ul>")
|
||||||
|
return "\n".join(parts)
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""Read-only looks at the computers, for the Computers screen.
|
||||||
|
|
||||||
|
Every probe is a fixed command. Whole lists come back (every agent, every process, every task,
|
||||||
|
every service) and are matched here, so no name from the settings is ever put into a command
|
||||||
|
line. The only values that reach a command are two folder paths from the site block, shell-quoted.
|
||||||
|
Nothing is ever changed, nothing is woken, and a probe never runs while the watchdog runs a fix.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
|
||||||
|
import status_panel as sp
|
||||||
|
|
||||||
|
TIMEOUT = 8
|
||||||
|
|
||||||
|
MAC_PROBE = (
|
||||||
|
'echo "SP_NAME=$(uname -n)"; echo "SP_OSV=$(sw_vers -productVersion 2>/dev/null)"; '
|
||||||
|
'echo "SP_PY=$(/usr/bin/python3 --version 2>&1)"; '
|
||||||
|
'echo "SP_KEY=$(grep -c status-panel ~/.ssh/authorized_keys 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_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'
|
||||||
|
)
|
||||||
|
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_SVC & sc query type= service state= all 2>nul & echo SP_PS & powershell -NoProfile -Command '
|
||||||
|
'"(Get-NetConnectionProfile | Select -First 1 -ExpandProperty NetworkCategory); '
|
||||||
|
'(Get-NetAdapterPowerManagement | Select -First 1 -ExpandProperty WakeOnMagicPacket); '
|
||||||
|
'((Get-NetFirewallRule -Name OpenSSH-Server-In-TCP -ErrorAction SilentlyContinue | Get-NetFirewallAddressFilter | '
|
||||||
|
'Select -ExpandProperty RemoteAddress) -join \',\')" 2>nul & echo SP_END'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def quoted_paths(cfg):
|
||||||
|
site = cfg.get("site", {})
|
||||||
|
return {"helper": shlex.quote(site.get("dvs_helper", "/usr/local/sbin/status-panel-dvs-start")),
|
||||||
|
"solo": shlex.quote(site.get("solo_dir", "~/Library/Application Support/status-panel").replace("~", "$HOME"))}
|
||||||
|
|
||||||
|
|
||||||
|
def run_ssh(cfg, alias, command, timeout=TIMEOUT):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(sp.ssh_cmd(cfg, alias) + [command], capture_output=True, text=True, timeout=timeout)
|
||||||
|
return r.returncode, r.stdout, r.stderr
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None, "", "no answer within %d s" % timeout
|
||||||
|
except OSError as e:
|
||||||
|
return None, "", str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def sections(out, markers):
|
||||||
|
"""Split probe output at the SP_... marker lines: {marker: [lines]}."""
|
||||||
|
parts, current = {}, None
|
||||||
|
for line in out.replace("\r", "").split("\n"):
|
||||||
|
key = line.strip()
|
||||||
|
if key in markers:
|
||||||
|
current = key
|
||||||
|
parts[current] = []
|
||||||
|
elif current:
|
||||||
|
parts[current].append(line)
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def values(lines_or_out):
|
||||||
|
"""SP_KEY=value lines -> {"KEY": "value"}."""
|
||||||
|
text = "\n".join(lines_or_out) if isinstance(lines_or_out, list) else lines_or_out
|
||||||
|
return dict(re.findall(r"^SP_([A-Z]+)=(.*)$", text.replace("\r", ""), re.M))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_launchctl(lines):
|
||||||
|
"""{label: pid or None} from `launchctl list`."""
|
||||||
|
agents = {}
|
||||||
|
for line in lines:
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) == 3 and parts[2] and parts[2] != "Label":
|
||||||
|
agents[parts[2]] = int(parts[0]) if parts[0].isdigit() else None
|
||||||
|
return agents
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv_rows(lines):
|
||||||
|
out = []
|
||||||
|
for line in lines:
|
||||||
|
if not line.startswith('"'):
|
||||||
|
continue
|
||||||
|
out.append([c.strip('"') for c in line.strip().split('","')])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sc(lines):
|
||||||
|
"""{service name: state word} from `sc query`."""
|
||||||
|
services, name = {}, None
|
||||||
|
for line in lines:
|
||||||
|
m = re.match(r"^\s*SERVICE_NAME:\s*(.+?)\s*$", line)
|
||||||
|
if m:
|
||||||
|
name = m.group(1)
|
||||||
|
m = re.match(r"^\s*STATE\s*:\s*\d+\s+(\w+)", line)
|
||||||
|
if m and name:
|
||||||
|
services[name] = m.group(1)
|
||||||
|
return services
|
||||||
|
|
||||||
|
|
||||||
|
def light(ok, reason=""):
|
||||||
|
return {"light": "ok" if ok else "problem", "reason": reason if not ok else ""}
|
||||||
|
|
||||||
|
|
||||||
|
def mac_items(host, parsed, cfg):
|
||||||
|
"""One line per thing that runs on a Mac, judged from the probe's lists."""
|
||||||
|
agents = parse_launchctl(parsed.get("SP_AGENTS", []))
|
||||||
|
procs = set(l.strip() for l in parsed.get("SP_PROCS", []))
|
||||||
|
proc_names = {os.path.basename(p) for p in procs} | {p.split("/")[-1] for p in procs}
|
||||||
|
apps = set(l.strip() for l in parsed.get("SP_APPS", []))
|
||||||
|
helper = "\n".join(parsed.get("SP_HELPER", []))
|
||||||
|
solo_lines = [l for l in parsed.get("SP_SOLO", []) if l.strip()]
|
||||||
|
now = int(("\n".join(parsed.get("SP_NOW", [])).strip() or "0") or 0)
|
||||||
|
items = []
|
||||||
|
for run in host.get("runs", []):
|
||||||
|
kind, name = run.get("kind"), run.get("name") or run.get("label") or run.get("process") or run.get("kind")
|
||||||
|
item = {"name": name, "kind": kind, "watched": run.get("watched", True)}
|
||||||
|
if kind == "sshd":
|
||||||
|
item.update(light(True))
|
||||||
|
elif kind == "app":
|
||||||
|
proc = run.get("process", "")
|
||||||
|
running = any(p == proc or p.endswith("/" + proc) for p in procs) or proc in proc_names
|
||||||
|
installed = (not run.get("path")) or True
|
||||||
|
item.update(light(running, "not running" if installed else "not installed"))
|
||||||
|
if not running and run.get("watched", True) is False:
|
||||||
|
item["light"] = "not_needed"
|
||||||
|
elif kind == "launchagent":
|
||||||
|
label = run.get("label", "")
|
||||||
|
if label not in agents:
|
||||||
|
item.update(light(False, "not loaded"))
|
||||||
|
elif agents[label] is None:
|
||||||
|
item.update(light(False, "loaded but not running"))
|
||||||
|
else:
|
||||||
|
item.update(light(True))
|
||||||
|
if run.get("watched", True) is False and item["light"] == "problem":
|
||||||
|
item["light"] = "not_needed"
|
||||||
|
elif kind == "disabled":
|
||||||
|
label = run.get("label", "")
|
||||||
|
item.update(light(label not in agents, "loaded - it must not run here"))
|
||||||
|
elif kind == "daemon":
|
||||||
|
proc = run.get("process", "")
|
||||||
|
item.update(light(any(proc in p for p in procs), "not running"))
|
||||||
|
elif kind == "helper_dvs":
|
||||||
|
installed = "No such file" not in helper and "-rwx" in helper
|
||||||
|
allowed = "NOPASSWD" in helper or "(root)" in helper or "may run" in helper.lower()
|
||||||
|
item.update(light(installed and allowed, "not installed" if not installed else "sudo rule missing"))
|
||||||
|
elif kind == "helper_solo":
|
||||||
|
label = run.get("label", "org.waterschurch.status-panel.solo-mode")
|
||||||
|
try:
|
||||||
|
data = json.loads(solo_lines[0]) if solo_lines else {}
|
||||||
|
age = now - int(solo_lines[1]) if len(solo_lines) > 1 and now else None
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
data, age = {}, None
|
||||||
|
running = agents.get(label) is not None
|
||||||
|
fresh = age is not None and age < 60
|
||||||
|
trusted = bool(data.get("trusted"))
|
||||||
|
if not running:
|
||||||
|
item.update(light(False, "helper not running"))
|
||||||
|
elif not trusted:
|
||||||
|
item.update(light(False, "Accessibility not allowed on that Mac"))
|
||||||
|
elif not fresh:
|
||||||
|
item.update(light(False, "helper not reporting"))
|
||||||
|
else:
|
||||||
|
item.update(light(True))
|
||||||
|
item["detail"] = {"afl": data.get("afl"), "latch": data.get("latch"), "age": age}
|
||||||
|
elif kind == "midi_session":
|
||||||
|
item.update({"light": "unknown", "reason": "see the MIDI key"})
|
||||||
|
elif kind in ("folder",):
|
||||||
|
item.update({"light": "unknown", "reason": "not checked here"})
|
||||||
|
else:
|
||||||
|
item.update({"light": "unknown", "reason": ""})
|
||||||
|
items.append(item)
|
||||||
|
return items, {"agents": sorted(a for a in agents if not a.startswith(("com.apple.", "application."))),
|
||||||
|
"processes": sorted(proc_names)[:200]}
|
||||||
|
|
||||||
|
|
||||||
|
def windows_items(host, parsed):
|
||||||
|
tasks = {row[0]: row for row in parse_csv_rows(parsed.get("SP_TASKS", [])) if row}
|
||||||
|
procs = {row[0].lower() for row in parse_csv_rows(parsed.get("SP_PROCS", [])) if row}
|
||||||
|
services = parse_sc(parsed.get("SP_SVC", []))
|
||||||
|
ps = [l.strip() for l in parsed.get("SP_PS", []) if l.strip()]
|
||||||
|
profile, wol, allow = (ps + ["", "", ""])[:3]
|
||||||
|
items = []
|
||||||
|
for run in host.get("runs", []):
|
||||||
|
kind, name = run.get("kind"), run.get("name") or run.get("kind")
|
||||||
|
item = {"name": name, "kind": kind, "watched": run.get("watched", True)}
|
||||||
|
if kind == "sshd":
|
||||||
|
item.update(light(services.get("sshd") == "RUNNING", "the SSH service is %s" % services.get("sshd", "missing")))
|
||||||
|
elif kind == "task":
|
||||||
|
full = "\\StatusPanel\\" + run["name"]
|
||||||
|
if full not in tasks:
|
||||||
|
item.update(light(False, "no scheduled task %s" % full))
|
||||||
|
else:
|
||||||
|
exe = (run.get("exe") or "").lower()
|
||||||
|
running = exe in procs if exe else None
|
||||||
|
if running is False:
|
||||||
|
item.update(light(False, "%s is not running" % run.get("exe")))
|
||||||
|
else:
|
||||||
|
item.update(light(True))
|
||||||
|
item["task_state"] = tasks.get(full, ["", "", ""])[2] if full in tasks else None
|
||||||
|
elif kind == "service":
|
||||||
|
state = services.get(run["name"])
|
||||||
|
item.update(light(state == "RUNNING", "service %s is %s" % (run["name"], state or "missing")))
|
||||||
|
elif kind in ("web", "artnet"):
|
||||||
|
item.update({"light": "unknown", "reason": "see the key that watches it"})
|
||||||
|
else:
|
||||||
|
item.update({"light": "unknown", "reason": ""})
|
||||||
|
items.append(item)
|
||||||
|
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()
|
||||||
|
if v == "RUNNING" and not k.lower().startswith(("w", "s", "d", "e", "n", "p", "t", "u", "c")))[:60]}, setup
|
||||||
|
|
||||||
|
|
||||||
|
def probe_host(cfg, alias, host):
|
||||||
|
"""One computer, over ssh. Returns the record the Computers screen shows."""
|
||||||
|
started = time.time()
|
||||||
|
rec = {"alias": alias, "name": host.get("name", alias), "os": host.get("os", "mac"), "time": started,
|
||||||
|
"reachable": False, "items": [], "found": {}, "setup": {}}
|
||||||
|
if host.get("self"):
|
||||||
|
return probe_self(cfg, alias, host)
|
||||||
|
if host.get("os") == "windows":
|
||||||
|
rc, out, err = run_ssh(cfg, alias, WIN_PROBE, TIMEOUT + 6)
|
||||||
|
if rc is None or "SP_END" not in out:
|
||||||
|
rec["error"] = err.strip() or "no answer"
|
||||||
|
return rec
|
||||||
|
rec["reachable"] = True
|
||||||
|
parsed = sections(out, ("SP_TASKS", "SP_PROCS", "SP_SVC", "SP_PS", "SP_END"))
|
||||||
|
rec["computer_name"] = values(out).get("NAME", "").strip()
|
||||||
|
rec["items"], rec["found"], rec["setup"] = windows_items(host, parsed)
|
||||||
|
rec["setup"]["key_ok"] = True
|
||||||
|
rec["setup"]["shell"] = "cmd" if rec["computer_name"] and "%" not in rec["computer_name"] else "not cmd"
|
||||||
|
else:
|
||||||
|
rc, out, err = run_ssh(cfg, alias, MAC_PROBE.format(**quoted_paths(cfg)))
|
||||||
|
if rc is None or "SP_END" not in out:
|
||||||
|
rec["error"] = err.strip() or "no answer"
|
||||||
|
return rec
|
||||||
|
rec["reachable"] = True
|
||||||
|
v = values(out)
|
||||||
|
parsed = sections(out, ("SP_AGENTS", "SP_PROCS", "SP_APPS", "SP_HELPER", "SP_SOLO", "SP_NOW", "SP_END"))
|
||||||
|
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["setup"] = {"key_ok": (v.get("KEY") or "0").strip() not in ("", "0"), "python_ok": "3." in v.get("PY", "")}
|
||||||
|
rec["took"] = round(time.time() - started, 1)
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def listening_ports():
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["/usr/sbin/lsof", "-nP", "-iTCP", "-sTCP:LISTEN"], capture_output=True, text=True, timeout=6).stdout
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return []
|
||||||
|
ports = set()
|
||||||
|
for line in out.splitlines()[1:]:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) > 8:
|
||||||
|
ports.add("%s %s" % (parts[0][:12], parts[8]))
|
||||||
|
return sorted(ports)
|
||||||
|
|
||||||
|
|
||||||
|
def interfaces():
|
||||||
|
"""[(name, ipv4, active?)] of this Mac's network ports."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["/sbin/ifconfig"], capture_output=True, text=True, timeout=5).stdout
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return []
|
||||||
|
found = []
|
||||||
|
for block in re.split(r"\n(?=\w)", out):
|
||||||
|
name = block.split(":")[0]
|
||||||
|
ip = re.search(r"inet (\d+\.\d+\.\d+\.\d+)", block)
|
||||||
|
if ip and name != "lo0":
|
||||||
|
found.append((name, ip.group(1), "status: active" in block))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def probe_self(cfg, alias, host):
|
||||||
|
"""This Mac: no ssh; the same questions asked locally, plus the things only it has."""
|
||||||
|
started = time.time()
|
||||||
|
rec = {"alias": alias, "name": host.get("name", "this Mac"), "os": "mac", "time": started, "reachable": True,
|
||||||
|
"items": [], "found": {}, "setup": {}}
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as e:
|
||||||
|
rec["error"] = str(e)
|
||||||
|
return rec
|
||||||
|
paths = quoted_paths(cfg)
|
||||||
|
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
|
||||||
|
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()))]}
|
||||||
|
rec["items"], rec["found"] = mac_items(host, parsed, cfg)
|
||||||
|
for item, run in zip(rec["items"], host.get("runs", [])):
|
||||||
|
if run.get("kind") == "folder":
|
||||||
|
path = os.path.expanduser(run["path"])
|
||||||
|
item.update(light(os.path.isdir(path), "folder not found (is Google Drive signed in?)"))
|
||||||
|
rec["found"]["ports"] = listening_ports()
|
||||||
|
ifaces = interfaces()
|
||||||
|
rec["found"]["interfaces"] = [{"iface": n, "ip": ip, "active": a} for n, ip, a in ifaces]
|
||||||
|
key = os.path.expanduser(cfg.get("ssh_key", "~/.ssh/id_status_panel"))
|
||||||
|
atem = cfg.get("atem") or {}
|
||||||
|
rec["setup"] = {
|
||||||
|
"login_key": os.path.exists(key) and os.path.exists(key + ".pub"),
|
||||||
|
"python_ok": (3, 9) <= tuple(int(x) for x in subprocess.run(["/usr/bin/python3", "-c", "import sys; print('%d.%d' % sys.version_info[:2])"],
|
||||||
|
capture_output=True, text=True, timeout=5).stdout.strip().split(".")) <= (3, 12),
|
||||||
|
"companion_db": bool(sp.companion_db_path(cfg)),
|
||||||
|
"netgear_keychain": "acct" in subprocess.run(["/usr/bin/security", "find-generic-password", "-s", "netgear-status-panel"],
|
||||||
|
capture_output=True, text=True, timeout=5).stdout if cfg.get("dante_switches") else None,
|
||||||
|
"atem_node": bool(sp.newest(atem["node"])) if atem.get("node") else None,
|
||||||
|
"atem_library": bool(sp.newest(atem["library"])) if atem.get("library") else None,
|
||||||
|
"switches": {s: port_open(s, 23) for s in cfg.get("dante_switches", [])},
|
||||||
|
"lan_ports": [n for n in host.get("networks", []) if n.get("use") == "LAN"],
|
||||||
|
}
|
||||||
|
rec["took"] = round(time.time() - started, 1)
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def port_open(host, port, timeout=2):
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=timeout):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def probe_all(cfg, data_dir=None, only=None):
|
||||||
|
"""Every computer at once (threads), results cached in machines-live.json."""
|
||||||
|
results, threads = {}, []
|
||||||
|
for alias, host in cfg.get("hosts", {}).items():
|
||||||
|
if only and alias not in only:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def work(alias=alias, host=host):
|
||||||
|
try:
|
||||||
|
results[alias] = probe_host(cfg, alias, host)
|
||||||
|
except Exception as e: # one broken probe must not hide the others
|
||||||
|
results[alias] = {"alias": alias, "name": host.get("name", alias), "reachable": False, "error": str(e),
|
||||||
|
"items": [], "found": {}, "setup": {}, "time": time.time()}
|
||||||
|
t = threading.Thread(target=work, daemon=True)
|
||||||
|
t.start()
|
||||||
|
threads.append(t)
|
||||||
|
for t in threads:
|
||||||
|
t.join(TIMEOUT + 10)
|
||||||
|
if data_dir:
|
||||||
|
path = os.path.join(data_dir, "machines-live.json")
|
||||||
|
try:
|
||||||
|
old = json.load(open(path)) if os.path.exists(path) else {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
old = {}
|
||||||
|
old.update(results)
|
||||||
|
sp.write_json(path, old)
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return results
|
||||||
@@ -0,0 +1,685 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""The settings page's server: a small stdlib HTTP server on the Ingest Mac, beside the watchdog.
|
||||||
|
|
||||||
|
Everything is readable by anyone on the church network (the alert web hook and the editing code
|
||||||
|
are masked); nothing changes without the editing code. The server never runs a command before an
|
||||||
|
Apply, never writes a file the watchdog writes, and reads the watchdog's own files for what it knows.
|
||||||
|
|
||||||
|
python3 -m settings.server --data-dir DIR [--port 8020] [--bind 127.0.0.1 ...]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import glob
|
||||||
|
import hashlib
|
||||||
|
import http.server
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from settings import PARENT, HERE # noqa: F401
|
||||||
|
import schema
|
||||||
|
import status_panel as sp
|
||||||
|
from settings import keyguide, probes
|
||||||
|
|
||||||
|
log = logging.getLogger("status-settings")
|
||||||
|
UI = os.path.join(HERE, "ui")
|
||||||
|
PRIVATE = [ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "127.0.0.0/8")]
|
||||||
|
LOGS = {"watchdog": "~/Library/Logs/status-panel.log", "errors": "~/Library/Logs/status-panel.err.log",
|
||||||
|
"settings": "~/Library/Logs/status-settings.log"}
|
||||||
|
LOG_FILTERS = {"holds": r"\b(HOLD|FORCE|TEST|FIX|ENABLE|RESTART|ACK)\b", "fixes": r"\bFIX\b|-> ",
|
||||||
|
"auto": r"\bAUTO\b|auto-fix", "errors": r"WARNING|ERROR|Traceback", "faults": r"\b(FAULT|CLEAR)\b"}
|
||||||
|
|
||||||
|
# What the Companion experiments found (the Help page shows this; filled in as they are run).
|
||||||
|
COMPANION_FACTS = [
|
||||||
|
("Which module a connection uses", "The HTTP API reports it (moduleId) - no need to read Companion's database for it."),
|
||||||
|
("Does OBS say when it is streaming?", "Yes: the OBS-Youtube connection's variable 'streaming' reads 'Off-Air' when it is not; "
|
||||||
|
"the on-air value is recorded the first time it streams."),
|
||||||
|
("Can the page create the custom variables?", "Not yet known; they are made by hand in Companion for now."),
|
||||||
|
("Can an old page's keys be blanked over the API?", "Not yet tried; after a page-number change the old keys are deleted by hand."),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_client(ip):
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(ip.split("%")[0])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(addr in n for n in PRIVATE)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_addresses(cfg, ifaces=None):
|
||||||
|
"""127.0.0.1 plus the IPv4 address of every LAN-use port named for this Mac (every private
|
||||||
|
address when none is named)."""
|
||||||
|
ifaces = probes.interfaces() if ifaces is None else ifaces
|
||||||
|
me = next((h for h in cfg.get("hosts", {}).values() if h.get("self")), {})
|
||||||
|
lan = [n["iface"] for n in me.get("networks", []) if n.get("use") == "LAN"]
|
||||||
|
out = ["127.0.0.1"]
|
||||||
|
for name, ip, active in ifaces:
|
||||||
|
if ip in out:
|
||||||
|
continue
|
||||||
|
if (name in lan) if lan else allowed_client(ip):
|
||||||
|
out.append(ip)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def tail(path, lines=400, pattern=None):
|
||||||
|
path = os.path.expanduser(path)
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
f.seek(0, 2)
|
||||||
|
size = f.tell()
|
||||||
|
f.seek(max(0, size - 512 * 1024))
|
||||||
|
text = f.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
rows = text.split("\n")[1:] if len(text) >= 512 * 1024 else text.split("\n")
|
||||||
|
if pattern:
|
||||||
|
rx = re.compile(pattern)
|
||||||
|
rows = [r for r in rows if rx.search(r)]
|
||||||
|
return [r for r in rows if r][-lines:]
|
||||||
|
|
||||||
|
|
||||||
|
class Site:
|
||||||
|
"""Everything the handlers share: the config, the data dir, Companion, caches."""
|
||||||
|
|
||||||
|
def __init__(self, data_dir, config_path=None, companion=None):
|
||||||
|
self.data_dir = data_dir
|
||||||
|
self.config_path = config_path or os.path.join(data_dir, "config.json")
|
||||||
|
self.cfg, self.cfg_sha, self.cfg_mtime = None, None, 0
|
||||||
|
self.load()
|
||||||
|
self.comp = companion or sp.make_companion(self.cfg)
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.probing = None
|
||||||
|
self.attempts = {} # client ip -> [count, first attempt time]
|
||||||
|
self.page_cache = {} # name -> (time, value)
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
st = os.stat(self.config_path)
|
||||||
|
if st.st_mtime != self.cfg_mtime:
|
||||||
|
with open(self.config_path, "rb") as f:
|
||||||
|
raw = f.read()
|
||||||
|
self.cfg = sp.load_config(self.config_path)
|
||||||
|
self.cfg_sha, self.cfg_mtime = hashlib.sha256(raw).hexdigest(), st.st_mtime
|
||||||
|
return self.cfg
|
||||||
|
|
||||||
|
def read_json(self, name, default=None):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(self.data_dir, name)) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def companion_get(self, path, timeout=3):
|
||||||
|
comp = self.cfg["companion"]
|
||||||
|
url = "http://%s:%d%s" % (comp.get("host", "127.0.0.1"), comp.get("port", 8000), path)
|
||||||
|
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||||
|
return r.status, r.read()
|
||||||
|
|
||||||
|
def cached(self, name, seconds, fn):
|
||||||
|
now = time.time()
|
||||||
|
hit = self.page_cache.get(name)
|
||||||
|
if hit and now - hit[0] < seconds:
|
||||||
|
return hit[1]
|
||||||
|
value = fn()
|
||||||
|
self.page_cache[name] = (now, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
# --- what the screens show ---------------------------------------------
|
||||||
|
|
||||||
|
def overview(self):
|
||||||
|
cfg = self.load()
|
||||||
|
names = cfg["variables"]
|
||||||
|
deck, hb, companion_ok = None, None, True
|
||||||
|
try:
|
||||||
|
raw = self.comp.get_custom(names["state"])
|
||||||
|
deck = json.loads(raw) if raw else None
|
||||||
|
hb = self.comp.get_custom(names["heartbeat"])
|
||||||
|
except Exception as e: # Companion down: the page still works
|
||||||
|
companion_ok, deck = False, None
|
||||||
|
log.warning("Companion: %s", e)
|
||||||
|
now = time.time()
|
||||||
|
hb = float(hb) if hb and str(hb).strip().replace(".", "").isdigit() else None
|
||||||
|
snap = self.read_json("snapshot.json", {}) or {}
|
||||||
|
started = self.read_json("started.json", {}) or {}
|
||||||
|
live = self.read_json("live-now.json", {}) or {}
|
||||||
|
running = self.read_json("fixes-running.json", {}) or {}
|
||||||
|
tiles = {t["id"]: t for t in cfg["tiles"]}
|
||||||
|
problems, needs = [], []
|
||||||
|
for tid, faults in (snap.get("faults") or {}).items():
|
||||||
|
tile = tiles.get(tid)
|
||||||
|
if not tile:
|
||||||
|
continue
|
||||||
|
for f in faults:
|
||||||
|
if f["level"] not in ("err", "warn"):
|
||||||
|
continue
|
||||||
|
text = f["msg"] if f["msg"] and f["msg"] != tile["label"] else tile["label"]
|
||||||
|
hold = keyguide.hold_words(tile, cfg)
|
||||||
|
problems.append({"key": tid, "label": tile["label"], "text": "%s: %s" % (tile["label"], f["msg"]),
|
||||||
|
"level": f["level"], "acked": f.get("acked"), "since": f.get("since"),
|
||||||
|
"action": "hold the %s key (%s)" % (tile["label"], hold) if hold and not hold.startswith("nothing")
|
||||||
|
else "no automatic fix: check the device"})
|
||||||
|
if started and started.get("config_sha") and started["config_sha"] != self.cfg_sha:
|
||||||
|
needs.append("The watchdog is running an older settings file than the one on disk (restart it to pick up the change).")
|
||||||
|
pages = self.pages_state()
|
||||||
|
if pages.get("status") and not pages["status"]["ok"]:
|
||||||
|
needs.append("Companion's page %d is not called %s." % (cfg["page"], pages["status"]["expected"]))
|
||||||
|
return {
|
||||||
|
"time": now, "site": cfg.get("site", {}), "deck": deck, "heartbeat_age": (now - hb) if hb else None,
|
||||||
|
"companion_ok": companion_ok, "watchdog": self.watchdog_state(started, hb, now),
|
||||||
|
"live": live, "running": running, "service_on": snap.get("service_on"), "alarms_override": snap.get("alarms_override"),
|
||||||
|
"auto_fix": {"phrase": schema.phrases(cfg)["auto_fix"], "state": snap.get("auto_fix")},
|
||||||
|
"problems": sorted(problems, key=lambda p: (p["level"] != "err", p["since"] or 0)), "needs": needs,
|
||||||
|
"computers": self.computer_lines(snap), "pages": pages, "snapshot_age": (now - snap["time"]) if snap.get("time") else None,
|
||||||
|
"last_change": self.last_change(), "phrases": schema.phrases(cfg), "version": started.get("version"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def watchdog_state(self, started, hb, now):
|
||||||
|
state = {"running": False, "since": started.get("started_at"), "version": started.get("version"), "pid": started.get("pid")}
|
||||||
|
try:
|
||||||
|
out = sp.subprocess.run(["/bin/launchctl", "print", "gui/%d/org.waterschurch.status-panel" % os.getuid()],
|
||||||
|
capture_output=True, text=True, timeout=5).stdout
|
||||||
|
m = re.search(r"state = (\w+)", out)
|
||||||
|
state["launchd"] = m.group(1) if m else "not loaded"
|
||||||
|
state["running"] = bool(m and m.group(1) == "running")
|
||||||
|
except (OSError, sp.subprocess.TimeoutExpired):
|
||||||
|
state["launchd"] = "unknown"
|
||||||
|
state["heartbeat_fresh"] = bool(hb and now - hb < cfg_stale(self.cfg))
|
||||||
|
return state
|
||||||
|
|
||||||
|
def computer_lines(self, snap):
|
||||||
|
"""One line per computer: a status word and the reason, from what the watchdog sees."""
|
||||||
|
cfg = self.cfg
|
||||||
|
live = self.read_json("machines-live.json", {}) or {}
|
||||||
|
tiles_by_host = {}
|
||||||
|
for t in cfg["tiles"]:
|
||||||
|
for c in t.get("checks", []):
|
||||||
|
h = c.get("host") if c["type"] in ("remote", "midi_peer") else None
|
||||||
|
if h:
|
||||||
|
tiles_by_host.setdefault(h, set()).add(t["id"])
|
||||||
|
for fid, fix in cfg.get("fixes", {}).items():
|
||||||
|
if fix.get("host"):
|
||||||
|
for t in cfg["tiles"]:
|
||||||
|
own = set()
|
||||||
|
for c in t.get("checks", []):
|
||||||
|
fx = c.get("fix")
|
||||||
|
own |= set(fx.values()) if isinstance(fx, dict) else ({fx} if fx else set())
|
||||||
|
if fid in own or t.get("hold_always") == fid:
|
||||||
|
tiles_by_host.setdefault(fix["host"], set()).add(t["id"])
|
||||||
|
lines = []
|
||||||
|
for alias, h in cfg.get("hosts", {}).items():
|
||||||
|
probe = live.get(alias) or {}
|
||||||
|
word, reason = "OK", ""
|
||||||
|
faults = []
|
||||||
|
for tid in sorted(tiles_by_host.get(alias, [])):
|
||||||
|
for f in (snap.get("faults") or {}).get(tid, []):
|
||||||
|
if f["level"] == "err" and (f["type"] in ("remote", "midi_peer") or True):
|
||||||
|
faults.append("%s %s" % (tiles_by_host and next(t["label"] for t in cfg["tiles"] if t["id"] == tid), f["msg"]))
|
||||||
|
if probe and not probe.get("reachable") and not h.get("self"):
|
||||||
|
word, reason = "Problem", "cannot be reached (%s)" % probe.get("error", "no answer")
|
||||||
|
elif faults:
|
||||||
|
word, reason = "Problem", "; ".join(faults[:2])
|
||||||
|
elif probe and any(i.get("light") == "problem" and i.get("watched", True) for i in probe.get("items", [])):
|
||||||
|
bad = [i for i in probe.get("items", []) if i.get("light") == "problem" and i.get("watched", True)]
|
||||||
|
word, reason = "Problem", "; ".join("%s %s" % (i["name"], i.get("reason", "")) for i in bad[:2])
|
||||||
|
elif not probe and not h.get("self"):
|
||||||
|
word, reason = "Not looked yet", ""
|
||||||
|
lines.append({"alias": alias, "name": h.get("name", alias), "role": h.get("role", ""), "word": word, "reason": reason,
|
||||||
|
"keys": sorted(tiles_by_host.get(alias, [])), "self": bool(h.get("self")),
|
||||||
|
"looked": probe.get("time")})
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def pages_state(self):
|
||||||
|
cfg = self.cfg
|
||||||
|
|
||||||
|
def look():
|
||||||
|
out = {}
|
||||||
|
for which, page, name in (("status", cfg["page"], cfg["page_name"]),) + (
|
||||||
|
(("restart", cfg["restart_page"]["page"], cfg["restart_page"]["page_name"]),) if cfg.get("restart_page") else ()):
|
||||||
|
try:
|
||||||
|
status, raw = self.companion_get("/api/variable/internal/page_number_%d_name/value" % page)
|
||||||
|
live = raw.decode().replace("\\n", "\n") if status == 200 else None
|
||||||
|
except (OSError, urllib.error.URLError):
|
||||||
|
live = None
|
||||||
|
out[which] = {"page": page, "expected": name.replace("\\n", " "), "live": live.replace("\n", " ") if live else None,
|
||||||
|
"ok": live is not None and live.replace("\n", " ") == name.replace("\\n", " ")}
|
||||||
|
return out
|
||||||
|
return self.cached("pages", 60, look)
|
||||||
|
|
||||||
|
def last_change(self):
|
||||||
|
baks = sorted(glob.glob(os.path.join(self.data_dir, "config.json.bak-*")))
|
||||||
|
if not baks:
|
||||||
|
return None
|
||||||
|
m = re.search(r"bak-(\d{8})-(\d{6})$", baks[-1])
|
||||||
|
return {"when": "%s-%s-%s %s:%s" % (m.group(1)[:4], m.group(1)[4:6], m.group(1)[6:], m.group(2)[:2], m.group(2)[2:4]) if m else None,
|
||||||
|
"by": "the installer", "note": "config.json replaced"}
|
||||||
|
|
||||||
|
def companion_view(self):
|
||||||
|
cfg = self.load()
|
||||||
|
watched = {}
|
||||||
|
for t in cfg["tiles"]:
|
||||||
|
for c in t.get("checks", []):
|
||||||
|
if c.get("conn"):
|
||||||
|
watched.setdefault(c["conn"], set()).add(t["id"])
|
||||||
|
for k in (cfg.get("restart_page") or {}).get("keys", []):
|
||||||
|
for w in k.get("watch", []):
|
||||||
|
if w.startswith("conn:"):
|
||||||
|
watched.setdefault(w[5:], set()).add(k["id"])
|
||||||
|
try:
|
||||||
|
conns = self.comp.connections()
|
||||||
|
ok = True
|
||||||
|
except Exception as e:
|
||||||
|
conns, ok = [], False
|
||||||
|
rows = []
|
||||||
|
for c in sorted(conns, key=lambda c: c["label"].lower()):
|
||||||
|
st = c.get("status") or {}
|
||||||
|
rows.append({"label": c["label"], "module": c.get("moduleId"), "enabled": c.get("enabled"), "category": st.get("category"),
|
||||||
|
"message": st.get("message"), "watched_by": sorted(watched.get(c["label"], []))})
|
||||||
|
labels = {c["label"] for c in conns}
|
||||||
|
missing = sorted(l for l in watched if ok and l not in labels)
|
||||||
|
names = cfg["variables"]
|
||||||
|
lights = {}
|
||||||
|
for key in ("state", "heartbeat", "command"):
|
||||||
|
try:
|
||||||
|
lights["variable " + names[key]] = self.comp.get_custom(names[key]) is not None
|
||||||
|
except Exception:
|
||||||
|
lights["variable " + names[key]] = None
|
||||||
|
try:
|
||||||
|
lights["blink clock " + names.get("flash", "flash_tick")] = self.comp.get_custom(names.get("flash", "flash_tick")) is not None
|
||||||
|
except Exception:
|
||||||
|
lights["blink clock " + names.get("flash", "flash_tick")] = None
|
||||||
|
modules = {c.get("moduleId") for c in conns}
|
||||||
|
lights["ATEM module"] = ("bmd-atem" in modules) if ok else None
|
||||||
|
lights["Tascam module"] = ("tascam-da-6400" in modules) if ok else None
|
||||||
|
pages = self.pages_state()
|
||||||
|
for which, p in pages.items():
|
||||||
|
lights["page %d is the %s page" % (p["page"], which.capitalize())] = p["ok"]
|
||||||
|
nav = self.read_json("nav_locations.json", {}) or {}
|
||||||
|
try:
|
||||||
|
here = json.load(open(os.path.join(PARENT, "nav_locations.json")))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
here = {}
|
||||||
|
coloured = len({k.split("/")[0] for k in (nav or here)})
|
||||||
|
return {"ok": ok, "build": self.cached("build", 600, self.companion_build), "connections": rows, "missing": missing,
|
||||||
|
"lights": lights, "page_number_buttons": {"pages_coloured": coloured, "recorded_in": "data dir" if nav else "repo copy"},
|
||||||
|
"address": "%s:%d" % (cfg["companion"].get("host", "127.0.0.1"), cfg["companion"].get("port", 8000)),
|
||||||
|
"variables": names}
|
||||||
|
|
||||||
|
def companion_build(self):
|
||||||
|
try:
|
||||||
|
status, raw = self.companion_get("/int/export/page/%d?includeSecrets=false" % self.cfg["page"], timeout=8)
|
||||||
|
try:
|
||||||
|
raw = gzip.decompress(raw)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return json.loads(raw).get("companionBuild")
|
||||||
|
except (OSError, ValueError, urllib.error.URLError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def machines_view(self):
|
||||||
|
cfg = self.load()
|
||||||
|
live = self.read_json("machines-live.json", {}) or {}
|
||||||
|
snap = self.read_json("snapshot.json", {}) or {}
|
||||||
|
out = []
|
||||||
|
for line in self.computer_lines(snap):
|
||||||
|
alias = line["alias"]
|
||||||
|
h = cfg["hosts"][alias]
|
||||||
|
probe = dict(live.get(alias) or {})
|
||||||
|
items = probe.get("items") or [{"name": r.get("name") or r.get("label") or r.get("kind"), "kind": r.get("kind"),
|
||||||
|
"light": "unknown", "reason": "not looked yet", "watched": r.get("watched", True)}
|
||||||
|
for r in h.get("runs", [])]
|
||||||
|
# what the watchdog already knows beats a stale probe
|
||||||
|
for item, run in zip(items, h.get("runs", [])):
|
||||||
|
k = run.get("kind")
|
||||||
|
if k == "midi_session":
|
||||||
|
sessions = snap.get("midi") if h.get("self") else (snap.get("remote_midi") or {}).get(alias)
|
||||||
|
if sessions is not None and run.get("name") in sessions:
|
||||||
|
peers = sessions[run["name"]].get("peers", [])
|
||||||
|
item.update({"light": "ok", "reason": "", "detail": "%d peer%s: %s" % (len(peers), "" if len(peers) == 1 else "s",
|
||||||
|
", ".join(p["name"] for p in peers))})
|
||||||
|
elif run.get("peer_of"):
|
||||||
|
for host_sessions in list((snap.get("remote_midi") or {}).values()) + [snap.get("midi") or {}]:
|
||||||
|
sess = (host_sessions or {}).get(run["peer_of"])
|
||||||
|
if sess and any(p["name"] == run["name"] for p in sess.get("peers", [])):
|
||||||
|
item.update({"light": "ok", "reason": "", "detail": "in %s" % run["peer_of"]})
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
item.update({"light": "problem", "reason": "not in the %s session" % run.get("peer_of")})
|
||||||
|
elif sessions is not None:
|
||||||
|
item.update({"light": "problem", "reason": "no such MIDI session"})
|
||||||
|
elif k == "web" or (k == "app" and run.get("http")):
|
||||||
|
ok = (snap.get("http") or {}).get(run.get("url") or run.get("http"))
|
||||||
|
if ok is not None:
|
||||||
|
item.update({"light": "ok" if ok else "problem", "reason": "" if ok else "its web page does not answer"})
|
||||||
|
elif k == "artnet":
|
||||||
|
age = (snap.get("artnet") or {}).get(run.get("source"))
|
||||||
|
if age is not None:
|
||||||
|
item.update({"light": "ok" if age < 10 else "problem", "reason": "" if age < 10 else "silent for %d s" % age})
|
||||||
|
probe["items"] = items
|
||||||
|
probe.update({"alias": alias, "name": h.get("name", alias), "os": h.get("os", "mac"), "role": h.get("role", ""),
|
||||||
|
"notes": h.get("notes", ""), "ssh": h.get("ssh"), "mac": h.get("mac"), "pingable": h.get("pingable", True),
|
||||||
|
"self": bool(h.get("self")), "networks": h.get("networks", []), "manual_done": h.get("manual_done", {}),
|
||||||
|
"word": line["word"], "reason": line["reason"], "keys": line["keys"]})
|
||||||
|
if not h.get("self") and h.get("pingable", True):
|
||||||
|
ip = h.get("ssh", "").split("@")[-1]
|
||||||
|
fails = (snap.get("ping") or {}).get(ip)
|
||||||
|
probe["ping"] = None if fails is None else (fails == 0)
|
||||||
|
out.append(probe)
|
||||||
|
return {"computers": out, "probing": bool(self.probing and self.probing.is_alive()),
|
||||||
|
"setup_steps": self.setup_steps(cfg)}
|
||||||
|
|
||||||
|
def setup_steps(self, cfg):
|
||||||
|
"""The one-time checklist per computer kind, with the light each line gets from the probe."""
|
||||||
|
return {
|
||||||
|
"mac": ["Remote Login is on", "the panel's login key is accepted", "python3 is present (for MIDI reads)",
|
||||||
|
"the DVS helper and its sudo rule (Macs that run Dante Virtual Soundcard)", "Audio MIDI Setup sessions named as the keys expect",
|
||||||
|
"Studio B only: the Pro Tools helper runs and Accessibility is allowed"],
|
||||||
|
"windows": ["the SSH service runs and starts with Windows", "the firewall lets this Mac in", "the panel's login key is accepted",
|
||||||
|
"a \\StatusPanel\\ task per program", "rtpMIDI's service restarts by itself (Lighting PC)", "wake-on-LAN is on in Windows and in the BIOS",
|
||||||
|
"the network profile is Public where rtpMIDI runs", "Wi-Fi power saving is off (Bible TV)"],
|
||||||
|
"self": ["Command Line Tools / python 3.9-3.12", "the watchdog and the settings page are loaded", "Companion's custom variables and blink clock exist",
|
||||||
|
"the login key pair exists", "the Netgear login is in the keychain", "the Spotify permission was allowed once",
|
||||||
|
"the DVS helper and its sudo rule", "the Dante and switch-management ports are up", "Google Drive is signed in (saved switcher files)"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def gear_view(self):
|
||||||
|
cfg = self.load()
|
||||||
|
snap = self.read_json("snapshot.json", {}) or {}
|
||||||
|
conns, pings, http = snap.get("conns") or {}, snap.get("ping") or {}, snap.get("http") or {}
|
||||||
|
hosts_ips = {h.get("ssh", "").split("@")[-1] for h in cfg.get("hosts", {}).values()}
|
||||||
|
gear = cfg.get("gear", {})
|
||||||
|
kinds = {"hyperdeck": "recorder", "tascam": "recorder", "shure": "wireless receiver", "psm": "in-ear transmitter",
|
||||||
|
"dante": "Dante", "atem": "switcher"}
|
||||||
|
rows = {}
|
||||||
|
for t in cfg["tiles"]:
|
||||||
|
for c in t.get("checks", []):
|
||||||
|
name, kind, address = None, None, None
|
||||||
|
if c.get("conn") and c["type"] != "dante":
|
||||||
|
name, address = c["conn"], (snap.get("conn_hosts") or {}).get(c["conn"])
|
||||||
|
mod = next((r.get("module") for r in [] if False), None)
|
||||||
|
kind = kinds.get(c["type"], "device")
|
||||||
|
elif c["type"] == "ping" and c.get("host") and c["host"] not in hosts_ips:
|
||||||
|
name, address, kind = c.get("name") or c["host"], c["host"], "device"
|
||||||
|
elif c["type"] == "http" and not any(ip in c["url"] for ip in hosts_ips) and "127.0.0.1" not in c["url"]:
|
||||||
|
name, address, kind = c.get("name") or c.get("msg") or c["url"], c["url"], "web address"
|
||||||
|
elif c["type"] == "artnet":
|
||||||
|
continue
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
row = rows.setdefault(name, {"name": name, "kind": kind, "address": address, "watched_by": set(), "light": "unknown",
|
||||||
|
"reason": "", "location": gear.get(name, {}).get("location", ""), "notes": gear.get(name, {}).get("notes", "")})
|
||||||
|
row["watched_by"].add(t["id"])
|
||||||
|
if address is None and row["address"] is None and c.get("host"):
|
||||||
|
row["address"] = c["host"]
|
||||||
|
if c.get("conn") and c["conn"] in conns:
|
||||||
|
st = conns[c["conn"]]
|
||||||
|
row["light"] = "ok" if st.get("category") == "good" else ("problem" if st.get("enabled") else "not_needed")
|
||||||
|
row["reason"] = "" if row["light"] == "ok" else (st.get("message") or st.get("category") or "")
|
||||||
|
if c["type"] == "ping" and c.get("host") in pings and row["light"] != "problem":
|
||||||
|
row["light"] = "ok" if pings[c["host"]] == 0 else "problem"
|
||||||
|
row["reason"] = "" if pings[c["host"]] == 0 else "does not answer ping"
|
||||||
|
if c["type"] == "http" and c["url"] in http and row["light"] == "unknown":
|
||||||
|
row["light"] = "ok" if http[c["url"]] else "problem"
|
||||||
|
for name in (snap.get("dante_map") or {}):
|
||||||
|
e = snap["dante_map"][name]
|
||||||
|
row = rows.setdefault(name, {"name": name, "kind": "Dante device", "address": e.get("ip"), "watched_by": {"dante"}, "light": "unknown",
|
||||||
|
"reason": "", "location": gear.get(name, {}).get("location", ""), "notes": gear.get(name, {}).get("notes", "")})
|
||||||
|
row["detail"] = "switch %s port %s%s" % (e.get("switch", "?"), e.get("port", "?"), ", PoE" if e.get("poe") else "")
|
||||||
|
for s in cfg.get("dante_switches", []):
|
||||||
|
rows.setdefault("Netgear " + s, {"name": "Netgear " + s, "kind": "Dante switch", "address": s, "watched_by": {"dante"}, "light": "unknown",
|
||||||
|
"reason": "", "location": gear.get("Netgear " + s, {}).get("location", ""), "notes": ""})
|
||||||
|
out = []
|
||||||
|
for r in rows.values():
|
||||||
|
r["watched_by"] = sorted(r["watched_by"])
|
||||||
|
out.append(r)
|
||||||
|
return sorted(out, key=lambda r: (r["light"] != "problem", r["kind"], r["name"].lower()))
|
||||||
|
|
||||||
|
def keys_view(self):
|
||||||
|
cfg = self.load()
|
||||||
|
tiles = []
|
||||||
|
for t in cfg["tiles"]:
|
||||||
|
words = []
|
||||||
|
for c in t.get("checks", []):
|
||||||
|
ws = [c["msg"]] if c.get("msg") else list(keyguide.FAULT_WORDS.get(c["type"], []))
|
||||||
|
if c.get("tag"):
|
||||||
|
ws = ["%s %s" % (c["tag"], w) for w in ws]
|
||||||
|
words += ws
|
||||||
|
tiles.append(dict(t, says=sorted(set(words)), holding=re.sub(r"<[^>]+>", "", keyguide.hold_words(t, cfg)),
|
||||||
|
checks=[dict(c, sentence=re.sub(r"<[^>]+>", "", keyguide.check_sentence(c, cfg)),
|
||||||
|
identity=sp.check_identity(c)) for c in t.get("checks", [])]))
|
||||||
|
rp = cfg.get("restart_page")
|
||||||
|
keys = []
|
||||||
|
if rp:
|
||||||
|
for k in rp["keys"]:
|
||||||
|
keys.append(dict(k, holding=re.sub(r"<[^>]+>", "", keyguide.fix_words(k["fix"], cfg))))
|
||||||
|
return {"page": cfg["page"], "page_name": cfg["page_name"], "tiles": tiles, "restart_page": dict(rp, keys=keys) if rp else None,
|
||||||
|
"hold": schema.phrases(cfg)["hold"], "grid": cfg.get("page_grid", schema.F["page_grid"]["default"])}
|
||||||
|
|
||||||
|
def history_view(self):
|
||||||
|
rows = []
|
||||||
|
for path in sorted(glob.glob(os.path.join(self.data_dir, "config.json.bak-*")), reverse=True):
|
||||||
|
m = re.search(r"bak-(\d{8})-(\d{6})$", path)
|
||||||
|
rows.append({"file": os.path.basename(path), "when": "%s-%s-%s %s:%s:%s" % (m.group(1)[:4], m.group(1)[4:6], m.group(1)[6:],
|
||||||
|
m.group(2)[:2], m.group(2)[2:4], m.group(2)[4:]) if m else "?",
|
||||||
|
"size": os.path.getsize(path)})
|
||||||
|
return {"entries": rows, "note": "Full history (who, what, Go back) arrives with step 2; these are the installer's copies."}
|
||||||
|
|
||||||
|
# --- editing code (the gate; nothing to gate yet in step 1) --------------
|
||||||
|
|
||||||
|
def check_code(self, ip, code):
|
||||||
|
count, first = self.attempts.get(ip, (0, 0))
|
||||||
|
now = time.time()
|
||||||
|
if count >= 5 and now - first < 600:
|
||||||
|
return {"ok": False, "locked_for": int(600 - (now - first))}
|
||||||
|
wanted = (self.cfg.get("site") or {}).get("editing_code") or ""
|
||||||
|
if wanted and code and code.strip().lower() == wanted.strip().lower():
|
||||||
|
self.attempts.pop(ip, None)
|
||||||
|
return {"ok": True}
|
||||||
|
if now - first >= 600:
|
||||||
|
count, first = 0, now
|
||||||
|
self.attempts[ip] = (count + 1, first)
|
||||||
|
return {"ok": False, "tries_left": max(0, 5 - count - 1), "no_code": not wanted}
|
||||||
|
|
||||||
|
|
||||||
|
def cfg_stale(cfg):
|
||||||
|
return cfg.get("page_stale_seconds", 15)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
site = None # set by serve()
|
||||||
|
server_version = "status-settings"
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
log.info("%s %s", self.client_address[0], fmt % args)
|
||||||
|
|
||||||
|
def send(self, status, body, ctype="application/json; charset=utf-8"):
|
||||||
|
data = body if isinstance(body, bytes) else (json.dumps(body, default=str) if ctype.startswith("application/json") else body).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", ctype)
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if not allowed_client(self.client_address[0]):
|
||||||
|
return self.send(403, {"error": "only from the church network"})
|
||||||
|
url = urllib.parse.urlparse(self.path)
|
||||||
|
q = urllib.parse.parse_qs(url.query)
|
||||||
|
path = url.path
|
||||||
|
site = self.site
|
||||||
|
try:
|
||||||
|
if path in ("/", "/index.html"):
|
||||||
|
return self.static("index.html", "text/html; charset=utf-8")
|
||||||
|
if path in ("/app.js", "/style.css"):
|
||||||
|
return self.static(path[1:], "text/javascript; charset=utf-8" if path.endswith(".js") else "text/css; charset=utf-8")
|
||||||
|
if path == "/key-guide.html":
|
||||||
|
return self.send(200, keyguide.key_guide(site.load()), "text/html; charset=utf-8")
|
||||||
|
if path == "/cheat.html":
|
||||||
|
return self.send(200, keyguide.cheat_sheet(site.load(), self.my_address()), "text/html; charset=utf-8")
|
||||||
|
if path == "/where.html":
|
||||||
|
return self.send(200, keyguide.where_things_are(site.load(), site.data_dir, self.my_address()), "text/html; charset=utf-8")
|
||||||
|
if path == "/api/config":
|
||||||
|
cfg = site.load()
|
||||||
|
return self.send(200, {"config": schema.mask_secrets(cfg), "sha": site.cfg_sha, "mtime": site.cfg_mtime})
|
||||||
|
if path == "/api/schema":
|
||||||
|
return self.send(200, {"fields": schema.F, "checks": schema.CHECKS, "fix_kinds": list(schema.FIX_KINDS),
|
||||||
|
"fix_kind_fields": schema.FIX_KIND_FIELDS, "glossary": schema.GLOSSARY})
|
||||||
|
if path == "/api/overview":
|
||||||
|
return self.send(200, site.overview())
|
||||||
|
if path == "/api/companion":
|
||||||
|
return self.send(200, site.companion_view())
|
||||||
|
if path == "/api/machines":
|
||||||
|
return self.send(200, site.machines_view())
|
||||||
|
if path == "/api/gear":
|
||||||
|
return self.send(200, site.gear_view())
|
||||||
|
if path == "/api/keys":
|
||||||
|
return self.send(200, site.keys_view())
|
||||||
|
if path == "/api/history":
|
||||||
|
return self.send(200, site.history_view())
|
||||||
|
if path == "/api/facts":
|
||||||
|
return self.send(200, {"facts": COMPANION_FACTS})
|
||||||
|
if path == "/api/snapshot":
|
||||||
|
return self.send(200, site.read_json("snapshot.json", {}) or {})
|
||||||
|
if path.startswith("/api/logs/"):
|
||||||
|
name = path[len("/api/logs/"):]
|
||||||
|
if name == "autofix":
|
||||||
|
folder = os.path.expanduser((site.cfg.get("auto_fix") or {}).get("log_dir", "~/Desktop/Auto-Fix Logs"))
|
||||||
|
files = sorted(f for f in (os.listdir(folder) if os.path.isdir(folder) else []) if sp.AutoLog.NAME.match(f))
|
||||||
|
want = q.get("file", [files[-1] if files else ""])[0]
|
||||||
|
if want and want in files:
|
||||||
|
return self.send(200, {"files": files, "file": want, "lines": tail(os.path.join(folder, want), 1000)})
|
||||||
|
return self.send(200, {"files": files, "file": None, "lines": []})
|
||||||
|
if name not in LOGS:
|
||||||
|
return self.send(404, {"error": "no such log"})
|
||||||
|
flt = q.get("filter", [""])[0]
|
||||||
|
return self.send(200, {"lines": tail(LOGS[name], int(q.get("lines", ["400"])[0]), LOG_FILTERS.get(flt))})
|
||||||
|
return self.send(404, {"error": "not found"})
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("GET %s", path)
|
||||||
|
return self.send(500, {"error": str(e)})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if not allowed_client(self.client_address[0]):
|
||||||
|
return self.send(403, {"error": "only from the church network"})
|
||||||
|
path = urllib.parse.urlparse(self.path).path
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = {}
|
||||||
|
if length:
|
||||||
|
try:
|
||||||
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
except ValueError:
|
||||||
|
return self.send(400, {"error": "bad JSON"})
|
||||||
|
host = (self.headers.get("Host") or "").split(":")[0]
|
||||||
|
origin = self.headers.get("Origin")
|
||||||
|
if origin and urllib.parse.urlparse(origin).hostname not in (host, "127.0.0.1", "localhost"):
|
||||||
|
return self.send(403, {"error": "wrong origin"})
|
||||||
|
site = self.site
|
||||||
|
try:
|
||||||
|
if path == "/api/machines/probe":
|
||||||
|
if site.read_json("fixes-running.json", {}):
|
||||||
|
return self.send(409, {"error": "a fix is running; try again in a minute"})
|
||||||
|
with site.lock:
|
||||||
|
if site.probing and site.probing.is_alive():
|
||||||
|
return self.send(200, {"started": False, "already": True})
|
||||||
|
only = body.get("only")
|
||||||
|
site.probing = threading.Thread(target=probes.probe_all, args=(site.load(), site.data_dir, only), daemon=True)
|
||||||
|
site.probing.start()
|
||||||
|
return self.send(200, {"started": True})
|
||||||
|
if path == "/api/editing/check":
|
||||||
|
return self.send(200, site.check_code(self.client_address[0], str(body.get("code", ""))))
|
||||||
|
return self.send(404, {"error": "not found"})
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("POST %s", path)
|
||||||
|
return self.send(500, {"error": str(e)})
|
||||||
|
|
||||||
|
def static(self, name, ctype):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(UI, name), "rb") as f:
|
||||||
|
return self.send(200, f.read(), ctype)
|
||||||
|
except OSError:
|
||||||
|
return self.send(404, {"error": "missing %s" % name})
|
||||||
|
|
||||||
|
def my_address(self):
|
||||||
|
cfg = self.site.cfg
|
||||||
|
host = cfg.get("site", {}).get("ingest_hostname") or (self.headers.get("Host") or "").split(":")[0] or "127.0.0.1"
|
||||||
|
return "http://%s:%d/" % (host, self.server.server_address[1])
|
||||||
|
|
||||||
|
|
||||||
|
class Server(http.server.ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
|
def serve(data_dir, port, binds=None, config_path=None):
|
||||||
|
site = Site(data_dir, config_path)
|
||||||
|
Handler.site = site
|
||||||
|
binds = binds or bind_addresses(site.cfg)
|
||||||
|
servers = []
|
||||||
|
for addr in binds:
|
||||||
|
try:
|
||||||
|
srv = Server((addr, port), Handler)
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("cannot listen on %s:%d: %s", addr, port, e)
|
||||||
|
continue
|
||||||
|
threading.Thread(target=srv.serve_forever, name="http-" + addr, daemon=True).start()
|
||||||
|
servers.append(srv)
|
||||||
|
log.info("listening on http://%s:%d/", addr, port)
|
||||||
|
if not servers:
|
||||||
|
raise SystemExit("could not listen anywhere")
|
||||||
|
return site, servers
|
||||||
|
|
||||||
|
|
||||||
|
def rebind_loop(site, servers, port, stop):
|
||||||
|
"""Every minute: if a LAN port's address changed, listen there too (DHCP, a cable moved)."""
|
||||||
|
have = {s.server_address[0] for s in servers}
|
||||||
|
while not stop.wait(60):
|
||||||
|
try:
|
||||||
|
for addr in bind_addresses(site.load()):
|
||||||
|
if addr not in have:
|
||||||
|
srv = Server((addr, port), Handler)
|
||||||
|
threading.Thread(target=srv.serve_forever, name="http-" + addr, daemon=True).start()
|
||||||
|
servers.append(srv)
|
||||||
|
have.add(addr)
|
||||||
|
log.info("now also listening on http://%s:%d/", addr, port)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("rebind: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("--data-dir", default=PARENT)
|
||||||
|
ap.add_argument("--config")
|
||||||
|
ap.add_argument("--port", type=int)
|
||||||
|
ap.add_argument("--bind", nargs="*")
|
||||||
|
ap.add_argument("--log", default=os.path.expanduser("~/Library/Logs/status-settings.log"))
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
handler = logging.handlers.RotatingFileHandler(args.log, maxBytes=1_000_000, backupCount=2)
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
|
log.addHandler(handler)
|
||||||
|
if sys.stderr.isatty():
|
||||||
|
log.addHandler(logging.StreamHandler())
|
||||||
|
log.setLevel(logging.INFO)
|
||||||
|
cfg = sp.load_config(args.config or os.path.join(args.data_dir, "config.json"))
|
||||||
|
port = args.port or cfg.get("site", {}).get("settings_port", 8020)
|
||||||
|
site, servers = serve(args.data_dir, port, args.bind, args.config)
|
||||||
|
stop = threading.Event()
|
||||||
|
threading.Thread(target=rebind_loop, args=(site, servers, port, stop), daemon=True).start()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(3600)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/* The settings page, step 1: every screen, read-only. Plain JS, no framework. */
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
const main = document.getElementById("main");
|
||||||
|
const state = { config: null, schema: null, keys: null, editing: null, name: null };
|
||||||
|
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
const api = async (path, opts) => {
|
||||||
|
const r = await fetch(path, opts);
|
||||||
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText);
|
||||||
|
return r.json();
|
||||||
|
};
|
||||||
|
const post = (path, body) => api(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) });
|
||||||
|
const ago = (s) => s == null ? "" : s < 90 ? Math.round(s) + " s ago" : s < 5400 ? Math.round(s / 60) + " min ago" : Math.round(s / 3600) + " h ago";
|
||||||
|
const when = (t) => t ? new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) : "";
|
||||||
|
const hex = (n) => "#" + (n >>> 0).toString(16).padStart(6, "0").slice(-6);
|
||||||
|
const lightDot = (l) => `<i class="light ${esc(l || "unknown")}"></i>`;
|
||||||
|
const wordOf = (w) => `<span class="word ${esc((w || "").split(" ")[0])}">${esc(w)}</span>`;
|
||||||
|
|
||||||
|
// ---- top bar -------------------------------------------------------------
|
||||||
|
document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; };
|
||||||
|
const editbtn = document.getElementById("editbtn");
|
||||||
|
editbtn.onclick = () => {
|
||||||
|
if (state.editing) { state.editing = null; editbtn.textContent = "Editing: off"; editbtn.classList.remove("on"); return; }
|
||||||
|
dialog(`<h3>Switch Editing on</h3><p class="help">The editing code is in the campus's settings file in the repo; ask Chris. In this step nothing can be changed yet: the switch only proves the code works from here.</p>
|
||||||
|
<input id="dcode" placeholder="editing code" autocomplete="off"><input id="dname" placeholder="your first name" autocomplete="off">
|
||||||
|
<div class="row"><button class="primary" id="dok">Switch on</button><button id="dcancel">Cancel</button></div><p id="dmsg" class="help"></p>`, (box) => {
|
||||||
|
box.querySelector("#dcancel").onclick = closeDialog;
|
||||||
|
box.querySelector("#dok").onclick = async () => {
|
||||||
|
const code = box.querySelector("#dcode").value, name = box.querySelector("#dname").value.trim();
|
||||||
|
const r = await post("/api/editing/check", { code });
|
||||||
|
if (r.ok && name) { state.editing = code; state.name = name; editbtn.textContent = "Editing as " + name; editbtn.classList.add("on"); closeDialog(); }
|
||||||
|
else box.querySelector("#dmsg").textContent = r.locked_for ? `Locked for ${Math.ceil(r.locked_for / 60)} minutes after too many tries.` : r.no_code ? "No editing code is set in the config yet." : !name ? "Your first name, please." : `Not the code (${r.tries_left} tries left).`;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
function dialog(html, wire) { const d = document.getElementById("dialog"); d.innerHTML = `<div class="box">${html}</div>`; d.hidden = false; wire(d.querySelector(".box")); }
|
||||||
|
function closeDialog() { document.getElementById("dialog").hidden = true; }
|
||||||
|
|
||||||
|
// ---- search --------------------------------------------------------------
|
||||||
|
const search = document.getElementById("search"), results = document.getElementById("searchresults");
|
||||||
|
search.oninput = () => {
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
if (!q || !state.config) { results.hidden = true; return; }
|
||||||
|
const hits = [];
|
||||||
|
const cfg = state.config;
|
||||||
|
for (const t of cfg.tiles) if ((t.label + " " + t.id).toLowerCase().includes(q)) hits.push([`Key ${t.label}`, `#keys/${t.id}`]);
|
||||||
|
for (const k of (cfg.restart_page || { keys: [] }).keys) if (k.label.toLowerCase().includes(q)) hits.push([`Restart key ${k.label}`, `#keys/restart/${k.id}`]);
|
||||||
|
for (const [a, h] of Object.entries(cfg.hosts || {})) if ((h.name || a).toLowerCase().includes(q) || (h.role || "").toLowerCase().includes(q)) hits.push([`Computer ${h.name || a}`, `#computers/${a}`]);
|
||||||
|
for (const [id, f] of Object.entries(cfg.fixes || {})) if ((id + " " + (f.label || "") + " " + (f.short || "")).toLowerCase().includes(q)) hits.push([`Fix ${f.short || id}: ${f.label || ""}`, `#fixes/${id}`]);
|
||||||
|
for (const [path, f] of Object.entries(state.schema ? state.schema.fields : {})) if (!path.includes("[]") && !path.includes("*") && (path + " " + f.help).toLowerCase().includes(q)) hits.push([`Setting ${path}`, `#site/${f.screen.toLowerCase().replace(/[^a-z]+/g, "-")}`]);
|
||||||
|
results.innerHTML = hits.slice(0, 20).map(([t, h]) => `<a href="${h}">${esc(t)}</a>`).join("") || `<a>Nothing called "${esc(q)}"</a>`;
|
||||||
|
results.hidden = false;
|
||||||
|
};
|
||||||
|
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
|
||||||
|
|
||||||
|
// ---- routing -------------------------------------------------------------
|
||||||
|
const screens = { overview, keys, computers, logs, history, help, site, fixes };
|
||||||
|
let timer = null;
|
||||||
|
async function route() {
|
||||||
|
const [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
|
||||||
|
for (const a of document.querySelectorAll(".menu a")) a.classList.toggle("active", a.getAttribute("href") === "#" + name);
|
||||||
|
clearInterval(timer); timer = null;
|
||||||
|
if (!state.config) {
|
||||||
|
const [c, s] = await Promise.all([api("/api/config"), api("/api/schema")]);
|
||||||
|
state.config = c.config; state.schema = s;
|
||||||
|
document.getElementById("sitename").textContent = (state.config.site || {}).name || "";
|
||||||
|
}
|
||||||
|
const fn = screens[name] || overview;
|
||||||
|
try { await fn(rest); } catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
|
||||||
|
if (name === "overview" || name === "computers") timer = setInterval(() => fn(rest, true).catch(() => {}), 5000);
|
||||||
|
}
|
||||||
|
window.addEventListener("hashchange", route);
|
||||||
|
route();
|
||||||
|
|
||||||
|
// ---- Overview ------------------------------------------------------------
|
||||||
|
async function overview(_, quiet) {
|
||||||
|
const o = await api("/api/overview");
|
||||||
|
const dot = document.getElementById("hbdot");
|
||||||
|
dot.className = "dot " + (o.heartbeat_age != null && o.heartbeat_age < 15 ? "ok" : "bad");
|
||||||
|
const prob = o.problems.length ? o.problems.map((p) => `<li><span class="lvl" style="background:${p.level === "err" ? "var(--problem)" : "var(--warn)"}"></span><b>${esc(p.text)}</b>${p.acked ? ' <span class="muted">(acknowledged)</span>' : ""} <span class="muted">— ${esc(p.action)}</span> <a href="#keys/${esc(p.key)}" class="small">open the key</a></li>`).join("") : `<li class="muted">Everything is fine.</li>`;
|
||||||
|
const needs = o.needs.length ? o.needs.map((n) => `<div class="notice grey">Needs Chris: ${esc(n)}</div>`).join("") : "";
|
||||||
|
const live = o.live && o.live.live ? `<div class="notice">Live now: ${esc(o.live.reason)}. Changes to the panel wait.</div>` : "";
|
||||||
|
const comps = o.computers.map((c) => `<tr class="click" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}</td><td class="muted small">${esc(c.reason)}</td></tr>`).join("");
|
||||||
|
const w = o.watchdog;
|
||||||
|
main.innerHTML = `
|
||||||
|
<h1>${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") + " in the room" : "Everything is fine"}</h1>
|
||||||
|
<p class="what">Is the room OK, and what do I do. Problems mirror the red keys; hold the key, or open it for the details.</p>
|
||||||
|
<ul class="problems">${prob}</ul>${needs}${live}
|
||||||
|
<div class="cols">
|
||||||
|
<div><h2>Live deck <span class="muted small">${o.deck ? "as the Stream Deck looks now · click a key" : "(Companion is not answering)"}</span></h2>${deckHtml(o.deck, state.config, "status")}
|
||||||
|
<div class="row" style="margin-top:10px"><button disabled title="step 4">Change the playlist</button><button disabled title="step 4">Add or remove a HyperDeck</button><button disabled title="step 4">Add a MIDI link</button></div></div>
|
||||||
|
<div><h2>Computers</h2><div class="tablewrap"><table>${comps}</table></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Watchdog</dt><dd>${w.running ? "OK, running" : "<b>not running</b>"}${w.since ? " since " + esc(when(w.since)) : ""}${w.version ? ` <span class="pill">code ${esc(w.version)}</span>` : ""}${o.heartbeat_age != null ? ` · heartbeat ${esc(ago(o.heartbeat_age))}` : ""}</dd>
|
||||||
|
<dt>Companion</dt><dd>${o.companion_ok ? "OK" : "<b>not answering</b>"}${o.pages.status ? (o.pages.status.ok ? ", pages OK" : ", page " + o.pages.status.page + " is called " + esc(o.pages.status.live || "?")) : ""}</dd>
|
||||||
|
<dt>Alarms</dt><dd>${o.service_on ? "ON" : "silenced until midnight"}${o.alarms_override != null ? " (by hand)" : ""} <span class="muted small">· ${esc(o.phrases.alarms)}</span></dd>
|
||||||
|
<dt>Auto-fix</dt><dd>${esc(o.auto_fix.phrase)}${o.auto_fix.state && o.auto_fix.state.on ? " · <b>may act now</b>" : ""}${o.auto_fix.state && o.auto_fix.state.paused ? " (waiting out a network blip)" : ""}</dd>
|
||||||
|
<dt>Running now</dt><dd>${Object.keys(o.running || {}).length ? Object.values(o.running).map((r) => esc(r.what + " on " + r.key)).join(", ") : "no fix is running"}</dd>
|
||||||
|
<dt>Last change</dt><dd>${o.last_change ? esc(o.last_change.when + " · " + o.last_change.note) : "—"} <a href="#history" class="small">History</a></dd>
|
||||||
|
</dl>
|
||||||
|
<div class="row" style="margin-top:10px"><button disabled title="step 2">Restart the watchdog</button><button disabled title="step 2">Silence alarms until midnight</button></div>
|
||||||
|
</div>`;
|
||||||
|
if (!quiet) window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deckHtml(deck, cfg, which, selected) {
|
||||||
|
const cells = {};
|
||||||
|
const rp = cfg.restart_page;
|
||||||
|
if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; }
|
||||||
|
else { for (const t of cfg.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
|
||||||
|
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : cfg.page), "2,0": "DOWN" };
|
||||||
|
let out = `<div class="deck">`;
|
||||||
|
for (let r = 0; r < 4; r++) for (let c = 0; c < 8; c++) {
|
||||||
|
const k = `${r},${c}`, cell = cells[k];
|
||||||
|
if (nav[k]) out += `<div class="key nav">${esc(nav[k])}</div>`;
|
||||||
|
else if (cell && cell.nav) out += `<div class="key nav">${esc(cell.nav)}</div>`;
|
||||||
|
else if (cell) {
|
||||||
|
const s = deck && deck[cell.id];
|
||||||
|
const style = s ? `--b1:${hex(s.b)};--f1:${hex(s.f)};--b2:${hex(s.b2)};--f2:${hex(s.f2)};background:${hex(s.b)};color:${hex(s.f)}` : "background:#444;color:#ddd";
|
||||||
|
const blink = s && s.b !== s.b2 ? " blink" : "";
|
||||||
|
out += `<div class="key${blink}${selected === cell.id ? " sel" : ""}" style="${style}" onclick="location.hash='#keys/${which === "restart" ? "restart/" : ""}${esc(cell.id)}'">${esc(s ? s.x : cell.label)}</div>`;
|
||||||
|
} else out += `<div class="key empty"></div>`;
|
||||||
|
}
|
||||||
|
return out + `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Keys ----------------------------------------------------------------
|
||||||
|
async function keys(rest) {
|
||||||
|
const k = state.keys || (state.keys = await api("/api/keys"));
|
||||||
|
const o = await api("/api/overview").catch(() => ({ deck: null }));
|
||||||
|
const restart = rest[0] === "restart";
|
||||||
|
const sel = restart ? rest[1] : rest[0];
|
||||||
|
const tabs = `<div class="tabs"><button class="${restart ? "" : "on"}" onclick="location.hash='#keys'">Status page (page ${k.page})</button>${k.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page (page ${k.restart_page.page})</button>` : ""}</div>`;
|
||||||
|
let panel = `<p class="muted">Click a key to see what it watches and what holding it does.</p>`;
|
||||||
|
if (sel && !restart) {
|
||||||
|
const t = k.tiles.find((x) => x.id === sel);
|
||||||
|
if (t) panel = keyPanel(t, k);
|
||||||
|
} else if (sel && restart) {
|
||||||
|
const r = k.restart_page.keys.find((x) => x.id === sel);
|
||||||
|
if (r) panel = `<div class="card"><h2>${esc(r.label)}</h2><p class="muted">row ${r.pos[0]}, column ${r.pos[1]}</p><p><b>HOLDING ${esc(k.hold.replace("HOLD ", ""))} WILL:</b> ${esc(r.holding)}, even when the Status page says it is fine.</p>${r.watch ? `<p class="small muted">Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}</p>` : ""}<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd><dt>fix</dt><dd class="mono">${esc(r.fix)}</dd></dl></details><p class="help">Editing arrives with step 2.</p></div>`;
|
||||||
|
}
|
||||||
|
const tray = k.tiles.filter((t) => !t.pos && t.kind !== "master").map((t) => `<button onclick="location.hash='#keys/${esc(t.id)}'">${esc(t.label)}</button>`).join(" ");
|
||||||
|
main.innerHTML = `<h1>Keys</h1><p class="what">The deck as people hold it. In this step you can look; moving and editing keys arrive with steps 2 and 3.</p>${tabs}
|
||||||
|
<div class="cols"><div>${deckHtml(o.deck, state.config, restart ? "restart" : "status", sel)}${!restart && tray ? `<p class="small muted" style="margin-top:8px">Shown inside their group, no key of their own: ${tray}</p>` : ""}</div><div>${panel}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyPanel(t, k) {
|
||||||
|
const cfg = state.config;
|
||||||
|
const checks = (t.checks || []).map((c, i) => `<li>${esc(c.sentence)} <details><summary>Advanced</summary><dl class="kv small">${Object.entries(c).filter(([n]) => !["sentence", "identity"].includes(n)).map(([n, v]) => `<dt>${esc(n)}</dt><dd class="mono">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details></li>`).join("");
|
||||||
|
const members = t.members ? `<p><b>Sums up:</b> ${t.members.map((m) => `<a href="#keys/${esc(m)}">${esc((cfg.tiles.find((x) => x.id === m) || {}).label || m)}</a>`).join(", ")}</p>` : "";
|
||||||
|
return `<div class="card"><h2>${esc(t.label)}</h2><p class="muted">${t.pos ? `row ${t.pos[0]}, column ${t.pos[1]}` : "inside its group"}${t.kind ? ` · ${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}` : ""}</p>
|
||||||
|
${t.says && t.says.length ? `<p><b>WHAT THIS KEY WILL SAY:</b> <span class="mono">${esc(t.says.join(", "))}</span></p>` : ""}
|
||||||
|
<p><b>HOLDING ${esc(k.hold.replace("HOLD ", ""))} WILL:</b> ${esc(t.holding)}${t.hold_always ? " <span class='pill'>also when green</span>" : ""}</p>
|
||||||
|
${members}${checks ? `<p><b>Checks:</b></p><ol>${checks}</ol>` : ""}
|
||||||
|
<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(t.id)}</dd></dl></details><p class="help">Editing arrives with step 2.</p></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Computers -----------------------------------------------------------
|
||||||
|
let compTab = "map";
|
||||||
|
async function computers(rest, quiet) {
|
||||||
|
const m = await api("/api/machines");
|
||||||
|
const selected = rest[0] && rest[0] !== "gear" && rest[0] !== "setup" ? rest[0] : null;
|
||||||
|
if (rest[0] === "gear" || rest[0] === "setup") compTab = rest[0]; else if (!quiet || !compTab) compTab = selected ? "map" : compTab || "map";
|
||||||
|
const tabs = `<div class="tabs"><button class="${compTab === "map" ? "on" : ""}" onclick="location.hash='#computers'">Map</button><button class="${compTab === "gear" ? "on" : ""}" onclick="location.hash='#computers/gear'">Gear</button><button class="${compTab === "setup" ? "on" : ""}" onclick="location.hash='#computers/setup'">Setup</button>
|
||||||
|
<button id="probe" ${m.probing ? "disabled" : ""}>${m.probing ? "Looking…" : "Check computers now"}</button></div>`;
|
||||||
|
let body = "";
|
||||||
|
if (compTab === "gear") body = await gearHtml();
|
||||||
|
else if (compTab === "setup") body = setupHtml(m);
|
||||||
|
else {
|
||||||
|
const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `<tr class="click${selected === c.alias ? " sel" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}${c.self ? ' <span class="muted small">(this Mac)</span>' : ""}</td><td class="small">${esc(c.role)}</td><td class="small">${c.keys.map((k) => `<span class="pill">${esc((state.config.tiles.find((t) => t.id === k) || { label: k }).label)}</span>`).join(" ")}</td><td class="muted small">${esc(c.reason)}${c.looked ? `<br>looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}</td></tr>`).join("");
|
||||||
|
body = `<div class="tablewrap"><table><tr><th>status</th><th>computer</th><th>what it does for the panel</th><th>keys that turn red if it dies</th><th></th></tr>${rows}</table></div>`;
|
||||||
|
const c = m.computers.find((x) => x.alias === selected);
|
||||||
|
if (c) body += computerCard(c);
|
||||||
|
}
|
||||||
|
main.innerHTML = `<h1>Computers</h1><p class="what">What runs where. One row per computer; click it for everything that runs there, with a light each.</p>${tabs}${body}`;
|
||||||
|
const pb = document.getElementById("probe");
|
||||||
|
if (pb) pb.onclick = async () => { pb.disabled = true; pb.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => computers(rest, true), 4000); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computerCard(c) {
|
||||||
|
const items = c.items.map((i) => `<tr><td>${lightDot(i.light)}${esc(i.light === "ok" ? "OK" : i.light === "problem" ? "Problem" : i.light === "not_needed" ? "Not needed" : "Can't see from here")}</td><td>${esc(i.name)}${i.watched === false ? ' <span class="muted small">(not watched)</span>' : ""}</td><td class="muted small">${esc(i.reason || i.detail || (i.detail && JSON.stringify(i.detail)) || "")}</td></tr>`).join("");
|
||||||
|
const found = c.found || {};
|
||||||
|
const foundHtml = Object.entries(found).filter(([, v]) => Array.isArray(v) && v.length).map(([k, v]) => `<p class="small"><b>${esc(k)}:</b> <span class="mono">${esc(typeof v[0] === "object" ? v.map((x) => x.iface + " " + x.ip + (x.active ? "" : " (down)")).join(", ") : v.join(", "))}</span></p>`).join("") || `<p class="muted small">Press "Check computers now" to look.</p>`;
|
||||||
|
const done = Object.entries(c.manual_done || {}).map(([k, v]) => `<li>${lightDot("hand")}${esc(k.replace(/_/g, " "))}: done by hand (${esc(v.by)}, ${esc(v.on)})</li>`).join("");
|
||||||
|
return `<div class="card" id="card"><h2>${esc(c.name)} ${wordOf(c.word)}</h2><p class="muted">${esc(c.role)}${c.ssh ? ` · logs in as <span class="mono">${esc(c.ssh)}</span>` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "<b>no answer</b>"}` : ""}${c.error ? ` · <b>${esc(c.error)}</b>` : ""}</p>
|
||||||
|
${c.notes ? `<p class="notice grey">${esc(c.notes)}</p>` : ""}
|
||||||
|
<h3>What runs here</h3><div class="tablewrap"><table>${items}</table></div>
|
||||||
|
${done ? `<ul class="small" style="list-style:none;padding:0">${done}</ul>` : ""}
|
||||||
|
<details><summary>Everything found on this computer</summary>${foundHtml}</details>
|
||||||
|
${c.self ? `<details><summary>This Mac's set-up lights</summary><dl class="kv small">${Object.entries(c.setup || {}).map(([k, v]) => `<dt>${esc(k.replace(/_/g, " "))}</dt><dd>${typeof v === "boolean" ? lightDot(v ? "ok" : "problem") + (v ? "OK" : "Problem") : esc(JSON.stringify(v))}</dd>`).join("")}</dl></details>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gearHtml() {
|
||||||
|
const g = await api("/api/gear");
|
||||||
|
return `<p class="small muted">Devices that are not computers, from the keys that watch them. Where each one lives is typed once on this list (step 2).</p><div class="tablewrap"><table><tr><th>light</th><th>device</th><th>kind</th><th>address</th><th>where / notes</th><th>watched by</th></tr>
|
||||||
|
${g.map((r) => `<tr><td>${lightDot(r.light)}${esc(r.light === "ok" ? "OK" : r.light === "problem" ? "Problem" : "–")}</td><td>${esc(r.name)}</td><td>${esc(r.kind)}</td><td class="mono small">${esc(r.address || "")}${r.detail ? `<br><span class="muted">${esc(r.detail)}</span>` : ""}</td><td class="small">${esc(r.location || "")} ${esc(r.notes || "")} <span class="muted">${esc(r.reason || "")}</span></td><td class="small">${r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc((state.config.tiles.find((t) => t.id === k) || { label: k }).label)}</a>`).join(", ")}</td></tr>`).join("")}</table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupHtml(m) {
|
||||||
|
return m.computers.map((c) => {
|
||||||
|
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
|
||||||
|
const steps = m.setup_steps[kind];
|
||||||
|
const lightFor = (step) => {
|
||||||
|
const s = c.setup || {};
|
||||||
|
if (/login key/.test(step)) return s.key_ok === true || s.login_key === true ? "ok" : s.key_ok === false || s.login_key === false ? "problem" : "unknown";
|
||||||
|
if (/python/.test(step)) return s.python_ok === true ? "ok" : s.python_ok === false ? "problem" : "unknown";
|
||||||
|
if (/SSH service/.test(step)) return c.reachable ? "ok" : "problem";
|
||||||
|
if (/firewall/.test(step)) return s.firewall_allow ? "ok" : "unknown";
|
||||||
|
if (/wake-on-LAN/.test(step)) return s.wake_on_lan === "Enabled" ? "ok" : s.wake_on_lan ? "problem" : "unknown";
|
||||||
|
if (/Public/.test(step)) return s.profile === "Public" ? "ok" : s.profile ? "problem" : "unknown";
|
||||||
|
if (/DVS helper/.test(step)) { const it = (c.items || []).find((i) => i.kind === "helper_dvs"); return it ? it.light : "not_needed"; }
|
||||||
|
if (/Pro Tools helper/.test(step)) { const it = (c.items || []).find((i) => i.kind === "helper_solo"); return it ? it.light : "not_needed"; }
|
||||||
|
if (/StatusPanel/.test(step)) { const ts = (c.items || []).filter((i) => i.kind === "task"); return ts.length ? (ts.every((i) => i.light === "ok") ? "ok" : "problem") : "unknown"; }
|
||||||
|
if (/rtpMIDI/.test(step)) { const it = (c.items || []).find((i) => i.kind === "service"); return it ? it.light : "not_needed"; }
|
||||||
|
if (/custom variables/.test(step)) return "unknown";
|
||||||
|
if (/Netgear/.test(step)) return s.netgear_keychain === true ? "ok" : s.netgear_keychain === false ? "problem" : "not_needed";
|
||||||
|
if (/Google Drive/.test(step)) { const it = (c.items || []).find((i) => i.kind === "folder"); return it ? it.light : "not_needed"; }
|
||||||
|
if (/watchdog and the settings/.test(step)) { const ok = (c.items || []).filter((i) => i.kind === "launchagent" && /watchdog|settings/.test(i.name)); return ok.length && ok.every((i) => i.light === "ok") ? "ok" : "unknown"; }
|
||||||
|
if (/Wi-Fi/.test(step)) return c.manual_done && c.manual_done.wifi_power ? "hand" : "unknown";
|
||||||
|
if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown";
|
||||||
|
return "unknown";
|
||||||
|
};
|
||||||
|
return `<div class="card"><h3>${esc(c.name)}</h3><ul style="list-style:none;padding:0;margin:0">${steps.map((s) => { const l = lightFor(s); return `<li>${lightDot(l)}${esc(s)} <span class="muted small">${l === "hand" ? "(done by hand)" : l === "unknown" ? "– can't see from here" : ""}</span></li>`; }).join("")}</ul><p class="help">Generated set-up scripts and the [how] steps arrive with step 5.</p></div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Logs ----------------------------------------------------------------
|
||||||
|
let logTab = "watchdog", logFilter = "";
|
||||||
|
async function logs(rest) {
|
||||||
|
logTab = rest[0] || logTab;
|
||||||
|
const tabs = ["watchdog", "errors", "autofix", "settings"].map((n) => `<button class="${logTab === n ? "on" : ""}" onclick="location.hash='#logs/${n}'">${{ watchdog: "Watchdog", errors: "Errors", autofix: "Auto-fix days", settings: "Settings page" }[n]}</button>`).join("");
|
||||||
|
const filters = logTab === "watchdog" ? `<div class="tabs">${["", "faults", "holds", "fixes", "auto", "errors"].map((f) => `<button class="${logFilter === f ? "on" : ""}" data-f="${f}">${f || "everything"}</button>`).join("")}</div>` : "";
|
||||||
|
let data;
|
||||||
|
if (logTab === "autofix") { data = await api("/api/logs/autofix" + (rest[1] ? "?file=" + encodeURIComponent(rest[1]) : "")); }
|
||||||
|
else data = await api(`/api/logs/${logTab}?filter=${encodeURIComponent(logFilter)}`);
|
||||||
|
const files = logTab === "autofix" ? `<p class="small">${(data.files || []).slice().reverse().map((f) => `<a href="#logs/autofix/${encodeURIComponent(f)}" class="${data.file === f ? "pill" : ""}">${esc(f.replace(" Auto-Fix.log", ""))}</a>`).join(" · ") || "no auto-fix days yet"}</p>` : "";
|
||||||
|
main.innerHTML = `<h1>Logs</h1><p class="what">What the watchdog did and saw. Newest lines at the bottom.</p><div class="tabs">${tabs}</div>${filters}${files}<pre class="log">${esc((data.lines || []).join("\n")) || "(empty)"}</pre>`;
|
||||||
|
for (const b of main.querySelectorAll("[data-f]")) b.onclick = () => { logFilter = b.dataset.f; logs(rest); };
|
||||||
|
const pre = main.querySelector("pre.log"); pre.scrollTop = pre.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- History -------------------------------------------------------------
|
||||||
|
async function history() {
|
||||||
|
const h = await api("/api/history");
|
||||||
|
main.innerHTML = `<h1>History</h1><p class="what">Every version of the settings that was ever applied. ${esc(h.note)}</p><div class="tablewrap"><table><tr><th>when</th><th>file</th><th>size</th></tr>${h.entries.map((e) => `<tr><td>${esc(e.when)}</td><td class="mono small">${esc(e.file)}</td><td>${esc(e.size)}</td></tr>`).join("") || "<tr><td colspan=3 class='muted'>nothing yet</td></tr>"}</table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Help ----------------------------------------------------------------
|
||||||
|
async function help(rest) {
|
||||||
|
const facts = await api("/api/facts");
|
||||||
|
const gl = state.schema.glossary;
|
||||||
|
const cards = [
|
||||||
|
["Read a red key", "Green is fine. A flashing red key names the problem. Press it once to acknowledge (it stops flashing, stays dark red). Hold it " + esc(state.schema.fields.page_hold_ms ? "2 s" : "2 s") + " and it runs the fix for what is failing."],
|
||||||
|
["Purple means the watchdog is down", "Every key turns purple when the watchdog on the Ingest Mac has been silent for 15 s. Open this page: if the Overview says Companion is not answering, the problem is Companion; otherwise restart the watchdog (step 2 adds the button; until then: ask Chris)."],
|
||||||
|
["Which computer or device is that?", "Computers › Map shows every computer and the keys that turn red if it dies; Computers › Gear lists the switcher, decks, recorders, wireless and LED gear with their addresses."],
|
||||||
|
["What is a connection?", "Companion's link to one device (a HyperDeck, the Tascam, the switcher). The keys watch those connections; Site setup › Companion lists them all and which key watches each."],
|
||||||
|
].map(([t, b]) => `<div class="card"><h3>${t}</h3><p>${b}</p></div>`).join("");
|
||||||
|
const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `<details open><summary><b>${esc(g)}</b></summary><p class="mono small">${esc(words.join(" · "))}</p></details>`).join("");
|
||||||
|
main.innerHTML = `<h1>Help</h1><p class="what">Start with the four cards. The pages below are made from the settings, so they are never out of date.</p><div class="cards">${cards}</div>
|
||||||
|
<h2>Pages</h2><p><a href="/key-guide.html" target="_blank">What each key does</a> · <a href="/cheat.html" target="_blank">Cheat sheet (one page)</a> · <a href="/where.html" target="_blank">Where things are</a></p>
|
||||||
|
<h2>First-day walkthrough (10 minutes, no code needed)</h2><ol><li>Overview: the live deck. Green, red, purple.</li><li>Click a red key (or any key): read what it checks and what holding it will do.</li><li>Logs › Watchdog, filter "holds": find a hold and what it did.</li><li>Keys › Restart page: each key restarts one thing, even when green.</li><li>Overview: when alarms are on and when auto-fix may act by itself.</li><li>Computers › Map: which computer a key talks to, and what runs there.</li><li>Help › What each key does: the whole deck in words.</li></ol>
|
||||||
|
<h2>The words a key can show</h2>${glossary}
|
||||||
|
<h2>Things only a person can do</h2><ul><li>Run the set-up script on a new computer (ARD on a Mac, PowerShell as administrator on a PC), allow Accessibility on the Studio B Mac, click Allow on the Spotify prompt once.</li><li>Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.</li><li>Type the Netgear login into the keychain; keep Google Drive signed in for the saved switcher files.</li><li>Save a new switcher file from ATEM Software Control when the show changes.</li></ul>
|
||||||
|
<h2>Companion facts (found by trying)</h2><dl class="kv">${facts.facts.map(([q, a]) => `<dt>${esc(q)}</dt><dd>${esc(a)}</dd>`).join("")}</dl>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Site setup (read-only) ----------------------------------------------
|
||||||
|
async function site(rest) {
|
||||||
|
const cfg = state.config, F = state.schema.fields;
|
||||||
|
const sections = { site: "Site", schedules: "Schedules", companion: "Companion", "network-dante": "Network & Dante", spotify: "Spotify", atem: "ATEM" };
|
||||||
|
const which = sections[rest[0]] ? rest[0] : "site";
|
||||||
|
const tabs = Object.entries(sections).map(([k, n]) => `<button class="${which === k ? "on" : ""}" onclick="location.hash='#site/${k}'">${n}</button>`).join("");
|
||||||
|
const screen = sections[which];
|
||||||
|
const get = (path) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), cfg);
|
||||||
|
const fieldHtml = (path, f) => { const v = get(path); return `<div class="field"><label>${esc(path)} <span class="help">${esc(f.help)}</span></label><div class="val">${v === undefined ? `<span class="muted">(default: ${esc(JSON.stringify(f.default))})</span>` : esc(typeof v === "object" ? JSON.stringify(v) : String(v))}</div></div>`; };
|
||||||
|
const own = Object.entries(F).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !["tiles", "fixes", "hosts", "gear"].includes(p));
|
||||||
|
let body = `<div class="card"><h3>Settings</h3>${own.filter(([, f]) => !f.advanced).map(([p, f]) => fieldHtml(p, f)).join("")}<details><summary>Advanced</summary>${own.filter(([, f]) => f.advanced).map(([p, f]) => fieldHtml(p, f)).join("")}</details></div>`;
|
||||||
|
if (which === "companion") {
|
||||||
|
const c = await api("/api/companion");
|
||||||
|
const lights = Object.entries(c.lights).map(([k, v]) => `<li>${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</li>`).join("");
|
||||||
|
const conns = c.connections.map((r) => `<tr><td>${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed")}${esc(r.label)}</td><td class="mono small">${esc(r.module || "")}</td><td class="small">${esc(r.enabled ? r.category || "" : "off")}${r.message ? ` <span class="muted">${esc(r.message)}</span>` : ""}</td><td class="small">${r.watched_by.length ? r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc(k.toUpperCase())}</a>`).join(", ") : '<span class="muted">not watched</span>'}</td></tr>`).join("");
|
||||||
|
body = `<div class="card"><h3>Companion ${c.ok ? "OK" : "<b>not answering</b>"}${c.build ? ` <span class="pill">${esc(c.build)}</span>` : ""}</h3><p class="small muted">Set up once by the person who installed Companion. You normally only come here to see which connection a key watches.</p><ul style="list-style:none;padding:0">${lights}</ul>
|
||||||
|
${c.missing.length ? `<p class="notice">Keys watch connections Companion does not have: ${esc(c.missing.join(", "))}</p>` : ""}
|
||||||
|
<p class="small">Page-number buttons coloured on ${c.page_number_buttons.pages_coloured} pages (${esc(c.page_number_buttons.recorded_in)}).</p>
|
||||||
|
<h3>Connections (live)</h3><div class="tablewrap"><table><tr><th>connection</th><th>module</th><th>status</th><th>watched by</th></tr>${conns}</table></div></div>` + body;
|
||||||
|
}
|
||||||
|
if (which === "network-dante") {
|
||||||
|
const d = (cfg.tiles.flatMap((t) => t.checks || []).find((c) => c.type === "dante") || {}).devices || {};
|
||||||
|
const snap = await api("/api/snapshot");
|
||||||
|
const map = snap.dante_map || {};
|
||||||
|
body += `<div class="card"><h3>Dante device list (the DANTE key's devices)</h3><div class="tablewrap"><table><tr><th>Dante name</th><th>key text</th><th>address</th><th>switch / port</th><th>PoE</th></tr>${Object.entries(d).map(([n, a]) => { const e = map[n] || {}; return `<tr><td class="mono small">${esc(n)}</td><td>${esc(a)}</td><td class="mono small">${esc(e.ip || "")}</td><td class="small">${e.switch ? esc(e.switch + " port " + e.port) : ""}</td><td>${e.poe ? "yes" : ""}</td></tr>`; }).join("")}</table></div><p class="small muted">Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.</p></div>`;
|
||||||
|
}
|
||||||
|
if (which === "schedules") {
|
||||||
|
const ph = await api("/api/overview").then((o) => o.phrases).catch(() => ({}));
|
||||||
|
body = `<div class="notice">${esc(ph.alarms || "")} Auto-fix ${esc(ph.auto_fix || "")}. ${esc(((ph.live || "")[0] || "").toUpperCase() + (ph.live || "").slice(1))}.</div><div class="card"><h3>Fixes auto-fix may run</h3><p>${(cfg.auto_fix.fixes || []).map((f) => `<span class="pill">${esc((cfg.fixes[f] || {}).short || f)}</span>`).join(" ")}</p></div>` + body;
|
||||||
|
}
|
||||||
|
main.innerHTML = `<h1>Site setup</h1><p class="what">Set up once. Each section opens on the few things a person changes; the numbers sit under Advanced. Editing arrives with step 2.</p><div class="tabs">${tabs}</div>${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Fixes ---------------------------------------------------------------
|
||||||
|
async function fixes(rest) {
|
||||||
|
const cfg = state.config, auto = new Set(cfg.auto_fix.fixes || []);
|
||||||
|
const used = {};
|
||||||
|
for (const t of cfg.tiles) for (const c of t.checks || []) { const fx = typeof c.fix === "object" && c.fix ? Object.values(c.fix) : [c.fix]; for (const f of fx) if (f) (used[f] = used[f] || new Set()).add(t.label); if (t.hold_always) (used[t.hold_always] = used[t.hold_always] || new Set()).add(t.label); }
|
||||||
|
for (const k of (cfg.restart_page || { keys: [] }).keys) (used[k.fix] = used[k.fix] || new Set()).add("restart key " + k.label);
|
||||||
|
const rows = Object.entries(cfg.fixes).map(([id, f]) => `<tr class="click${rest[0] === id ? " sel" : ""}" onclick="location.hash='#fixes/${esc(id)}'"><td><b>${esc(f.short || id)}</b></td><td>${esc(f.label || "")}</td><td class="small">${esc(f.kind || "ssh")}${f.host ? " on " + esc((cfg.hosts[f.host] || {}).name || f.host) : f.kind === "local" || f.kind === "spotify" ? " on this Mac" : ""}</td><td class="small">${[...(used[id] || [])].join(", ")}</td><td class="small">${auto.has(id) ? "may run by itself" : f.never_auto ? "never by itself" : "hold only"}</td></tr>`).join("");
|
||||||
|
const f = cfg.fixes[rest[0]];
|
||||||
|
const detail = f ? `<div class="card"><h2>${esc(f.short || rest[0])}</h2><p>${esc(f.label || "")}</p><details open><summary>Advanced</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details><p class="help">Builders (fixes made from fields instead of commands) arrive with step 2.</p></div>` : "";
|
||||||
|
main.innerHTML = `<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(cfg.fixes).length} today.</p><div class="tablewrap"><table><tr><th>key text</th><th>plain words</th><th>built as</th><th>used by</th><th>by itself</th></tr>${rows}</table></div>${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertBox(msg) { dialog(`<p>${esc(msg)}</p><button id="dok" class="primary">OK</button>`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); }
|
||||||
|
})();
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Status Panel</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="top">
|
||||||
|
<div class="brand"><span class="dot" id="hbdot" title="watchdog heartbeat"></span><b>STATUS PANEL</b> <span id="sitename" class="muted"></span></div>
|
||||||
|
<input id="search" type="search" placeholder="find a key, computer, device, fix or setting" aria-label="search" autocomplete="off">
|
||||||
|
<div class="topright">
|
||||||
|
<button class="link" id="legendbtn" title="What the status words mean">?</button>
|
||||||
|
<button id="editbtn" class="toggle">Editing: off</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div id="legend" class="legend" hidden>
|
||||||
|
<b>Status words, the same everywhere:</b>
|
||||||
|
<span><i class="l-ok"></i>OK</span>
|
||||||
|
<span><i class="l-problem"></i>Problem (with the reason)</span>
|
||||||
|
<span><i class="l-unknown"></i>Can't see from here – ask at the computer</span>
|
||||||
|
<span><i class="l-none"></i>Not needed</span>
|
||||||
|
<span><i class="l-hand"></i>Done by hand (who, when)</span>
|
||||||
|
</div>
|
||||||
|
<div id="searchresults" class="searchresults" hidden></div>
|
||||||
|
<div class="shell">
|
||||||
|
<nav class="menu" id="menu">
|
||||||
|
<a href="#overview">Overview</a>
|
||||||
|
<a href="#keys">Keys</a>
|
||||||
|
<a href="#computers">Computers</a>
|
||||||
|
<a href="#logs">Logs</a>
|
||||||
|
<a href="#history">History</a>
|
||||||
|
<a href="#help">Help</a>
|
||||||
|
<div class="divider">Set up once</div>
|
||||||
|
<a href="#site">Site setup</a>
|
||||||
|
<a href="#fixes">Fixes</a>
|
||||||
|
</nav>
|
||||||
|
<main id="main"><p class="muted">Loading…</p></main>
|
||||||
|
</div>
|
||||||
|
<div id="dialog" class="dialog" hidden></div>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/* Layout: a fixed top bar, a narrow menu on the left, the screen on the right; one column on a phone. */
|
||||||
|
:root {
|
||||||
|
--bg: #f4f6f3; --surface: #ffffff; --fg: #1d2420; --muted: #5d6963; --line: #d5dcd6; --accent: #0f6b3a; --accent-soft: #e1f0e7;
|
||||||
|
--deck: #1b1f1d; --ok: #007a2e; --problem: #d8322b; --warn: #e3a000; --unknown: #8b96a0; --none: #c3c9c3; --hand: #2b6cb0; --purple: #6a1b9a;
|
||||||
|
--font: -apple-system, "Helvetica Neue", Arial, sans-serif; --mono: ui-monospace, Menlo, monospace;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
|
||||||
|
--bg: #14181a; --surface: #1d2326; --fg: #e6ebe6; --muted: #9aa69e; --line: #333c3f; --accent: #5fcf8a; --accent-soft: #1f3a2b;
|
||||||
|
--deck: #0c0e0d; --ok: #3fbf6f; --problem: #ff6a5e; --warn: #f0b84a; --unknown: #8b96a0; --none: #4c5553; --hand: #7fb3ef; color-scheme: dark } }
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #14181a; --surface: #1d2326; --fg: #e6ebe6; --muted: #9aa69e; --line: #333c3f; --accent: #5fcf8a; --accent-soft: #1f3a2b;
|
||||||
|
--deck: #0c0e0d; --ok: #3fbf6f; --problem: #ff6a5e; --warn: #f0b84a; --unknown: #8b96a0; --none: #4c5553; --hand: #7fb3ef; color-scheme: dark }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 var(--font); }
|
||||||
|
.top { position: sticky; top: env(safe-area-inset-top, 0px); z-index: 5; display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--surface); border-bottom: 1px solid var(--line); }
|
||||||
|
.brand { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||||
|
.brand b { letter-spacing: .06em; }
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--unknown); display: inline-block; }
|
||||||
|
.dot.ok { background: var(--ok); } .dot.bad { background: var(--purple); }
|
||||||
|
#search { flex: 1; min-width: 0; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--fg); font: inherit; }
|
||||||
|
.topright { display: flex; gap: 8px; align-items: center; }
|
||||||
|
button { font: inherit; cursor: pointer; border: 1px solid var(--line); background: var(--surface); color: var(--fg); border-radius: 8px; padding: 6px 12px; }
|
||||||
|
button:hover { border-color: var(--accent); } button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
button.link { border: 0; background: none; color: var(--accent); padding: 4px 8px; font-weight: 700; }
|
||||||
|
button.toggle { font-weight: 600; } button.toggle.on { background: var(--accent-soft); border-color: var(--accent); }
|
||||||
|
button[disabled] { opacity: .5; cursor: not-allowed; }
|
||||||
|
.legend { padding: 8px 16px; background: var(--accent-soft); border-bottom: 1px solid var(--line); display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: .92rem; }
|
||||||
|
.legend i, .light { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; background: var(--unknown); }
|
||||||
|
.l-ok, .light.ok { background: var(--ok); } .l-problem, .light.problem { background: var(--problem); } .l-unknown, .light.unknown { background: var(--unknown); }
|
||||||
|
.l-none, .light.not_needed { background: var(--none); } .l-hand, .light.hand { background: var(--hand); } .light.warn { background: var(--warn); }
|
||||||
|
.shell { display: grid; grid-template-columns: 170px 1fr; min-height: calc(100vh - 50px); }
|
||||||
|
.menu { padding: 12px 8px; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.menu a { display: block; padding: 8px 10px; border-radius: 8px; color: var(--fg); text-decoration: none; }
|
||||||
|
.menu a.active, .menu a:hover { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.menu .divider { margin: 14px 10px 4px; font-size: .74rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); border-top: 1px solid var(--line); padding-top: 10px; }
|
||||||
|
main { padding: 18px 20px 48px; min-width: 0; max-width: 1200px; }
|
||||||
|
h1 { font-size: 1.5rem; margin: 0 0 4px; } h2 { font-size: 1.15rem; margin: 22px 0 8px; } h3 { font-size: 1rem; margin: 14px 0 6px; }
|
||||||
|
.what { color: var(--muted); margin: 0 0 14px; max-width: 70ch; }
|
||||||
|
.muted { color: var(--muted); } .small { font-size: .88rem; } .mono { font-family: var(--mono); font-size: .88em; }
|
||||||
|
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin: 10px 0; min-width: 0; }
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; }
|
||||||
|
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||||
|
.cols { display: grid; grid-template-columns: 3fr 2fr; gap: 14px; } @media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: .93rem; } th, td { text-align: left; vertical-align: top; padding: 6px 9px; border-bottom: 1px solid var(--line); }
|
||||||
|
th { color: var(--muted); font-weight: 600; font-size: .85rem; } tr.click { cursor: pointer; } tr.click:hover { background: var(--accent-soft); }
|
||||||
|
.tablewrap { overflow-x: auto; }
|
||||||
|
.word { font-weight: 700; } .word.OK { color: var(--ok); } .word.Problem { color: var(--problem); } .word.Not { color: var(--muted); }
|
||||||
|
.problems { list-style: none; padding: 0; margin: 0; } .problems li { padding: 6px 0; border-bottom: 1px dashed var(--line); }
|
||||||
|
.problems .lvl { display: inline-block; min-width: 10px; height: 10px; border-radius: 3px; margin-right: 8px; }
|
||||||
|
.pill { display: inline-block; padding: 1px 8px; border-radius: 999px; background: var(--accent-soft); font-size: .85rem; font-family: var(--mono); }
|
||||||
|
.deck { display: grid; grid-template-columns: repeat(8, 1fr); gap: 5px; background: var(--deck); padding: 8px; border-radius: 12px; max-width: 640px; }
|
||||||
|
.key { aspect-ratio: 1; border-radius: 6px; display: flex; align-items: center; justify-content: center; text-align: center; padding: 3px; font: 600 .68rem/1.1 var(--font); white-space: pre-line; cursor: pointer; border: 2px solid transparent; }
|
||||||
|
.key.sel { border-color: #fff; } .key.empty { background: #2b2f2d; cursor: default; opacity: .5; } .key.nav { background: #37474f; color: #fff; cursor: default; }
|
||||||
|
@keyframes blink { 0%, 49% { background: var(--b1); color: var(--f1); } 50%, 100% { background: var(--b2); color: var(--f2); } }
|
||||||
|
.key.blink { animation: blink 1s steps(1) infinite; } @media (prefers-reduced-motion: reduce) { .key.blink { animation: none; } }
|
||||||
|
.tabs { display: flex; gap: 6px; margin: 8px 0 12px; flex-wrap: wrap; } .tabs button.on { background: var(--accent-soft); border-color: var(--accent); }
|
||||||
|
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; align-items: baseline; } .kv dt { color: var(--muted); } .kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
details { margin: 6px 0; } summary { cursor: pointer; color: var(--muted); }
|
||||||
|
pre.log { background: var(--deck); color: #d6dbe4; padding: 10px 12px; border-radius: 10px; font: .8rem/1.5 var(--mono); overflow: auto; max-height: 70vh; white-space: pre-wrap; }
|
||||||
|
.field { margin: 6px 0; } .field label { display: block; color: var(--muted); font-size: .85rem; } .field .val { font-family: var(--mono); font-size: .9em; overflow-wrap: anywhere; }
|
||||||
|
.help { color: var(--muted); font-size: .85rem; }
|
||||||
|
.searchresults { position: absolute; left: 190px; right: 16px; top: 46px; z-index: 6; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.18); max-height: 50vh; overflow: auto; }
|
||||||
|
.searchresults a { display: block; padding: 8px 12px; color: var(--fg); text-decoration: none; border-bottom: 1px solid var(--line); } .searchresults a:hover { background: var(--accent-soft); }
|
||||||
|
.dialog { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 9; padding: 16px; }
|
||||||
|
.dialog .box { background: var(--surface); border-radius: 12px; padding: 18px; max-width: 460px; width: 100%; }
|
||||||
|
.dialog input { width: 100%; padding: 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--fg); font: inherit; margin: 8px 0; }
|
||||||
|
.notice { padding: 8px 12px; border-radius: 8px; background: var(--accent-soft); margin: 8px 0; } .notice.grey { background: var(--bg); border: 1px solid var(--line); color: var(--muted); }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.shell { grid-template-columns: 1fr; } .menu { flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.menu .divider { border: 0; margin: 0 6px; padding: 8px 0; } main { padding: 14px 16px 40px; } .searchresults { left: 16px; }
|
||||||
|
.top { flex-wrap: wrap; } #search { order: 3; flex-basis: 100%; }
|
||||||
|
}
|
||||||
+82
-3
@@ -1783,14 +1783,39 @@ def remember_peers(snap, host, sessions):
|
|||||||
|
|
||||||
|
|
||||||
def companion_db_path(cfg):
|
def companion_db_path(cfg):
|
||||||
|
"""Companion's settings database: "db" in config "companion", else the folder of the build the
|
||||||
|
pages are written for ("companion.build", e.g. 5.0.7 -> v5.0), else the newest folder whose
|
||||||
|
database opens (an upgrade that was tried and abandoned leaves a folder with a dead copy)."""
|
||||||
if cfg["companion"].get("db"):
|
if cfg["companion"].get("db"):
|
||||||
return os.path.expanduser(cfg["companion"]["db"])
|
return os.path.expanduser(cfg["companion"]["db"])
|
||||||
root = os.path.expanduser("~/Library/Application Support/companion")
|
root = os.path.expanduser("~/Library/Application Support/companion")
|
||||||
vers = [d for d in os.listdir(root) if re.fullmatch(r"v\d+\.\d+", d)] if os.path.isdir(root) else []
|
vers = [d for d in os.listdir(root) if re.fullmatch(r"v\d+\.\d+", d)] if os.path.isdir(root) else []
|
||||||
if not vers:
|
if not vers:
|
||||||
return None
|
return None
|
||||||
newest = max(vers, key=lambda d: tuple(int(x) for x in d[1:].split(".")))
|
build = str(cfg["companion"].get("build") or "").split("+")[0]
|
||||||
return os.path.join(root, newest, "db.sqlite")
|
wanted = "v" + ".".join(build.split(".")[:2]) if build else None
|
||||||
|
order = sorted(vers, key=lambda d: tuple(int(x) for x in d[1:].split(".")), reverse=True)
|
||||||
|
if wanted in order:
|
||||||
|
order.remove(wanted)
|
||||||
|
order.insert(0, wanted)
|
||||||
|
for d in order:
|
||||||
|
path = os.path.join(root, d, "db.sqlite")
|
||||||
|
if os.path.exists(path) and db_opens(path):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def db_opens(path):
|
||||||
|
import sqlite3
|
||||||
|
try:
|
||||||
|
db = sqlite3.connect("file:%s?mode=ro" % urllib.parse.quote(path), uri=True, timeout=2)
|
||||||
|
try:
|
||||||
|
db.execute("SELECT 1 FROM instances LIMIT 1").fetchall()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return True
|
||||||
|
except sqlite3.Error:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def companion_conn_hosts(path):
|
def companion_conn_hosts(path):
|
||||||
@@ -2628,6 +2653,54 @@ def start_pollers(cfg, snap, stop, data_dir=None):
|
|||||||
return threads
|
return threads
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_summary(snap, engine, now):
|
||||||
|
"""Everything the watchdog knows right now, in plain JSON (the settings page reads it instead of
|
||||||
|
asking the gear again): what Companion reports, pings, MIDI sessions here and on the other Macs,
|
||||||
|
helpers and programs, web checks, Spotify, the switcher, the Dante map, and every key's faults."""
|
||||||
|
out = {
|
||||||
|
"time": now,
|
||||||
|
"conns": {label: {"enabled": c.get("enabled"), "category": c.get("category"), "message": c.get("message")}
|
||||||
|
for label, c in snap.conns.items()},
|
||||||
|
"conns_fresh": snap.conns_fresh(now),
|
||||||
|
"conn_hosts": dict(snap.conn_hosts),
|
||||||
|
"ping": dict(snap.ping),
|
||||||
|
"link": snap.link,
|
||||||
|
"midi": snap.midi if snap.midi_time else None,
|
||||||
|
"remote_midi": {h: v for h, v in snap.remote_midi.items()},
|
||||||
|
"remote_midi_age": {h: round(now - t, 1) for h, t in snap.remote_midi_time.items()},
|
||||||
|
"mdns": sorted(snap.mdns) if snap.mdns_time else None,
|
||||||
|
"launchd": dict(snap.launchd),
|
||||||
|
"procs": dict(snap.procs),
|
||||||
|
"http": dict(snap.http),
|
||||||
|
"disk": dict(snap.disk),
|
||||||
|
"prefs": {"%s/%s" % k: v for k, v in snap.prefs.items()},
|
||||||
|
"remote": {k.replace("\0", "|"): v for k, v in snap.remote.items()},
|
||||||
|
"spotify": snap.spotify,
|
||||||
|
"spotify_known_songs": len(snap.spotify_tracks),
|
||||||
|
"atem": ({"connected": snap.atem.get("connected"), "audio": snap.atem.get("audio"), "camera": snap.atem.get("camera"),
|
||||||
|
"age": round(now - snap.atem.get("time", now), 1)} if snap.atem else None),
|
||||||
|
"dante_map": snap.dante_map,
|
||||||
|
"dante_map_age": round(now - snap.dante_map_time, 1) if snap.dante_map_time else None,
|
||||||
|
"artnet": {src: round(now - t, 1) for src, t in snap.artnet_last.items()},
|
||||||
|
"midi_rx": {name: {"connected": l.connected, "age": None if l.last_time is None else round(now - l.last_time, 1)}
|
||||||
|
for name, l in snap.listeners.items()},
|
||||||
|
"service_on": engine.service.on(now),
|
||||||
|
"alarms_override": engine.service.override,
|
||||||
|
"auto_fix": {"on": engine.auto_on, "paused": engine.auto_paused},
|
||||||
|
"burst": engine.burst and {"start": engine.burst["start"], "conns": sorted(engine.burst["conns"]),
|
||||||
|
"cleared": engine.burst["cleared"]},
|
||||||
|
"live": list(engine.live_now(now)),
|
||||||
|
"faults": {},
|
||||||
|
}
|
||||||
|
for key, tr in engine.trackers.items():
|
||||||
|
if tr.confirmed and key in engine.meta:
|
||||||
|
tile_id, chk, sub = engine.meta[key]
|
||||||
|
out["faults"].setdefault(tile_id, []).append(
|
||||||
|
{"check": check_identity(chk), "type": chk["type"], "sub": sub, "level": tr.result.level,
|
||||||
|
"msg": tr.result.msg, "since": tr.since, "acked": tr.acked})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def post_webhook(url, text):
|
def post_webhook(url, text):
|
||||||
if not url:
|
if not url:
|
||||||
return
|
return
|
||||||
@@ -2712,7 +2785,7 @@ def run(cfg, data_dir, dry_run=False, stop=None, config_sha=None):
|
|||||||
except (OSError, http.client.HTTPException) as e:
|
except (OSError, http.client.HTTPException) as e:
|
||||||
log.warning("could not read the command variable at start: %s", e)
|
log.warning("could not read the command variable at start: %s", e)
|
||||||
stop.wait(2.5) # let the first polls land before judging anything
|
stop.wait(2.5) # let the first polls land before judging anything
|
||||||
last_json, last_write, last_eval = None, 0.0, 0.0
|
last_json, last_write, last_eval, last_snapshot = None, 0.0, 0.0, 0.0
|
||||||
down_since, alerted, warned_missing = None, False, set()
|
down_since, alerted, warned_missing = None, False, set()
|
||||||
log.info("status panel running (code %s, %s)", VERSION, "dry run" if dry_run else "live")
|
log.info("status panel running (code %s, %s)", VERSION, "dry run" if dry_run else "live")
|
||||||
try:
|
try:
|
||||||
@@ -2740,6 +2813,12 @@ def run(cfg, data_dir, dry_run=False, stop=None, config_sha=None):
|
|||||||
for kind, text in engine.events:
|
for kind, text in engine.events:
|
||||||
log.info("%s %s", kind, text)
|
log.info("%s %s", kind, text)
|
||||||
engine.events = []
|
engine.events = []
|
||||||
|
if now - last_snapshot >= 5:
|
||||||
|
last_snapshot = now
|
||||||
|
try:
|
||||||
|
write_json(os.path.join(data_dir, "snapshot.json"), snapshot_summary(snap, engine, now))
|
||||||
|
except (OSError, TypeError, ValueError) as e:
|
||||||
|
log.warning("snapshot.json: %s", e)
|
||||||
js = json.dumps(tiles, separators=(",", ":"), sort_keys=True)
|
js = json.dumps(tiles, separators=(",", ":"), sort_keys=True)
|
||||||
if dry_run:
|
if dry_run:
|
||||||
if js != last_json:
|
if js != last_json:
|
||||||
|
|||||||
@@ -380,6 +380,21 @@ class TestChecks(unittest.TestCase):
|
|||||||
tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, self.now, 4)
|
tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, self.now, 4)
|
||||||
self.assertEqual(tiles["rf"]["x"], "RF\nPSM 3 NO PING")
|
self.assertEqual(tiles["rf"]["x"], "RF\nPSM 3 NO PING")
|
||||||
|
|
||||||
|
def test_companion_db_path_skips_a_dead_newer_folder(self):
|
||||||
|
import sqlite3
|
||||||
|
root = tempfile.mkdtemp(prefix="sp-companion-")
|
||||||
|
for v in ("v5.0", "v5.1"):
|
||||||
|
os.makedirs(os.path.join(root, v))
|
||||||
|
good = os.path.join(root, "v5.0", "db.sqlite")
|
||||||
|
db = sqlite3.connect(good); db.execute("CREATE TABLE instances (id TEXT, value TEXT)"); db.commit(); db.close()
|
||||||
|
open(os.path.join(root, "v5.1", "db.sqlite"), "w").write("not a database") # an abandoned upgrade
|
||||||
|
from unittest import mock
|
||||||
|
real = os.path.expanduser
|
||||||
|
with mock.patch.object(sp.os.path, "expanduser", lambda p: root if "companion" in p else real(p)):
|
||||||
|
self.assertEqual(sp.companion_db_path({"companion": {"build": "5.0.7+9763"}}), good)
|
||||||
|
self.assertEqual(sp.companion_db_path({"companion": {}}), good) # newest that opens
|
||||||
|
self.assertEqual(sp.companion_db_path({"companion": {"db": "/x/y.sqlite"}}), "/x/y.sqlite")
|
||||||
|
|
||||||
def test_companion_conn_hosts_reads_its_database_read_only(self):
|
def test_companion_conn_hosts_reads_its_database_read_only(self):
|
||||||
import sqlite3
|
import sqlite3
|
||||||
d = tempfile.mkdtemp()
|
d = tempfile.mkdtemp()
|
||||||
@@ -1489,6 +1504,18 @@ class TestFixes(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
sp.load_config(path)
|
sp.load_config(path)
|
||||||
|
|
||||||
|
def test_snapshot_summary_is_plain_json(self):
|
||||||
|
snap = self.snap
|
||||||
|
snap.conns["ATEM"]["category"] = "error"
|
||||||
|
run_steps(self.engine, snap, SUNDAY, 5)
|
||||||
|
out = sp.snapshot_summary(snap, self.engine, SUNDAY + 5)
|
||||||
|
json.dumps(out) # nothing in it that JSON cannot hold
|
||||||
|
self.assertEqual(out["conns"]["ATEM"]["category"], "error")
|
||||||
|
self.assertEqual(out["faults"]["atem"][0]["msg"], "OFFLINE")
|
||||||
|
self.assertEqual(out["faults"]["atem"][0]["check"], "ATEM")
|
||||||
|
self.assertEqual(out["live"], [False, ""])
|
||||||
|
self.assertEqual(out["midi"]["NA Ingest"]["peers"][0]["name"], "Lighting PC")
|
||||||
|
|
||||||
def test_every_fix_is_valid(self):
|
def test_every_fix_is_valid(self):
|
||||||
for f in CONFIG["fixes"].values():
|
for f in CONFIG["fixes"].values():
|
||||||
self.assertIn(f.get("kind", "ssh"), sp.FIX_KINDS)
|
self.assertIn(f.get("kind", "ssh"), sp.FIX_KINDS)
|
||||||
|
|||||||
Reference in New Issue
Block a user