- 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>
686 lines
36 KiB
Python
686 lines
36 KiB
Python
#!/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()
|