Phase 33 completion pass is done. Final report:
## Phase 33 — completion report
**All 8 task files complete** (`.agents/phases/complete/33_gateway_route_switching/`), full gate green, docs verified.
### Work in this pass
1. **`tests/test_routectl.py` added** (17 tests, all green) — exercises the *real* helper binary unprivileged against a fake tree (`--root`), the real agent parser, the captured gateway fixtures, and stubbed `nginx`/`haproxy`/`systemctl` on `PATH`. Pins the full protocol (locate → state → dry-run → temp-file/fsync/`os.replace` write → checker → restore on checker failure → reload-never-restart → re-read proof → sidecar record), all three route shapes (haproxy `server`, nginx `proxy_pass`, nginx stream `map` via its sink-value id), `drift` / unknown-id / `map-default` refusals, `list`/`verify`, and the `--serve` peer gate (`SO_PEERCRED` uid check, one JSON line per request, exact body shape).
2. **Real helper defect fixed** — `deploy/dashboards-routectl.py`: `main()`'s early-return paths returned `({"ok": False, …}, EXIT_*)` tuples, so `sys.exit()` printed a Python repr instead of a JSON line (usage errors, agent-import refusal). Now prints `json.dumps(…)` and returns only the code, keeping the one-JSON-line contract.
3. **Documentation consistency fixes** (task 08 criterion 6):
- `README.md`: garbled verdict-colour sentence on the switch ("green and to the position") → "in the **on** position and tinted `--gate-on`" (the existence-pair contract).
- `README.md`: audit sentence now names all three event kinds — `route-disabled` / `route-enabled` / **`route-denied`** (the code stores one per refused call; dry runs store none).
- `page-gateway.js`: replaced the stale pre-33 module docstring ("the switch is not wired") with the two-arm `GATEWAY_ALLOW_WRITES` contract.
- `AGENTS.md`: every phase-33 claim re-verified against code (the ten gates, the 35 s/30 s budget pair, `gateway.writes_enabled`, `/api/summary`'s `disabled`, the 1.4.0 agent bullet, the gap list) — no changes needed.
### Verification
- `uv run pytest --cov`: **1970 passed**, 18 skipped — coverage **98.74%** (floor 94%)
- `ruff` clean · `pyright` 0 errors · `bash .agents/validate.sh` **exit 0** (incl. `node --check` on every page script)
- Dev server running; all 10 sources `ok`; bundles exported
### Live infrastructure
**No live changes made.** The live drill remains unexecuted per `data/logs/NOTES-33-switching.md` (no operator approval; checker binaries unavailable in the podman proxy units). All routes stay enabled, config hashes unchanged, and the sandbox rehearsal + backup tree are in place for the operator's own `DANGER` call. All changes are in the working tree only.
37 KiB
AGENTS.md — homelab dashboards
Orientation for an agent starting work in this repo: what the app is, the rules that keep
its numbers honest, where things live, the fleet it watches, and the commands. README.md
is the operator-facing how-to (quickstart, agent install, signing in, configuration,
deployment) — read it for those; this file does not repeat it. This file is orientation,
not a changelog: phase history lives in .agents/phases/ and data/logs/. When a phase
closes, update the sections below only if a rule, invariant, command, or fleet fact
changed — never append the phase's narrative.
Working protocol
- Read
.agents/PLAN.mdfirst; follow the phased execution protocol in.agents/phases/. - Never modify
.agents/PLAN.mdor files in.agents/phases/complete/; ask the user before updating anything in.agents/phases/todo/. - Strictly adhere to the LOCKED DECISIONS in
.agents/PLAN.mdand the section at the bottom of this file.
What this is
Five dashboards for one homelab, behind one domain, in one process, backed by one SQLite file — plus an overview page:
| Route | Question it answers | Source |
|---|---|---|
/ |
Is anything wrong, and where do I look first? | all five, in one request |
/system |
CPU, memory, disk, GPU, network, and the history | agent/host_agent.py, our own stdlib agent per host |
/disks |
Are any physical disks failing, and which — health, temp, wear | the same agents, smartctl -i -A -H per whole drive |
/llm |
Tokens/s, active sessions, queue depth, KV pressure, latency | gufo /metrics + /slots, vLLM /metrics |
/ups |
Battery health, runtime left, power events, test buttons | NUT upsd (our own wire client; port 3051, not 3493) |
/gateway |
Which domains on which port, and where each goes | the same agent's haproxy + nginx sections joined to /proc/net/tcp{,6} on the one GATEWAY_HOST host (the page needs agent ≥ 1.2.0; the route switch's #off marker read is 1.4.0) |
History is a first-class feature: every number worth graphing is stored, rolled up
(raw → 1m → 1h) and served from /api/*/history.
None of it is public. One admin signs in at /login (the only page an anonymous visitor
reaches); /settings (⚙ Account) and /certs (⚷ Certs) live behind it. Every page and
every /api route requires the session except the public entries in invariant 12.
Non-negotiable rules
Breaking one is a bug even if the page still renders.
- Read-only by construction, with exactly two exceptions, and both are gated on purpose.
Nothing writes to an upstream except
POST /api/ups/commandandPOST /api/gateway/route, and each is re-checked on every request by its own gates: the UPS one is gated four times (env allow, notDASH_READ_ONLY, the upsd account actually having the command, a UI confirmation — typedDANGERfor anything that can cut power) and power-cutting commands are refused outright while on battery; the gateway one is gated ten (notDASH_READ_ONLY,GATEWAY_ALLOW_WRITES— off by default — a per-address write throttle, a hand-parsed body, the route's state in the current payload, the freshness of the read end to end, and a typeddanger="DANGER"for both verbs — adry_runpreview asks nothing and writes nothing — for anything that can cut a route through the fleet's only ingress), and it dials once through the same revocation gate the reads take. Hiding a button is not a safety control. - Never invent a number. A value the upstream does not publish renders as
n/a, never as0. Every derived value carries itssourcestring andderived: true; the rate estimator is keyed by the resolved series name plus its label set and refuses a window too short or too stale. Aggregating two different series into one rate is the failure mode this rule exists to catch. - Unconfigured is not broken. A source with no env config answers
404 {"configured": false}without dialling, and the page says "not configured". A red panel for a server the operator never installed destroys the signal. - One dead source must not blink another — and nothing may stop polling in silence.
Each source is its own asyncio task with its own timeout and health record. A failed
poll keeps the last good payload and marks it stale instead of zeroing it; a host that
never answered still gets its row (
unreachable: true, dimmed,n/a), because dropping it would turn a dead agent into "never configured". The task is the source's only poller, so no exception may end it: a store that refuses an otherwise good poll is a reported failure (ok: false, the real reason,last_okfrozen, one event thenrecordingon recovery), and anything that escapes the poll body is logged and the loop continues. A frozen source that looks healthy is the one failure mode worse than an outage. - No CDN, no build step, no internet at runtime. Every asset is served by this app;
charts are hand-rolled SVG (
src/dashboards/web/static/charts.js). The engines live on a network with no outbound access — a CDN reference is a request that never comes back. - Never leak a credential. Target URLs may carry a userinfo token (
parse_targetsmoves it intoAuthorization, soTarget.urlends up clean), but nothing that leaves the process may: URLs inUpstreamErrormessages, health records,eventrows (/api/eventsserves them to the browser) and/api/configare redacted. The admin's credential, the private keys, and session tokens are the same case:auth.pyrecords nothing and answersFalse/Noneto every rejection,certs.pyhas nologgingimport, and both modules'raisesites andauth.py's oneloggingcall carry fixed text with nothing interpolated — pinned structurally bytests/test_auth.pyandtests/test_certs.py. No token, password, or personal hostname goes into tracked files or fixtures; captured socket tables and resolver answers substitute globally routable addresses for documentation space (RFC 5737 / RFC 3849), idempotently. - Verify against the deployed upstream; never guess a metric name. Field maps in
sources/llm.pyandsources/nut.pywere captured from the running engines and pinned bytests/fixtures/. Re-capture withscripts/capture-fixtures.sh(andscripts/capture-gateway-config.shfor the gateway's proxy tree, socket tables and resolved names) rather than trusting a README or a memory of an upstream. - Keep dependencies current on purpose. After touching dependencies,
uv pip list --outdatedmust come back empty. Python is 3.14 everywhere (requires-python, ruffpy314, pyright, Containerfile) — do not lower it to make something else work. - SQLite owns the history and must stay bounded. Writes go through
store.py; new metrics need a rollup path and a retention story, or a year of charts becomes a disk emergency. Put the DB on a volume in deployment. - The dev server stays up while work happens (
./scripts/dev-server.sh restart, http://127.0.0.1:8090), and UI changes are verified in a real browser with a screenshot (data/logs/*.png) before they are called done. - The dashboard never starts open, and the stored password beats the environment. The
admin verifier and the session-signing key are durable
metarows (auth.verifier,auth.session_secret) and the only place either secret lives;DASH_ADMIN_PASSWORDis a seed that hashes into the row on the first boot of a database and is ignored thereafter. A server with neither a stored verifier nor the seed refuses to start (aRuntimeErrorout of the lifespan, non-zero exit).testpassis the local-testing password (dev config and the hermetic suite), never a production fallback. - The gate is default-deny and its public surface is four entries (
/login,/api/auth/login,/api/health,/static/). A pageGETgets303to/login?next=…(query string included, checked byauth.safe_next); everything else the one JSON refusal; every answer carriesno-store; a write additionally needs a matchingOrigin/Sec-Fetch-Siteand — when it came in on the cookie — this session'sX-CSRF-Token. A process whose lifespan never producedctx.authanswers503for everything, public paths included.tests/test_auth_api.pywalks the served route table so a route that forgets the gate fails a test instead of shipping — the middleware guards by default./api/healthis a contract with deployment, which means nothing may be added to it without re-deciding that: it is public, and a new field there is a field the whole internet reads.
Architecture
agent/host_agent.py (each host) gufo / vLLM upsd (NUT)
│ GET /snapshot │ GET /metrics │ LIST UPS / VAR / CMD
▼ ▼ ▼
collectors.py ── fetch, one UpstreamError type, no writes except run_ups_command and post_route
│ parse + Δcounter/Δt
▼
sources/host.py · disks.py · llm.py · nut.py · gateway.py poller.py (task per source)
│ Result(payload, samples, events)
▼
store.py ── SQLite (WAL): sample(source, host, metric, res, ts) + event ──► api.py ──► web/
One line each — the load-bearing behaviour is in the bullet, the rest is in the module:
agent/host_agent.py— stdlib-only (3.10+), one per host; servesGET /snapshot,/info,/schema,/health. Reads/proc,/sys/block, amdgpu DRM for GPUs,statvfs;smartctl -i -A -Hper whole/sys/blockdisk with a scaled pass budget (20 s floor, 5 s per disk) whose refresh runs on a background thread — never inline on a later poll. Refuses to bind off-loopback without a token or a certificate bundle (exit 2). TLS mode (≥ 1.1.0): the bundle is the auth (the bearer token is the compatibility path), the# REVOKEDdenylist in the bundle is re-read when the bundle changes, and a serial on it is refused on every path including/health. The proxy add-on (≥ 1.2.0): thehaproxy,nginx(plus its one-levelincludes) andsocketssections; the resolver section (1.3.0) looks up only the names the two config files declare, through libc on the box that dials, cached and single-flight. All four sections are additive,availableis three-way (true/false/null) with exactly one error line per non-truearm — the missing one saying not found, the pinned wording the dashboard reads to answer "is it installed?" — and--no-proxy/--no-resolveremove their keys in silence. Nothing in a section can fail/snapshotor/health. Counters stay counters: the dashboard computes rates. The one write (1.4.0, gateway hosts): a singlePOST /route, refused without a verified, non-revoked client certificate and the agent's own--allow-writesflag (certificate first, in that order), forwarding exactly{id, action, dry_run}to the socket-activated root helperdashboards-routectland answering its JSON verbatim — one connection, one request, one answer, no retry, because a retry of a write is a second reload. The privilege is the helper's socket, not the agent's: the unit keepsNoNewPrivileges=true+ProtectSystem=strictbyte-identical with and without--with-routectl, and no sudoers line is involved. EveryGETpath is untouched —#offmarkers are read on the normal proxy sections (the marker's own grammar is the agent's:ROUTE_MARKER/split_marker/config_line_index—disabledonserver/proxy_pass/mapnodes; a marked line stays in the document).config.py— env only, no config file format, no CLI flags..envis a seed; a real env var always wins. Safe defaults except the sources, which default to unset. The gateway source is enabled only whenGATEWAY_HOSTnames aHOST_TARGETSentry (defaultgateway). The write's two settings are beside it:GATEWAY_ALLOW_WRITES(default off, the NUT flag's truthy rule) andGATEWAY_WRITE_TIMEOUT_SECONDS(default 35 s, deliberately longer than the agent's 30 s peer timeout so the dashboard never times out first); the read's 10 s budget stays the read's alone.poller.py— job loop, health records, stale/last-good semantics, transition events.disks:<host>is a source of its own (300 s / 30 s) andgatewayis one job (30 s / 10 s) that exists only when configured, because the page is about the single ingress; it is handed noRateEstimator(a count is not a rate). The success path records (payload → samples → events) and then moveslast_ok, so a refused SQLite write ages the source and alerts instead of leaving it current with charts that silently stop drawing. Events are stored under the dashboard name (system/disks/gateway/llm/ups), not the sample source. The job list is built once (app.build_hub()) because it is wired twice (production and the injected-store test path).collectors.py— the fetch half.AgentTlsmakes anhttps://HOST_TARGETSentry a certificate-authenticated dial (per-dial client, this app's client cert verified against its own root; the revocation answer is a fresh read per poll, so a/certsrevoke bites on the next poll with no restart, before any socket opens).post_routeis the write's dial besidefetch_snapshot: the same per-dial client and the same revocation check before any socket opens, butAuthorizationis never sent on the certificate path — the bundle is the credential there, and the token travels only on the plain token path. The answer is the helper's own object, verbatim. Failure messages name the redacted URL. Agenterrorsbecomedegradedexcept the add-on lines (ADDON_ERROR_PREFIXES:smart,haproxy,nginx,sockets,resolver) — a host without smartctl or a proxy config is unconfigured, not broken (rule 3); those lines stay verbatim for the page that owns the add-on. A new agent add-on section adds its prefix here in the same commit.sources/host.py— snapshot → payload + samples; reboot detection; GPU series per card. Carries the agent'stls/bundle_revpassthrough (present only when published — rule 2).sources/disks.py— physical drives only (never a partition, never a mount), the agent'ssmartblock verbatim,smartctl_availablethree-way on purpose, per-rowseverity(temp_c,percentage_used, the drive's own word forhealth); sector counters are alarm chips, never graded. Nothing here is a rate.sources/gateway.py— the snapshot projected onto the connection topology. The join is two attributions and nothing else: a socket whose local port names a configured listener is inbound; a socket whose remote(address, port)equals a target written as an address in a config is outbound; everything else is ignored. A name target is credited only through an address the gateway's ownresolversection published — the module never calls a resolver itself (spy-pinned). Pipes arenull, never0, when unmeasured.warnings()judges the two config files against themselves (seven kinds, each{id, kind, subject, message}) and never joins a warning to a row. Each row'senabledcarries the config's own state (the 1.4.0#offmarker, or a map entry pointed at the helper's sink); a merge that would mix states splits into two rows keyed byenabled(a union of selectors with one disabled line is two facts; the#offsuffix keeps the halves distinct); the marker-caused warning shapes — an emptied backend, a targetless route, a sink-pointed entry — warn nothing, while the same shape from a hand-deleted line still warns; the switch-off tally is published as/api/summary'sdisabledfield. Noseverityanywhere in the payload (a busy proxy is the job).sources/llm.py— gufo + vLLM field maps, measured Δ rates diffed per label set, prefill speed, the session list from gufo/slots. A refused estimator window is still an answer: the cell keeps itssourceand an honestΔ<window>beside anull.sources/nut.py— the NUT wire client,assess()(explicit checks, not a mystery score),SELF_TEST_OUTCOMES, theCOMMANDScatalog with risk tiers andavailable_commands()filtering, kelvin/celsius and charger-status normalisation.sources/nut_sim.py— a labelled simulator; never the default, every payload sayssimulated; its modelled battery date ages relative to today.prom.py— in-house Prometheus text parser (only for engines we don't own);values_by_series()is the per-label-set view counter deltas need.rates.py— the Δ/window estimator: keyed by series name + label set, window bounded both sides (floor;2 × spanceiling), a rate ≥ 8× the previous one held one poll and confirmed only by an independent anchor.app.build_hubbuilds one estimator per measuring cadence — host atRATE_WINDOW_SECONDS(10 s), LLM atLLM_INTERVAL_SECONDS(2 s) — because one estimator cannot be right about two cadences.severity.py— the only place thresholds live.classify()→ok/warn/fail/None; verdicts travel additively in payloads; pages render the class and never compare a number to a threshold; unknown in → unknown out, never a green pass. There is nogpu.*row and nogateway.*row: a busy GPU or a busy proxy is the job at every level.alerts.py— the pure "what is wrong right now" projection/api/summaryserves: reachability, severity breaches, UPS checks. An unconfigured source never alerts; a dead source gets exactly one line; one alert per (source, metric), at most 12. It has no thresholds of its own.store.py—sample+event+meta, rollups, pruning.metais the only durable key/value state, reached throughget_meta/set_meta/delete_metaonly. No per-request path may pay a whole-table scan:series_names()andstats()answer from short-TTL caches.maintain()is windowed (only what became due since the last pass, snapped down to a bucket boundary) and its prune is oneDELETEper resolution; it ends withPRAGMA optimize(auto-analyze is off, so no poller's commit pays for it).auth.py— every auth decision as a pure function, stdlib only: PBKDF2 verifier (self-describing, so the cost can rise without a migration), HMAC-signed stateless session cookie, CSRF bound to the session, per-address login throttle,safe_next(), and the verifier bootstrap (env seeds once; the stored row wins; no row and no seed raises). The primitives never raise and record nothing — the reason an input was rejected is the credential (rule 6).certs.py— the fleet's certificate authority over the same three-methodMetaStoreprotocol: root CA, dashboard client cert, and per-host agent certs, all inmeta; the bundle is the install artifact (agent cert + key + root certificate, never the root key, +# BUNDLE-REV+ the denylist). A stored row always wins — the root is generated on the first boot of a database and never on a later one.ensure_certsis the startup read (it mints; nothing else may call it);read_stateis the request-time read (same validation, never a write).cryptographyis the app's only new dependency, used here alone; the agent stays stdlib.api.py— GETs everywhere plus two POSTs that dial an upstream (/api/ups/commandand/api/gateway/route, invariant 1's two exceptions). Every source's configured gate runs before anything that could reach the network (spy-pinned: no client, no fetch)./api/configis a redaction boundary and servesgateway.writes_enabledso the page can render the honest arm. All five history routes go through one door (history_many). The upstream write set is exactly those two (pinned by enumerating every served route's verbs; a third fails by name). The route write's gates (invariant 1) run in a pinned order — read-only, flag, per-address throttle, body shape, the id's state in the current payload, end-to-end freshness, typedDANGERfor both verbs — and every answered call stores one audit event under its own kind (route-disabled/route-enabled/route-denied; a dry run stores none) in a fixed shape: the id, the action, the helper's words — never the config text.app.py— assembly and the session gate middleware (invariant 12), answering in this order: noAuthState→503for everything; no session and no such route → the router's404; public → through (the origin check still applies to a public write); no session →303for a page / JSON for the rest; a write → origin, then CSRF.build_hubis called in both the owned-store and injected-store branches; the lifespan buildsctx.authbefore the first poll, and aRuntimeErrorthere leaves the process non-zero.web/— the six dashboards plus/login,/settings,/certs; one stylesheet,charts.js, no framework. Colour comes only from the payloadseverityverdict (/gatewaycolours nothing by verdict — its only pair is existence);n/afor the unknown,SIMULATEDbadges, per-source status dots,Δ<window>labels on measured rates. The served-byte pins intests/test_api.pyare the UI contract — when a UI rule is in doubt, read that test.
Metric naming: dotted (cpu.usage_pct, mem.used_pct, disk.used_pct@/srv,
gpu.busy_pct@0, ups.charge_pct), @stream for per-device series. sample.source is
the dashboard family — host, llm, ups, disks (the SMART series) and gateway
(the connection gauges: gateway.in_conns@<listen-port>, gateway.out_conns@<host>:<port>
— the port belongs in the outbound name because two services on one host are two facts) —
so GET /api/metrics?source=… answers "what does this page have history for".
Treat the scheme as a public interface of the history API.
The fleet (real environment)
| Host | ssh | What it gives |
|---|---|---|
driveripper |
driveripper-ops |
Threadripper 24c/48t + Radeon AI PRO R9700, vLLM :8000, upsd, the dashboards. 16 whole disks; the agent unit runs User=root with CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN (kernel 7.2's unprivileged-passthrough gate; an empty bounding set is capability-less, CapEff 0) |
deskwork |
deskwork-ops |
Strix Halo (Radeon 8060S iGPU), gufo :8731 |
gateway |
gateway-ops |
the router, the fleet's ingress: haproxy (/etc/haproxy/haproxy.cfg, six frontends → six backends) + nginx (/etc/nginx/nginx.conf plus conf.d/ vhosts and stream.d/'s map on :443); runs agent 1.4.0 with the dashboards-routectl helper units |
- Transport is direct dial; the fleet is mTLS.
HOST_TARGETSarehttps://with no userinfo — the certificate bundle is the credential (the token in a URL is the compatibility path for a host nobody has bundled). All three hosts run the installeddashboards-agentsystemd service, installed bydeploy/install-agent.shunder the sudo grant — registration is the install:--fetch-bundle <url> --cert-name <name> --session <token>pulls the host's own bundle from this dashboard during the install, so a host is never registered onto a token URL because acurlwas forgotten. Re-stage/home/ops/agent-install/first — the grant names a path, not a checkout — and a re-run whose rendered unit differs from the installed one prints the diff and refuses without--force.deskwork/driveripperstill run 1.1.0: the 1.2.0/1.3.0/1.4.0 sections are purely additive (a 1.1.0 snapshot has none of the keys, whichsources/gateway.pyanswers assupported: false), so they move at their next re-stage. A TLS agent serves https only — rolling a host back means removing the bundle lines too, not just the URL. The gateway's write (1.4.0) installs beside the agent, never into it:install-agent.sh --allow-writes --with-routectladds one argument to the agent unit'sExecStart(--allow-writes— a diff that shows a security directive moving is a stop, not a--force) and installsdashboards-routectl.py(0755, root,/usr/local/sbin/) plus the two unitsdashboards-routectl.socket(0600, owned by the agent's user) anddashboards-routectl@.service(root, one instance per connection,ReadWritePaths=/etc/haproxy+/etc/nginx/conf.d+/etc/nginx/stream.donly), verifying the agent's uid can connect before reporting success. No sudoers line is involved — the grant is a file mode systemd enforces, and--with-routectlalone leaves the agent's unit byte-identical; the helper edits one#offroute (the marker carries the line's original text after itswas:, a per-route state sidecar records who last changed it and the file's hash, anenablerestores only an unedited line — an edited one is adriftrefusal, no byte moves — and a switched-offmapentry points at the helper's own sinkserver), validates with the proxy's own checker (restore on failure) and reloads, never restarts.--without-routectlremoves it, printing the still-off routes first;deploy/gateway-routectl-README.mdis the operator's page. The live drill has not run: the install is verified end to end (task 07), but this fleet's proxies run in podman containers, so the host has nonginx/haproxybinary for the checker —data/logs/NOTES-33-switching.mdrecords it, and a realDANGERis still the operator's call. - NUT 2.8.5 on driveripper: configs in
/etc/ups/(not/etc/nut/), upsd on port 3051 (not 3493 — nothing listens on 3493). NUT 2.8 has noACLdirective; per-source restriction is a firewall rule, never anupsd.confline. Two UPSes:apc(APC Back-UPS RS 1500MS) andcyberpower(CyberPower PR1500LCDRT2U — that isups.description; the driver's ownidentity.modelanswersGX1500U). Real driver quirks the parsers already handle: CyberPower'sbattery.mfr.dateisCPS(not a date →unknown, never a fake age); neither driver reports a battery temperature orbattery.charger.status;ups.test.resultisNo test initiated(APC) vsDone and passed(CyberPower) — both words re-read from the wire and pinned intests/test_nut.py. - SMART:
smartctl7.5 on deskwork and driveripper; the agent shells it as itself (never sudo). This binary annotates the attribute table's RAW_VALUE column (35 (Min/Max 34/44),43894h+43m+32.519s), so the parser reads that column, not the end of the line.gatewayhas no smartmontools (a VM) →smartctl_available: false→ no/diskscard at all (the visibility gate,api._disk_visible), while thedisks:gatewayjob still polls and its events still reach the page. - Ground truth:
./scripts/dev-server.sh status(per-source ok/DOWN, unauthenticated) anduv run pytest -m live --live(opt-in, read-only). The per-host install record is indata/logs/(gitignored) — re-run the probes rather than trusting a note.
Commands
uv sync --python 3.14 # deps + editable install
set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no tunnels
./scripts/dev-server.sh restart # keep http://127.0.0.1:8090 up; sign in with testpass
./scripts/dev-server.sh status # per-source ok/DOWN (unauthenticated)
./scripts/dev-server.sh bundles # the bundles the restart exported to data/certs/
./scripts/dev-server.sh logs
uv run pytest # hermetic: no network (skips 12 live + 4 store_perf)
uv run pytest --cov # coverage gate (floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd — read-only
uv run pytest -m store_perf --store-perf # opt-in: store hot-path timings vs store_perf.BUDGETS
uv run ruff check src tests agent && uv run pyright
bash .agents/validate.sh # the gate: the four checks above + repo hygiene
./scripts/capture-fixtures.sh # re-record ground truth (UPS + gufo /slots + smartctl)
./scripts/capture-gateway-config.sh # the gateway's haproxy/nginx tree, /proc/net/tcp{,6},
# and the recorded resolver answers (via $GATEWAY_SSH)
Deployment: Containerfile (two-stage, uv sync --frozen, runs as uid 10001 on
0.0.0.0:8090, SQLite under /var/lib/dashboards — put a volume there) and
deploy/install-agent.sh per monitored host. The README's Deployment section is the
operator's how-to for both.
Testing policy
Real captures for shape, fakes for behaviour, live for drift.
- Anything that must match an upstream (
/proclayouts, vLLM series names, NUT framing, smartctl output, the gateway's configs) is asserted against a verbatim capture intests/fixtures/. If the schema drifts, a fixture test fails before a dashboard does. - Failure modes (a dead upsd, an engine 500ing, a host rebooting mid-poll, a counter
reset) are fakes in
tests/conftest.py. live(tests/test_live_fleet.py) re-runs the production code paths against the real fleet. Skipped unless--live; each test skips when its source is unset. Live tests must not write — a battery test on hardware something is plugged into is not a test. Anhttps://agent is dialled throughfetch_snapshotwith the stored authority, never a hand-rolledclient.get.- A verdict the operator sees is pinned on the surface that shows it, in both directions — an absence-only assertion is satisfied by deleting the check.
- API tests use
create_app()with an in-memory DB and thetestpassseed atpbkdf2_iterations=1000(the real 600 000 cost is pinned separately intests/test_auth.pyandtests/test_config.py, so the cheap cost cannot become the default there). tests/test_auth_api.pyis the perimeter: it walks the served route table and demands a session for every path outside the four public entries, and proves the UPS CSRF refusal on the wire — 403 afterinstcmdwould be a power cut with an error message attached.- A cost claim is pinned as a shape and a control, never as a wall clock (timings
live behind
store_perfand in the probe scripts, with budgets in a table). - Coverage: 80 % is the repo floor,
bash .agents/validate.shenforces 94 %; new/modified Python must still clear > 90 % on its own.
UI conventions (short)
- Hand-rolled SVG (
charts.js), one stylesheet, dark/light toggle, no framework, no CDN, no build step. - Colour comes only from the payload
severityverdict, throughDash.severityFrom(); a missing number or verdict printsn/auncoloured — never0, never green. A value the API should rate gets a row inseverity.py, never a bound in JS. Δ<window>labels on measured rates print the window the estimator actually used (the payload'swindow_seconds), never the poll interval;SIMULATEDbadges on modelled data; per-source status dots in the header.- Flavour text is not helpful information: copy survives only where the operator would
act differently without it — a why-this-panel-is-empty sentence, a provenance tag, a
measured
Δlabel, a unit. No pipeline narration. - A wide table scrolls sideways inside its own card at ≤ 640 px; card headers wrap their facts; disclosures scroll what they reveal; nothing is painted past a card edge and eaten. The masthead is three rows, the two control rows flush right, and the bar is not sticky.
- An event feed is a window (50 per page,
Dash.eventsPager), not a printout;/upssplits power events (behind a collapsed disclosure) from connection errors (a card of their own, hidden at rest). /gatewayis two panels (one per config file), trunks per bound port with their configured routes branching off; its only colour pair is existence (--gate-on/--gate-off) — no verdict colour anywhere on the page; its filter box is an eye, not a query (it hides rows whose own payload text does not contain what was typed; no DNS, no re-ranking). Each row's switch runs onGATEWAY_ALLOW_WRITES: off is aaria-disabledreading with the flag's own sentence (no hover, no listener, no wire); on is a real control whose press is the dry-run diff → typedDANGER→ one send, and the row's colour is the payload's word from the next poll — never the click's — withoffreading--gate-offend to end (row, spine, the dialog's diff tints) and the diff scrolling in its own<pre>.- Every chart names itself for assistive tech (
role="img"+aria-label); every chart on every page has one, pinned intests/test_api.py.
Known gaps (the current state, one line each)
- The UPS gates are the control: the upsd account has
instcmds = all, and NUT has no call for a user's privileges —enabled: truecannot mean "may send", so the risk tiers and typedDANGERare load-bearing, not decoration. - One admin, one factor — no second factor and no per-user accounts; a logout ends only this browser's cookie, and the only revocation is a password change (which rotates the session secret). The throttle is per-process and keys on the socket peer address (behind nginx every login shares one bucket; a restart clears it).
- GPU coverage is AMD only — an NVIDIA host would report no GPU at all.
- UPS history depth: events and numeric series are stored; long-window capacity fade has the rollups but no dedicated view.
- The store's cold-cache tail (a ~1.3 s whole-table re-derivation once per cache window)
is a known cost, not a bug; the
database is lockedcascade is fixed by the windowedmaintain(). driveripper's drives read live only because the unit runs as root with the bounded gate capabilities (kernel 7.2's unprivileged-passthrough gate); the installer template reproduces it with--as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force.- The fleet trusts this deployment's CA — a second deployment with its own
certs.*rows cannot dial these agents until it carries the same root or re-issues and re-installs every bundle. - Route switching is closed, and its honest limits are the gap list's:
GATEWAY_ALLOW_WRITESis off by default (a fresh deployment sees no switch arm at all); the switch is one admin (gap 2) — whoever holds the session can switch a route — with no per-host kill switch and no scheduler; and there is no runtimedisable serverstate: the marker is a config line, so a hand reload or a reboot does not undo it (a runtime switch would be quietly undone by the next reload).
Locked decisions (and why)
- SQLite, not Prometheus/Grafana. Handful of boxes, 1–10 s resolution, one file the container owns; rollups keep a year of charts at a few hundred MB.
- Our own stdlib host agent, not node_exporter. One JSON snapshot with exactly the
fields the page needs versus ~1500 series and a re-labeling layer; no dependency to
install,
ProtectSystem=strictfriendly. - Our own NUT client, not
pynut/upsc. The protocol is ~10-line commands; the third-party libs are subprocess-based or stalled; we need instant commands with explicit gating. - In-house Prometheus parser. Only two upstreams use the format; keeps
prometheus_clientout of the dependency set. cryptographyfor X.509, dashboard-side only. Minting a CA is not something to hand-roll; the agent stays stdlib (it loads the PEMs withssl), which is why the bundle is plain text and the agent runs on a host's Python 3.10.- Port 8090, inherited from the
llm_monitorprototype: 8000 is vLLM and 8731 is gufo in this fleet. .envis a seed, never an override — container/systemd env must win.
Definition of done (per change)
uv run pytest green · coverage not down · ruff + pyright clean · uv pip list --outdated empty if deps moved · dev server restarted and /api/health sane · UI change
screenshotted in a browser against live data (in data/logs/) · fixtures
re-captured if an upstream contract changed · this file and README.md updated when a
rule, command, or fleet fact changed.