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:
@@ -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()
|
||||
Reference in New Issue
Block a user