Commit 63ce1034 by PLN (Algolia)

feat(bridge): The Bridge — always-on ParVagues web dashboard + perf toolbar

Answers 'is there a ParVagues web service running a dashboard?' — now yes.
Folds perf-tray's function into a web toolbar + extensible fleet hub.

- perf.py: rootless Thermals (hwmon) + detect_mode + sudo-backed set_mode,
  ported from perf-tray.py with Qt dropped (DRY-able with the tray later)
- server.py: stdlib http.server (Foundry lineage); GET/POST /api/perf, /api/hub.
  Binds 127.0.0.1 by default — mode-switch flips the CPU governor via sudo, so
  not LAN-triggerable unless --host 0.0.0.0
- ui/index.html: Ship's Bridge toolbar (live temp badge 55/70/85, mode switch,
  freq/fan/throttle) + hub cards (Foundry :8765, Armada :8731)
- bridge.py CLI (serve/status/perf); parvagues-bridge.service (systemd --user)
- 6 tests on pure perf logic; README incl. deploy + security notes

Smoke-tested: tests green, API live, mode detect + thermals reading correctly.
parent 607c408d
__pycache__/
*.pyc
.pytest_cache/
# ⚓ The Bridge — ParVagues web dashboard
The always-on **Ship's Bridge**: a proper webserver dashboard with a live
**perf/thermal toolbar** (folded from `perf-tray.py`) and an extensible **hub**
of the fleet's tools (Foundry, Armada…). The web answer to "is there a ParVagues
service running a dashboard?" — now yes.
```bash
cd tools/bridge
python3 bridge.py serve # → http://127.0.0.1:8773/
python3 bridge.py status # one perf/thermal snapshot (JSON)
python3 bridge.py perf # quick mode/temp line
python3 bridge.py perf cool # switch perf mode (needs deploy, below)
```
## What it shows
- **Toolbar** — live package-temp badge (colour tracks heat: green<55 ·
amber<70 · orange<85 · red≥85, same thresholds as the tray), current perf mode
as a segmented switch (Cool / Standard / Extreme / Normal), and freq / fan /
thermal-throttle readouts. Polls `/api/perf` every 2 s.
- **Hub** — cards launching the fleet's GUIs (Foundry :8765, Armada :8731…).
Extend by adding a dict to `HUB` in `server.py`.
## Why a web service (and how it relates to perf-tray)
`perf-tray.py` is a PyQt system-tray badge — great for an at-a-glance icon, but
not a dashboard and not reachable from a phone/another screen. The Bridge reuses
the tray's *proven, rootless* logic — `Thermals` (hwmon sysfs), `detect_mode()`,
the `sudo -n` mode switch — ported into `perf.py` with no Qt, and serves it over
HTTP. The two can coexist (tray icon + web dashboard); a later pass can DRY them
onto this one `perf.py`.
## Security — why it binds localhost
`POST /api/perf` flips the CPU governor via `sudo`. So the server **binds
`127.0.0.1` by default** — mode-switching is not LAN-triggerable. Opt into LAN
(e.g. to drive it from your phone) explicitly:
```bash
python3 bridge.py serve --host 0.0.0.0 # ⚠ exposes mode-switch to the LAN
```
## Deploy mode-switching (root-owned perf.sh + sudoers)
Reading thermals/mode is rootless. *Changing* mode runs
`sudo -n /usr/local/sbin/perf-audio --<mode>` against a **root-owned** copy of
`perf.sh`, whitelisted NOPASSWD via `perf-audio.sudoers` (shared with the tray —
never sudo the editable repo copy). If the binary isn't deployed the mode
buttons are disabled and the API returns a hint.
```bash
sudo install -m 755 -o root -g root \
~/Work/Sound/Tidal/perf.sh /usr/local/sbin/perf-audio
sudo install -m 440 -o root -g root \
~/Work/Sound/Tidal/perf-audio.sudoers /etc/sudoers.d/perf-audio
```
## Always-on (systemd --user)
```bash
systemctl --user enable --now \
~/Work/Sound/Tidal/tools/bridge/parvagues-bridge.service
# (or: cp the unit into ~/.config/systemd/user/ then enable --now)
```
## Tests
```bash
cd tools/bridge && python3 -m pytest tests/ -q # pure perf logic (no sudo)
```
## Layout
```
tools/bridge/
perf.py # rootless thermals + mode detect + sudo switch (from perf-tray)
server.py # stdlib http.server dashboard (GET/POST /api/perf, /api/hub)
bridge.py # CLI (serve / status / perf)
ui/index.html # the dashboard (Ship's Bridge tokens)
parvagues-bridge.service # systemd --user unit (autostart)
tests/test_perf.py
```
#!/usr/bin/env python3
"""bridge — The Bridge driver (ParVagues dashboard + perf control).
python3 bridge.py serve [--host --port] # run the dashboard (default 127.0.0.1:8773)
python3 bridge.py status # print a perf/thermal snapshot
python3 bridge.py perf [cool|standard|extreme|normal] # read or switch mode
Runs under system python3 (stdlib only). See README to install the systemd
--user service for always-on autostart.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import perf as P # noqa: E402
def cmd_status(a):
print(json.dumps(P.snapshot(), indent=2))
def cmd_perf(a):
if not a.mode:
s = P.snapshot()
print(f"mode={s['mode']} pkg={s['package_c']}°C ({s['temp_state']}) "
f"freq={s['freq_max_mhz']}MHz throttle+{s['throttle_delta']}")
return
ok, msg = P.set_mode(a.mode)
print(("✓ " if ok else "✗ ") + msg)
raise SystemExit(0 if ok else 1)
def cmd_serve(a):
import server
server.run(host=a.host, port=a.port)
def main(argv=None):
p = argparse.ArgumentParser(prog="bridge", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("serve")
sp.add_argument("--host", default="127.0.0.1")
sp.add_argument("--port", type=int, default=8773)
sp.set_defaults(fn=cmd_serve)
sub.add_parser("status").set_defaults(fn=cmd_status)
sp = sub.add_parser("perf"); sp.add_argument("mode", nargs="?"); sp.set_defaults(fn=cmd_perf)
a = p.parse_args(argv)
a.fn(a)
if __name__ == "__main__":
main()
[Unit]
Description=The Bridge (ParVagues web dashboard + perf toolbar)
After=graphical-session.target
PartOf=graphical-session.target
[Service]
# Always-on dashboard. Binds 127.0.0.1 by default (mode-switch is sudo-backed —
# don't expose to LAN unless you mean to; pass --host 0.0.0.0 to opt in).
ExecStart=/usr/bin/python3 %h/Work/Sound/Tidal/tools/bridge/bridge.py serve
Restart=on-failure
RestartSec=3
[Install]
WantedBy=graphical-session.target
"""perf — rootless thermal/perf-mode reader + sudo-backed mode switch.
Ported from perf-tray.py (the PyQt tray) with the Qt dropped, so the same proven
logic backs the web Bridge. Reading thermals/mode is rootless (hwmon + cpufreq
sysfs); only *changing* mode needs root, done via `sudo -n` against a ROOT-OWNED,
sudoers-whitelisted copy of perf.sh at /usr/local/sbin/perf-audio (see
perf-audio.sudoers) — never the user-editable repo copy.
DRY note: perf-tray.py still has its own copy of Thermals/detect_mode; a later
pass can have it import this module. Kept separate now to avoid touching the
working tray.
"""
from __future__ import annotations
import glob
import os
import subprocess
# Root-owned, sudoers-whitelisted deployment of perf.sh (same as perf-tray).
SCRIPT = os.environ.get("PERF_TRAY_SCRIPT", "/usr/local/sbin/perf-audio")
# mode key -> (label, perf.sh flag)
MODES = {
"cool": ("Cool", "--cool"),
"standard": ("Standard", "--optimize"),
"extreme": ("Extreme", "--extreme"),
"normal": ("Normal", "--stop"),
}
# temp thresholds (°C) → state name; the UI maps state→color (matches tray).
TEMP_STATES = ((55, "cool"), (70, "warm"), (85, "hot"), (10_000, "critical"))
def _read(path, default=None):
try:
with open(path) as f:
return f.read().strip()
except OSError:
return default
def _read_int(path, default=None):
v = _read(path)
try:
return int(v)
except (TypeError, ValueError):
return default
def _hwmon_by_name(name):
for d in glob.glob("/sys/class/hwmon/hwmon*"):
if _read(os.path.join(d, "name")) == name:
return d
return None
def temp_state(c):
if c is None:
return "unknown"
for ceiling, state in TEMP_STATES:
if c < ceiling:
return state
return "critical"
class Thermals:
"""Rootless reader for package/core temps, fans, freq and throttle counts."""
def __init__(self):
self.coretemp = _hwmon_by_name("coretemp")
self.dell = _hwmon_by_name("dell_smm")
self._pkg_input = self._find_label_input(self.coretemp, "Package id 0")
self._throttle_base = self._throttle_sum()
def _find_label_input(self, chip, label):
if not chip:
return None
for lbl in glob.glob(os.path.join(chip, "temp*_label")):
if _read(lbl) == label:
return lbl.replace("_label", "_input")
t1 = os.path.join(chip, "temp1_input")
return t1 if os.path.exists(t1) else None
def package_c(self):
v = _read_int(self._pkg_input)
return round(v / 1000) if v is not None else None
def cores_c(self):
out = []
if self.coretemp:
for lbl in sorted(glob.glob(os.path.join(self.coretemp, "temp*_label"))):
if (_read(lbl) or "").startswith("Core"):
v = _read_int(lbl.replace("_label", "_input"))
if v is not None:
out.append(round(v / 1000))
return out
def fans_rpm(self):
out = []
if self.dell:
for fi in sorted(glob.glob(os.path.join(self.dell, "fan*_input"))):
v = _read_int(fi)
if v is not None:
out.append(v)
return out
def freq_mhz(self):
freqs = [_read_int(p) for p in
glob.glob("/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq")]
freqs = [f for f in freqs if f]
if not freqs:
return (None, None)
return (round(max(freqs) / 1000), round(sum(freqs) / len(freqs) / 1000))
def _throttle_sum(self):
total = 0
for p in glob.glob(
"/sys/devices/system/cpu/cpu*/thermal_throttle/core_throttle_count"):
total += _read_int(p, 0) or 0
return total
def throttle_delta(self):
return self._throttle_sum() - self._throttle_base
def detect_mode():
gov = _read("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
epp = _read("/sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference")
maxp = _read_int("/sys/devices/system/cpu/intel_pstate/max_perf_pct", 100)
minp = _read_int("/sys/devices/system/cpu/intel_pstate/min_perf_pct", 0)
if gov == "performance" and minp >= 100:
return "extreme"
if gov == "performance":
return "standard"
if epp == "balance_performance" and (maxp or 100) < 100:
return "cool"
return "normal"
def script_available():
"""True if the sudoers-whitelisted root perf.sh is deployed."""
return os.path.exists(SCRIPT)
def set_mode(mode):
"""Switch perf mode via `sudo -n`. Returns (ok, message)."""
if mode not in MODES:
return False, f"unknown mode {mode!r}; have {sorted(MODES)}"
if not script_available():
return False, (f"{SCRIPT} not deployed — install the root-owned perf.sh + "
"sudoers rule (see README 'Deploy mode-switching')")
flag = MODES[mode][1]
try:
r = subprocess.run(["sudo", "-n", SCRIPT, flag],
capture_output=True, text=True, timeout=30)
except (OSError, subprocess.SubprocessError) as e:
return False, str(e)
if r.returncode != 0:
return False, (r.stderr or r.stdout or f"exit {r.returncode}").strip()
return True, (r.stdout or "ok").strip()
def snapshot(therm: "Thermals | None" = None) -> dict:
"""One JSON-ready reading of mode + thermals (the /api/perf payload)."""
t = therm or Thermals()
pkg = t.package_c()
fmax, favg = t.freq_mhz()
return {
"mode": detect_mode(),
"package_c": pkg,
"temp_state": temp_state(pkg),
"cores_c": t.cores_c(),
"fans_rpm": t.fans_rpm(),
"freq_max_mhz": fmax,
"freq_avg_mhz": favg,
"throttle_delta": t.throttle_delta(),
"script_available": script_available(),
"modes": {k: v[0] for k, v in MODES.items()},
}
#!/usr/bin/env python3
"""server — The Bridge: always-on ParVagues web dashboard.
A proper webserver (stdlib http.server, same lineage as the Foundry's) serving a
Ship's Bridge dashboard: a live perf/thermal toolbar (folded from perf-tray) plus
an extensible hub of the fleet's tools. Designed to run as a systemd --user
service (parvagues-bridge.service).
python3 bridge.py serve # http://127.0.0.1:8773/
python3 bridge.py serve --host 0.0.0.0 # opt into LAN (phone)
Binds 127.0.0.1 by DEFAULT: POST /api/perf flips the CPU governor via sudo, so
mode-switching must not be LAN-triggerable unless you say so.
API:
GET /api/perf → perf.snapshot() (mode, temps, freq, fans, throttle)
POST /api/perf {mode} → switch perf mode → {ok, msg, snapshot}
GET /api/hub → [{name, url, blurb}] (the tool launcher)
GET /api/health → {ok:true}
"""
from __future__ import annotations
import json
import os
import socket
import sys
from functools import partial
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import perf as P # noqa: E402
UI = HERE / "ui"
# Hub entries — the fleet's other GUIs. Extensible: add a dict, it shows up as a
# card. url is opened in a new tab; ports match each tool's default serve port.
HUB = [
{"name": "The Foundry", "url": "http://127.0.0.1:8765/",
"blurb": "URL → audio → stems → loops (sampling)"},
{"name": "L'Armada", "url": "http://127.0.0.1:8731/",
"blurb": "catalog · distribution · gigs (serve.py)"},
]
# One Thermals instance per process → throttle_delta is relative to Bridge start.
_THERM = P.Thermals()
class Handler(BaseHTTPRequestHandler):
host_is_lan = False
def do_GET(self):
p = urlparse(self.path).path
if p in ("/", ""):
return self._file(UI / "index.html", "text/html; charset=utf-8")
if p == "/api/health":
return self._json({"ok": True})
if p == "/api/perf":
return self._json(P.snapshot(_THERM))
if p == "/api/hub":
return self._json(HUB)
# static assets under ui/
asset = (UI / p.lstrip("/")).resolve()
if UI in asset.parents and asset.is_file():
return self._file(asset, self._ctype(asset))
return self.send_error(404)
def do_POST(self):
p = urlparse(self.path).path
if p == "/api/perf":
body = self._body()
mode = (body.get("mode") or "").strip()
ok, msg = P.set_mode(mode)
return self._json({"ok": ok, "msg": msg, "snapshot": P.snapshot(_THERM)},
200 if ok else 400)
return self.send_error(404)
# ── helpers ─────────────────────────────────────────────────────────
def _body(self):
n = int(self.headers.get("Content-Length", 0))
return json.loads(self.rfile.read(n) or b"{}") if n else {}
def _json(self, obj, code=200):
b = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def _file(self, path: Path, ctype: str):
if not path.is_file():
return self.send_error(404, f"missing {path.name}")
b = path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
@staticmethod
def _ctype(path: Path):
return {".css": "text/css", ".js": "text/javascript",
".svg": "image/svg+xml", ".png": "image/png"}.get(
path.suffix, "application/octet-stream")
def log_message(self, *a):
pass
def _lan_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("192.168.1.1", 1))
return s.getsockname()[0]
finally:
s.close()
def run(host: str = "127.0.0.1", port: int = 8773):
httpd = ThreadingHTTPServer((host, port), partial(Handler))
print(f"⚓ The Bridge — perf mode: {P.detect_mode()}, "
f"pkg {P.snapshot(_THERM)['package_c']}°C")
print(f" http://{'127.0.0.1' if host in ('127.0.0.1','localhost') else host}:{port}/")
if host == "0.0.0.0":
try:
print(f" LAN: http://{_lan_ip()}:{port}/ ⚠ mode-switch exposed to LAN")
except OSError:
pass
if not P.script_available():
print(f" ⓘ mode-switching disabled ({P.SCRIPT} not deployed — see README)")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n⚓ Bridge docked.")
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8773)
a = ap.parse_args()
run(a.host, a.port)
"""Pure-logic tests for The Bridge perf module (no sudo, hardware-tolerant).
cd tools/bridge && python3 -m pytest tests/ -q
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import perf as P
def test_temp_state_thresholds():
assert P.temp_state(None) == "unknown"
assert P.temp_state(40) == "cool"
assert P.temp_state(54) == "cool"
assert P.temp_state(60) == "warm"
assert P.temp_state(75) == "hot"
assert P.temp_state(90) == "critical"
def test_modes_shape():
assert set(P.MODES) == {"cool", "standard", "extreme", "normal"}
assert P.MODES["extreme"][1] == "--extreme"
assert P.MODES["normal"][1] == "--stop"
def test_set_mode_rejects_unknown():
ok, msg = P.set_mode("turbo")
assert ok is False and "unknown mode" in msg
def test_set_mode_guards_missing_script(monkeypatch):
monkeypatch.setattr(P, "script_available", lambda: False)
ok, msg = P.set_mode("cool")
assert ok is False and "not deployed" in msg
def test_snapshot_shape():
s = P.snapshot()
for k in ("mode", "package_c", "temp_state", "cores_c", "fans_rpm",
"freq_max_mhz", "throttle_delta", "script_available", "modes"):
assert k in s
assert s["mode"] in P.MODES
assert isinstance(s["cores_c"], list)
assert isinstance(s["script_available"], bool)
def test_detect_mode_returns_known():
assert P.detect_mode() in P.MODES
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>⚓ The Bridge — ParVagues</title>
<style>
:root{
--surface:#0a0a0a; --raised:#111; --overlay:#171717; --hairline:#ffffff1f;
--ink:#e8e8ea; --mute:#9a9aa0; --faint:#6a6a70; --brand:#d900ff; --brand-deep:#8900b3;
--cool:#2e7d32; --warm:#f9a825; --hot:#ef6c00; --critical:#c62828; --unknown:#607d8b;
--mono:'Geist Mono',ui-monospace,'SF Mono',Menlo,monospace;
--sans:'Geist',system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--surface);color:var(--ink);font-family:var(--sans);font-size:14px}
/* ── toolbar ─────────────────────────────────────────────── */
.bar{display:flex;align-items:center;gap:18px;padding:12px 20px;
border-bottom:1px solid var(--hairline);background:var(--raised);
position:sticky;top:0;flex-wrap:wrap}
.brand{font-weight:600;letter-spacing:.04em}.brand .a{color:var(--brand)}
.badge{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;color:var(--mute)}
.therm{width:46px;height:30px;border-radius:8px;display:flex;align-items:center;justify-content:center;
font-family:var(--mono);font-weight:700;color:#fff;font-size:15px;transition:background .4s}
.therm.cool{background:var(--cool)}.therm.warm{background:var(--warm)}
.therm.hot{background:var(--hot)}.therm.critical{background:var(--critical)}
.therm.unknown{background:var(--unknown)}
.modes{display:flex;gap:0;border:1px solid var(--hairline);border-radius:9px;overflow:hidden;margin-left:auto}
.modes button{background:transparent;border:0;border-right:1px solid var(--hairline);
color:var(--mute);font-family:var(--mono);font-size:12px;padding:7px 13px;cursor:pointer;transition:.15s}
.modes button:last-child{border-right:0}
.modes button:hover{background:var(--overlay);color:var(--ink)}
.modes button.on{background:var(--brand-deep);color:#fff}
.modes button:disabled{opacity:.4;cursor:not-allowed}
.stat{font-family:var(--mono);font-size:11px;color:var(--faint)}
.stat b{color:var(--mute);font-weight:600}
/* ── hub ─────────────────────────────────────────────────── */
main{max-width:880px;margin:0 auto;padding:28px 20px}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:var(--faint);margin:0 0 14px;font-family:var(--mono)}
.hub{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px}
.card{display:block;text-decoration:none;color:inherit;background:var(--raised);
border:1px solid var(--hairline);border-radius:12px;padding:18px;transition:.15s}
.card:hover{border-color:var(--brand);transform:translateY(-2px)}
.card h3{margin:0 0 4px;font-size:15px}.card p{margin:0;color:var(--mute);font-size:12px}
.card .arr{float:right;color:var(--faint)}
.toast{font-family:var(--mono);font-size:12px;color:var(--mute);padding:0 20px 8px;min-height:16px}
.toast.err{color:var(--critical)}
</style>
</head>
<body>
<div class="bar">
<span class="brand"><span class="a"></span> THE BRIDGE</span>
<div class="badge"><div class="therm unknown" id="therm">--</div>
<span id="thermlbl">package</span></div>
<span class="stat" id="freq"><b>freq</b></span>
<span class="stat" id="fan"><b>fan</b></span>
<span class="stat" id="thr"><b>throttle</b> 0</span>
<div class="modes" id="modes"></div>
</div>
<div class="toast" id="toast"></div>
<main>
<h2>Fleet</h2>
<div class="hub" id="hub"></div>
</main>
<script>
const $=s=>document.querySelector(s), api=(u,o)=>fetch(u,o).then(r=>r.json());
let MODES={}, scriptOK=true;
function toast(m,err=false){const t=$("#toast");t.textContent=m;t.className="toast"+(err?" err":"");}
function paint(s){
const t=$("#therm");
t.className="therm "+(s.temp_state||"unknown");
t.textContent=(s.package_c==null?"--":s.package_c);
$("#thermlbl").textContent=s.package_c==null?"no sensor":(s.cores_c?.length?`${s.cores_c.length} cores`:"package");
$("#freq").innerHTML=`<b>freq</b> ${s.freq_max_mhz?(s.freq_max_mhz/1000).toFixed(1)+"G":"–"}`;
$("#fan").innerHTML=`<b>fan</b> ${s.fans_rpm?.length?Math.max(...s.fans_rpm)+"rpm":"–"}`;
$("#thr").innerHTML=`<b>throttle</b> ${s.throttle_delta??0}`;
scriptOK=s.script_available;
if(JSON.stringify(MODES)!==JSON.stringify(s.modes)){MODES=s.modes;buildModes();}
document.querySelectorAll(".modes button").forEach(b=>b.classList.toggle("on",b.dataset.m===s.mode));
}
function buildModes(){
const box=$("#modes");box.innerHTML="";
Object.entries(MODES).forEach(([k,label])=>{
const b=document.createElement("button");b.dataset.m=k;b.textContent=label;
b.disabled=!scriptOK; if(!scriptOK)b.title="deploy /usr/local/sbin/perf-audio to enable";
b.onclick=()=>setMode(k,b); box.appendChild(b);
});
}
async function setMode(mode,btn){
document.querySelectorAll(".modes button").forEach(b=>b.disabled=true);
toast(`switching → ${MODES[mode]}…`);
try{
const r=await api("/api/perf",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({mode})});
toast(r.ok?`✓ ${MODES[mode]}`:`✗ ${r.msg}`,!r.ok);
if(r.snapshot)paint(r.snapshot);
}catch(e){toast("✗ "+e,true);}
buildModes();
}
async function tick(){try{paint(await api("/api/perf"));}catch(e){}}
async function loadHub(){
const hub=await api("/api/hub"), box=$("#hub");
box.innerHTML=hub.map(h=>`<a class="card" href="${h.url}" target="_blank">
<span class="arr">↗</span><h3>${h.name}</h3><p>${h.blurb}</p></a>`).join("");
}
loadHub(); tick(); setInterval(tick,2000);
</script>
</body>
</html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment