Step 2: the settings page can change things - shared draft, Review & Apply, rollback, history, undo
- settings/apply.py: the Apply pipeline (check, keep history, write, restart the watchdog, re-import pages by the marker-only rule, recolour page keys), rollback when the watchdog does not come back, the recording guard with the Emergency (LIVE) override, history entries and Go back / Undo. - settings/server.py: the write routes behind the editing code (draft PUT/DELETE with 409 on a conflict, validate, apply, restore, undo, watchdog restart/stop/start, commands to the watchdog, the switcher file upload / pick newest / discard, gear notes). - settings/ui/app.js: every screen grows inputs with Editing on; keys (checks, fixes, hold-always, members), computers, fixes, site setup (schedules, Dante devices, Spotify, ATEM), Review & Apply with live progress, History with Go back and Undo, Run this fix now. - schema.unmask, push_page.push, nav_keys.colour; tests for all of it (163 pass). - Verified live on NA Ingest: a page-only Apply, a restart Apply, Undo, history. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -145,7 +145,7 @@ The page reads what the watchdog writes next to its config (`snapshot.json` ever
|
|||||||
This folder is the source of truth. Edit `config.json` here, then copy the folder to NA Ingest:
|
This folder is the source of truth. Edit `config.json` here, then copy the folder to NA Ingest:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scp status_panel.py coremidi.py netgear.py make_page.py push_page.py nav_keys.py nav_templates.json config.json install.sh atem_watch.js atem_state.js atem-reference.xml checklist-ingest:status-panel-install/
|
scp -r status_panel.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py nav_templates.json config.json install.sh atem_watch.js atem_state.js atem-reference.xml settings checklist-ingest:status-panel-install/
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run the installer there. It checks the config with the new code first (a bad config never gets as far as the watchdog), keeps the code that was running in `prev/`, backs up the old config, writes the LaunchAgent with the exit timeout this config needs (the longest fix plus its verify time, so a restart never cuts a fix in half; launchd honours at most 60 s of it) and restarts the watchdog:
|
Then run the installer there. It checks the config with the new code first (a bad config never gets as far as the watchdog), keeps the code that was running in `prev/`, backs up the old config, writes the LaunchAgent with the exit timeout this config needs (the longest fix plus its verify time, so a restart never cuts a fix in half; launchd honours at most 60 s of it) and restarts the watchdog:
|
||||||
|
|||||||
+31
@@ -75,6 +75,37 @@ def find_nav_keys(host, port, status_page):
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def colour(host, port, status_page, coloured, record_path, revert=False):
|
||||||
|
"""Colour (or restore) the nav keys on every page; returns how many keys changed."""
|
||||||
|
record = json.load(open(record_path)) if os.path.exists(record_path) else {}
|
||||||
|
found = find_nav_keys(host, port, status_page)
|
||||||
|
if revert:
|
||||||
|
work = [(p, r, c, k, "restore") for p, r, c, k, ours in found if ours]
|
||||||
|
else:
|
||||||
|
work = [(p, r, c, k, "colour") for p, r, c, k, ours in found if k in coloured]
|
||||||
|
work += [(p, r, c, k, "restore") for p, r, c, k, ours in found if ours and k not in coloured]
|
||||||
|
ws = WebSocket(host, port, "/trpc")
|
||||||
|
done = 0
|
||||||
|
try:
|
||||||
|
api = Trpc(ws)
|
||||||
|
for page, row, col, kind, what in work:
|
||||||
|
location = {"pageNumber": page, "row": row, "column": col}
|
||||||
|
if what == "restore":
|
||||||
|
api.mutation("controls.resetControl", {"location": location, "newType": kind})
|
||||||
|
record.pop("%d/%d/%d" % (page, row, col), None)
|
||||||
|
else:
|
||||||
|
r, c = NAV_LOCATIONS[kind]
|
||||||
|
source = {"pageNumber": status_page, "row": int(r), "column": int(c)}
|
||||||
|
if not api.mutation("controls.copyControl", {"fromLocation": source, "toLocation": location}):
|
||||||
|
raise RuntimeError("copy to page %d row %d col %d failed" % (page, row, col))
|
||||||
|
record["%d/%d/%d" % (page, row, col)] = kind
|
||||||
|
done += 1
|
||||||
|
finally:
|
||||||
|
ws.close()
|
||||||
|
json.dump(record, open(record_path, "w"), indent=1, sort_keys=True)
|
||||||
|
return done
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
cfg = json.load(open(os.path.join(HERE, "config.json")))
|
cfg = json.load(open(os.path.join(HERE, "config.json")))
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
|||||||
@@ -178,6 +178,35 @@ def page_name(host, port, page):
|
|||||||
return r.read().decode()
|
return r.read().decode()
|
||||||
|
|
||||||
|
|
||||||
|
def push(host, port, page, file_path, expected_name):
|
||||||
|
"""Upload a built page file and import it into `page` (the caller has checked safe_target)."""
|
||||||
|
data = open(file_path, "rb").read()
|
||||||
|
built = json.loads(gzip.decompress(data))
|
||||||
|
if (built.get("page") or {}).get("name") != expected_name:
|
||||||
|
raise ValueError("%s builds page %r, not %r" % (os.path.basename(file_path), (built.get("page") or {}).get("name"), expected_name))
|
||||||
|
ws = WebSocket(host, port, "/trpc")
|
||||||
|
try:
|
||||||
|
api = Trpc(ws)
|
||||||
|
for attempt in range(40):
|
||||||
|
try:
|
||||||
|
session = api.mutation("importExport.prepareImport.start", {"name": os.path.basename(file_path), "size": len(data)})
|
||||||
|
break
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "already exists" not in str(e) or attempt == 39:
|
||||||
|
raise
|
||||||
|
time.sleep(3)
|
||||||
|
for offset in range(0, len(data), CHUNK):
|
||||||
|
api.mutation("importExport.prepareImport.uploadChunk",
|
||||||
|
{"sessionId": session, "offset": offset, "data": base64.b64encode(data[offset:offset + CHUNK]).decode()})
|
||||||
|
summary = api.mutation("importExport.prepareImport.complete",
|
||||||
|
{"sessionId": session, "expectedChecksum": hashlib.sha1(data).hexdigest(), "userData": None})
|
||||||
|
if isinstance(summary, list) and summary and summary[0]:
|
||||||
|
raise RuntimeError("Companion could not read the file: %s" % summary[0])
|
||||||
|
api.mutation("importExport.importSinglePage", {"targetPage": page, "sourcePage": 1, "connectionIdRemapping": {}})
|
||||||
|
finally:
|
||||||
|
ws.close()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
cfg = json.load(open(os.path.join(HERE, "config.json")))
|
cfg = json.load(open(os.path.join(HERE, "config.json")))
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
|||||||
@@ -644,6 +644,28 @@ def mask_secrets(obj):
|
|||||||
return walk(copy.deepcopy(obj), "")
|
return walk(copy.deepcopy(obj), "")
|
||||||
|
|
||||||
|
|
||||||
|
def unmask(draft, live):
|
||||||
|
"""A draft that came back from the page carries masked secrets ("set (N characters)"): put the
|
||||||
|
live values back so a masked value never overwrites a real one."""
|
||||||
|
secret_paths = {p for p, f in F.items() if f["secret"]}
|
||||||
|
|
||||||
|
def walk(node, ref, path):
|
||||||
|
if isinstance(node, dict):
|
||||||
|
out = {}
|
||||||
|
for k, v in node.items():
|
||||||
|
p = "%s.%s" % (path, k) if path else k
|
||||||
|
if (p in secret_paths or k in secret_paths) and isinstance(v, str) and (v.startswith("set (") or v == "") \
|
||||||
|
and isinstance(ref, dict) and isinstance(ref.get(k), str) and ref.get(k):
|
||||||
|
out[k] = ref[k] if v.startswith("set (") else ""
|
||||||
|
else:
|
||||||
|
out[k] = walk(v, ref.get(k) if isinstance(ref, dict) else None, p)
|
||||||
|
return out
|
||||||
|
if isinstance(node, list):
|
||||||
|
return [walk(v, ref[i] if isinstance(ref, list) and i < len(ref) else None, path + "[]") for i, v in enumerate(node)]
|
||||||
|
return node
|
||||||
|
return walk(copy.deepcopy(draft), live, "")
|
||||||
|
|
||||||
|
|
||||||
def phrases(cfg):
|
def phrases(cfg):
|
||||||
"""The sentences the page and the logs build from the config."""
|
"""The sentences the page and the logs build from the config."""
|
||||||
a = cfg.get("auto_fix") or {}
|
a = cfg.get("auto_fix") or {}
|
||||||
|
|||||||
@@ -0,0 +1,548 @@
|
|||||||
|
"""Saving a change: the draft, the checks, the history, and Apply itself.
|
||||||
|
|
||||||
|
Apply, in order, each step only after the previous one worked:
|
||||||
|
keep a copy of the live config in history/ -> write the new file in one go (and the new switcher
|
||||||
|
file with it) -> wait for any running fix to finish -> restart the watchdog -> prove it came back
|
||||||
|
(started.json carries the new file's fingerprint) -> only then re-import the Companion pages when
|
||||||
|
the built page differs from the last verified one -> recolour the page-number keys when a page
|
||||||
|
changed. If the watchdog does not come back, the previous version is put back, once.
|
||||||
|
Nothing here runs while a HyperDeck is recording unless the Emergency override says so.
|
||||||
|
"""
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from settings import PARENT # noqa: F401
|
||||||
|
import make_page
|
||||||
|
import nav_keys
|
||||||
|
import push_page
|
||||||
|
import schema
|
||||||
|
import status_panel as sp
|
||||||
|
|
||||||
|
WATCHDOG = "org.waterschurch.status-panel"
|
||||||
|
# Fields whose change needs no restart and no page: the watchdog never reads them.
|
||||||
|
PAGE_ONLY = {"none"}
|
||||||
|
|
||||||
|
|
||||||
|
def sha(data):
|
||||||
|
return hashlib.sha256(data if isinstance(data, bytes) else json.dumps(data, sort_keys=True).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def strip_notes(export):
|
||||||
|
export = json.loads(json.dumps(export))
|
||||||
|
for row in export["page"]["controls"].values():
|
||||||
|
for ctl in (row or {}).values():
|
||||||
|
if ctl and "options" in ctl:
|
||||||
|
ctl["options"].pop("notes", None)
|
||||||
|
return export
|
||||||
|
|
||||||
|
|
||||||
|
def build_hashes(cfg):
|
||||||
|
"""Fingerprints of both pages as built from cfg, notes stripped (a note change never re-imports)."""
|
||||||
|
out = {"status": sha(strip_notes(make_page.build(cfg)))}
|
||||||
|
if cfg.get("restart_page"):
|
||||||
|
out["restart"] = sha(strip_notes(make_page.build_restart(cfg)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def config_text(cfg):
|
||||||
|
return (json.dumps(cfg, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class Draft:
|
||||||
|
"""One shared draft per site, in draft.json (0600): a closed tab loses nothing."""
|
||||||
|
|
||||||
|
def __init__(self, data_dir):
|
||||||
|
self.path = os.path.join(data_dir, "draft.json")
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
try:
|
||||||
|
with open(self.path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def write(self, draft):
|
||||||
|
with self.lock:
|
||||||
|
sp.write_json(self.path, draft)
|
||||||
|
os.chmod(self.path, 0o600)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
try:
|
||||||
|
os.remove(self.path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update(self, config, by, base_sha, expect=None):
|
||||||
|
"""Replace the draft's config. `expect` is the version the tab last saw; another tab's
|
||||||
|
newer write is reported with the fields that differ (409), never overwritten silently."""
|
||||||
|
with self.lock:
|
||||||
|
cur = self.read()
|
||||||
|
if cur and expect is not None and cur.get("version") != expect:
|
||||||
|
return None, cur
|
||||||
|
now = time.time()
|
||||||
|
draft = {"config": config, "by": by, "base_sha": base_sha, "started": (cur or {}).get("started", now),
|
||||||
|
"started_by": (cur or {}).get("started_by", by), "updated": now, "version": "%d-%s" % (int(now * 1000), by)}
|
||||||
|
sp.write_json(self.path, draft)
|
||||||
|
os.chmod(self.path, 0o600)
|
||||||
|
return draft, None
|
||||||
|
|
||||||
|
|
||||||
|
def changed_paths(a, b, prefix=""):
|
||||||
|
"""Every leaf path that differs between two configs, with (before, after)."""
|
||||||
|
out = []
|
||||||
|
if isinstance(a, dict) and isinstance(b, dict):
|
||||||
|
for k in sorted(set(a) | set(b)):
|
||||||
|
p = "%s.%s" % (prefix, k) if prefix else k
|
||||||
|
if k not in a:
|
||||||
|
out.append((p, None, b[k]))
|
||||||
|
elif k not in b:
|
||||||
|
out.append((p, a[k], None))
|
||||||
|
else:
|
||||||
|
out += changed_paths(a[k], b[k], p)
|
||||||
|
elif isinstance(a, list) and isinstance(b, list) and all(isinstance(x, dict) for x in a + b) and any("id" in x for x in a + b):
|
||||||
|
# lists of records with ids (tiles, restart keys): match by id so a move is not a delete + add
|
||||||
|
ida = {x.get("id"): x for x in a}
|
||||||
|
idb = {x.get("id"): x for x in b}
|
||||||
|
for k in list(dict.fromkeys(list(ida) + list(idb))):
|
||||||
|
p = "%s[%s]" % (prefix, k)
|
||||||
|
if k not in ida:
|
||||||
|
out.append((p, None, idb[k]))
|
||||||
|
elif k not in idb:
|
||||||
|
out.append((p, ida[k], None))
|
||||||
|
else:
|
||||||
|
out += changed_paths(ida[k], idb[k], p)
|
||||||
|
elif isinstance(a, list) and isinstance(b, list) and a and len(a) == len(b) and all(isinstance(x, dict) for x in a + b):
|
||||||
|
# lists of plain records of the same length (checks, windows): by position
|
||||||
|
for i, (x, y) in enumerate(zip(a, b)):
|
||||||
|
out += changed_paths(x, y, "%s.%d" % (prefix, i))
|
||||||
|
elif a != b:
|
||||||
|
out.append((prefix, a, b))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def generic_path(path):
|
||||||
|
"""A concrete path -> the registry's spelling: tiles[hd2].checks.0.optional -> tiles[].checks[].optional,
|
||||||
|
hosts.lighting.notes -> hosts.*.notes, auto_fix.windows.0.start -> auto_fix.windows[].start."""
|
||||||
|
g = re.sub(r"\[[^\]]*\]", "[]", path)
|
||||||
|
g = re.sub(r"^(tiles\[\]|restart_page\.keys\[\])\.checks\.\d+\.", r"\1.checks[].", g)
|
||||||
|
g = re.sub(r"^(hosts|fixes|gear)\.[^.]+", r"\1.*", g)
|
||||||
|
g = re.sub(r"^(auto_fix\.windows|service_windows|site\.live_services)\.\d+", r"\1[]", g)
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def leaves(path, value):
|
||||||
|
"""Every leaf under an added or removed subtree, so each can be looked up in the registry."""
|
||||||
|
if isinstance(value, dict) and value:
|
||||||
|
for k, v in value.items():
|
||||||
|
yield from leaves("%s.%s" % (path, k), v)
|
||||||
|
elif isinstance(value, list) and value and all(isinstance(x, dict) for x in value):
|
||||||
|
for i, v in enumerate(value):
|
||||||
|
yield from leaves("%s[%s]" % (path, v.get("id", i)) if "id" in v else "%s.%d" % (path, i), v)
|
||||||
|
else:
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def words_for(path, before, after, cfg_before, cfg_after):
|
||||||
|
"""One plain-words line for a changed path, using the registry's names."""
|
||||||
|
tiles = {t["id"]: t.get("label", t["id"]) for t in (cfg_after.get("tiles") or cfg_before.get("tiles") or [])}
|
||||||
|
keys = {k["id"]: k.get("label", k["id"]) for k in ((cfg_after.get("restart_page") or cfg_before.get("restart_page") or {}).get("keys") or [])}
|
||||||
|
hosts = {a: h.get("name", a) for a, h in {**(cfg_before.get("hosts") or {}), **(cfg_after.get("hosts") or {})}.items()}
|
||||||
|
fixes = {a: f.get("short", a) for a, f in {**(cfg_before.get("fixes") or {}), **(cfg_after.get("fixes") or {})}.items()}
|
||||||
|
where = path
|
||||||
|
where = re.sub(r"^tiles\[([^\]]+)\]", lambda m: "key %s" % tiles.get(m.group(1), m.group(1)), where)
|
||||||
|
where = re.sub(r"^restart_page\.keys\[([^\]]+)\]", lambda m: "restart key %s" % keys.get(m.group(1), m.group(1)), where)
|
||||||
|
where = re.sub(r"^hosts\.([a-z0-9_]+)", lambda m: "computer %s" % hosts.get(m.group(1), m.group(1)), where)
|
||||||
|
where = re.sub(r"^fixes\.([A-Za-z0-9_]+)", lambda m: "fix %s" % fixes.get(m.group(1), m.group(1)), where)
|
||||||
|
where = re.sub(r"\.checks\.(\d+)", lambda m: " › check %d" % (int(m.group(1)) + 1), where)
|
||||||
|
where = where.replace("restart_page", "Restart page").replace("auto_fix", "auto-fix").replace("site.", "site › ")
|
||||||
|
field = path.split(".")[-1].split("[")[0]
|
||||||
|
generic = {f["path"].split(".")[-1]: f for f in schema.F.values()}
|
||||||
|
help_ = (generic.get(field) or {}).get("help", "")
|
||||||
|
if where.endswith("." + field):
|
||||||
|
where = where[:-len(field) - 1] + " › " + field.replace("_", " ")
|
||||||
|
elif where.endswith(field):
|
||||||
|
where = where[:-len(field)] + field.replace("_", " ")
|
||||||
|
|
||||||
|
def val(v):
|
||||||
|
if v is None:
|
||||||
|
return "—"
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return "yes" if v else "no"
|
||||||
|
if isinstance(v, (dict, list)):
|
||||||
|
s = json.dumps(v, ensure_ascii=False)
|
||||||
|
return s if len(s) < 80 else s[:77] + "..."
|
||||||
|
return str(v)
|
||||||
|
secret = any(f["secret"] for f in schema.F.values() if f["path"].endswith(field))
|
||||||
|
if secret:
|
||||||
|
return {"path": path, "where": where, "text": "%s changed" % where, "command": False}
|
||||||
|
text = "%s: %s → %s" % (where, val(before), val(after)) if before is not None and after is not None else \
|
||||||
|
("%s added: %s" % (where, val(after)) if before is None else "%s removed (was %s)" % (where, val(before)))
|
||||||
|
command = field in ("run", "cmd", "match") or path.endswith("pref.want")
|
||||||
|
return {"path": path, "where": where, "text": text, "help": help_, "command": command}
|
||||||
|
|
||||||
|
|
||||||
|
def what_apply_does(cfg_before, cfg_after, hashes_before):
|
||||||
|
"""Which steps a change needs: the watchdog restarts unless every changed field is page-only
|
||||||
|
data; the pages re-import when the built page differs; page-number keys follow a page change."""
|
||||||
|
changes = changed_paths(cfg_before, cfg_after)
|
||||||
|
restart = False
|
||||||
|
for path, before, after in changes:
|
||||||
|
for leaf in leaves(path, after if before is None else before):
|
||||||
|
f = schema.F.get(generic_path(leaf))
|
||||||
|
if f is None or f["apply"] != "none":
|
||||||
|
restart = True
|
||||||
|
pages = []
|
||||||
|
try:
|
||||||
|
after = build_hashes(cfg_after)
|
||||||
|
except Exception as e: # a config that cannot build is caught by validate; be safe here
|
||||||
|
return {"restart": restart, "pages": [], "nav": False, "error": str(e), "changes": changes}
|
||||||
|
for which, h in after.items():
|
||||||
|
if hashes_before.get(which) != h:
|
||||||
|
pages.append(which)
|
||||||
|
if cfg_before.get("restart_page") and not cfg_after.get("restart_page"):
|
||||||
|
pages.append("restart-removed")
|
||||||
|
return {"restart": restart, "pages": pages, "nav": bool(pages) or cfg_before.get("page") != cfg_after.get("page")
|
||||||
|
or (cfg_before.get("nav_keys") != cfg_after.get("nav_keys")), "changes": changes, "hashes_after": after}
|
||||||
|
|
||||||
|
|
||||||
|
class Applier:
|
||||||
|
"""Runs one Apply in a thread and reports its steps. One at a time (apply.lock)."""
|
||||||
|
|
||||||
|
def __init__(self, site, run=None, sleep=None):
|
||||||
|
self.site = site
|
||||||
|
self.run = run or subprocess.run
|
||||||
|
self.sleep = sleep or time.sleep
|
||||||
|
self.status = {"state": "idle", "steps": [], "started": None, "finished": None, "outcome": "", "note": "", "by": ""}
|
||||||
|
self.thread = None
|
||||||
|
self.lock_path = os.path.join(site.data_dir, "apply.lock")
|
||||||
|
self.log_path = os.path.join(site.data_dir, "apply.log")
|
||||||
|
|
||||||
|
# --- helpers ---
|
||||||
|
def log(self, text):
|
||||||
|
line = "%s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), text)
|
||||||
|
try:
|
||||||
|
with open(self.log_path, "a") as f:
|
||||||
|
f.write(line)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def step(self, name, state, detail=""):
|
||||||
|
for s in self.status["steps"]:
|
||||||
|
if s["name"] == name:
|
||||||
|
s.update(state=state, detail=detail)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self.status["steps"].append({"name": name, "state": state, "detail": detail})
|
||||||
|
self.log("%s: %s %s" % (name, state, detail))
|
||||||
|
self.write_status()
|
||||||
|
|
||||||
|
def write_status(self):
|
||||||
|
try:
|
||||||
|
sp.write_json(os.path.join(self.site.data_dir, "apply-status.json"), self.status)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def busy(self):
|
||||||
|
return bool(self.thread and self.thread.is_alive())
|
||||||
|
|
||||||
|
def live(self):
|
||||||
|
"""(live?, reason) from the watchdog's live-now.json; stale = not live (the schedule has none at NA)."""
|
||||||
|
d = self.site.read_json("live-now.json", {}) or {}
|
||||||
|
if d and time.time() - d.get("time", 0) < 30:
|
||||||
|
return bool(d.get("live")), d.get("reason", "")
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
def hashes(self):
|
||||||
|
h = self.site.read_json("build-hashes.json", None)
|
||||||
|
if h is None: # first use: the pages were imported from the live config
|
||||||
|
h = build_hashes(self.site.load())
|
||||||
|
sp.write_json(os.path.join(self.site.data_dir, "build-hashes.json"), h)
|
||||||
|
return h
|
||||||
|
|
||||||
|
def history_dir(self):
|
||||||
|
d = os.path.join(self.site.data_dir, "history")
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
def keep_history(self, note, by, address, actions, sha_before, sha_after, outcome="applied"):
|
||||||
|
"""Copy the live config (and name the switcher file) into history/, 0600."""
|
||||||
|
ts = time.strftime("%Y%m%d-%H%M%S")
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "-", (note or "change").lower())[:30].strip("-") or "change"
|
||||||
|
base = os.path.join(self.history_dir(), "%s-%s" % (ts, slug))
|
||||||
|
shutil.copyfile(self.site.config_path, base + ".json")
|
||||||
|
os.chmod(base + ".json", 0o600)
|
||||||
|
xml = os.path.join(self.site.data_dir, "atem-reference.xml")
|
||||||
|
meta = {"when": time.time(), "by": by, "address": address, "note": note, "sha_before": sha_before, "sha_after": sha_after,
|
||||||
|
"actions": actions, "outcome": outcome,
|
||||||
|
"atem_sha": sha(open(xml, "rb").read()) if os.path.exists(xml) else None}
|
||||||
|
sp.write_json(base + ".meta.json", meta)
|
||||||
|
os.chmod(base + ".meta.json", 0o600)
|
||||||
|
return os.path.basename(base)
|
||||||
|
|
||||||
|
def watchdog_pid(self):
|
||||||
|
try:
|
||||||
|
out = self.run(["/bin/launchctl", "print", "gui/%d/%s" % (os.getuid(), WATCHDOG)], capture_output=True, text=True, timeout=5).stdout
|
||||||
|
m = re.search(r"pid = (\d+)", out)
|
||||||
|
return int(m.group(1)) if m else None, "state = running" in out
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
def restart_watchdog(self, want_sha, timeout=25):
|
||||||
|
"""kickstart and wait for started.json with our fingerprint. (ok, detail)."""
|
||||||
|
waited = 0
|
||||||
|
while self.site.read_json("fixes-running.json", {}) and waited < 60:
|
||||||
|
self.sleep(2)
|
||||||
|
waited += 2
|
||||||
|
if self.site.read_json("fixes-running.json", {}):
|
||||||
|
return False, "a fix is still running on %s" % ", ".join(v.get("key", "?") for v in self.site.read_json("fixes-running.json", {}).values())
|
||||||
|
old_pid, loaded = self.watchdog_pid()
|
||||||
|
t0 = time.time()
|
||||||
|
domain = "gui/%d" % os.getuid()
|
||||||
|
if loaded or old_pid:
|
||||||
|
r = self.run(["/bin/launchctl", "kickstart", "-k", "%s/%s" % (domain, WATCHDOG)], capture_output=True, text=True, timeout=30)
|
||||||
|
else:
|
||||||
|
plist = os.path.expanduser("~/Library/LaunchAgents/%s.plist" % WATCHDOG)
|
||||||
|
r = self.run(["/bin/launchctl", "bootstrap", domain, plist], capture_output=True, text=True, timeout=30)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return False, "launchctl: %s" % (r.stderr or r.stdout).strip()[-200:]
|
||||||
|
waited = 0
|
||||||
|
while waited < timeout:
|
||||||
|
self.sleep(1)
|
||||||
|
waited += 1
|
||||||
|
st = self.site.read_json("started.json", {}) or {}
|
||||||
|
if st.get("started_at", 0) >= t0 - 1 and st.get("pid") != old_pid:
|
||||||
|
if want_sha and st.get("config_sha") != want_sha:
|
||||||
|
return False, "the watchdog started on a different settings file (%s)" % (st.get("config_sha") or "?")[:12]
|
||||||
|
self.sleep(3)
|
||||||
|
pid, running = self.watchdog_pid()
|
||||||
|
if not running:
|
||||||
|
return False, "the watchdog started and stopped again - see the log"
|
||||||
|
return True, "running (code %s)" % st.get("version")
|
||||||
|
return False, "no start stamp after %d s - see ~/Library/Logs/status-panel.err.log" % timeout
|
||||||
|
|
||||||
|
def import_pages(self, cfg, which):
|
||||||
|
"""make_page -> push_page with the marker-only rule -> verify, for the pages in `which`."""
|
||||||
|
comp = cfg["companion"]
|
||||||
|
host, port = comp.get("host", "127.0.0.1"), comp.get("port", 8000)
|
||||||
|
out_dir = self.site.data_dir
|
||||||
|
done = []
|
||||||
|
for name in which:
|
||||||
|
if name == "status":
|
||||||
|
page, expected, fname = cfg["page"], cfg["page_name"], "status-page.companionconfig"
|
||||||
|
export = make_page.build(cfg)
|
||||||
|
elif name == "restart" and cfg.get("restart_page"):
|
||||||
|
page, expected, fname = cfg["restart_page"]["page"], cfg["restart_page"]["page_name"], "restart-page.companionconfig"
|
||||||
|
export = make_page.build_restart(cfg)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
path = os.path.join(out_dir, fname)
|
||||||
|
make_page.write(path, export)
|
||||||
|
live = push_page.fetch_page(host, port, page)
|
||||||
|
ok, why = push_page.safe_target(live, expected)
|
||||||
|
if not ok:
|
||||||
|
return False, "page %d: %s" % (page, why)
|
||||||
|
push_page.push(host, port, page, path, expected)
|
||||||
|
after = push_page.fetch_page(host, port, page)
|
||||||
|
want = {(r, c) for r, row in export["page"]["controls"].items() for c, ctl in row.items() if ctl and ctl.get("type") == "button-layered"}
|
||||||
|
have = {(r, c) for r, row in (after.get("page") or {}).get("controls", {}).items() for c, ctl in (row or {}).items() if ctl and ctl.get("type") == "button-layered"}
|
||||||
|
if want != have:
|
||||||
|
return False, "page %d: the import did not land as built (%d keys built, %d on the page)" % (page, len(want), len(have))
|
||||||
|
done.append(name)
|
||||||
|
return True, ", ".join("page %d" % (cfg["page"] if n == "status" else cfg["restart_page"]["page"]) for n in done) or "nothing to import"
|
||||||
|
|
||||||
|
def colour_nav_keys(self, cfg):
|
||||||
|
comp = cfg["companion"]
|
||||||
|
try:
|
||||||
|
n = nav_keys.colour(comp.get("host", "127.0.0.1"), comp.get("port", 8000), cfg["page"], cfg.get("nav_keys", list(make_page.NAV_LOCATIONS)),
|
||||||
|
os.path.join(self.site.data_dir, "nav_locations.json"))
|
||||||
|
return True, "%d page keys recoloured" % n
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
# --- the pipeline ---
|
||||||
|
def start(self, new_cfg, note, by, address, emergency=None, atem_file=None):
|
||||||
|
"""Begin an Apply in the background; the caller shows /api/apply/status. Refusals are immediate."""
|
||||||
|
if self.busy():
|
||||||
|
return {"ok": False, "error": "an Apply is already running"}
|
||||||
|
live, reason = self.live()
|
||||||
|
if live and not (emergency and emergency.get("word") == "LIVE" and emergency.get("reason")):
|
||||||
|
return {"ok": False, "error": "live now (%s): changes wait until it is over" % reason, "live": True}
|
||||||
|
if os.path.exists(self.lock_path) and time.time() - os.path.getmtime(self.lock_path) < 600:
|
||||||
|
return {"ok": False, "error": "an Apply is already running"}
|
||||||
|
self.status = {"state": "running", "steps": [], "started": time.time(), "finished": None, "outcome": "", "note": note, "by": by,
|
||||||
|
"emergency": emergency}
|
||||||
|
self.thread = threading.Thread(target=self._run, args=(new_cfg, note, by, address, atem_file), daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def _run(self, new_cfg, note, by, address, atem_file):
|
||||||
|
open(self.lock_path, "w").close()
|
||||||
|
try:
|
||||||
|
self._pipeline(new_cfg, note, by, address, atem_file)
|
||||||
|
except Exception as e:
|
||||||
|
self.log("apply crashed: %r" % e)
|
||||||
|
self.status["state"], self.status["outcome"] = "failed", "Apply stopped with an error: %s" % e
|
||||||
|
finally:
|
||||||
|
self.status["finished"] = time.time()
|
||||||
|
try:
|
||||||
|
os.remove(self.lock_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.write_status()
|
||||||
|
|
||||||
|
def _pipeline(self, new_cfg, note, by, address, atem_file):
|
||||||
|
site = self.site
|
||||||
|
old_cfg = site.load()
|
||||||
|
sha_before = site.cfg_sha
|
||||||
|
if self.status.get("emergency"):
|
||||||
|
self.log("EMERGENCY apply by %s: %s" % (by, self.status["emergency"].get("reason")))
|
||||||
|
# 1. check
|
||||||
|
self.step("check", "running")
|
||||||
|
errors, warnings = schema.validate(new_cfg)
|
||||||
|
tmp = tempfile.NamedTemporaryFile("wb", suffix=".json", delete=False)
|
||||||
|
tmp.write(config_text(new_cfg)); tmp.close()
|
||||||
|
try:
|
||||||
|
sp.load_config(tmp.name)
|
||||||
|
except (ValueError, KeyError, TypeError) as e:
|
||||||
|
errors.append("the watchdog would not accept it: %s" % e)
|
||||||
|
finally:
|
||||||
|
os.remove(tmp.name)
|
||||||
|
if errors:
|
||||||
|
self.step("check", "failed", "; ".join(errors)[:500])
|
||||||
|
self.status["state"], self.status["outcome"] = "failed", "Not applied: " + errors[0]
|
||||||
|
return
|
||||||
|
plan = what_apply_does(old_cfg, new_cfg, self.hashes())
|
||||||
|
if plan.get("error"):
|
||||||
|
self.step("check", "failed", plan["error"])
|
||||||
|
self.status["state"], self.status["outcome"] = "failed", "Not applied: the pages could not be built: " + plan["error"]
|
||||||
|
return
|
||||||
|
self.step("check", "ok", "%d change%s" % (len(plan["changes"]), "" if len(plan["changes"]) == 1 else "s"))
|
||||||
|
# 2. history + write
|
||||||
|
self.step("save", "running")
|
||||||
|
actions = [a for a, on in (("restart", plan["restart"] or bool(atem_file)), ("pages", bool(plan["pages"])), ("nav", plan["nav"])) if on]
|
||||||
|
if atem_file and os.path.exists(atem_file):
|
||||||
|
actions.append("switcher file")
|
||||||
|
text = config_text(new_cfg)
|
||||||
|
sha_after = sha(text)
|
||||||
|
entry = self.keep_history(note, by, address, actions, sha_before, sha_after)
|
||||||
|
tmp_path = site.config_path + ".tmp"
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
f.write(text)
|
||||||
|
os.replace(tmp_path, site.config_path)
|
||||||
|
xml_backup = None
|
||||||
|
if atem_file and os.path.exists(atem_file):
|
||||||
|
xml = os.path.join(site.data_dir, "atem-reference.xml")
|
||||||
|
if os.path.exists(xml):
|
||||||
|
hist = os.path.join(self.history_dir(), "atem")
|
||||||
|
os.makedirs(hist, exist_ok=True)
|
||||||
|
xml_backup = os.path.join(hist, time.strftime("%Y%m%d-%H%M%S") + ".xml")
|
||||||
|
shutil.copyfile(xml, xml_backup)
|
||||||
|
os.replace(atem_file, xml)
|
||||||
|
site.load()
|
||||||
|
self.step("save", "ok", "kept as %s" % entry)
|
||||||
|
# 3. restart the watchdog
|
||||||
|
if plan["restart"] or atem_file:
|
||||||
|
self.step("restart", "running")
|
||||||
|
ok, detail = self.restart_watchdog(sha_after)
|
||||||
|
if not ok:
|
||||||
|
self.step("restart", "failed", detail)
|
||||||
|
self._rollback(entry, xml_backup, plan, old_cfg, detail)
|
||||||
|
return
|
||||||
|
self.step("restart", "ok", detail)
|
||||||
|
else:
|
||||||
|
self.step("restart", "skipped", "nothing the watchdog reads changed")
|
||||||
|
# 4. pages
|
||||||
|
if plan["pages"]:
|
||||||
|
self.step("pages", "running")
|
||||||
|
ok, detail = self.import_pages(new_cfg, [p for p in plan["pages"] if p in ("status", "restart")])
|
||||||
|
if not ok:
|
||||||
|
self.step("pages", "failed", detail)
|
||||||
|
sp.write_json(os.path.join(site.data_dir, "pages-out-of-sync.json"), {"when": time.time(), "detail": detail})
|
||||||
|
self._rollback(entry, xml_backup, plan, old_cfg, "the pages could not be imported: " + detail)
|
||||||
|
return
|
||||||
|
sp.write_json(os.path.join(site.data_dir, "build-hashes.json"), plan["hashes_after"])
|
||||||
|
try:
|
||||||
|
os.remove(os.path.join(site.data_dir, "pages-out-of-sync.json"))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.step("pages", "ok", detail)
|
||||||
|
else:
|
||||||
|
self.step("pages", "skipped", "the deck's layout did not change")
|
||||||
|
if plan["nav"]:
|
||||||
|
self.step("page keys", "running")
|
||||||
|
ok, detail = self.colour_nav_keys(new_cfg)
|
||||||
|
self.step("page keys", "ok" if ok else "failed", detail)
|
||||||
|
self.status["state"], self.status["outcome"] = "done", "Applied. " + ("The watchdog restarted. " if plan["restart"] else "") + \
|
||||||
|
("Pages re-imported. " if plan["pages"] else "")
|
||||||
|
self.log("applied by %s (%s): %s" % (by, address, note))
|
||||||
|
|
||||||
|
def _rollback(self, entry, xml_backup, plan, old_cfg, why):
|
||||||
|
"""Put the previous version back, once."""
|
||||||
|
site = self.site
|
||||||
|
self.step("rollback", "running", why)
|
||||||
|
src = os.path.join(self.history_dir(), entry + ".json")
|
||||||
|
shutil.copyfile(src, site.config_path + ".tmp")
|
||||||
|
os.replace(site.config_path + ".tmp", site.config_path)
|
||||||
|
if xml_backup:
|
||||||
|
shutil.copyfile(xml_backup, os.path.join(site.data_dir, "atem-reference.xml"))
|
||||||
|
site.load()
|
||||||
|
ok, detail = self.restart_watchdog(site.cfg_sha)
|
||||||
|
pages_note = ""
|
||||||
|
if ok and plan.get("pages"):
|
||||||
|
pok, pdetail = self.import_pages(old_cfg, [p for p in plan["pages"] if p in ("status", "restart")])
|
||||||
|
pages_note = "; pages put back" if pok else "; the pages could not be put back: " + pdetail
|
||||||
|
self.step("rollback", "ok" if ok else "failed", ("previous settings restored, " + detail + pages_note) if ok else detail)
|
||||||
|
self.status["state"] = "failed"
|
||||||
|
self.status["outcome"] = "Not applied - %s. %s" % (why, "The previous settings are back." if ok else "The previous settings could not be restarted either: " + detail)
|
||||||
|
sp.write_json(os.path.join(site.data_dir, "last-apply-failed.json"), {"when": time.time(), "why": why, "restored": ok})
|
||||||
|
for e in (entry,):
|
||||||
|
meta_path = os.path.join(self.history_dir(), e + ".meta.json")
|
||||||
|
try:
|
||||||
|
meta = json.load(open(meta_path))
|
||||||
|
meta["outcome"] = "failed, rolled back: " + why
|
||||||
|
sp.write_json(meta_path, meta)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def history_entries(data_dir):
|
||||||
|
"""Newest first: the Apply copies plus the installer's config.json.bak-* files."""
|
||||||
|
out = []
|
||||||
|
hist = os.path.join(data_dir, "history")
|
||||||
|
if os.path.isdir(hist):
|
||||||
|
for name in sorted(os.listdir(hist), reverse=True):
|
||||||
|
if not name.endswith(".json") or name.endswith(".meta.json"):
|
||||||
|
continue
|
||||||
|
base = name[:-5]
|
||||||
|
try:
|
||||||
|
meta = json.load(open(os.path.join(hist, base + ".meta.json")))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
meta = {}
|
||||||
|
out.append({"id": base, "when": meta.get("when"), "by": meta.get("by", "?"), "note": meta.get("note", ""),
|
||||||
|
"outcome": meta.get("outcome", ""), "actions": meta.get("actions", []), "kind": "apply"})
|
||||||
|
for name in sorted(os.listdir(data_dir), reverse=True):
|
||||||
|
m = re.match(r"config\.json\.bak-(\d{8})-(\d{6})$", name)
|
||||||
|
if m:
|
||||||
|
t = time.mktime(time.strptime(m.group(1) + m.group(2), "%Y%m%d%H%M%S"))
|
||||||
|
out.append({"id": name, "when": t, "by": "the installer", "note": "config.json replaced by install.sh", "outcome": "", "actions": [], "kind": "install"})
|
||||||
|
out.sort(key=lambda e: e["when"] or 0, reverse=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def history_config(data_dir, entry_id):
|
||||||
|
path = os.path.join(data_dir, "history", entry_id + ".json") if not entry_id.startswith("config.json.bak-") else os.path.join(data_dir, entry_id)
|
||||||
|
if not os.path.realpath(path).startswith(os.path.realpath(data_dir)):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.load(open(path))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
+269
-7
@@ -11,6 +11,7 @@ import argparse
|
|||||||
import gzip
|
import gzip
|
||||||
import glob
|
import glob
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import tempfile
|
||||||
import http.server
|
import http.server
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
@@ -29,6 +30,7 @@ import urllib.request
|
|||||||
from settings import PARENT, HERE # noqa: F401
|
from settings import PARENT, HERE # noqa: F401
|
||||||
import schema
|
import schema
|
||||||
import status_panel as sp
|
import status_panel as sp
|
||||||
|
from settings import apply as applying
|
||||||
from settings import keyguide, probes
|
from settings import keyguide, probes
|
||||||
|
|
||||||
log = logging.getLogger("status-settings")
|
log = logging.getLogger("status-settings")
|
||||||
@@ -45,6 +47,7 @@ COMPANION_FACTS = [
|
|||||||
("Does OBS say when it is streaming?", "Yes: the OBS-Youtube connection's variable 'streaming' reads 'Off-Air' when it is not; "
|
("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."),
|
"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 the page create the custom variables?", "Not yet known; they are made by hand in Companion for now."),
|
||||||
|
("Which Companion runs here", "The one listening on port 8000 (5.0.7); an abandoned 5.1 upgrade left a dead settings folder, which the panel now skips."),
|
||||||
("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."),
|
("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."),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -102,6 +105,8 @@ class Site:
|
|||||||
self.probing = None
|
self.probing = None
|
||||||
self.attempts = {} # client ip -> [count, first attempt time]
|
self.attempts = {} # client ip -> [count, first attempt time]
|
||||||
self.page_cache = {} # name -> (time, value)
|
self.page_cache = {} # name -> (time, value)
|
||||||
|
self.draft = applying.Draft(data_dir)
|
||||||
|
self.applier = applying.Applier(self)
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
st = os.stat(self.config_path)
|
st = os.stat(self.config_path)
|
||||||
@@ -536,6 +541,24 @@ class Site:
|
|||||||
return {"ok": False, "tries_left": max(0, 5 - count - 1), "no_code": not wanted}
|
return {"ok": False, "tries_left": max(0, 5 - count - 1), "no_code": not wanted}
|
||||||
|
|
||||||
|
|
||||||
|
def spotify_info(url):
|
||||||
|
"""Name, song count and URI of a public playlist, from open.spotify.com only."""
|
||||||
|
m = re.search(r"(?:playlist[/:])([A-Za-z0-9]{22})", url or "")
|
||||||
|
if not m:
|
||||||
|
return {"ok": False, "error": "paste a Spotify playlist link (open.spotify.com/playlist/...)"}
|
||||||
|
pid = m.group(1)
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request("https://open.spotify.com/embed/playlist/" + pid, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
html = urllib.request.urlopen(req, timeout=10, context=sp._UNVERIFIED).read().decode("utf-8", "replace")
|
||||||
|
mm = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', html)
|
||||||
|
entity = json.loads(mm.group(1))["props"]["pageProps"]["state"]["data"]["entity"]
|
||||||
|
return {"ok": True, "uri": "spotify:playlist:" + pid, "name": entity.get("name") or entity.get("title"),
|
||||||
|
"songs": len(entity.get("trackList", [])), "public": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": True, "uri": "spotify:playlist:" + pid, "name": None, "songs": None, "public": False,
|
||||||
|
"warning": "the page cannot read this playlist (private?): the key will show WRONG PLAYLIST less reliably (%s)" % e}
|
||||||
|
|
||||||
|
|
||||||
def cfg_stale(cfg):
|
def cfg_stale(cfg):
|
||||||
return cfg.get("page_stale_seconds", 15)
|
return cfg.get("page_stale_seconds", 15)
|
||||||
|
|
||||||
@@ -591,7 +614,36 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
if path == "/api/keys":
|
if path == "/api/keys":
|
||||||
return self.send(200, site.keys_view())
|
return self.send(200, site.keys_view())
|
||||||
if path == "/api/history":
|
if path == "/api/history":
|
||||||
return self.send(200, site.history_view())
|
return self.send(200, {"entries": applying.history_entries(site.data_dir)})
|
||||||
|
if path.startswith("/api/history/"):
|
||||||
|
entry = path[len("/api/history/"):]
|
||||||
|
cfg = applying.history_config(site.data_dir, entry)
|
||||||
|
if cfg is None:
|
||||||
|
return self.send(404, {"error": "no such version"})
|
||||||
|
live_cfg = site.load()
|
||||||
|
changes = [applying.words_for(p, a, b, cfg, live_cfg) for p, a, b in applying.changed_paths(cfg, live_cfg)]
|
||||||
|
return self.send(200, {"config": schema.mask_secrets(cfg), "changes_to_now": changes})
|
||||||
|
if path == "/api/draft":
|
||||||
|
d = site.draft.read()
|
||||||
|
return self.send(200, {"draft": self.masked_draft(d) if d else None, "live_sha": site.cfg_sha,
|
||||||
|
"stale": bool(d and d.get("base_sha") != site.cfg_sha),
|
||||||
|
"atem_pending": os.path.exists(os.path.join(site.data_dir, "draft-atem.xml"))})
|
||||||
|
if path == "/api/apply/status":
|
||||||
|
st = dict(site.applier.status)
|
||||||
|
st["live"] = dict(zip(("live", "reason"), site.applier.live()))
|
||||||
|
st["failed_last"] = site.read_json("last-apply-failed.json", None)
|
||||||
|
return self.send(200, st)
|
||||||
|
if path == "/api/live/spotify":
|
||||||
|
url = q.get("url", [""])[0]
|
||||||
|
return self.send(200, spotify_info(url))
|
||||||
|
if path == "/api/atem-reference/folder":
|
||||||
|
folder = os.path.expanduser((site.cfg.get("atem") or {}).get("source_folder") or "")
|
||||||
|
files = sorted(glob.glob(os.path.join(folder, "*.xml")), key=os.path.getmtime, reverse=True) if folder and os.path.isdir(folder) else []
|
||||||
|
return self.send(200, {"folder": folder, "mounted": bool(folder and os.path.isdir(folder)),
|
||||||
|
"files": [{"name": os.path.basename(f), "when": os.path.getmtime(f)} for f in files[:10]]})
|
||||||
|
if path == "/api/live/midi":
|
||||||
|
snap = site.read_json("snapshot.json", {}) or {}
|
||||||
|
return self.send(200, {"local": snap.get("midi"), "remote": snap.get("remote_midi")})
|
||||||
if path == "/api/facts":
|
if path == "/api/facts":
|
||||||
return self.send(200, {"facts": COMPANION_FACTS})
|
return self.send(200, {"facts": COMPANION_FACTS})
|
||||||
if path == "/api/snapshot":
|
if path == "/api/snapshot":
|
||||||
@@ -614,15 +666,38 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
log.exception("GET %s", path)
|
log.exception("GET %s", path)
|
||||||
return self.send(500, {"error": str(e)})
|
return self.send(500, {"error": str(e)})
|
||||||
|
|
||||||
|
def gate(self):
|
||||||
|
"""Every change needs the editing code and a name. (ok, who) or (False, response sent)."""
|
||||||
|
code, who = self.headers.get("X-Edit-Key", ""), (self.headers.get("X-Editor") or "").strip()[:40]
|
||||||
|
r = self.site.check_code(self.client_address[0], code)
|
||||||
|
if not r.get("ok"):
|
||||||
|
self.send(401, dict(r, error="the editing code is needed" if not r.get("locked_for") else "locked out after too many wrong codes"))
|
||||||
|
return False, None
|
||||||
|
if not who:
|
||||||
|
self.send(400, {"error": "your first name is needed"})
|
||||||
|
return False, None
|
||||||
|
return True, who
|
||||||
|
|
||||||
|
def read_body(self):
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
return self.rfile.read(length) if length else b""
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
self.do_POST()
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
self.do_POST()
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
if not allowed_client(self.client_address[0]):
|
if not allowed_client(self.client_address[0]):
|
||||||
return self.send(403, {"error": "only from the church network"})
|
return self.send(403, {"error": "only from the church network"})
|
||||||
path = urllib.parse.urlparse(self.path).path
|
path = urllib.parse.urlparse(self.path).path
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
raw = self.read_body()
|
||||||
|
ctype = self.headers.get("Content-Type") or ""
|
||||||
body = {}
|
body = {}
|
||||||
if length:
|
if raw and ctype.startswith("application/json"):
|
||||||
try:
|
try:
|
||||||
body = json.loads(self.rfile.read(length) or b"{}")
|
body = json.loads(raw or b"{}")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return self.send(400, {"error": "bad JSON"})
|
return self.send(400, {"error": "bad JSON"})
|
||||||
host = (self.headers.get("Host") or "").split(":")[0]
|
host = (self.headers.get("Host") or "").split(":")[0]
|
||||||
@@ -631,6 +706,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
return self.send(403, {"error": "wrong origin"})
|
return self.send(403, {"error": "wrong origin"})
|
||||||
site = self.site
|
site = self.site
|
||||||
try:
|
try:
|
||||||
|
if path == "/api/editing/check":
|
||||||
|
return self.send(200, site.check_code(self.client_address[0], str(body.get("code", ""))))
|
||||||
if path == "/api/machines/probe":
|
if path == "/api/machines/probe":
|
||||||
if site.read_json("fixes-running.json", {}):
|
if site.read_json("fixes-running.json", {}):
|
||||||
return self.send(409, {"error": "a fix is running; try again in a minute"})
|
return self.send(409, {"error": "a fix is running; try again in a minute"})
|
||||||
@@ -641,13 +718,198 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
site.probing = threading.Thread(target=probes.probe_all, args=(site.load(), site.data_dir, only), daemon=True)
|
site.probing = threading.Thread(target=probes.probe_all, args=(site.load(), site.data_dir, only), daemon=True)
|
||||||
site.probing.start()
|
site.probing.start()
|
||||||
return self.send(200, {"started": True})
|
return self.send(200, {"started": True})
|
||||||
if path == "/api/editing/check":
|
# ---- everything below changes something: the editing code and a name are needed ----
|
||||||
return self.send(200, site.check_code(self.client_address[0], str(body.get("code", ""))))
|
ok, who = self.gate()
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
if path == "/api/draft" and self.command == "PUT":
|
||||||
|
cfg = schema.unmask(body.get("config") or {}, site.load())
|
||||||
|
draft, conflict = site.draft.update(cfg, who, site.cfg_sha, body.get("expect"))
|
||||||
|
if conflict:
|
||||||
|
changed = applying.changed_paths(cfg, conflict["config"])
|
||||||
|
return self.send(409, {"error": "%s changed this a moment ago" % conflict.get("by", "someone"), "draft": self.masked_draft(conflict),
|
||||||
|
"changed": [applying.words_for(p, a, b, cfg, conflict["config"])["where"] for p, a, b in changed[:5]]})
|
||||||
|
return self.send(200, {"draft": self.masked_draft(draft)})
|
||||||
|
if path == "/api/draft" and self.command == "DELETE":
|
||||||
|
site.draft.clear()
|
||||||
|
self.discard_atem()
|
||||||
|
return self.send(200, {"ok": True})
|
||||||
|
if path == "/api/validate":
|
||||||
|
cfg = schema.unmask(body.get("config") or (site.draft.read() or {}).get("config") or site.load(), site.load())
|
||||||
|
return self.send(200, self.check(cfg))
|
||||||
|
if path == "/api/apply":
|
||||||
|
draft = site.draft.read()
|
||||||
|
if not draft:
|
||||||
|
return self.send(400, {"error": "there is no draft to apply"})
|
||||||
|
if draft.get("base_sha") != site.cfg_sha:
|
||||||
|
return self.send(409, {"error": "the live settings changed since this draft was started - review it again",
|
||||||
|
"changed_since": True})
|
||||||
|
atem = os.path.join(site.data_dir, "draft-atem.xml")
|
||||||
|
r = site.applier.start(draft["config"], str(body.get("note", ""))[:200], who, self.client_address[0], body.get("emergency"),
|
||||||
|
atem if os.path.exists(atem) else None)
|
||||||
|
if r.get("ok"):
|
||||||
|
site.draft.clear()
|
||||||
|
return self.send(200 if r.get("ok") else 409, r)
|
||||||
|
if path.startswith("/api/restore/"):
|
||||||
|
entry = path[len("/api/restore/"):]
|
||||||
|
cfg = applying.history_config(site.data_dir, entry)
|
||||||
|
if cfg is None:
|
||||||
|
return self.send(404, {"error": "no such version"})
|
||||||
|
draft, _ = site.draft.update(cfg, who, site.cfg_sha)
|
||||||
|
return self.send(200, {"draft": self.masked_draft(draft), "from": entry})
|
||||||
|
if path == "/api/undo":
|
||||||
|
entries = [e for e in applying.history_entries(site.data_dir) if e["kind"] == "apply"]
|
||||||
|
if not entries:
|
||||||
|
return self.send(404, {"error": "nothing to undo"})
|
||||||
|
cfg = applying.history_config(site.data_dir, entries[0]["id"])
|
||||||
|
draft, _ = site.draft.update(cfg, who, site.cfg_sha)
|
||||||
|
return self.send(200, {"draft": self.masked_draft(draft), "from": entries[0]["id"], "note": entries[0]["note"]})
|
||||||
|
if path in ("/api/watchdog/restart", "/api/watchdog/stop", "/api/watchdog/start"):
|
||||||
|
live, reason = site.applier.live()
|
||||||
|
if live and path != "/api/watchdog/start":
|
||||||
|
return self.send(409, {"error": "live now (%s)" % reason})
|
||||||
|
if path.endswith("/stop") and body.get("word") != "STOP":
|
||||||
|
return self.send(400, {"error": "type STOP to confirm"})
|
||||||
|
verb = path.rsplit("/", 1)[1]
|
||||||
|
return self.send(200, self.watchdog_control(verb, who))
|
||||||
|
if path == "/api/command":
|
||||||
|
text = str(body.get("text", "")).strip()
|
||||||
|
verb = text.split(" ")[0].lower() if text else ""
|
||||||
|
if verb not in ("test", "resurvey", "service"):
|
||||||
|
return self.send(400, {"error": "not a command the page may send"})
|
||||||
|
if verb == "test":
|
||||||
|
live, reason = site.applier.live()
|
||||||
|
if live:
|
||||||
|
return self.send(409, {"error": "live now (%s): no test runs" % reason})
|
||||||
|
text = "%s %d" % (text, int(time.time()))
|
||||||
|
names = site.cfg["variables"]
|
||||||
|
cur = site.comp.get_custom(names["command"]) or ""
|
||||||
|
ok, status = site.comp.set_custom(names["command"], (cur + ";" + text) if cur.strip() else text)
|
||||||
|
log.info("%s sent command %r -> %s", who, text, ok)
|
||||||
|
return self.send(200 if ok else 502, {"ok": ok, "sent": text})
|
||||||
|
if path == "/api/atem-reference":
|
||||||
|
return self.send(200, self.take_atem(raw))
|
||||||
|
if path == "/api/atem-reference/pick-newest":
|
||||||
|
folder = os.path.expanduser((site.cfg.get("atem") or {}).get("source_folder") or "")
|
||||||
|
files = sorted(glob.glob(os.path.join(folder, "*.xml")), key=os.path.getmtime) if folder and os.path.isdir(folder) else []
|
||||||
|
if not files:
|
||||||
|
return self.send(404, {"error": "no .xml files in the folder (is Google Drive signed in?)"})
|
||||||
|
with open(files[-1], "rb") as f:
|
||||||
|
return self.send(200, dict(self.take_atem(f.read()), file=os.path.basename(files[-1])))
|
||||||
|
if path == "/api/atem-reference/discard":
|
||||||
|
self.discard_atem()
|
||||||
|
return self.send(200, {"ok": True})
|
||||||
|
if path == "/api/gear":
|
||||||
|
cfg = site.load()
|
||||||
|
gear = dict(cfg.get("gear") or {})
|
||||||
|
name = str(body.get("name", ""))[:80]
|
||||||
|
gear[name] = {"location": str(body.get("location", ""))[:120], "notes": str(body.get("notes", ""))[:300]}
|
||||||
|
draft = site.draft.read() or {"config": cfg}
|
||||||
|
draft_cfg = dict(draft["config"], gear=gear)
|
||||||
|
d, _ = site.draft.update(draft_cfg, who, site.cfg_sha)
|
||||||
|
return self.send(200, {"draft": self.masked_draft(d)})
|
||||||
return self.send(404, {"error": "not found"})
|
return self.send(404, {"error": "not found"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("POST %s", path)
|
log.exception("%s %s", self.command, path)
|
||||||
return self.send(500, {"error": str(e)})
|
return self.send(500, {"error": str(e)})
|
||||||
|
|
||||||
|
def masked_draft(self, draft):
|
||||||
|
return dict(draft, config=schema.mask_secrets(draft["config"]))
|
||||||
|
|
||||||
|
def check(self, cfg):
|
||||||
|
"""Everything Review shows: errors, warnings, the plain-words changes, what Apply will do."""
|
||||||
|
site = self.site
|
||||||
|
live_cfg = site.load()
|
||||||
|
try:
|
||||||
|
conns = {c["label"]: c.get("moduleId") for c in site.comp.connections()}
|
||||||
|
except Exception:
|
||||||
|
conns = None
|
||||||
|
errors, warnings = schema.validate(cfg, {"connections": conns} if conns else None)
|
||||||
|
tmp = tempfile.NamedTemporaryFile("wb", suffix=".json", delete=False)
|
||||||
|
tmp.write(applying.config_text(cfg)); tmp.close()
|
||||||
|
try:
|
||||||
|
sp.load_config(tmp.name)
|
||||||
|
except (ValueError, KeyError, TypeError) as e:
|
||||||
|
errors.append("the watchdog would not accept it: %s" % e)
|
||||||
|
finally:
|
||||||
|
os.remove(tmp.name)
|
||||||
|
plan = applying.what_apply_does(live_cfg, cfg, site.applier.hashes()) if not errors else {"restart": False, "pages": [], "nav": False, "changes": applying.changed_paths(live_cfg, cfg)}
|
||||||
|
changes = [applying.words_for(p, a, b, live_cfg, cfg) for p, a, b in plan["changes"]]
|
||||||
|
live, reason = site.applier.live()
|
||||||
|
atem_pending = os.path.exists(os.path.join(site.data_dir, "draft-atem.xml"))
|
||||||
|
return {"errors": errors, "warnings": warnings, "changes": changes, "plan": {k: plan[k] for k in ("restart", "pages", "nav")},
|
||||||
|
"live": {"live": live, "reason": reason}, "atem_pending": atem_pending, "feel": self.feel(plan, atem_pending)}
|
||||||
|
|
||||||
|
def feel(self, plan, atem_pending):
|
||||||
|
if plan.get("error"):
|
||||||
|
return "The pages could not be built: " + plan["error"]
|
||||||
|
parts = []
|
||||||
|
if plan["restart"] or atem_pending:
|
||||||
|
parts.append("the deck goes purple for about 5 seconds and comes back with your changes")
|
||||||
|
if plan["pages"]:
|
||||||
|
parts.append("the %s re-imported" % " and ".join("%s page is" % p.capitalize() for p in plan["pages"] if p in ("status", "restart")))
|
||||||
|
if plan["nav"]:
|
||||||
|
parts.append("the page-number keys are recoloured")
|
||||||
|
return (", ".join(parts) + ".").capitalize() if parts else "Nothing on the deck changes."
|
||||||
|
|
||||||
|
def take_atem(self, raw):
|
||||||
|
"""An uploaded saved switcher file: capped, no DOCTYPE or entities, parsed with the watchdog's own
|
||||||
|
parser; kept as draft-atem.xml until Apply. Returns what differs from the current file."""
|
||||||
|
site = self.site
|
||||||
|
if len(raw) > 20 * 1024 * 1024:
|
||||||
|
return {"ok": False, "error": "the file is bigger than 20 MB"}
|
||||||
|
head = raw[:4096].decode("utf-8", "replace")
|
||||||
|
if "<!DOCTYPE" in head or "<!ENTITY" in head:
|
||||||
|
return {"ok": False, "error": "the file holds a DOCTYPE or entities; a saved switcher file never does"}
|
||||||
|
pending = os.path.join(site.data_dir, "draft-atem.xml")
|
||||||
|
with open(pending + ".tmp", "wb") as f:
|
||||||
|
f.write(raw)
|
||||||
|
try:
|
||||||
|
new = sp.atem_reference(pending + ".tmp", (site.cfg.get("atem") or {}).get("compare"))
|
||||||
|
except Exception as e:
|
||||||
|
os.remove(pending + ".tmp")
|
||||||
|
return {"ok": False, "error": "not a saved switcher file: %s" % e}
|
||||||
|
os.replace(pending + ".tmp", pending)
|
||||||
|
os.chmod(pending, 0o600)
|
||||||
|
current = os.path.join(site.data_dir, "atem-reference.xml")
|
||||||
|
try:
|
||||||
|
old = sp.atem_reference(current, (site.cfg.get("atem") or {}).get("compare"))
|
||||||
|
except Exception:
|
||||||
|
old = {"audio": {}, "camera": {}}
|
||||||
|
product = re.search(r'product="([^"]+)"', head)
|
||||||
|
diffs = []
|
||||||
|
for part in ("audio", "camera"):
|
||||||
|
for k in sorted(set(old[part]) | set(new[part])):
|
||||||
|
if old[part].get(k) != new[part].get(k):
|
||||||
|
diffs.append({"part": part, "key": k, "before": old[part].get(k), "after": new[part].get(k)})
|
||||||
|
return {"ok": True, "product": product.group(1) if product else None, "audio": len(new["audio"]), "camera": len(new["camera"]),
|
||||||
|
"diffs": diffs[:50], "diff_count": len(diffs)}
|
||||||
|
|
||||||
|
def discard_atem(self):
|
||||||
|
try:
|
||||||
|
os.remove(os.path.join(self.site.data_dir, "draft-atem.xml"))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def watchdog_control(self, verb, who):
|
||||||
|
domain = "gui/%d" % os.getuid()
|
||||||
|
label = applying.WATCHDOG
|
||||||
|
plist = os.path.expanduser("~/Library/LaunchAgents/%s.plist" % label)
|
||||||
|
try:
|
||||||
|
if verb == "stop":
|
||||||
|
r = sp.subprocess.run(["/bin/launchctl", "bootout", "%s/%s" % (domain, label)], capture_output=True, text=True, timeout=30)
|
||||||
|
log.info("%s stopped the watchdog -> %s", who, r.returncode)
|
||||||
|
return {"ok": r.returncode == 0, "detail": (r.stderr or "stopped").strip()[-200:]}
|
||||||
|
if verb == "start":
|
||||||
|
r = sp.subprocess.run(["/bin/launchctl", "bootstrap", domain, plist], capture_output=True, text=True, timeout=30)
|
||||||
|
log.info("%s started the watchdog -> %s", who, r.returncode)
|
||||||
|
return {"ok": r.returncode == 0, "detail": (r.stderr or "started").strip()[-200:]}
|
||||||
|
ok, detail = self.site.applier.restart_watchdog(None)
|
||||||
|
log.info("%s restarted the watchdog -> %s %s", who, ok, detail)
|
||||||
|
return {"ok": ok, "detail": detail}
|
||||||
|
except (OSError, sp.subprocess.TimeoutExpired) as e:
|
||||||
|
return {"ok": False, "detail": str(e)}
|
||||||
|
|
||||||
def static(self, name, ctype):
|
def static(self, name, ctype):
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(UI, name), "rb") as f:
|
with open(os.path.join(UI, name), "rb") as f:
|
||||||
|
|||||||
+459
-134
@@ -1,119 +1,275 @@
|
|||||||
/* The settings page, step 1: every screen, read-only. Plain JS, no framework. */
|
/* The settings page. Plain JS, no framework. Everything is readable; with Editing on (the editing
|
||||||
|
code + your first name) the same screens grow inputs; changes live in one shared draft on the server
|
||||||
|
until Review & Apply. */
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
const main = document.getElementById("main");
|
const main = document.getElementById("main");
|
||||||
const state = { config: null, schema: null, keys: null, editing: null, name: null };
|
const state = { live: null, liveSha: null, schema: null, draft: null, draftStale: false, atemPending: false, editing: null, name: null, saveTimer: null };
|
||||||
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
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 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 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 hex = (n) => "#" + (n >>> 0).toString(16).padStart(6, "0").slice(-6);
|
||||||
const lightDot = (l) => `<i class="light ${esc(l || "unknown")}"></i>`;
|
const lightDot = (l) => `<i class="light ${esc(l || "unknown")}"></i>`;
|
||||||
const wordOf = (w) => `<span class="word ${esc((w || "").split(" ")[0])}">${esc(w)}</span>`;
|
const wordOf = (w) => `<span class="word ${esc((w || "").split(" ")[0])}">${esc(w)}</span>`;
|
||||||
|
const deep = (o) => JSON.parse(JSON.stringify(o));
|
||||||
|
const canon = (v) => Array.isArray(v) ? "[" + v.map(canon).join(",") + "]" : v && typeof v === "object" ? "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canon(v[k])).join(",") + "}" : JSON.stringify(v);
|
||||||
|
const cfg = () => (state.draft ? state.draft.config : state.live);
|
||||||
|
const editing = () => !!state.editing;
|
||||||
|
const store = { get: (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch (e) {} } };
|
||||||
|
const closes = (f) => /pkill|taskkill|killall/.test(f.run || "") && !f.restart_ok;
|
||||||
|
|
||||||
// ---- top bar -------------------------------------------------------------
|
async function api(path, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const headers = Object.assign({ "Content-Type": "application/json" }, opts.headers || {});
|
||||||
|
if (state.editing) { headers["X-Edit-Key"] = state.editing; headers["X-Editor"] = state.name || ""; }
|
||||||
|
const r = await fetch(path, Object.assign({}, opts, { headers }));
|
||||||
|
let body = null;
|
||||||
|
try { body = await r.json(); } catch (e) { body = {}; }
|
||||||
|
if (!r.ok) { const err = new Error(body.error || r.statusText); err.status = r.status; err.body = body; throw err; }
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
const post = (path, body, method) => api(path, { method: method || "POST", body: JSON.stringify(body || {}) });
|
||||||
|
|
||||||
|
// ---- top bar: legend, editing code ----------------------------------------
|
||||||
document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; };
|
document.getElementById("legendbtn").onclick = () => { const l = document.getElementById("legend"); l.hidden = !l.hidden; };
|
||||||
const editbtn = document.getElementById("editbtn");
|
const editbtn = document.getElementById("editbtn");
|
||||||
|
function showEditing() {
|
||||||
|
editbtn.textContent = state.editing ? "Editing as " + state.name : "Editing: off";
|
||||||
|
editbtn.classList.toggle("on", !!state.editing);
|
||||||
|
document.body.classList.toggle("editing", !!state.editing);
|
||||||
|
}
|
||||||
|
state.editing = store.get("sp-code"); state.name = store.get("sp-name");
|
||||||
|
showEditing();
|
||||||
editbtn.onclick = () => {
|
editbtn.onclick = () => {
|
||||||
if (state.editing) { state.editing = null; editbtn.textContent = "Editing: off"; editbtn.classList.remove("on"); return; }
|
if (state.editing) { state.editing = null; state.name = null; store.set("sp-code", null); store.set("sp-name", null); showEditing(); route(); 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>
|
dialog(`<h3>Switch Editing on</h3><p class="help">The editing code is in the campus's settings file in the repo; ask Chris.</p>
|
||||||
<input id="dcode" placeholder="editing code" autocomplete="off"><input id="dname" placeholder="your first name" autocomplete="off">
|
<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) => {
|
<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("#dcancel").onclick = closeDialog;
|
||||||
box.querySelector("#dok").onclick = async () => {
|
const go = async () => {
|
||||||
const code = box.querySelector("#dcode").value, name = box.querySelector("#dname").value.trim();
|
const code = box.querySelector("#dcode").value, name = box.querySelector("#dname").value.trim();
|
||||||
const r = await post("/api/editing/check", { code });
|
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(); }
|
if (r.ok && name) { state.editing = code; state.name = name; store.set("sp-code", code); store.set("sp-name", name); showEditing(); closeDialog(); route(); }
|
||||||
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).`;
|
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).`;
|
||||||
};
|
};
|
||||||
|
box.querySelector("#dok").onclick = go;
|
||||||
|
box.querySelector("#dname").onkeydown = (e) => { if (e.key === "Enter") go(); };
|
||||||
|
box.querySelector("#dcode").focus();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
function dialog(html, wire) { const d = document.getElementById("dialog"); d.innerHTML = `<div class="box">${html}</div>`; d.hidden = false; wire(d.querySelector(".box")); }
|
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; }
|
function closeDialog() { document.getElementById("dialog").hidden = true; }
|
||||||
|
function alertBox(msg) { dialog(`<p>${esc(msg)}</p><button id="dok" class="primary">OK</button>`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); }
|
||||||
|
function confirmBox(msg, label, onOk, word) {
|
||||||
|
dialog(`<p>${esc(msg)}</p>${word ? `<input id="dword" placeholder="type ${esc(word)} to confirm" autocomplete="off">` : ""}<div class="row"><button class="primary" id="dok">${esc(label)}</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||||||
|
b.querySelector("#dcancel").onclick = closeDialog;
|
||||||
|
b.querySelector("#dok").onclick = () => { if (word && b.querySelector("#dword").value.trim() !== word) return; closeDialog(); onOk(); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- search --------------------------------------------------------------
|
// ---- the draft ------------------------------------------------------------
|
||||||
|
async function loadAll() {
|
||||||
|
const [c, s, d] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft")]);
|
||||||
|
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending;
|
||||||
|
document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
|
||||||
|
badge();
|
||||||
|
}
|
||||||
|
function ensureDraft() {
|
||||||
|
if (!editing()) { alertBox("Switch Editing on first (top right)."); return false; }
|
||||||
|
if (!state.draft) state.draft = { config: deep(state.live), version: null, by: state.name };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function changed() {
|
||||||
|
if (!state.draft) return 0;
|
||||||
|
let n = 0;
|
||||||
|
const walk = (a, b) => {
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) { if (canon(a) !== canon(b)) n++; return; }
|
||||||
|
if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) { for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) walk(a[k], b[k]); return; }
|
||||||
|
if (canon(a) !== canon(b)) n++;
|
||||||
|
};
|
||||||
|
walk(state.live, state.draft.config);
|
||||||
|
return n + (state.atemPending ? 1 : 0);
|
||||||
|
}
|
||||||
|
function badge() {
|
||||||
|
let b = document.getElementById("badge");
|
||||||
|
if (!b) { b = document.createElement("a"); b.id = "badge"; b.className = "badge"; b.href = "#review"; document.querySelector(".topright").prepend(b); }
|
||||||
|
const n = changed();
|
||||||
|
b.hidden = !n && !state.draft;
|
||||||
|
b.textContent = n ? `${n} change${n > 1 ? "s" : ""} – Review & Apply` : "Draft – Review";
|
||||||
|
b.classList.toggle("warn", !!state.draftStale);
|
||||||
|
}
|
||||||
|
function saveDraft() {
|
||||||
|
badge();
|
||||||
|
clearTimeout(state.saveTimer);
|
||||||
|
state.saveTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT");
|
||||||
|
state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); }
|
||||||
|
else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); }
|
||||||
|
else alertBox("Could not save the draft: " + e.message);
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
// set a value at a dotted path in the draft (creating objects on the way)
|
||||||
|
function setPath(path, value) {
|
||||||
|
if (!ensureDraft()) return;
|
||||||
|
const parts = path.split("."); let o = state.draft.config;
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) { if (o[parts[i]] == null || typeof o[parts[i]] !== "object") o[parts[i]] = {}; o = o[parts[i]]; }
|
||||||
|
if (value === undefined) delete o[parts[parts.length - 1]]; else o[parts[parts.length - 1]] = value;
|
||||||
|
saveDraft();
|
||||||
|
}
|
||||||
|
const getPath = (path, from) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), from || cfg());
|
||||||
|
|
||||||
|
// ---- generic inputs from the registry -----------------------------------
|
||||||
|
function parse(f, raw) {
|
||||||
|
if (f.type === "int") { const n = parseInt(raw, 10); return isNaN(n) ? undefined : n; }
|
||||||
|
if (f.type === "float") { const n = parseFloat(raw); return isNaN(n) ? undefined : n; }
|
||||||
|
if (f.type === "bool") return !!raw;
|
||||||
|
if (f.type === "list") return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (f.type === "dict") { try { return raw.trim() ? JSON.parse(raw) : undefined; } catch (e) { return undefined; } }
|
||||||
|
return raw === "" ? undefined : raw;
|
||||||
|
}
|
||||||
|
function inputHtml(path, f, value, extra) {
|
||||||
|
if (!f) return "";
|
||||||
|
const id = "f-" + path.replace(/[^A-Za-z0-9]/g, "-");
|
||||||
|
const dis = editing() ? "" : "disabled";
|
||||||
|
let html;
|
||||||
|
if (f.type === "bool") html = `<input type="checkbox" id="${id}" data-path="${esc(path)}" ${value ? "checked" : ""} ${dis}>`;
|
||||||
|
else if (f.type === "enum" && f.options) html = `<select id="${id}" data-path="${esc(path)}" ${dis}><option value="">–</option>${f.options.map((o) => `<option ${value === o ? "selected" : ""}>${esc(o)}</option>`).join("")}</select>`;
|
||||||
|
else if (f.type === "dict") html = `<textarea id="${id}" data-path="${esc(path)}" rows="2" ${dis}>${esc(value == null ? "" : JSON.stringify(value))}</textarea>`;
|
||||||
|
else if (f.type === "list") html = `<input id="${id}" data-path="${esc(path)}" value="${esc(Array.isArray(value) ? value.join(", ") : value || "")}" ${dis}>`;
|
||||||
|
else html = `<input id="${id}" data-path="${esc(path)}" type="${f.type === "int" || f.type === "float" ? "number" : f.secret ? "password" : "text"}" value="${esc(value == null ? "" : value)}" ${f.lo != null ? `min="${f.lo}"` : ""} ${f.hi != null ? `max="${f.hi}"` : ""} ${f.type === "float" ? 'step="any"' : ""} placeholder="${esc(f.default == null || typeof f.default === "object" ? "" : f.default)}" ${dis}>`;
|
||||||
|
const dflt = f.default != null && typeof f.default !== "object" && f.default !== "" ? ` <span class="muted">(default ${esc(f.default)})</span>` : "";
|
||||||
|
return `<div class="field"><label for="${id}">${esc(path.split(".").pop().replace(/_/g, " "))}${f.required ? " *" : ""}</label>${html}<div class="help">${esc(f.help)}${dflt}${extra || ""}</div></div>`;
|
||||||
|
}
|
||||||
|
function wireInputs(root) {
|
||||||
|
for (const el of root.querySelectorAll("[data-path]")) {
|
||||||
|
if (el.onchange) continue; // a screen wired it itself
|
||||||
|
el.onchange = () => {
|
||||||
|
const path = el.dataset.path, f = fieldFor(path);
|
||||||
|
const v = el.type === "checkbox" ? el.checked : el.value;
|
||||||
|
setPath(path, f ? parse(f, v) : v);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fieldFor(path) {
|
||||||
|
const F = state.schema.fields;
|
||||||
|
if (F[path]) return F[path];
|
||||||
|
const generic = path.replace(/^tiles\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^tiles\.\d+\./, "tiles[].")
|
||||||
|
.replace(/^restart_page\.keys\.\d+\./, "restart_page.keys[].").replace(/^fixes\.[^.]+\./, "fixes.*.").replace(/^hosts\.[^.]+\./, "hosts.*.")
|
||||||
|
.replace(/^gear\.[^.]+\./, "gear.*.").replace(/^(auto_fix\.windows|site\.live_services|service_windows)\.\d+\./, "$1[].");
|
||||||
|
return F[generic] || null;
|
||||||
|
}
|
||||||
|
const F = (name) => state.schema.fields[name];
|
||||||
|
|
||||||
|
// ---- search ----------------------------------------------------------------
|
||||||
const search = document.getElementById("search"), results = document.getElementById("searchresults");
|
const search = document.getElementById("search"), results = document.getElementById("searchresults");
|
||||||
search.oninput = () => {
|
search.oninput = () => {
|
||||||
const q = search.value.trim().toLowerCase();
|
const q = search.value.trim().toLowerCase();
|
||||||
if (!q || !state.config) { results.hidden = true; return; }
|
if (!q || !cfg()) { results.hidden = true; return; }
|
||||||
const hits = [];
|
const c = cfg(), hits = [];
|
||||||
const cfg = state.config;
|
c.tiles.forEach((t) => { if ((t.label + " " + t.id).toLowerCase().includes(q)) hits.push([`Key ${t.label}`, `#keys/${t.id}`]); });
|
||||||
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 (c.restart_page || { keys: [] }).keys) if (k.label.toLowerCase().includes(q)) hits.push([`Restart key ${k.label}`, `#keys/restart/${k.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(c.hosts || {})) if ((h.name || a).toLowerCase().includes(q) || (h.role || "").toLowerCase().includes(q)) hits.push([`Computer ${h.name || a}`, `#computers/${a}`]);
|
||||||
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(c.fixes || {})) if ((id + " " + (f.label || "") + " " + (f.short || "")).toLowerCase().includes(q)) hits.push([`Fix ${f.short || id}: ${f.label || ""}`, `#fixes/${id}`]);
|
||||||
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.fields)) if (!path.includes("[]") && !path.includes("*") && (path + " " + f.help).toLowerCase().includes(q)) hits.push([`Setting ${path}`, `#site/${f.screen.toLowerCase().replace(/[^a-z]+/g, "-")}`]);
|
||||||
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.innerHTML = hits.slice(0, 20).map(([t, h]) => `<a href="${h}">${esc(t)}</a>`).join("") || `<a>Nothing called "${esc(q)}"</a>`;
|
||||||
results.hidden = false;
|
results.hidden = false;
|
||||||
};
|
};
|
||||||
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
|
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
|
||||||
|
|
||||||
// ---- routing -------------------------------------------------------------
|
// ---- routing ---------------------------------------------------------------
|
||||||
const screens = { overview, keys, computers, logs, history, help, site, fixes };
|
const screens = { overview, keys, computers, logs, history, help, site, fixes, review };
|
||||||
let timer = null;
|
let timer = null;
|
||||||
async function route() {
|
async function route() {
|
||||||
const [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
|
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);
|
for (const a of document.querySelectorAll(".menu a")) a.classList.toggle("active", a.getAttribute("href") === "#" + name);
|
||||||
clearInterval(timer); timer = null;
|
clearInterval(timer); timer = null;
|
||||||
if (!state.config) {
|
try {
|
||||||
const [c, s] = await Promise.all([api("/api/config"), api("/api/schema")]);
|
if (!state.live) await loadAll();
|
||||||
state.config = c.config; state.schema = s;
|
const fn = screens[name] || overview;
|
||||||
document.getElementById("sitename").textContent = (state.config.site || {}).name || "";
|
await fn(rest);
|
||||||
}
|
wireInputs(main);
|
||||||
const fn = screens[name] || overview;
|
const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0])); // never under a typist's hands
|
||||||
try { await fn(rest); } catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
|
if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000);
|
||||||
if (name === "overview" || name === "computers") timer = setInterval(() => fn(rest, true).catch(() => {}), 5000);
|
} catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
|
||||||
}
|
}
|
||||||
window.addEventListener("hashchange", route);
|
window.addEventListener("hashchange", route);
|
||||||
route();
|
route();
|
||||||
|
|
||||||
// ---- Overview ------------------------------------------------------------
|
// ---- Overview --------------------------------------------------------------
|
||||||
async function overview(_, quiet) {
|
async function overview(_, quiet) {
|
||||||
|
if (quiet && document.activeElement && document.activeElement.tagName === "INPUT") return; // never pull the field out from under a typist
|
||||||
const o = await api("/api/overview");
|
const o = await api("/api/overview");
|
||||||
const dot = document.getElementById("hbdot");
|
const dot = document.getElementById("hbdot");
|
||||||
dot.className = "dot " + (o.heartbeat_age != null && o.heartbeat_age < 15 ? "ok" : "bad");
|
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 isLive = o.live && o.live.live;
|
||||||
const needs = o.needs.length ? o.needs.map((n) => `<div class="notice grey">Needs Chris: ${esc(n)}</div>`).join("") : "";
|
const runnable = (p) => {
|
||||||
const live = o.live && o.live.live ? `<div class="notice">Live now: ${esc(o.live.reason)}. Changes to the panel wait.</div>` : "";
|
const t = cfg().tiles.find((x) => x.id === p.key); if (!t || !editing() || isLive) return "";
|
||||||
|
const fixes = (t.checks || []).map((c, j) => [typeof c.fix === "string" ? c.fix : null, j]).filter(([f]) => f && canRunNow(f));
|
||||||
|
return fixes.slice(0, 1).map(([f, j]) => ` <button class="small" data-run="${esc(t.id)}|${esc(f)}|${j}">Run the fix now</button>`).join("");
|
||||||
|
};
|
||||||
|
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>${runnable(p)}</li>`).join("") : `<li class="muted">Everything is fine.</li>`;
|
||||||
|
const needs = (o.needs || []).map((n) => `<div class="notice grey">Needs Chris: ${esc(n)}</div>`).join("");
|
||||||
|
const failed = o.failed_last ? `<div class="notice grey">The last Apply was put back: ${esc(o.failed_last.why || "")} <a href="#history">History</a></div>` : "";
|
||||||
|
const live = isLive ? `<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 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;
|
const w = o.watchdog;
|
||||||
|
const dis = editing() ? "" : "disabled title='switch Editing on'";
|
||||||
main.innerHTML = `
|
main.innerHTML = `
|
||||||
<h1>${o.problems.length ? o.problems.length + " problem" + (o.problems.length > 1 ? "s" : "") + " in the room" : "Everything is fine"}</h1>
|
<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>
|
<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}
|
<ul class="problems">${prob}</ul>${needs}${failed}${live}
|
||||||
<div class="cols">
|
<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><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, cfg(), "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 class="row" style="margin-top:10px"><button disabled title="arrives with step 4">Change the playlist</button><button disabled title="arrives with step 4">Add or remove a HyperDeck</button><button disabled title="arrives with step 4">Add a MIDI link</button></div></div>
|
||||||
<div><h2>Computers</h2><div class="tablewrap"><table>${comps}</table></div></div>
|
<div><h2>Computers</h2><div class="tablewrap"><table>${comps}</table></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<dl class="kv">
|
<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>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>Companion</dt><dd>${o.companion_ok ? "OK" : "<b>not answering</b>"}${o.pages && 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 == null ? "<span class='muted'>not reported yet</span>" : o.service_on ? "ON" : "silenced until midnight"}${o.alarms_override != null ? " (by hand)" : ""} <span class="muted small">· ${esc(o.phrases.alarms)}</span></dd>
|
<dt>Alarms</dt><dd>${o.service_on == null ? "<span class='muted'>not reported yet</span>" : 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>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>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>
|
<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>
|
</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 class="row" style="margin-top:10px">
|
||||||
|
<button id="restartwd" ${dis}>${w.running ? "Restart the watchdog" : "Start the watchdog"}</button>
|
||||||
|
<button id="alarms" ${dis}>${o.service_on === false ? "Alarms back on" : "Silence alarms until midnight"}</button>
|
||||||
|
${w.running ? `<button id="stopwd" ${dis}>Stop the watchdog</button>` : ""}
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
main.querySelector("#restartwd").onclick = () => confirmBox(w.running ? "Restart the watchdog? The deck goes purple for about 5 seconds; a fix that is running finishes first." : "Start the watchdog?", w.running ? "Restart" : "Start", async () => { try { const r = await post(w.running ? "/api/watchdog/restart" : "/api/watchdog/start"); alertBox(r.ok ? "Done: " + r.detail : "Not done: " + r.detail); } catch (e) { alertBox(e.message); } });
|
||||||
|
const stop = main.querySelector("#stopwd"); if (stop) stop.onclick = () => confirmBox("Stop the watchdog? The deck goes purple and every alarm is off until it is started again.", "Stop", async () => { try { const r = await post("/api/watchdog/stop", { word: "STOP" }); alertBox(r.ok ? "Stopped." : "Not stopped: " + r.detail); } catch (e) { alertBox(e.message); } }, "STOP");
|
||||||
|
main.querySelector("#alarms").onclick = async () => { try { await post("/api/command", { text: o.service_on === false ? "service on" : "service off" }); setTimeout(() => overview(_, true), 1500); } catch (e) { alertBox(e.message); } };
|
||||||
|
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
|
||||||
if (!quiet) window.scrollTo(0, 0);
|
if (!quiet) window.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
|
function canRunNow(fixId) {
|
||||||
|
const f = cfg().fixes[fixId]; if (!f) return false;
|
||||||
|
if (["reconnect", "hyperdeck", "midi_connect", "spotify"].includes(f.kind)) return true;
|
||||||
|
if ((f.kind || "ssh") === "ssh" || f.kind === "local") return !closes(f) && !f.never_auto;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function runNow(key, fixId, which) {
|
||||||
|
const f = cfg().fixes[fixId];
|
||||||
|
confirmBox(`Run "${f.label || fixId}" now? It runs through the watchdog exactly like a hold on the ${key.toUpperCase()} key.`, "Run it", async () => {
|
||||||
|
try { await post("/api/command", { text: `test ${key} ${fixId} ${which}` }); alertBox("Sent. Watch the key, or Logs › Watchdog (TEST)."); } catch (e) { alertBox(e.message); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function deckHtml(deck, cfg, which, selected) {
|
function deckHtml(deck, c, which, selected) {
|
||||||
const cells = {};
|
const cells = {};
|
||||||
const rp = cfg.restart_page;
|
const rp = c.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" }; }
|
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" }; }
|
else { for (const t of c.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" };
|
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : c.page), "2,0": "DOWN" };
|
||||||
let out = `<div class="deck">`;
|
let out = `<div class="deck">`;
|
||||||
for (let r = 0; r < 4; r++) for (let c = 0; c < 8; c++) {
|
for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) {
|
||||||
const k = `${r},${c}`, cell = cells[k];
|
const k = `${r},${col}`, cell = cells[k];
|
||||||
if (nav[k]) out += `<div class="key nav">${esc(nav[k])}</div>`;
|
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 && cell.nav) out += `<div class="key nav">${esc(cell.nav)}</div>`;
|
||||||
else if (cell) {
|
else if (cell) {
|
||||||
@@ -126,99 +282,142 @@
|
|||||||
return out + `</div>`;
|
return out + `</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Keys ----------------------------------------------------------------
|
// ---- Keys ------------------------------------------------------------------
|
||||||
async function keys(rest) {
|
async function keys(rest) {
|
||||||
const k = state.keys || (state.keys = await api("/api/keys"));
|
|
||||||
const o = await api("/api/overview").catch(() => ({ deck: null }));
|
const o = await api("/api/overview").catch(() => ({ deck: null }));
|
||||||
|
const c = cfg();
|
||||||
const restart = rest[0] === "restart";
|
const restart = rest[0] === "restart";
|
||||||
const sel = restart ? rest[1] : rest[0];
|
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>`;
|
const hold = ((c.page_hold_ms || 2000) / 1000) + " s";
|
||||||
let panel = `<p class="muted">Click a key to see what it watches and what holding it does.</p>`;
|
const tabs = `<div class="tabs"><button class="${restart ? "" : "on"}" onclick="location.hash='#keys'">Status page (page ${c.page})</button>${c.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page (page ${c.restart_page.page})</button>` : ""}</div>`;
|
||||||
if (sel && !restart) {
|
let panel = `<p class="muted">Click a key to see what it watches and what holding it does.${editing() ? " With Editing on you can change it here." : ""}</p>`;
|
||||||
const t = k.tiles.find((x) => x.id === sel);
|
if (sel && !restart) { const i = c.tiles.findIndex((x) => x.id === sel); if (i >= 0) panel = await keyPanel(i, hold); }
|
||||||
if (t) panel = keyPanel(t, k);
|
else if (sel && restart && c.restart_page) { const i = c.restart_page.keys.findIndex((x) => x.id === sel); if (i >= 0) panel = restartKeyPanel(i, hold); }
|
||||||
} else if (sel && restart) {
|
const tray = c.tiles.filter((t) => !t.pos && t.kind !== "master").map((t) => `<button class="small" onclick="location.hash='#keys/${esc(t.id)}'">${esc(t.label)}</button>`).join(" ");
|
||||||
const r = k.restart_page.keys.find((x) => x.id === sel);
|
main.innerHTML = `<h1>Keys</h1><p class="what">The deck as people hold it. Click a key; with Editing on, change what it watches and what holding it does (moving keys arrives with step 3).</p>${tabs}
|
||||||
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>`;
|
<div class="cols"><div>${deckHtml(o.deck, c, 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 id="panel">${panel}</div></div>`;
|
||||||
}
|
wireKeyPanel(sel, restart);
|
||||||
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) {
|
async function keyPanel(i, hold) {
|
||||||
const cfg = state.config;
|
const c = cfg(), t = c.tiles[i];
|
||||||
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 k = await api("/api/keys");
|
||||||
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>` : "";
|
const kt = k.tiles.find((x) => x.id === t.id) || { says: [], holding: "", checks: [] };
|
||||||
|
const fixOptions = (cur) => `<option value="">none</option><option value="restart-connection" ${cur === "restart-connection" ? "selected" : ""}>reconnect its Companion connection</option><option value="dante-auto" ${cur === "dante-auto" ? "selected" : ""}>Dante auto (PoE port or the Dante module)</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${cur === id ? "selected" : ""}>${esc(f.short || id)} – ${esc(f.label || "")}</option>`).join("")}`;
|
||||||
|
const checks = (t.checks || []).map((chk, j) => {
|
||||||
|
const spec = state.schema.checks[chk.type] || { fields: [], question: "" };
|
||||||
|
const sentence = (kt.checks[j] || {}).sentence || chk.type;
|
||||||
|
if (!editing()) return `<li>${esc(sentence)}<details><summary>Advanced</summary><dl class="kv small">${Object.entries(chk).map(([n, v]) => `<dt>${esc(n)}</dt><dd class="mono">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}</dl></details></li>`;
|
||||||
|
const base = `tiles.${i}.checks.${j}.`;
|
||||||
|
const fields = (spec.fields || []).filter((f) => f !== "fix" && f !== "type").map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("");
|
||||||
|
const fixSel = typeof chk.fix === "object" && chk.fix ? `<div class="field"><label>Fix (per sub-check)</label><textarea data-path="${base}fix" rows="2" class="fixtable">${esc(JSON.stringify(chk.fix))}</textarea><div class="help">A table: sub-check → fix, "*" for everything else.</div></div>`
|
||||||
|
: `<div class="field"><label>Fix: what holding runs for this check</label><select data-path="${base}fix" class="fixsel">${fixOptions(chk.fix)}</select></div>`;
|
||||||
|
const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `<button class="small" data-run="${esc(t.id)}|${esc(chk.fix)}|${j}">Run this fix now</button>` : "";
|
||||||
|
return `<li><b>${esc(spec.question || chk.type)}</b> <span class="muted">${esc(sentence)}</span><div class="checkbox">${fields}${fixSel}
|
||||||
|
<details><summary>Advanced</summary>${["tag", "service_only", "debounce_seconds"].filter((f) => !(spec.fields || []).includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("")}<p class="mono small">type ${esc(chk.type)}</p></details>
|
||||||
|
<div class="row">${run}<button class="small danger" data-removecheck="${j}">Remove this check</button></div></div></li>`;
|
||||||
|
}).join("");
|
||||||
|
const plain = !t.members && t.kind !== "master";
|
||||||
|
const members = t.members ? (editing() ? `<div class="field"><label>Group members</label>${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => `<label class="inline"><input type="checkbox" data-member="${esc(x.id)}" ${t.members.includes(x.id) ? "checked" : ""}> ${esc(x.label)}</label>`).join(" ")}</div>` : `<p><b>Sums up:</b> ${t.members.map((m) => `<a href="#keys/${esc(m)}">${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}</a>`).join(", ")}</p>`) : "";
|
||||||
|
const addMenu = editing() && plain ? `<div class="field"><label>Add a check</label><select id="addcheck"><option value="">– pick a question –</option>${Object.entries(groupBy(state.schema.checks, (s) => s.question || "Other")).map(([q, types]) => `<optgroup label="${esc(q)}">${types.map((ty) => `<option value="${esc(ty)}">${esc(state.schema.checks[ty].words || ty)}</option>`).join("")}</optgroup>`).join("")}</select></div>` : "";
|
||||||
|
const label = editing() ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : "";
|
||||||
|
const holdAlways = editing() && plain ? `<div class="field"><label>Hold runs even when the key is green</label><select data-path="tiles.${i}.hold_always" class="holdsel"><option value="">no – only what is failing</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${t.hold_always === id ? "selected" : ""}>${esc(f.short || id)} – ${esc(f.label || "")}</option>`).join("")}</select></div>` : "";
|
||||||
|
const pos = `<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(t.id)}</dd></dl>${editing() && !t.kind ? `<div class="field"><label>Position (row, column; blank = inside its group only)</label><input id="pos" value="${esc(t.pos ? t.pos.join(", ") : "")}" placeholder="row, column"></div>` : ""}</details>`;
|
||||||
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>
|
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>` : ""}
|
${kt.says && kt.says.length ? `<p><b>WHAT THIS KEY WILL SAY:</b> <span class="mono">${esc(kt.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>
|
<p><b>HOLDING ${esc(hold)} WILL:</b> ${esc(kt.holding)}${t.hold_always ? " <span class='pill'>also when green</span>" : ""}</p>
|
||||||
${members}${checks ? `<p><b>Checks:</b></p><ol>${checks}</ol>` : ""}
|
${label}${members}${holdAlways}${checks ? `<p><b>Checks:</b></p><ol class="checks">${checks}</ol>` : ""}${addMenu}${pos}</div>`;
|
||||||
<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>`;
|
}
|
||||||
|
function groupBy(obj, fn) { const out = {}; for (const [k, v] of Object.entries(obj)) (out[fn(v)] = out[fn(v)] || []).push(k); return out; }
|
||||||
|
function wireKeyPanel(sel, restart) {
|
||||||
|
const c = cfg();
|
||||||
|
const i = restart ? -1 : c.tiles.findIndex((x) => x.id === sel);
|
||||||
|
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
|
||||||
|
for (const b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; state.draft.config.tiles[i].checks.splice(+b.dataset.removecheck, 1); saveDraft(); route(); });
|
||||||
|
for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); };
|
||||||
|
const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ type: add.value }); saveDraft(); route(); };
|
||||||
|
const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const m = pos.value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); if (m) t.pos = [+m[1], +m[2]]; else if (!pos.value.trim()) delete t.pos; else return alertBox("Row, column: two numbers, like 2, 5."); saveDraft(); route(); };
|
||||||
|
for (const s of main.querySelectorAll("select.fixsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
|
||||||
|
for (const ta of main.querySelectorAll("textarea.fixtable")) ta.onchange = () => { try { setPath(ta.dataset.path, JSON.parse(ta.value)); } catch (e) { alertBox("Not a valid table."); } };
|
||||||
|
for (const s of main.querySelectorAll("select.holdsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
|
||||||
|
for (const s of main.querySelectorAll("select.watchsel")) s.onchange = () => setPath(s.dataset.path, [...s.selectedOptions].map((o) => o.value));
|
||||||
|
}
|
||||||
|
function restartKeyPanel(i, hold) {
|
||||||
|
const c = cfg(), r = c.restart_page.keys[i];
|
||||||
|
const f = c.fixes[r.fix] || {};
|
||||||
|
const conns = new Set(); for (const t of c.tiles) for (const ch of t.checks || []) if (ch.conn) conns.add(ch.conn);
|
||||||
|
const edit = editing() ? `${inputHtml(`restart_page.keys.${i}.label`, F("restart_page.keys[].label"), r.label)}
|
||||||
|
<div class="field"><label>What holding runs</label><select data-path="restart_page.keys.${i}.fix">${Object.entries(c.fixes).map(([id, fx]) => `<option value="${esc(id)}" ${r.fix === id ? "selected" : ""}>${esc(fx.short || id)} – ${esc(fx.label || "")}</option>`).join("")}</select></div>
|
||||||
|
<div class="field"><label>Keep flashing HOLD TO FIX while these are down (hold cmd/ctrl for several)</label><select multiple size="5" class="watchsel" data-path="restart_page.keys.${i}.watch">${Object.keys(c.fixes).map((id) => `<option value="${esc(id)}" ${(r.watch || []).includes(id) ? "selected" : ""}>fix ${esc(id)}</option>`).join("")}${[...conns].sort().map((cn) => `<option value="conn:${esc(cn)}" ${(r.watch || []).includes("conn:" + cn) ? "selected" : ""}>connection ${esc(cn)}</option>`).join("")}</select></div>` : "";
|
||||||
|
return `<div class="card"><h2>${esc(r.label)}</h2><p class="muted">row ${r.pos[0]}, column ${r.pos[1]}</p><p><b>HOLDING ${esc(hold)} WILL:</b> ${esc(f.label || r.fix)}, even when the Status page says it is fine.</p>${r.watch && r.watch.length ? `<p class="small muted">Keeps flashing HOLD TO FIX while down: ${esc(r.watch.join(", "))}</p>` : ""}${edit}
|
||||||
|
${canRunNow(r.fix) && editing() ? `<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run this fix now</button>` : ""}<details><summary>Advanced</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd></dl></details></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Computers -----------------------------------------------------------
|
// ---- Computers ---------------------------------------------------------------
|
||||||
let compTab = "map";
|
let compTab = "map";
|
||||||
async function computers(rest, quiet) {
|
async function computers(rest, quiet) {
|
||||||
|
if (quiet && document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) return;
|
||||||
const m = await api("/api/machines");
|
const m = await api("/api/machines");
|
||||||
const selected = rest[0] && rest[0] !== "gear" && rest[0] !== "setup" ? rest[0] : null;
|
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";
|
compTab = rest[0] === "gear" || rest[0] === "setup" ? rest[0] : "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>
|
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>`;
|
<button id="probe" ${m.probing ? "disabled" : ""}>${m.probing ? "Looking…" : "Check computers now"}</button></div>`;
|
||||||
let body = "";
|
let body = "";
|
||||||
if (compTab === "gear") body = await gearHtml();
|
if (compTab === "gear") body = await gearHtml();
|
||||||
else if (compTab === "setup") body = setupHtml(m);
|
else if (compTab === "setup") body = setupHtml(m);
|
||||||
else {
|
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("");
|
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((cfg().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>`;
|
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);
|
const c = m.computers.find((x) => x.alias === selected);
|
||||||
if (c) body += computerCard(c);
|
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}`;
|
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");
|
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); };
|
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).then(() => wireInputs(main)), 4000); };
|
||||||
|
for (const b of main.querySelectorAll("[data-gear]")) b.onclick = async () => { const row = b.closest("tr"); try { await post("/api/gear", { name: b.dataset.gear, location: row.querySelector("[data-loc]").value, notes: row.querySelector("[data-notes]").value }); const d = await api("/api/draft"); state.draft = d.draft; badge(); b.textContent = "Saved"; } catch (e) { alertBox(e.message); } };
|
||||||
|
const runs = main.querySelector("#runs"); if (runs) runs.onchange = () => { if (!ensureDraft()) return; try { state.draft.config.hosts[selected].runs = runs.value.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l)); saveDraft(); } catch (err) { alertBox("Each line must be a valid {...} entry."); } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function computerCard(c) {
|
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 || (typeof i.detail === "object" && i.detail ? Object.entries(i.detail).map(([k, v]) => k + " " + (v === true ? "yes" : v === false ? "no" : k === "age" ? ago(v) : v)).join(", ") : i.detail || ""))}</td></tr>`).join("");
|
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 || (typeof i.detail === "object" && i.detail ? Object.entries(i.detail).map(([k, v]) => k + " " + (v === true ? "yes" : v === false ? "no" : k === "age" ? ago(v) : v)).join(", ") : i.detail || ""))}</td></tr>`).join("");
|
||||||
const found = c.found || {};
|
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 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("");
|
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("");
|
||||||
|
const base = `hosts.${c.alias}.`;
|
||||||
|
const edit = editing() ? `<details open><summary>Change this computer</summary>${["name", "role", "notes", "os"].concat(c.self ? [] : ["ssh", "mac", "wake_broadcast", "pingable"]).map((f) => inputHtml(base + f, F("hosts.*." + f), getPath(base + f))).join("")}
|
||||||
|
<div class="field"><label>What runs here (one per line, as {"kind": ..., "name": ...})</label><textarea id="runs" rows="${Math.max(4, (getPath(base + "runs") || []).length + 1)}" class="mono">${esc((getPath(base + "runs") || []).map((r) => JSON.stringify(r)).join("\n"))}</textarea><div class="help">Kinds: sshd, app (process), launchagent (label), daemon (process), task (name, exe), service (name), web (url), helper_dvs, helper_solo (label), midi_session (name, peer_of), artnet (source), folder (path), disabled (label). "watched": false for things the panel only lists.</div></div></details>` : "";
|
||||||
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>
|
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>` : ""}
|
${c.notes ? `<p class="notice grey">${esc(c.notes)}</p>` : ""}
|
||||||
<h3>What runs here</h3><div class="tablewrap"><table>${items}</table></div>
|
<h3>What runs here</h3><div class="tablewrap"><table>${items}</table></div>
|
||||||
${done ? `<ul class="small" style="list-style:none;padding:0">${done}</ul>` : ""}
|
${done ? `<ul class="small" style="list-style:none;padding:0">${done}</ul>` : ""}
|
||||||
<details><summary>Everything found on this computer</summary>${foundHtml}</details>
|
<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>` : ""}
|
${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>`;
|
${edit}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gearHtml() {
|
async function gearHtml() {
|
||||||
const g = await api("/api/gear");
|
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>
|
return `<p class="small muted">Devices that are not computers, from the keys that watch them.${editing() ? " Type where each one lives and press Save." : ""}</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>`;
|
${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">${editing() ? `<input data-loc value="${esc(r.location || "")}" placeholder="where it is"> <input data-notes value="${esc(r.notes || "")}" placeholder="notes"> <button class="small" data-gear="${esc(r.name)}">Save</button>` : `${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((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</a>`).join(", ")}</td></tr>`).join("")}</table></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupHtml(m) {
|
function setupHtml(m) {
|
||||||
return m.computers.map((c) => {
|
return m.computers.map((c) => {
|
||||||
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
|
const kind = c.self ? "self" : c.os === "windows" ? "windows" : "mac";
|
||||||
const steps = m.setup_steps[kind];
|
const steps = (m.setup_steps || {})[kind] || [];
|
||||||
const lightFor = (step) => {
|
const lightFor = (step) => {
|
||||||
const s = c.setup || {};
|
const s = c.setup || {};
|
||||||
|
const item = (k) => (c.items || []).find((i) => i.kind === k);
|
||||||
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 (/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 (/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 (/SSH service/.test(step)) return c.reachable ? "ok" : "problem";
|
||||||
if (/firewall/.test(step)) return s.firewall_allow ? "ok" : "unknown";
|
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 (/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 (/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 (/DVS helper/.test(step)) { const it = item("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 (/Pro Tools helper/.test(step)) { const it = item("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 (/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 (/rtpMIDI/.test(step)) { const it = item("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 (/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 (/Google Drive/.test(step)) { const it = item("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 (/watchdog and the settings/.test(step)) { const ok = (c.items || []).filter((i) => i.kind === "launchagent" && /watchdog|settings/i.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 (/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";
|
if (/BIOS/.test(step)) return c.manual_done && c.manual_done.bios_wol ? "hand" : "unknown";
|
||||||
return "unknown";
|
return "unknown";
|
||||||
@@ -227,14 +426,14 @@
|
|||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Logs ----------------------------------------------------------------
|
// ---- Logs ------------------------------------------------------------------
|
||||||
let logTab = "watchdog", logFilter = "";
|
let logTab = "watchdog", logFilter = "";
|
||||||
async function logs(rest) {
|
async function logs(rest) {
|
||||||
logTab = rest[0] || logTab;
|
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 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>` : "";
|
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;
|
let data;
|
||||||
if (logTab === "autofix") { data = await api("/api/logs/autofix" + (rest[1] ? "?file=" + encodeURIComponent(rest[1]) : "")); }
|
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)}`);
|
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>` : "";
|
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>`;
|
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>`;
|
||||||
@@ -242,75 +441,201 @@
|
|||||||
const pre = main.querySelector("pre.log"); pre.scrollTop = pre.scrollHeight;
|
const pre = main.querySelector("pre.log"); pre.scrollTop = pre.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- History -------------------------------------------------------------
|
// ---- History ----------------------------------------------------------------
|
||||||
async function history() {
|
async function history(rest) {
|
||||||
const h = await api("/api/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>`;
|
let detail = "";
|
||||||
|
if (rest[0]) {
|
||||||
|
const d = await api("/api/history/" + encodeURIComponent(rest[0]));
|
||||||
|
detail = `<div class="card"><h2>${esc(rest[0])}</h2><p class="muted">What is different between that version and now:</p>${d.changes_to_now.length ? `<ul>${d.changes_to_now.map((c) => `<li>${esc(c.text)}</li>`).join("")}</ul>` : "<p>Nothing: it is the same as now.</p>"}
|
||||||
|
${editing() ? `<button class="primary" id="goback">Go back to this version</button> <span class="help">It becomes your draft; Review & Apply puts it live.</span>` : `<p class="help">Switch Editing on to go back to it.</p>`}</div>`;
|
||||||
|
}
|
||||||
|
const rows = h.entries.map((e) => `<tr class="click" onclick="location.hash='#history/${encodeURIComponent(e.id)}'"><td>${esc(when(e.when))}</td><td>${esc(e.by)}</td><td>${esc(e.note)}</td><td class="small muted">${esc((e.actions || []).join(", "))}${e.outcome && e.outcome !== "applied" ? " · " + esc(e.outcome) : ""}</td></tr>`).join("");
|
||||||
|
main.innerHTML = `<h1>History</h1><p class="what">Every version of the settings that was ever applied: who, when, the note, what was different.</p>
|
||||||
|
<div class="row">${editing() && h.entries.length ? `<button id="undo">Undo the last change</button>` : ""}</div>
|
||||||
|
<div class="tablewrap"><table><tr><th>when</th><th>who</th><th>note</th><th></th></tr>${rows || "<tr><td colspan=4 class='muted'>nothing yet</td></tr>"}</table></div>${detail}`;
|
||||||
|
const gb = main.querySelector("#goback"); if (gb) gb.onclick = async () => { try { const r = await post("/api/restore/" + encodeURIComponent(rest[0])); state.draft = r.draft; badge(); location.hash = "#review"; } catch (e) { alertBox(e.message); } };
|
||||||
|
const un = main.querySelector("#undo"); if (un) un.onclick = () => confirmBox("Undo the last change? The version before it becomes your draft; Review & Apply puts it live.", "Undo", async () => { try { const r = await post("/api/undo"); state.draft = r.draft; badge(); location.hash = "#review"; } catch (e) { alertBox(e.message); } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Help ----------------------------------------------------------------
|
// ---- Review & Apply -------------------------------------------------------------
|
||||||
async function help(rest) {
|
async function review() {
|
||||||
|
const st = await api("/api/apply/status");
|
||||||
|
if (st.state === "running") return applyProgress(st);
|
||||||
|
const d = await api("/api/draft"); state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; badge();
|
||||||
|
if (!state.draft) {
|
||||||
|
main.innerHTML = `<h1>Review & Apply</h1><p class="what">Nothing to apply: there is no draft. Change something on any screen with Editing on.</p>${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await post("/api/validate", {});
|
||||||
|
const changes = r.changes.map((c) => `<li class="${c.command ? "command" : ""}">${esc(c.text)}${c.command ? ' <span class="pill">command</span>' : ""}</li>`).join("") + (r.atem_pending ? "<li>A new saved switcher file replaces the current one.</li>" : "");
|
||||||
|
const errors = r.errors.map((e) => `<li>${esc(e)}</li>`).join("");
|
||||||
|
const warnings = r.warnings.map((w) => `<li>${esc(w)}</li>`).join("");
|
||||||
|
const live = r.live && r.live.live ? `<div class="notice">Live now: ${esc(r.live.reason)}. Apply waits until it is over; your draft is saved. ${editing() ? '<button id="emergency" class="danger">Apply anyway (emergency)</button>' : ""}</div>` : "";
|
||||||
|
main.innerHTML = `<h1>Review & Apply</h1><p class="what">Started by ${esc(state.draft.started_by || state.draft.by || "?")}${state.draft.started ? " at " + esc(when(state.draft.started)) : ""}. ${state.draftStale ? "<b>The live settings changed since this draft was started: look again before applying.</b>" : ""}</p>
|
||||||
|
<div class="card"><h3>Your changes (${r.changes.length + (r.atem_pending ? 1 : 0)})</h3>${changes ? `<ul class="changes">${changes}</ul>` : "<p class='muted'>No change yet.</p>"}
|
||||||
|
<p><b>What will happen:</b> ${esc(r.feel)}</p>
|
||||||
|
${errors ? `<div class="notice problem"><b>Not possible yet:</b><ul>${errors}</ul></div>` : ""}${warnings ? `<div class="notice grey"><b>Worth knowing:</b><ul>${warnings}</ul></div>` : ""}${live}
|
||||||
|
<div class="field"><label for="note">Note for history</label><input id="note" placeholder="what you changed and why" maxlength="200"></div>
|
||||||
|
<div class="row"><button class="primary" id="apply" ${errors || (r.live && r.live.live) || !editing() || !changes ? "disabled" : ""}>APPLY</button><button id="discard" ${editing() ? "" : "disabled"}>Throw the draft away</button></div></div>
|
||||||
|
${st.state === "done" || st.state === "failed" ? applyResult(st) : ""}`;
|
||||||
|
const go = (emergency) => async () => {
|
||||||
|
const note = main.querySelector("#note").value.trim();
|
||||||
|
try { const res = await post("/api/apply", { note, emergency }); if (res.ok) { state.draft = null; state.atemPending = false; badge(); review(); } else alertBox(res.error); }
|
||||||
|
catch (e) { alertBox(e.message); if (e.body && e.body.changed_since) { state.draftStale = true; badge(); } }
|
||||||
|
};
|
||||||
|
main.querySelector("#apply").onclick = go(null);
|
||||||
|
const em = main.querySelector("#emergency"); if (em) em.onclick = () => dialog(`<h3>Apply during a live service</h3><p>${esc(r.feel)} Say why, and type LIVE.</p><input id="why" placeholder="why it cannot wait"><input id="word" placeholder="type LIVE"><div class="row"><button class="primary danger" id="dok">Apply now</button><button id="dcancel">Cancel</button></div>`, (b) => { b.querySelector("#dcancel").onclick = closeDialog; b.querySelector("#dok").onclick = () => { const why = b.querySelector("#why").value.trim(), word = b.querySelector("#word").value.trim(); if (word !== "LIVE" || !why) return; closeDialog(); go({ word, reason: why })(); }; });
|
||||||
|
main.querySelector("#discard").onclick = () => confirmBox("Throw the draft away? Every change since the last Apply is lost.", "Throw it away", async () => { try { await post("/api/draft", {}, "DELETE"); state.draft = null; state.atemPending = false; badge(); review(); } catch (e) { alertBox(e.message); } });
|
||||||
|
}
|
||||||
|
function applyProgress(st) {
|
||||||
|
main.innerHTML = `<h1>Applying…</h1><p class="what">${esc(st.note || "")}</p>${stepsHtml(st)}<p class="muted">This page updates by itself.</p>`;
|
||||||
|
setTimeout(async () => { try { const s2 = await api("/api/apply/status"); if (s2.state === "running") applyProgress(s2); else { await loadAll(); route(); } } catch (e) { setTimeout(() => applyProgress(st), 2000); } }, 1500);
|
||||||
|
}
|
||||||
|
function stepsHtml(st) { return `<ol class="steps">${(st.steps || []).map((s) => `<li class="${esc(s.state)}">${lightDot(s.state === "ok" ? "ok" : s.state === "failed" ? "problem" : s.state === "running" ? "warn" : s.state === "skipped" ? "not_needed" : "unknown")}<b>${esc(s.name)}</b> ${esc(s.state === "skipped" ? "skipped: " + s.detail : s.detail || s.state)}</li>`).join("")}</ol>`; }
|
||||||
|
function applyResult(st) { return `<div class="card"><h3>${st.state === "done" ? "Applied" : "Not applied"} <span class="muted small">${esc(when(st.finished))}${st.by ? " · " + esc(st.by) : ""}</span></h3><p>${esc(st.outcome)}</p>${stepsHtml(st)}</div>`; }
|
||||||
|
|
||||||
|
// ---- Help ------------------------------------------------------------------
|
||||||
|
async function help() {
|
||||||
const facts = await api("/api/facts");
|
const facts = await api("/api/facts");
|
||||||
const gl = state.schema.glossary;
|
const gl = state.schema.glossary || {};
|
||||||
const cards = [
|
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."],
|
["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 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)."],
|
["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 press Restart the watchdog on the Overview."],
|
||||||
["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."],
|
["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."],
|
["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("");
|
].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("");
|
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>
|
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>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>Changing something</h2><ol><li>Switch Editing on (top right): the editing code and your first name.</li><li>Change it where it lives: a key on Keys, a computer on Computers, a fix on Fixes, the rest on Site setup.</li><li>The badge at the top counts your changes; it opens Review & Apply.</li><li>Review reads your changes back in plain words and says what will happen (the deck goes purple for a few seconds; pages re-import only when the layout changed). Press APPLY.</li><li>If the watchdog does not come back, the previous version is put back by itself. History keeps every version; Go back to any of them.</li></ol>
|
||||||
<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>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>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>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, then pick it on Site setup › ATEM.</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>`;
|
<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) ----------------------------------------------
|
// ---- Site setup -----------------------------------------------------------------
|
||||||
|
const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys"];
|
||||||
async function site(rest) {
|
async function site(rest) {
|
||||||
const cfg = state.config, F = state.schema.fields;
|
const c = cfg();
|
||||||
const sections = { site: "Site", schedules: "Schedules", companion: "Companion", "network-dante": "Network & Dante", spotify: "Spotify", atem: "ATEM" };
|
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 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 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 screen = sections[which];
|
||||||
const get = (path) => path.split(".").reduce((o, p) => (o && o[p] !== undefined ? o[p] : undefined), cfg);
|
const own = Object.entries(state.schema.fields).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !HAND_MADE.includes(p) && !HAND_MADE.some((h) => h.startsWith(p + ".")) && !Object.keys(state.schema.fields).some((k) => k.startsWith(p + ".") || k.startsWith(p + "[]")));
|
||||||
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 fieldHtml = ([path, f]) => inputHtml(path, f, getPath(path));
|
||||||
const own = Object.entries(F).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !["tiles", "fixes", "hosts", "gear"].includes(p));
|
const plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced);
|
||||||
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>`;
|
let body = own.length ? `<div class="card"><h3>Settings</h3>${plain.map(fieldHtml).join("") || "<p class='muted small'>Only numbers here; they sit under Advanced.</p>"}${adv.length ? `<details><summary>Advanced</summary>${adv.map(fieldHtml).join("")}</details>` : ""}</div>` : "";
|
||||||
if (which === "companion") {
|
if (which === "companion") body = (await companionHtml()) + body;
|
||||||
const c = await api("/api/companion");
|
if (which === "schedules") body = schedulesHtml(c) + body;
|
||||||
const lights = Object.entries(c.lights).map(([k, v]) => `<li>${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</li>`).join("");
|
if (which === "network-dante") body += await danteHtml(c);
|
||||||
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("");
|
if (which === "spotify") body = spotifyHtml(c) + body;
|
||||||
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>
|
if (which === "atem") body = (await atemHtml(c)) + body;
|
||||||
${c.missing.length ? `<p class="notice">Keys watch connections Companion does not have: ${esc(c.missing.join(", "))}</p>` : ""}
|
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() ? "" : " Switch Editing on to change anything."}</p><div class="tabs">${tabs}</div>${body}`;
|
||||||
<p class="small">Page-number buttons coloured on ${c.page_number_buttons.pages_coloured} pages (${esc(c.page_number_buttons.recorded_in)}).</p>
|
wireSite();
|
||||||
<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;
|
}
|
||||||
|
function windowsHtml(path, list, title, help) {
|
||||||
|
const days = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
||||||
|
const dis = editing() ? "" : "disabled";
|
||||||
|
const rows = (list || []).map((w, i) => `<div class="window" data-win="${path}.${i}">${days.map((d) => `<label class="inline"><input type="checkbox" data-day="${d}" ${(w.days || []).includes(d) ? "checked" : ""} ${dis}> ${d}</label>`).join("")} from <input class="hhmm" data-start value="${esc(w.start)}" ${dis}> to <input class="hhmm" data-end value="${esc(w.end)}" ${dis}> ${editing() ? `<button class="small" data-delwin="${path}.${i}">remove</button>` : ""}</div>`).join("");
|
||||||
|
return `<div class="field"><label>${esc(title)}</label>${rows || "<p class='muted small'>none</p>"}${editing() ? `<button class="small" data-addwin="${path}">+ window</button>` : ""}<div class="help">${esc(help)}</div></div>`;
|
||||||
|
}
|
||||||
|
function schedulesHtml(c) {
|
||||||
|
const fixes = Object.entries(c.fixes).map(([id, f]) => { const cl = closes(f); const on = (c.auto_fix.fixes || []).includes(id); return `<label class="inline"><input type="checkbox" data-autofix="${esc(id)}" ${on ? "checked" : ""} ${editing() && !cl && !f.never_auto ? "" : "disabled"}> ${esc(f.short || id)}${cl ? ' <span class="muted small">(closes a program: hold only)</span>' : f.never_auto ? ' <span class="muted small">(never by itself)</span>' : ""}</label>`; }).join(" ");
|
||||||
|
return `<div class="notice">Alarms are on every day. The ALARMS key silences them until midnight, then they come back by themselves. Changes to the panel wait while a HyperDeck is recording.</div>
|
||||||
|
<div class="card"><h3>Auto-fix</h3>${windowsHtml("auto_fix.windows", c.auto_fix.windows, "When auto-fix may act by itself", "An end earlier than the start runs past midnight.")}<div class="field"><label>Fixes it may run (gentle ones only)</label>${fixes}</div></div>
|
||||||
|
<div class="card"><h3>Changes wait</h3>${windowsHtml("site.live_services", (c.site || {}).live_services, "Clock windows during which changes to the panel wait", "A recording HyperDeck always counts, whatever the clock says. NA has no clock windows.")}</div>`;
|
||||||
|
}
|
||||||
|
async function companionHtml() {
|
||||||
|
const cc = await api("/api/companion");
|
||||||
|
const lights = Object.entries(cc.lights || {}).map(([k, v]) => `<li>${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</li>`).join("");
|
||||||
|
const conns = (cc.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("");
|
||||||
|
return `<div class="card"><h3>Companion ${cc.ok ? "OK" : "<b>not answering</b>"}${cc.build ? ` <span class="pill">${esc(cc.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>
|
||||||
|
${(cc.missing || []).length ? `<p class="notice">Keys watch connections Companion does not have: ${esc(cc.missing.join(", "))}</p>` : ""}
|
||||||
|
${cc.page_number_buttons ? `<p class="small">Page-number buttons coloured on ${cc.page_number_buttons.pages_coloured} pages (${esc(cc.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>`;
|
||||||
|
}
|
||||||
|
function danteCheck(c) { const t = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "dante")); return t ? t.checks.find((ch) => ch.type === "dante") : null; }
|
||||||
|
async function danteHtml(c) {
|
||||||
|
const chk = danteCheck(c);
|
||||||
|
if (!chk) return "";
|
||||||
|
const snap = await api("/api/snapshot").catch(() => ({}));
|
||||||
|
const map = snap.dante_map || {};
|
||||||
|
const fixOf = (n) => typeof chk.fix === "object" && chk.fix ? (chk.fix[n] || "") : "";
|
||||||
|
const fixSel = (n) => `<select data-dantefix="${esc(n)}" ${editing() ? "" : "disabled"}><option value="">(everything else)</option><option value="dante-auto" ${fixOf(n) === "dante-auto" ? "selected" : ""}>Dante auto</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${fixOf(n) === id ? "selected" : ""}>${esc(f.short || id)}</option>`).join("")}</select>`;
|
||||||
|
const rows = Object.entries(chk.devices || {}).map(([n, a]) => { const e = map[n] || {}; return `<tr><td class="mono small">${esc(n)}</td><td>${editing() ? `<input data-dantekey="${esc(n)}" value="${esc(a)}" size="10">` : 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><td>${fixSel(n)}</td><td>${editing() ? `<button class="small danger" data-dantedel="${esc(n)}">remove</button>` : ""}</td></tr>`; }).join("");
|
||||||
|
const extra = Object.keys(map).filter((n) => !((chk.devices || {})[n]));
|
||||||
|
return `<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><th>when it is gone, hold runs</th><th></th></tr>${rows}</table></div>
|
||||||
|
<p class="small muted">Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.${editing() ? ` <button class="small" id="resurvey">Check the Dante network now</button>` : ""}</p>
|
||||||
|
${editing() ? `<div class="field"><label>Add a device</label><input id="dantenew" placeholder="Dante name (as Dante Controller shows it)" list="dantefound"><datalist id="dantefound">${extra.map((n) => `<option value="${esc(n)}">`).join("")}</datalist> <input id="dantenewkey" placeholder="key text" size="10"> <button class="small" id="danteadd">Add</button>${extra.length ? `<div class="help">Seen on the network but not listed: ${esc(extra.join(", "))}</div>` : ""}</div>` : ""}</div>`;
|
||||||
|
}
|
||||||
|
function spotifyHtml(c) {
|
||||||
|
const s = c.spotify || {};
|
||||||
|
return `<div class="card"><h3>The service playlist</h3><div class="field"><label>Playlist link</label><input id="splink" value="${esc(s.playlist || "")}" placeholder="paste the open.spotify.com/playlist/... link" ${editing() ? "" : "disabled"}> ${editing() ? `<button class="small" id="splook">Look it up</button>` : ""}<div class="help">The SPOTIFY key, the play fix and the key guide all use it.</div></div>
|
||||||
|
<div class="field"><label>Name</label><input id="spname" value="${esc(s.name || "")}" ${editing() ? "" : "disabled"}><div class="help">As the keys say it.</div></div><p id="spinfo" class="help"></p></div>`;
|
||||||
|
}
|
||||||
|
async function atemHtml(c) {
|
||||||
|
const folder = await api("/api/atem-reference/folder").catch(() => ({ files: [], mounted: false }));
|
||||||
|
const pending = state.atemPending ? `<div class="notice">A new saved switcher file is waiting for Apply. ${editing() ? `<button class="small" id="atemdiscard">Discard it</button>` : ""}</div>` : "";
|
||||||
|
return `<div class="card"><h3>The saved switcher file</h3><p class="small muted">The panel keeps the switcher's audio and camera settings in line with this file (atem-reference.xml). Save a new one from ATEM Software Control when the show changes.</p>${pending}
|
||||||
|
${editing() ? `<div class="row"><label class="btn">Upload a saved file <input type="file" id="atemfile" accept=".xml" hidden></label><button id="atemnewest" ${folder.mounted && (folder.files || []).length ? "" : "disabled"}>Pick the newest from the folder</button></div>` : ""}
|
||||||
|
<p class="small">Folder: <span class="mono">${esc(folder.folder || "(not set)")}</span> ${folder.mounted ? `· newest: ${(folder.files || []).length ? esc(folder.files[0].name) + " (" + esc(when(folder.files[0].when)) + ")" : "none"}` : "· <b>not mounted</b>"}</p><p id="ateminfo" class="help"></p></div>`;
|
||||||
|
}
|
||||||
|
function wireSite() {
|
||||||
|
const q = (s) => main.querySelector(s);
|
||||||
|
for (const b of main.querySelectorAll("[data-addwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.addwin; const list = (getPath(path, state.draft.config) || []).slice(); list.push({ days: ["sun"], start: "09:00", end: "12:00" }); setPath(path, list); route(); };
|
||||||
|
for (const b of main.querySelectorAll("[data-delwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.delwin.replace(/\.\d+$/, ""), i = +b.dataset.delwin.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list.splice(i, 1); setPath(path, list); route(); };
|
||||||
|
for (const w of main.querySelectorAll("[data-win]")) for (const el of w.querySelectorAll("input")) el.onchange = () => { if (!ensureDraft()) return; const path = w.dataset.win.replace(/\.\d+$/, ""), i = +w.dataset.win.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list[i] = { days: [...w.querySelectorAll("[data-day]")].filter((x) => x.checked).map((x) => x.dataset.day), start: w.querySelector("[data-start]").value.trim(), end: w.querySelector("[data-end]").value.trim() }; setPath(path, list); };
|
||||||
|
for (const cb of main.querySelectorAll("[data-autofix]")) cb.onchange = () => { if (!ensureDraft()) return; const set = new Set(state.draft.config.auto_fix.fixes || []); cb.checked ? set.add(cb.dataset.autofix) : set.delete(cb.dataset.autofix); setPath("auto_fix.fixes", [...set]); };
|
||||||
|
const dc = () => danteCheck(state.draft.config);
|
||||||
|
for (const el of main.querySelectorAll("[data-dantekey]")) el.onchange = () => { if (!ensureDraft()) return; dc().devices[el.dataset.dantekey] = el.value.trim(); saveDraft(); };
|
||||||
|
for (const el of main.querySelectorAll("[data-dantefix]")) el.onchange = () => { if (!ensureDraft()) return; const ch = dc(); if (typeof ch.fix !== "object" || !ch.fix) ch.fix = { "*": ch.fix || "dante-auto" }; if (el.value) ch.fix[el.dataset.dantefix] = el.value; else delete ch.fix[el.dataset.dantefix]; saveDraft(); };
|
||||||
|
for (const b of main.querySelectorAll("[data-dantedel]")) b.onclick = () => { if (!ensureDraft()) return; const ch = dc(); delete ch.devices[b.dataset.dantedel]; if (typeof ch.fix === "object" && ch.fix) delete ch.fix[b.dataset.dantedel]; saveDraft(); route(); };
|
||||||
|
const da = q("#danteadd"); if (da) da.onclick = () => { if (!ensureDraft()) return; const n = q("#dantenew").value.trim(), k = q("#dantenewkey").value.trim(); if (!n) return; const ch = dc(); ch.devices = ch.devices || {}; ch.devices[n] = k || n.slice(0, 10).toUpperCase(); saveDraft(); route(); };
|
||||||
|
const rs = q("#resurvey"); if (rs) rs.onclick = async () => { try { await post("/api/command", { text: "resurvey" }); alertBox("Asked the watchdog to look at the switches; the map updates within a minute."); } catch (e) { alertBox(e.message); } };
|
||||||
|
const look = q("#splook"); if (look) look.onclick = async () => { const info = q("#spinfo"); info.textContent = "Looking…"; try { const r = await api("/api/live/spotify?url=" + encodeURIComponent(q("#splink").value)); if (!r.ok) { info.textContent = r.error; return; } if (!ensureDraft()) return; setPath("spotify.playlist", r.uri); if (r.name) { setPath("spotify.name", r.name); q("#spname").value = r.name; } q("#splink").value = r.uri; info.textContent = r.name ? `${r.name}: ${r.songs} songs. The play fix's words follow the name.` : (r.warning || ""); } catch (e) { info.textContent = e.message; } };
|
||||||
|
const spname = q("#spname"); if (spname) spname.onchange = () => setPath("spotify.name", spname.value.trim());
|
||||||
|
const splink = q("#splink"); if (splink) splink.onchange = () => { const m = splink.value.match(/(?:playlist[/:])([A-Za-z0-9]{22})/); if (m) setPath("spotify.playlist", "spotify:playlist:" + m[1]); else if (splink.value.trim()) alertBox("That is not a playlist link or URI."); };
|
||||||
|
const af = q("#atemfile"); if (af) af.onchange = async () => { const f = af.files[0]; if (!f) return; try { const r = await fetch("/api/atem-reference", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/xml" }, body: await f.arrayBuffer() }).then((x) => x.json()); showAtem(r); } catch (e) { alertBox(e.message); } };
|
||||||
|
const an = q("#atemnewest"); if (an) an.onclick = async () => { try { showAtem(await post("/api/atem-reference/pick-newest")); } catch (e) { alertBox(e.message); } };
|
||||||
|
const ad = q("#atemdiscard"); if (ad) ad.onclick = async () => { try { await post("/api/atem-reference/discard"); state.atemPending = false; badge(); route(); } catch (e) { alertBox(e.message); } };
|
||||||
|
function showAtem(r) {
|
||||||
|
if (!r.ok) { alertBox(r.error || "Not taken."); return; }
|
||||||
|
state.atemPending = true; if (!state.draft && ensureDraft()) saveDraft(); badge();
|
||||||
|
const diffs = (r.diffs || []).slice(0, 12).map((d) => d.part + " " + d.key + ": " + JSON.stringify(d.before) + " → " + JSON.stringify(d.after)).join("\n");
|
||||||
|
alertBox(`${r.file ? r.file + ": " : ""}${r.product || ""}, ${r.audio} audio and ${r.camera} camera values; ${r.diff_count} differ from the current file${diffs ? ":\n" + diffs : "."}\nApply replaces the file.`);
|
||||||
|
route();
|
||||||
}
|
}
|
||||||
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 ---------------------------------------------------------------
|
// ---- Fixes -------------------------------------------------------------------------
|
||||||
async function fixes(rest) {
|
async function fixes(rest) {
|
||||||
const cfg = state.config, auto = new Set(cfg.auto_fix.fixes || []);
|
const c = cfg(), auto = new Set(c.auto_fix.fixes || []);
|
||||||
const used = {};
|
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); }
|
const use = (f, who) => { if (f) (used[f] = used[f] || new Set()).add(who); };
|
||||||
for (const k of (cfg.restart_page || { keys: [] }).keys) (used[k.fix] = used[k.fix] || new Set()).add("restart key " + k.label);
|
for (const t of c.tiles) { for (const ch of t.checks || []) for (const f of (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix])) use(f, t.label); use(t.hold_always, t.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("");
|
for (const k of (c.restart_page || { keys: [] }).keys) use(k.fix, "restart key " + k.label);
|
||||||
const f = cfg.fixes[rest[0]];
|
const rows = Object.entries(c.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((c.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 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>` : "";
|
const id = rest[0], f = c.fixes[id];
|
||||||
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}`;
|
let detail = "";
|
||||||
|
if (f) {
|
||||||
|
const kind = f.kind || "ssh";
|
||||||
|
const fields = ["label", "short"].concat((state.schema.fix_kind_fields || {})[kind] || []).concat(["restart_ok", "never_auto", "timeout"]);
|
||||||
|
const inputs = editing() ? [...new Set(fields)].map((fl) => {
|
||||||
|
const Fd = F("fixes.*." + fl); if (!Fd) return "";
|
||||||
|
if (fl === "host") return `<div class="field"><label>Computer</label><select data-path="fixes.${esc(id)}.host"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${f.host === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select></div>`;
|
||||||
|
if (fl === "run") return `<div class="field"><label>Command (shown as a command at Review; never run before Apply)</label><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run || "")}</textarea><div class="help">zsh on a Mac, cmd.exe on a PC. ${esc(Fd.help)}</div></div>`;
|
||||||
|
return inputHtml(`fixes.${id}.${fl}`, Fd, f[fl]);
|
||||||
|
}).join("") : "";
|
||||||
|
const kindSel = editing() ? `<div class="field"><label>Built as</label><select data-path="fixes.${esc(id)}.kind" class="kindsel">${(state.schema.fix_kinds || ["ssh", "local"]).map((k) => `<option ${kind === k ? "selected" : ""}>${k}</option>`).join("")}</select></div>` : "";
|
||||||
|
const keys = [...(used[id] || [])];
|
||||||
|
detail = `<div class="card"><h2>${esc(f.short || id)}</h2><p>${esc(f.label || "")}</p>${kindSel}${inputs}${canRunNow(id) && editing() ? `<p class="help">Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).</p>` : ""}<details ${editing() ? "" : "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("")}<dt>id</dt><dd class="mono">${esc(id)}</dd></dl></details>${editing() && !keys.length ? `<button class="danger small" id="delfix">Delete this fix</button>` : ""}</div>`;
|
||||||
|
}
|
||||||
|
const add = editing() ? `<div class="row"><input id="newfix" placeholder="new fix id (letters, digits, _)"><button id="addfix" class="small">+ New fix</button></div>` : "";
|
||||||
|
main.innerHTML = `<h1>Fixes</h1><p class="what">Everything a hold can run. ${Object.keys(c.fixes).length} today.</p>${add}<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}`;
|
||||||
|
const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value); route(); };
|
||||||
|
const del = main.querySelector("#delfix"); if (del) del.onclick = () => confirmBox(`Delete the fix ${id}? No key uses it.`, "Delete", () => { if (!ensureDraft()) return; delete state.draft.config.fixes[id]; const af2 = state.draft.config.auto_fix.fixes || []; state.draft.config.auto_fix.fixes = af2.filter((x) => x !== id); saveDraft(); location.hash = "#fixes"; });
|
||||||
|
const af = main.querySelector("#addfix"); if (af) af.onclick = () => { const nid = main.querySelector("#newfix").value.trim(); if (!/^[A-Za-z0-9_]+$/.test(nid) || c.fixes[nid]) return alertBox("A new id: letters, digits and _ only, not in use."); if (!ensureDraft()) return; state.draft.config.fixes[nid] = { kind: "local", short: nid.toUpperCase().slice(0, 12), label: "", run: "" }; saveDraft(); location.hash = "#fixes/" + nid; };
|
||||||
}
|
}
|
||||||
|
|
||||||
function alertBox(msg) { dialog(`<p>${esc(msg)}</p><button id="dok" class="primary">OK</button>`, (b) => { b.querySelector("#dok").onclick = closeDialog; }); }
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -73,3 +73,21 @@ pre.log { background: var(--deck); color: #d6dbe4; padding: 10px 12px; border-ra
|
|||||||
.menu .divider { border: 0; margin: 0 6px; padding: 8px 0; } main { padding: 14px 16px 40px; } .searchresults { left: 16px; }
|
.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%; }
|
.top { flex-wrap: wrap; } #search { order: 3; flex-basis: 100%; }
|
||||||
}
|
}
|
||||||
|
/* step 2: editing */
|
||||||
|
.badge { display: inline-block; padding: 4px 10px; border-radius: 999px; background: var(--accent); color: #fff; text-decoration: none; font-weight: 600; font-size: .88rem; white-space: nowrap; }
|
||||||
|
.badge.warn { background: var(--warn); color: #1d2420; }
|
||||||
|
button.toggle.on { background: var(--accent); color: #fff; }
|
||||||
|
button.danger { border-color: var(--problem); color: var(--problem); } button.primary.danger { background: var(--problem); color: #fff; }
|
||||||
|
button.small { padding: 3px 9px; font-size: .85rem; }
|
||||||
|
label.inline { display: inline-flex; align-items: center; gap: 4px; margin: 2px 10px 2px 0; color: var(--fg); font-size: .92rem; }
|
||||||
|
label.btn { display: inline-block; cursor: pointer; border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 6px 12px; }
|
||||||
|
.field input:not([type=checkbox]), .field select, .field textarea { width: 100%; max-width: 520px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--fg); font: inherit; }
|
||||||
|
.field textarea.mono, textarea.mono { font-family: var(--mono); font-size: .85rem; }
|
||||||
|
.field input[disabled], .field select[disabled], .field textarea[disabled] { opacity: .75; border-style: dashed; }
|
||||||
|
.field input.hhmm { width: 5.5em; display: inline-block; }
|
||||||
|
.window { margin: 4px 0; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; }
|
||||||
|
ol.checks li { margin: 8px 0; } .checkbox { padding: 6px 10px; margin: 4px 0; border-left: 3px solid var(--line); }
|
||||||
|
ul.changes li.command { font-family: var(--mono); font-size: .9em; color: var(--problem); }
|
||||||
|
.notice.problem { background: #fdecea; color: #7a1d16; border: 1px solid var(--problem); }
|
||||||
|
ol.steps { padding-left: 18px; } ol.steps li { margin: 3px 0; }
|
||||||
|
tr.sel { background: var(--accent-soft); }
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Saving a change, offline: the shared draft, what Apply will do, the pipeline against a temp data
|
||||||
|
dir with a fake launchctl and a fake Companion, rollback when the watchdog does not come back, the
|
||||||
|
recording guard with its Emergency override, history and Go back."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import status_panel as sp # noqa: E402
|
||||||
|
from settings import apply as ap # noqa: E402
|
||||||
|
from settings import server # noqa: E402
|
||||||
|
from test_status_panel import CONFIG # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLaunchctl:
|
||||||
|
"""Records calls; a kickstart or bootstrap "starts" the watchdog by writing started.json
|
||||||
|
(with the sha of the config on disk) unless told to fail."""
|
||||||
|
|
||||||
|
def __init__(self, data_dir, fail=False):
|
||||||
|
self.data_dir, self.fail, self.calls, self.pid = data_dir, fail, [], 100
|
||||||
|
|
||||||
|
def __call__(self, argv, **kw):
|
||||||
|
self.calls.append(argv)
|
||||||
|
|
||||||
|
class R:
|
||||||
|
returncode, stdout, stderr = 0, "", ""
|
||||||
|
r = R()
|
||||||
|
if argv[1] == "print":
|
||||||
|
r.stdout = "state = running\n\tpid = %d\n" % self.pid
|
||||||
|
elif argv[1] in ("kickstart", "bootstrap"):
|
||||||
|
if not self.fail:
|
||||||
|
self.pid += 1
|
||||||
|
cfg_sha = ap.sha(open(os.path.join(self.data_dir, "config.json"), "rb").read())
|
||||||
|
sp.write_json(os.path.join(self.data_dir, "started.json"), {"pid": self.pid, "started_at": time.time(), "config_sha": cfg_sha, "version": "test"})
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class TestDraftAndPlan(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.data = tempfile.mkdtemp(prefix="sp-apply-")
|
||||||
|
self.cfg = json.loads(json.dumps(CONFIG))
|
||||||
|
self.cfg["companion"] = {"host": "127.0.0.1", "port": 1}
|
||||||
|
with open(os.path.join(self.data, "config.json"), "wb") as f:
|
||||||
|
f.write(ap.config_text(self.cfg))
|
||||||
|
|
||||||
|
def test_draft_is_shared_and_never_overwritten_silently(self):
|
||||||
|
d = ap.Draft(self.data)
|
||||||
|
a, _ = d.update({"x": 1}, "sam", "sha1")
|
||||||
|
self.assertEqual(oct(os.stat(d.path).st_mode)[-3:], "600")
|
||||||
|
b, conflict = d.update({"x": 2}, "chris", "sha1", expect=a["version"])
|
||||||
|
self.assertIsNone(conflict)
|
||||||
|
_, conflict = d.update({"x": 3}, "sam", "sha1", expect=a["version"]) # sam's tab is behind
|
||||||
|
self.assertEqual(conflict["by"], "chris")
|
||||||
|
self.assertEqual(d.read()["config"], {"x": 2})
|
||||||
|
|
||||||
|
def test_what_apply_does(self):
|
||||||
|
hashes = ap.build_hashes(self.cfg)
|
||||||
|
same = json.loads(json.dumps(self.cfg))
|
||||||
|
self.assertEqual(ap.what_apply_does(self.cfg, same, hashes)["changes"], [])
|
||||||
|
notes = json.loads(json.dumps(self.cfg))
|
||||||
|
notes["gear"] = {"UHDJr": {"location": "lobby rack"}}
|
||||||
|
plan = ap.what_apply_does(self.cfg, notes, hashes)
|
||||||
|
self.assertFalse(plan["restart"]) # page-only data
|
||||||
|
self.assertEqual(plan["pages"], [])
|
||||||
|
timing = json.loads(json.dumps(self.cfg))
|
||||||
|
timing["debounce_seconds"] = 4
|
||||||
|
self.assertTrue(ap.what_apply_does(self.cfg, timing, hashes)["restart"])
|
||||||
|
self.assertEqual(ap.what_apply_does(self.cfg, timing, hashes)["pages"], [])
|
||||||
|
moved = json.loads(json.dumps(self.cfg))
|
||||||
|
next(t for t in moved["tiles"] if t["id"] == "led")["pos"] = [2, 8]
|
||||||
|
plan = ap.what_apply_does(self.cfg, moved, hashes)
|
||||||
|
self.assertEqual(plan["pages"], ["status"])
|
||||||
|
self.assertTrue(plan["nav"])
|
||||||
|
label = json.loads(json.dumps(self.cfg))
|
||||||
|
label["fixes"]["vista"]["label"] = "start Vista please" # only a key note changes
|
||||||
|
self.assertEqual(ap.what_apply_does(self.cfg, label, hashes)["pages"], [])
|
||||||
|
|
||||||
|
def test_words(self):
|
||||||
|
after = json.loads(json.dumps(self.cfg))
|
||||||
|
hd2 = next(t for t in after["tiles"] if t["id"] == "hd2")
|
||||||
|
hd2["checks"][0]["optional"] = False
|
||||||
|
after["site"]["editing_code"] = "new words"
|
||||||
|
after["hosts"]["lighting"]["notes"] = "keep it Public"
|
||||||
|
texts = [ap.words_for(p, a, b, self.cfg, after)["text"] for p, a, b in ap.changed_paths(self.cfg, after)]
|
||||||
|
self.assertIn("key HD2 › check 1 › optional: yes → no", texts)
|
||||||
|
self.assertIn("computer Lighting PC › notes: Keep the network profile on Public: rtpMIDI's Bonjour rules only allow Public. → keep it Public", texts)
|
||||||
|
self.assertTrue(any(t == "site › editing code changed" for t in texts), texts) # a secret: never its value
|
||||||
|
|
||||||
|
|
||||||
|
class TestPipeline(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.data = tempfile.mkdtemp(prefix="sp-pipe-")
|
||||||
|
self.cfg = json.loads(json.dumps(CONFIG))
|
||||||
|
self.cfg["companion"] = {"host": "127.0.0.1", "port": 1}
|
||||||
|
with open(os.path.join(self.data, "config.json"), "wb") as f:
|
||||||
|
f.write(ap.config_text(self.cfg))
|
||||||
|
open(os.path.join(self.data, "atem-reference.xml"), "w").write("<x/>")
|
||||||
|
self.site = server.Site(self.data, companion=mock.Mock())
|
||||||
|
self.launchctl = FakeLaunchctl(self.data)
|
||||||
|
self.applier = ap.Applier(self.site, run=self.launchctl, sleep=lambda s: None)
|
||||||
|
|
||||||
|
def run_apply(self, new_cfg, note="t", emergency=None):
|
||||||
|
r = self.applier.start(new_cfg, note, "sam", "10.1.1.50", emergency)
|
||||||
|
if r.get("ok"):
|
||||||
|
self.applier.thread.join(30)
|
||||||
|
return r
|
||||||
|
|
||||||
|
def test_a_timing_change_restarts_and_is_kept_in_history(self):
|
||||||
|
new = json.loads(json.dumps(self.cfg))
|
||||||
|
new["debounce_seconds"] = 4
|
||||||
|
r = self.run_apply(new, "slower")
|
||||||
|
self.assertTrue(r["ok"])
|
||||||
|
st = self.applier.status
|
||||||
|
self.assertEqual(st["state"], "done", st)
|
||||||
|
names = {s["name"]: s["state"] for s in st["steps"]}
|
||||||
|
self.assertEqual((names["check"], names["save"], names["restart"], names["pages"]), ("ok", "ok", "ok", "skipped"))
|
||||||
|
self.assertEqual(json.load(open(os.path.join(self.data, "config.json")))["debounce_seconds"], 4)
|
||||||
|
self.assertTrue(any(a[1] == "kickstart" for a in self.launchctl.calls))
|
||||||
|
entries = ap.history_entries(self.data)
|
||||||
|
self.assertEqual((entries[0]["by"], entries[0]["note"], entries[0]["actions"]), ("sam", "slower", ["restart"]))
|
||||||
|
self.assertEqual(ap.history_config(self.data, entries[0]["id"])["debounce_seconds"], 3) # the version before
|
||||||
|
self.assertEqual(oct(os.stat(os.path.join(self.data, "history", entries[0]["id"] + ".json")).st_mode)[-3:], "600")
|
||||||
|
|
||||||
|
def test_page_only_data_needs_no_restart(self):
|
||||||
|
new = json.loads(json.dumps(self.cfg))
|
||||||
|
new["gear"] = {"UHDJr": {"location": "lobby"}}
|
||||||
|
self.run_apply(new)
|
||||||
|
names = {s["name"]: s["state"] for s in self.applier.status["steps"]}
|
||||||
|
self.assertEqual(names["restart"], "skipped")
|
||||||
|
self.assertFalse(any(a[1] == "kickstart" for a in self.launchctl.calls))
|
||||||
|
|
||||||
|
def test_a_bad_config_is_refused_before_anything_happens(self):
|
||||||
|
new = json.loads(json.dumps(self.cfg))
|
||||||
|
new["fixes"]["vista"]["host"] = "nowhere"
|
||||||
|
self.run_apply(new)
|
||||||
|
self.assertEqual(self.applier.status["state"], "failed")
|
||||||
|
self.assertIn("unknown computer nowhere", self.applier.status["outcome"])
|
||||||
|
self.assertEqual(json.load(open(os.path.join(self.data, "config.json")))["fixes"]["vista"]["host"], "lighting")
|
||||||
|
self.assertEqual(ap.history_entries(self.data), [])
|
||||||
|
|
||||||
|
def test_rollback_when_the_watchdog_does_not_come_back(self):
|
||||||
|
self.launchctl.fail = True
|
||||||
|
new = json.loads(json.dumps(self.cfg))
|
||||||
|
new["debounce_seconds"] = 5
|
||||||
|
self.run_apply(new, "will fail")
|
||||||
|
st = self.applier.status
|
||||||
|
self.assertEqual(st["state"], "failed")
|
||||||
|
self.assertIn("previous settings", st["outcome"].lower())
|
||||||
|
self.assertEqual(json.load(open(os.path.join(self.data, "config.json")))["debounce_seconds"], 3) # put back
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(self.data, "last-apply-failed.json")))
|
||||||
|
meta = ap.history_entries(self.data)[0]
|
||||||
|
self.assertIn("rolled back", meta["outcome"])
|
||||||
|
|
||||||
|
def test_layout_change_imports_the_page_and_recolours_the_nav_keys(self):
|
||||||
|
new = json.loads(json.dumps(self.cfg))
|
||||||
|
next(t for t in new["tiles"] if t["id"] == "led")["pos"] = [2, 8]
|
||||||
|
pushed, coloured, calls = [], [], {"n": 0}
|
||||||
|
|
||||||
|
def fetch(h, p, page): # before the push: only page keys; after: as built
|
||||||
|
calls["n"] += 1
|
||||||
|
return {"page": {"name": "19\\nStatus", "controls": {}}} if calls["n"] % 2 == 1 else ap.make_page.build(new)
|
||||||
|
with mock.patch.object(ap.push_page, "fetch_page", fetch), \
|
||||||
|
mock.patch.object(ap.push_page, "push", lambda *a: pushed.append(a[2])), \
|
||||||
|
mock.patch.object(ap.nav_keys, "colour", lambda *a: coloured.append(a) or 96):
|
||||||
|
self.run_apply(new, "move LED")
|
||||||
|
st = self.applier.status
|
||||||
|
self.assertEqual(st["state"], "done", st)
|
||||||
|
self.assertEqual(pushed, [19])
|
||||||
|
self.assertEqual(len(coloured), 1)
|
||||||
|
self.assertEqual(json.load(open(os.path.join(self.data, "build-hashes.json")))["status"], ap.build_hashes(new)["status"])
|
||||||
|
|
||||||
|
def test_a_foreign_key_on_the_page_stops_the_import_and_rolls_back(self):
|
||||||
|
new = json.loads(json.dumps(self.cfg))
|
||||||
|
next(t for t in new["tiles"] if t["id"] == "led")["pos"] = [2, 8]
|
||||||
|
busy = {"page": {"name": "19\\nStatus", "controls": {"2": {"3": {"type": "button-layered", "options": {"notes": "someone's key"}}}}}}
|
||||||
|
with mock.patch.object(ap.push_page, "fetch_page", lambda h, p, page: busy), mock.patch.object(ap.push_page, "push", lambda *a: self.fail("must not push")):
|
||||||
|
self.run_apply(new, "move LED")
|
||||||
|
st = self.applier.status
|
||||||
|
self.assertEqual(st["state"], "failed")
|
||||||
|
self.assertIn("row 2 col 3", st["outcome"])
|
||||||
|
self.assertEqual(next(t for t in json.load(open(os.path.join(self.data, "config.json")))["tiles"] if t["id"] == "led")["pos"], [3, 6])
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(self.data, "pages-out-of-sync.json")))
|
||||||
|
|
||||||
|
def test_live_refuses_unless_emergency(self):
|
||||||
|
sp.write_json(os.path.join(self.data, "live-now.json"), {"live": True, "reason": "a HyperDeck is recording", "time": time.time()})
|
||||||
|
new = json.loads(json.dumps(self.cfg))
|
||||||
|
new["debounce_seconds"] = 4
|
||||||
|
r = self.run_apply(new)
|
||||||
|
self.assertFalse(r["ok"])
|
||||||
|
self.assertTrue(r.get("live"))
|
||||||
|
r = self.run_apply(new, "urgent", {"word": "LIVE", "reason": "the deck is wrong"})
|
||||||
|
self.assertTrue(r["ok"])
|
||||||
|
self.assertEqual(self.applier.status["state"], "done")
|
||||||
|
self.assertIn("EMERGENCY apply by sam", open(os.path.join(self.data, "apply.log")).read())
|
||||||
|
sp.write_json(os.path.join(self.data, "live-now.json"), {"live": True, "reason": "stale", "time": time.time() - 120})
|
||||||
|
self.assertEqual(self.applier.live(), (False, "")) # stale: the schedule (none) decides
|
||||||
|
|
||||||
|
def test_a_new_switcher_file_goes_with_the_config_and_is_kept(self):
|
||||||
|
pending = os.path.join(self.data, "draft-atem.xml")
|
||||||
|
open(pending, "w").write("<y/>")
|
||||||
|
new = json.loads(json.dumps(self.cfg))
|
||||||
|
r = self.applier.start(new, "new switcher file", "sam", "10.1.1.50", None, pending)
|
||||||
|
self.applier.thread.join(30)
|
||||||
|
self.assertEqual(open(os.path.join(self.data, "atem-reference.xml")).read(), "<y/>")
|
||||||
|
self.assertTrue(os.listdir(os.path.join(self.data, "history", "atem"))) # the old one, dated
|
||||||
|
self.assertFalse(os.path.exists(pending))
|
||||||
|
self.assertIn("switcher file", ap.history_entries(self.data)[0]["actions"])
|
||||||
|
|
||||||
|
def test_one_apply_at_a_time(self):
|
||||||
|
gate = threading.Event()
|
||||||
|
slow = ap.Applier(self.site, run=self.launchctl, sleep=lambda s: gate.wait(2))
|
||||||
|
new = json.loads(json.dumps(self.cfg))
|
||||||
|
new["debounce_seconds"] = 4
|
||||||
|
self.assertTrue(slow.start(new, "a", "sam", "x")["ok"])
|
||||||
|
self.assertIn("already running", slow.start(new, "b", "sam", "x")["error"])
|
||||||
|
gate.set()
|
||||||
|
slow.thread.join(30)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+81
-3
@@ -20,10 +20,11 @@ from settings import keyguide, probes, server # noqa: E402
|
|||||||
from test_status_panel import CONFIG, FakeCompanion, healthy_snapshot # noqa: E402
|
from test_status_panel import CONFIG, FakeCompanion, healthy_snapshot # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def get(port, path, json_body=None, origin=None):
|
def get(port, path, json_body=None, origin=None, method=None, code=None, who=None):
|
||||||
data = json.dumps(json_body).encode() if json_body is not None else None
|
data = json.dumps(json_body).encode() if json_body is not None else None
|
||||||
req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, path), data=data,
|
headers = {"Content-Type": "application/json", **({"Origin": origin} if origin else {}),
|
||||||
headers={"Content-Type": "application/json", **({"Origin": origin} if origin else {})})
|
**({"X-Edit-Key": code} if code else {}), **({"X-Editor": who} if who else {})}
|
||||||
|
req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, path), data=data, headers=headers, method=method)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=10) as r:
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
body = r.read()
|
body = r.read()
|
||||||
@@ -134,6 +135,83 @@ class TestServer(unittest.TestCase):
|
|||||||
self.site.attempts.clear()
|
self.site.attempts.clear()
|
||||||
self.assertEqual(get(self.port, "/api/editing/check", {"code": "x"}, origin="http://evil.example")[0], 403)
|
self.assertEqual(get(self.port, "/api/editing/check", {"code": "x"}, origin="http://evil.example")[0], 403)
|
||||||
|
|
||||||
|
# ---- step 2: the write routes ----
|
||||||
|
CODE = "apple river stone"
|
||||||
|
|
||||||
|
def put(self, path, body, method="PUT", who="sam", code=None):
|
||||||
|
st, raw = get(self.port, path, body, method=method, code=code or self.CODE, who=who)
|
||||||
|
return st, json.loads(raw)
|
||||||
|
|
||||||
|
def test_writes_need_the_code_and_a_name(self):
|
||||||
|
self.site.attempts.clear()
|
||||||
|
self.assertEqual(get(self.port, "/api/draft", {"config": {}}, method="PUT")[0], 401)
|
||||||
|
self.assertEqual(get(self.port, "/api/draft", {"config": {}}, method="PUT", code="wrong", who="sam")[0], 401)
|
||||||
|
self.assertEqual(get(self.port, "/api/draft", {"config": {}}, method="PUT", code=self.CODE)[0], 400) # no name
|
||||||
|
self.site.attempts.clear()
|
||||||
|
|
||||||
|
def test_draft_round_trip_conflict_and_discard(self):
|
||||||
|
self.site.attempts.clear()
|
||||||
|
live = self.site.load()
|
||||||
|
mine = json.loads(json.dumps(live)); mine["debounce_seconds"] = 4
|
||||||
|
st, r = self.put("/api/draft", {"config": mine})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
v1 = r["draft"]["version"]
|
||||||
|
self.assertEqual(r["draft"]["config"]["debounce_seconds"], 4)
|
||||||
|
self.assertTrue(r["draft"]["config"]["site"]["editing_code"].startswith("set (")) # masked on the way out
|
||||||
|
self.assertEqual(json.loads(get(self.port, "/api/draft")[1])["draft"]["by"], "sam")
|
||||||
|
theirs = json.loads(json.dumps(live)); theirs["debounce_seconds"] = 5
|
||||||
|
st, r = self.put("/api/draft", {"config": theirs, "expect": v1}, who="chris")
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
st, r = self.put("/api/draft", {"config": mine, "expect": v1}) # sam's tab is behind
|
||||||
|
self.assertEqual(st, 409)
|
||||||
|
self.assertIn("chris", r["error"])
|
||||||
|
self.assertEqual(r["draft"]["config"]["debounce_seconds"], 5)
|
||||||
|
self.assertEqual(self.site.draft.read()["config"]["site"]["editing_code"], self.CODE) # the secret survived the masked round trip
|
||||||
|
st, r = self.put("/api/validate", {})
|
||||||
|
self.assertEqual(st, 200)
|
||||||
|
self.assertTrue(any("debounce seconds: %d → 5" % live["debounce_seconds"] in c["text"] for c in r["changes"]), r["changes"])
|
||||||
|
self.assertIn("purple", r["feel"].lower())
|
||||||
|
self.assertTrue(r["live"]["live"])
|
||||||
|
self.assertEqual(self.put("/api/draft", {}, method="DELETE")[0], 200)
|
||||||
|
self.assertIsNone(json.loads(get(self.port, "/api/draft")[1])["draft"])
|
||||||
|
|
||||||
|
def test_apply_waits_for_live_then_runs_restores_and_undoes(self):
|
||||||
|
from test_apply import FakeLaunchctl
|
||||||
|
self.site.attempts.clear()
|
||||||
|
fake = FakeLaunchctl(self.data)
|
||||||
|
self.site.applier.run, self.site.applier.sleep = fake, lambda s: None
|
||||||
|
live = self.site.load()
|
||||||
|
mine = json.loads(json.dumps(live)); mine["debounce_seconds"] = 6
|
||||||
|
self.put("/api/draft", {"config": mine})
|
||||||
|
st, r = self.put("/api/apply", {"note": "slower"}, method="POST")
|
||||||
|
self.assertEqual(st, 409)
|
||||||
|
self.assertTrue(r.get("live"))
|
||||||
|
self.assertIsNotNone(self.site.draft.read()) # the draft is kept
|
||||||
|
st, r = self.put("/api/apply", {"note": "slower", "emergency": {"word": "LIVE", "reason": "deck wrong"}}, method="POST")
|
||||||
|
self.assertEqual(st, 200, r)
|
||||||
|
self.site.applier.thread.join(30)
|
||||||
|
self.assertIsNone(self.site.draft.read())
|
||||||
|
status = json.loads(get(self.port, "/api/apply/status")[1])
|
||||||
|
self.assertEqual(status["state"], "done", status)
|
||||||
|
self.assertEqual(self.site.load()["debounce_seconds"], 6)
|
||||||
|
hist = json.loads(get(self.port, "/api/history")[1])["entries"]
|
||||||
|
self.assertEqual((hist[0]["by"], hist[0]["note"]), ("sam", "slower"))
|
||||||
|
detail = json.loads(get(self.port, "/api/history/" + hist[0]["id"])[1])
|
||||||
|
self.assertTrue(any("debounce seconds: %d → 6" % live["debounce_seconds"] in c["text"] for c in detail["changes_to_now"]), detail)
|
||||||
|
st, r = self.put("/api/restore/" + hist[0]["id"], {}, method="POST")
|
||||||
|
self.assertEqual(r["draft"]["config"]["debounce_seconds"], live["debounce_seconds"])
|
||||||
|
self.put("/api/draft", {}, method="DELETE")
|
||||||
|
st, r = self.put("/api/undo", {}, method="POST")
|
||||||
|
self.assertEqual((st, r["draft"]["config"]["debounce_seconds"], r["note"]), (200, live["debounce_seconds"], "slower"))
|
||||||
|
self.put("/api/draft", {}, method="DELETE")
|
||||||
|
# watchdog control waits for live too; a command goes to the watchdog through Companion
|
||||||
|
self.assertEqual(self.put("/api/watchdog/restart", {}, method="POST")[0], 409)
|
||||||
|
st, r = self.put("/api/command", {"text": "service off"}, method="POST")
|
||||||
|
self.assertEqual((st, r["sent"]), (200, "service off"))
|
||||||
|
self.assertIn("service off", FakeCompanion.store["custom"]["sp_cmd"])
|
||||||
|
self.assertEqual(self.put("/api/command", {"text": "rm -rf"}, method="POST")[0], 400)
|
||||||
|
self.assertEqual(self.put("/api/command", {"text": "test hd2 vista 0"}, method="POST")[0], 409) # no test while live
|
||||||
|
|
||||||
def test_probe_is_refused_while_a_fix_runs(self):
|
def test_probe_is_refused_while_a_fix_runs(self):
|
||||||
json.dump({"fix-x": {"key": "hd2"}}, open(os.path.join(self.data, "fixes-running.json"), "w"))
|
json.dump({"fix-x": {"key": "hd2"}}, open(os.path.join(self.data, "fixes-running.json"), "w"))
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user