**Phase 07 final verification pass — all tasks already in `complete/`; every criterion re-verified live.**
- **Old slot table gone**: no `#slots-card`/`#slots-table`/`renderSlots` in HTML/JS/CSS (only the new grid's `.slots-empty` *class*); pinned by `test_the_broken_slot_table_is_gone_from_html_js_and_css`.
- **`tests/fixtures/slots_gufo.json`**: present; live `GET ${GUFO_BASE_URL}/slots` this pass matches it key-for-key (row + `next_token[0]`), so the capture is reproducible; drift proven to fail (mutation tests + live `test_gufo_slots_answer_the_pinned_shape`, 9 live tests pass).
- **Aggregate block**: live page shows `2 of 2 engines`, running/waiting/prompt/decode/total-tokens straight from `fleet_totals()`, dot `ok`, no verdict colour; on a throwaway single-engine instance (:8091, private temp DB) `#fleet` is `hidden`, one full-width strip card + one panel, zero banners → "one clean half".
- **Strip + slot grid on live data**: 2 `.card.stat` (KV bars `bar ok` 13 %/0 %), gufo grid rendered real cards incl. a busy slot (`#2189`, 67849 prompt tok, 99 % cache share, `processing`), vLLM note-only with no grid; no `NaN`/`undefined` anywhere. Screenshots: `data/logs/llm-phase07-final-live-{two-engines,single-engine}.png`, `ups-charts-aria-labels.png`.
- **Defect found in earlier work and fixed**: `/ups`' four `LineChart`s shipped no `ariaLabel`, violating AGENTS.md's "every chart names itself for assistive tech" (`/system`+`/llm` were pinned, `/ups` wasn't) → added labels to `page-ups.js` and `test_every_ups_chart_names_itself_for_assistive_tech`; browser-verified `role="img"` + label on all four.
- **Gate**: `bash .agents/validate.sh` → **exit 0**; `uv run pytest --cov --cov-fail-under=94` → **604 passed, 9 skipped, coverage 97.91 %**; `uv run ruff check src tests agent` → All checks passed; `uv run pyright` → **0 errors**; `node --check` every page script ok; `uv run pytest -m live --live` → **9 passed**; `uv pip list --outdated` empty.
- **Docs**: README + AGENTS.md reviewed end-to-end; fixed test count (582→604), `/llm` source column + both architecture diagrams now name gufo `/slots`, AGENTS.md agent line gained `GET /schema` + status page, ariaLabel convention now covers all three chart pages, README Testing block now lists `bash .agents/validate.sh`. Env-var table, agent/install flags, Containerfile facts, `/api/*` shapes, range chips, remote URL all verified accurate — no other changes needed.
- **Live-infra**: no fleet/host/config change; the temp :8091 instance, its temp DB and the fake processes are removed; dev server restarted and `/api/health` `ok` (all 6 sources ok). One thing I deliberately did **not** touch: the gitignored local `.env.dev` still carries a comment claiming the NUT `[dashboards]` account has only battery-test `instcmds`, whereas AGENTS.md/README record `instcmds = all` (verified on hardware 2026-10-17) — operator-owned file, flag for the operator to reword.
- **Next pending phase**: `08_events_paging_and_copy`.
50 KiB
AGENTS.md — homelab dashboards
The plan, the rules, and the environment facts an agent needs to work on this repo without
re-deriving them. README.md is the operator-facing how-to; this file is the why, the
invariants, and the state of the fleet.
Working protocol
This project runs the .agents/phases/ phased-execution pipeline.
- Always read
.agents/PLAN.mdfirst to understand the project context and goals. - Follow the phased execution protocol in
.agents/phases/. - Never modify
.agents/PLAN.mdor any files in.agents/phases/complete/. - If you need to update any file in
.agents/phases/todo/, you must ask the user for permission first. - Strictly adhere to the LOCKED DECISIONS listed in
.agents/PLAN.md.
Everything below this section is the project's own content: what the app is, the rules that keep its numbers honest, and the state of the fleet it watches.
What this is
Three dashboards for one homelab, behind one domain, in one process, backed by one SQLite file — plus an overview page that says which of the three you need:
| Route | Question it answers | Source |
|---|---|---|
/ |
Is anything wrong, and where do I look first? | all three, in one request |
/system |
What are my machines doing — CPU, memory, disk, GPU, network, and the history | agent/host_agent.py, our own stdlib agent/host |
/llm |
Tokens/sec, active sessions, queue depth, KV pressure, latency percentiles | gufo /metrics + /slots, vLLM /metrics |
/ups |
Battery health, runtime left, power events, battery-test buttons | NUT upsd (our own wire-protocol client; 3051 here, not 3493) |
History is a first-class feature, not a sparkline afterthought: every number worth graphing
is stored, rolled up (raw → 1m → 1h), and served from /api/*/history.
Non-negotiable rules
These are the things that make the numbers trustworthy. Breaking one is a bug even if the page still renders.
- Read-only by construction, with exactly one exception. Nothing in this app writes to
an upstream except
POST /api/ups/command. That endpoint is gated four times (NUT_ALLOW_ACTIONS, notDASH_READ_ONLY, the upsd.users account actually having the command, and a UI confirmation —DANGERfor anything that can cut power) and the server re-checks every gate on every request. Hiding a button is not a safety control. Commands that would cut output are refused outright while the UPS is already on battery. - Never invent a number. A value the upstream does not publish renders as
n/a, never as0. Every derived value carries thesourcestring it came from andderived: trueso the UI can print how it was obtained (computed:Δtokens/Δt,Δ10.0s). Aggregating two different series into one rate is the failure mode this rule exists to catch — the rate estimator is keyed by the resolved upstream series name for exactly that reason. - 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 from the panels that matter. - 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 (
poller.py). A failed poll keeps the last good payload and marks it stale in the UI instead of zeroing it — on/too, where a host that has never answered still gets a fleet row (unreachable: true, dimmed, every numbern/a) because dropping the row would turn a dead agent into "never configured". Transitions write events; repeated failure writes one line, not 500. 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 inerror,last_okfrozen so the page ages the numbers, onestore-errorevent then onerecordingevent on recovery), and anything that escapes the poll body is logged and the loop continues. Four dev-server sources once froze behind an unretrieved SQLite exception while/api/healthwent on calling themok— 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 (
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 into anAuthorizationheader, soTarget.urlends up clean). Nothing that leaves the process may carry one:/api/configservesTarget.display_urlandconfig.strip_userinfo-redacted engine URLs, and every URL inside anUpstreamErrormessage is redacted too — those strings go into the health record and theeventtable, which/api/eventsserves to the browser andpage-system.jsprints. No token, password, or personal hostname goes into tracked files, fixtures, or the browser. - Verify against the deployed upstream; never guess a metric name. Field maps in
sources/llm.pyandsources/nut.pywere captured from the running engines and NUT 2.8.5, and are pinned by fixtures intests/fixtures/. Re-capture withscripts/capture-fixtures.sh(andscripts/capture-node-exporter.shwhen comparing exporters) rather than trusting a README or a memory of an upstream. - Keep dependencies current on purpose. LLM-written version floors are usually stale.
Check after touching dependencies:
uv pip list --outdatedmust come back empty. Python is 3.14 (requires-python >=3.14, ruffpy314, pyright3.14, Containerfilepython3.14) — do not lower these 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 before they are called done.data/logs/*.pngis where those land.
Architecture
agent/host_agent.py (each host) gufo / vLLM upsd (NUT)
│ GET /snapshot │ GET /metrics │ LIST UPS / LIST VAR / LIST CMD
│ │ + /slots (gufo) │
▼ ▼ ▼
collectors.py ── fetch, one UpstreamError type, no writes except run_ups_command ──┐
│ parse + Δcounter/Δt │
▼ ▼
sources/host.py · sources/llm.py · sources/nut.py poller.py (task per source)
│ Result(payload, samples, events) │
▼ ▼
store.py ── SQLite (WAL): sample(source, host, metric, res, ts) + event ──► api.py ──► web/
Responsibilities, one line each:
agent/host_agent.py— stdlib-only (3.10+), reads/proc,/sys/block,/sys/class/drm,statvfs; servesGET /snapshot+GET /info+GET /schema+GET /health, plus an HTML status page atGET /. Refuses to bind off-loopback without a token (exit 2). Counters stay counters: the dashboard computes rates so the agent never remembers who polled it last.config.py— env only, no config file format, no CLI flags..envis a seed; a real env var always wins. Defaults are safe except the sources, which default to unset.poller.py— job loop, health records, stale/last-good semantics, transition events. The success path records (payload → samples → events) and only then moveslast_ok, so a refused SQLite write ages the source and alerts instead of leaving it current with charts that have silently stopped drawing. Events are stored under the dashboard name (system/llm/ups), not the sample source (host) — a page filtering/api/eventsbyhostgets an empty feed forever (found on/systemduring phase 03 browser verification).collectors.py— the fetch half.NutMonitorowns per-UPS transition bookkeeping. Failure messages name the redacted URL (strip_userinfo/Target.display_url), never the one that was dialled, because they become health records and event rows.sources/host.py— snapshot → payload + samples; reboot detection; GPU series per card (gpu.busy_pct@<index>).sources/llm.py— gufo + vLLM field maps (GUFO_SERIES,VLLM_SERIES,VLLM_HISTOGRAMS), measured Δ rates, prefill speed asmean(prompt tokens)/mean(prefill time), session list from gufo/slots.sources/nut.py— upsd client,UpsReading,VAR_MAP,assess()(explicit checks, not a mystery score),COMMANDScatalog with risk tiers,COMMAND_ALIASES+resolve_command()(drivers answer two names for the same action —beeper.enable/beeper.on,beeper.disable/beeper.off— so one entry is offered per action and any of its names hits that entry's verdict, never a gate the rendered name missed),available_commands()filtering, kelvin-vs-celsius normalisation, andcharger_statusnormalised to theCHARGER_STATUSkeys (the same lowercase tokenstates()andon_batteryalready compare), andstates()publishes one chip per fact — a driver exporting bothbattery.charger.statusand the legacy flag that says the same word gets "charging, online", not "charging, online, charging".battery_runtimecomes frombattery.runtimeonly —battery.runtime.lowis a shutdown threshold, not time left, so it lives atbattery_runtime_lowand never becomes the hero's "runtime left".sources/nut_sim.py— a labelled simulator so the UPS page is developable without a battery. It must never be the default, and every payload it produces sayssimulated. Its modelledbattery.mfr.dateis computed relative to today (not a fixed string that ages), so a fresh simulator reads healthy and amber keeps meaning something. Its status follows the real drivers: a below-float pack on mains reportsOL CHRG/chg(the glyph's bolt state is keyed on that token, and the model has to be able to produce it) and a battery being drawn from isOB/dchg, neverol. Every command it advertises is implemented and visible in a variable a real driver publishes — the beeper ones setups.beeper.status(enabled/muted/disabled), and a test window that ends writesups.test.result: PASSEDwhile an aborted one writes nothing; the load/shutdown commands answerOKwithout modelling an outlet, because a faked power cut in the history is indistinguishable from a real one.from_env()is the single place theNUT_SIMULATEtruthy rule lives —app.pybuilds the monitor through it, so the assembly and the model cannot disagree about what is enabled.prom.py— Prometheus text-exposition parser (in-house; only for engines we don't own).rates.py— Δ/window estimator, keyed by resolved series name.severity.py— the only place thresholds live:THRESHOLDS/INVERTEDper metric,classify()→ok/warn/fail/None, plusattach()/with_severity()to put the verdict beside a value. No measurement, no verdict:Nonein →Noneout, never a green pass for an unknown. The verdicts travel additively in payloads —cpu.severity,memory.severity,disks.severity, each UPS card'sseverity(charge/load/runtime),severityinside rated LLM cells (KV pressure, classified on a percent scale because the engines publish a 0-1 ratio), andseverityper host/fleet/UPS row in/api/summary. Pages render the class; they never compare a number to a threshold. There is nogpu.*row and nogpus[].severity(phase 06): these are LLM servers, a busy GPU is the job at every level, so the GPU card and the fleet GPU column render uncoloured — likenet.*, whose colourlessness predates it. Removing the verdict is not removing the data:gpu.busy_pct@N/gpu.mem_used_pct@Nare still stored and charted.alerts.py—build(settings, job_states, host_payloads, engine_payloads, ups_payload, recent_events)→ the "what is wrong right now" list/api/summaryserves asalerts({id, severity, source, message, href, at}, fail first then warn, then by source). A pure projection: no I/O, no store, no thresholds of its own — it reads job states for reachability, theseveritydicts for breaches,health.checks[]for UPS conditions. Rules: an unconfigured source never alerts (invariant 3); a source that is ok but older than 3× its own poll interval iswarnwhile a source that stopped answering isfail; a dead source gets exactly one line (its stale payload adds nothing) that names it once — the poller's"UpstreamError: "wrapper and the duplicated host name are stripped, because a message that saysgatewaytwice reads as two problems;unknownand missing numbers are silence; one alert per (source, metric) and at most 12, the last being a marker that says how many it hid. This is why the overview can be two panels.store.py—sample(source,host,metric,res,ts)+event+meta, rollups, pruning.api.py— GETs everywhere plus the one POST./api/configis redaction boundary./api/summaryis the overview's single call and the cheapest whole-fleet probe there is: the page renders onlyalertsplussystem.fleet/system.hosts, whilellm.engines,ups.ups, thestatesmaps andrecent_eventsstay for programmatic consumers (live tests, an external check, a notifier).system/llm/ups/recent_eventsare unchanged andalertsis additive —tests/test_api.pypins every one of those key paths, with and without targets configured, so retiring a panel can never shrink the payload. A configured job with no payload yet is listed as a stub (unreachable: true, every valuenull) insystem.hosts/llm.enginesexactly as/api/system/snapshotdoes, sohosts_totalcounts the machines there are andhosts_livethe ones answering.app.py— assembly;create_app()takes injectable settings/store so tests build an app with an in-memory DB and no targets.web/— four static pages, one stylesheet,charts.js. No framework.app.jsowns the colour vocabulary:Dash.severityFrom(cellOrSeverity)turns a payload verdict into anok|warn|failclass (''for anything unrated, so unknown never looks green) andDash.hero(el, {label, value, unit, digits, severity, sub, bar})renders/patches a.herobig number (plus an optional.bar) from that class — it never compares a number to a threshold.Dash.severityFor(value, warnAt, failAt)is the documented fallback for values the API does not rate, and no page script calls it any more —/was converted in phase 02,/systemin phase 03,/llmin phase 04 and/upsin phase 05, so every colour on all four pages comes from aseverityverdict the API sent (tests/test_api.pyfails if a page script names it again). A value the API should rate gets a row inseverity.py, never a bound in JS.
Metric naming: dotted (cpu.usage_pct, mem.used_pct, disk.used_pct@/srv,
gpu.busy_pct@0, llm.decode_tokens_per_second, ups.charge_pct), @stream for per-device
series — /api/metrics answers with exactly these names.
That scheme is the history API's key — treat it as a public interface.
The fleet (real environment, verified 2026-10)
| Host | ssh alias | What it gives |
|---|---|---|
driveripper |
driveripper-ops |
Threadripper 24c/48t + Radeon AI PRO R9700, vLLM :8000, upsd, the dashboards |
deskwork |
deskwork-ops |
Strix Halo (Radeon 8060S iGPU), gufo :8731 |
gateway |
gateway-ops |
router, no GPU |
- Agents run from
~/lab-agent/host_agent.pyon each host, forwarded to127.0.0.1:9121/9122/9123byscripts/dev-tunnels.sh(DEV_TUNNELS). Nothing listens on the hosts in dev. - Engines are reached directly: gufo
deskwork.reeselink.com:8731, vLLMdriveripper.reeselink.com:8000. - NUT 2.8.5 on driveripper: configs live in
/etc/ups/, not/etc/nut/, and upsd is on port 3051, not 3493. Nothing listens on 3493 at all, so a default client reports Connection refused and it looks like a dead service —ss -ltnHplusprintf 'LIST UPS\n' | nc 127.0.0.1 3051(answersBEGIN LIST UPS) is how that was pinned down. Two UPSes are exported over USB and read live throughDEV_FORWARDS="3494:driveripper-ops:3051":apc= APC Back-UPS RS 1500MS,cyberpower= CyberPower PR1500LCDRT2U (that isups.description, the operator's label — the driver's ownidentity.modelanswersGX1500U, so the description is what identifies the box). Real-hardware quirks the parsers already handle:battery.mfr.dateis the useful date (battery.dateis often an unset placeholder —2001/09/25, slashes and all, on the APC), CyberPower reportsbattery.mfr.date: CPS(not a date →unknown, never a fake age), neither driver reports a battery temperature or the NUT 2.8battery.charger.status(so the battery glyph's state has to come fromon_battery, andcharger_statusmust stay optional), andups.test.result: No test initiatedis the factory state on both. Both UPSes are recorded verbatim (LIST UPS/LIST VAR/LIST CMD, serials scrubbed) astests/fixtures/nut_<ups>.varsby./scripts/capture-fixtures.sh, and the wire quotes a field only when it must —VAR apc ups.status "OL", not three quoted fields. - Host access is via ssh keys as
ops. Passwordless sudo exists but is command-scoped; the set we rely on is documented indeploy/nut-sudoers(additions are listed there for the work that is still blocked).
Commands
uv sync --python 3.14 # deps + editable install
cp .env.example .env # production-shaped config
cp .env.dev.example .env.dev # dev-only: ssh tunnels + live targets
./scripts/dev-tunnels.sh start # agents (9121-9123) + DEV_FORWARDS (upsd 3494)
./scripts/dev-server.sh restart # keep http://127.0.0.1:8090 up
./scripts/dev-server.sh status # per-source ok/DOWN
./scripts/dev-server.sh logs
uv run pytest # hermetic: 604 passed + 9 live-skipped, no network
uv run pytest --cov # coverage gate (97.9%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
uv run ruff check src tests agent && uv run pyright
./scripts/capture-fixtures.sh # re-record ground truth (agents + UPS + gufo /slots)
bash .agents/validate.sh # the phase gate: the four checks above (pytest
# --cov --cov-fail-under=94, ruff, pyright, plus a
# node --check of web/static) + repo hygiene.
# Always `uv run` — a bare `python3`/`ruff` on PATH
# tests an interpreter nobody deploys.
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 (systemd unit, own user, --bind/--port/--token/--rotate-token/
--status/--uninstall; it stores the token in a root-owned 0600 file and hands the agent
--token-file, never argv, and runs under ProtectSystem=strict).
The quadlet unit + env template + nginx/SNI live in the deployment repo, like every other
service here. The source itself is git@git.reeseapps.com:services/dashboards.git (origin,
branch main); there is no CI on it, so bash .agents/validate.sh is the only gate.
Testing policy
Real captures for shape, fakes for behaviour, live for drift.
- Anything that must match an upstream —
/proclayouts, vLLM series names, NUT framing — is asserted against a verbatim capture intests/fixtures/. If the schema drifts, a fixture test fails before a dashboard does. - Anything that needs a failure mode — upsd refusing a command, an engine 500ing, a host
rebooting mid-poll, a counter reset — is a fake in
tests/conftest.py. - Page payloads that a rewrite must not break are pinned as a table.
tests/card_contract.pyholds the dotted/api/system/snapshotpaths the/systemhero cards read (CARD_METRICS,GPU_METRICS,HOST_SHAPES);tests/test_system_contract.pyasserts it against the three captures (discrete GPU, APU, no GPU) and over HTTP, andtest_every_host_serves_the_hero_card_contractasserts the same table against the live agents — so a rename fails a test, and a card that showsn/acannot be "fixed" by inventing a field.networkcarries noseverityon purpose (throughput has no good/bad) and neither does a GPU row (phase 06: a busy GPU is the job on an LLM server) — the contract pins both absences.tests/ups_contract.pyis the same device for the/upshero row and battery glyph (HERO_FIELDS,UPS_SHAPES,assert_ups_hero_contract):tests/test_ups_contract.pydrives it fromtests/fixtures/nut_<ups>.vars— verbatimLIST UPS/LIST VAR/LIST CMDreplies from the two real UPSes — andtest_ups_hero_fields_are_present_on_the_real_batteriesdrives it against live upsd. The captures are what make the driver disagreements testable: one of them answersbattery.mfr.date: CPS, neither exportsbattery.temperatureorbattery.charger.status.tests/slots_contract.pyis the same device for gufo's/slotsrows, which the/llmslot grid (phase 07) reads (assert_slot_shape, required/optional field tables, duplicate slot ids, thenext_token[0]entries): hermetically fromtests/fixtures/slots_gufo.json— the verbatim/slotsreply, idle slots and all, captured wheneverGUFO_BASE_URLis set — and live viatest_gufo_slots_answer_the_pinned_shape. That fixture exists because its absence is how the deleted slot table rendered dashes forever: its field names (slot.state,slot.n_prompt,slot.decode_per_second) were a memory of an upstream, not a capture. The contract names one canonical key per fact and the grid renders that one: the capture answersid_taskandtask_idfor the same request, soCANONICAL_TASK_ID_FIELDdecides and the alias is captured but rendered by nothing — a rename fails a test instead of becoming a silenta ?? bchain in the page.tests/test_api.pyreads the servedSLOT_FIELDStable and resolves every key it names against the capture, in both directions. A busy slot'spromptis the user's in-flight request text, so the capture script blanks it and the page never reads it. live(tests/test_live_fleet.py) re-runs the production code paths against the real fleet, engines, and upsd. Skipped unless--live; each test skips when its source is unset, so partial fleet availability is normal. Live tests must not write — a battery test on hardware something is plugged into is not a test.- The agent's real-
/proctest (tests/test_agent.py) touches only the box running pytest, so it stays in the default run and is not markedlive. - Coverage gate: 80% is the repo floor, the phase gate in
.agents/validate.shenforces 94% (--cov-fail-under), and ~98% is where we are; keep it there. New/modified Python still has to clear >90% on its own. API tests usecreate_app()with an in-memory DB, never a real target, and never a real upstream:/api/ups/command's send is proven against a scriptednut.NutClient(ScriptedNutClientintests/test_api.py) because the live suite is read-only — that fake is the only place the write reaches a "driver".
UI conventions
Hand-rolled SVG (charts.js), one stylesheet, dark/light toggle, Δ<window> labels on
measured rates, n/a for the unknown, SIMULATED badges on modelled data, per-source status
dots in the header. Every chart names itself for assistive tech: pages pass ariaLabel to
LineChart, which puts role="img" + aria-label on the <svg> (an unlabelled plot is
silence, and the legend is a sibling that labels nothing). Every LineChart on every page has
one — /system's three per-host slots, /llm's four trend plots, /ups's four battery
plots — each pinned by tests/test_api.py, so a chart that ships anonymous fails a test.
Window chips (5m…30d on /system and /llm, 1h…90d on /ups; the API accepts 5m…90d). A
panel that cannot get data says why in the panel — the operator should never have to open
devtools to learn that a source is unconfigured. Escape anything upstream-provided through DashCharts.escapeHtml.
The overview is exactly two panels — Warnings & errors (ul.alerts, one row per
/api/summary alert: verdict class, source linked to its dashboard via the alert's own
href, message, age) and Fleet. A non-empty list floats the alerts panel above the
fleet table (#overview-panels[data-state] swaps them); an empty one is an explicit
"all clear" state below it, because an empty list is a message, not a blank box. A host that
is not answering keeps its row, dimmed with the shared .stale class and reading n/a, so
the table never implies last good numbers are live and never silently loses a machine.
No summary cards and no raw event feed survive on / — tests/test_api.py pins the
two-panel shape against the served HTML.
Nothing waits for a writer that never came: tests/test_api.py fails if a page keeps an
id that no page script, no shared script, no stylesheet rule and no aria-*/for
attribute references — the orphan a deleted renderer leaves behind, caught on served bytes
because there is no JS test runner (a locked anchor). Same rule for dash.css: delete the
rules a removed panel made dead (.grid.wide went with the overview's fleet+events row);
component vocabulary the pages are still being converted to (.hero-bar, the grid and bar
variants) stays even while only one page uses it.
A big number is Dash.hero() on .hero / .hero-bar (dash.css): 2.4rem tabular numerals,
muted unit, mono .sub footnote (Δ10.0s, "at current load"), coloured by the payload's
severity through the existing .ok / .warn / .fail classes — there is no .sev-*
family and no second palette, and tests/test_api.py fails if either appears or if a CSS
rule reaches for a token that :root does not declare.
A cell-scale trend is DashCharts.sparkline() — the llm_monitor drawSpark idiom, so a
percent metric is plotted against fixedMax (never autoscaled: an 8 % KV window that once
held a 92 % spike must not read flat, and the axis gets top/bottom scale marks so "a third of
the way up" says a third of what). align: 'right' pins the newest sample right and grows the
line leftwards inside a real span in seconds — x stays time, not sample index. Stroke colour
comes from the payload severity via the --ok/--warn/--fail tokens only; an unrated
series draws a faint --text-faint stroke with no fill, because a shape is not a verdict.
Below minPoints (2) it prints no history rather than drawing a confident line through one
point, labels: {current, max} renders the now / max foot through the caller's format, and
there is no animation at all (prefers-reduced-motion holds by construction). minSpan is the
other half of the same honesty for count metrics: history arrives as rollups, so eight seconds
of one queued request averages to 0.14 and an axis fitted to that draws a full-height spike for
a queue nobody was in. /llm's count rows moved to the phase-07 stats strip (numbers, not
sparks), so no page passes minSpan today and drawSparks() still plumbs it through — a count
sparkline that comes back must set it, and the component's own contract test keeps the option
honest (test_sparkline_ships_the_fixed_scale_and_verdict_options).
A battery is DashCharts.battery(container, {charge, severity, state, label}) — one inline SVG
whose fill is the charge: 1 viewBox unit per percent, clamped to 0–100 and never rounded up
(a 2 % cell must look nearly empty, because a reassuring battery above a flat one is a lie),
clipped to the plate so 0 % leaves an empty shell rather than a stray pixel. Colour comes only from
the payload severity token; a measured charge with no verdict draws faint grey at half strength
(sparkline's rule — a shape is not a verdict) and never an empty shell ("0 %" and "not rated" are
different sentences). Every state has a shape as well as a colour, so the glyph survives
monochrome: solid shell online, bolt charging, dashed outline onbattery, no fill with a dash
pattern through the body unknown; a state word it does not know degrades to unknown, never to
a confident "on line". Accessible by default — role="img" + <title> + aria-label built
from the inputs ("apc: Charge 100 percent, on line"), because the glyph carries information the
markup does not say in words — and a page that prints that sentence next to it passes
decorative: true to make the <svg> aria-hidden rather than say it twice. No animation and no
ResizeObserver: one viewBox + preserveAspectRatio scaled by .battery in dash.css, so the
hero row gets a big cell on a desktop and a small one on a phone.
The /ups card is led by the glyph and three heroes (phase 05): the battery on the left, then
Charge %, Load % and Runtime left on .heroes at the shared 2.4rem, coloured from
severity.charge_pct / severity.load_pct / severity.battery_runtime and nothing else. Charge
carries no .bar — the glyph beside it is that value drawn as a shape, and one value drawn
twice in one row is the pattern this app avoids (Load keeps its bar; nothing else draws it). Its
.sub lines are the context each number needs and nothing more: 24 V nominal · 27.3 V for the
pack, 128 W of 900 W when the driver publishes both realpower figures (a wattage inferred from
a percent against another model's rating would be an invented number), at the current load for
runtime. Runtime prose is runtimeText()'s (1h 32m, 10m 21s) and an estimate the driver never
made goes into Dash.hero() as null, so it prints faint and uncoloured rather than as 0m. The
readings the hero row displaced — line in, output, battery volts and amps, battery °C — stay on the
card, demoted to .ups-electrical: muted, .95rem, never folded away, because they are what you
reach for while the UPS is on battery, and battery °C reads n/a on both real drivers here.
state is derived in the page from on_battery → charger_status (chg charges; dchg/rec are
the server's own on-battery tokens; full is on line, not charging — a rested floating battery
reports it) with one fallback: a state chip the API rated ok, because nut.states() reserves
ok for on-line/fully-charged and neither real driver here publishes battery.charger.status at
all. ups.status itself is never re-parsed in JS (tests/test_ups_contract.py). /ups passes
decorative: true, because the charge hero and the state chips already say the sentence. The glyph
repaints only when charge / verdict / state change (drawGlyph() keys a data-drawn triple): the
charge float jitters every NUT_INTERVAL_SECONDS, and rebuilding the <svg> on that re-announces
an accessible name for a difference no eye can see.
An event row names its subject once. Every feed (/system, /llm, /ups) renders the
message through Dash.eventText(event), which drops the UpstreamError: wrapper and a
leading duplicated subject the way alerts._clean_error() does for the overview — poller.py
messages start with their own label, and each row already prints that label as a chip, so
"gufo gufo is answering again" reads as two problems. A name in the middle of a message stays:
the UPS rows used to replace() it anywhere and mangled "Quick battery test sent to apc: OK".
This is presentation only — the stored event and /api/events keep every word, and the raw
error string still surfaces in /api/health.
The /system host card is one row of six heroes — CPU, MEM, TEMP, DISK, GPU, NET (the
.heroes grid; tests/test_api.py pins the slots and their order on the served script).
Colour comes from the payload's severity dicts and nowhere else; a missing number or
verdict — gateway's CPU temp, any host's absent GPU — prints n/a uncoloured. NET is
one slot with two lines (.hero-lines, ↓ in / ↑ out) and is deliberately colourless:
throughput has no good/bad, so network carries no severity. The GPU slot holds one hero
per card whose .sub carries model, VRAM used/total, the honest APU pool view and the
card's temperature (gpus[].temp_c — the agent's max over that card's hwmon labels, the
same value stored as gpu.temp_c@N, so the hero and the history cannot disagree), and it is
colourless for the same reason (phase 06): a 95 %-busy card on an LLM server is the job, so
the hero carries no severity and no utilisation bar — a bar under a verdictless number
would be a verdict by shape. A card whose driver exposes no temperature input prints no
temperature in that footnote rather than n/a or 0 °C: "where you can" is the operator's
wording, and n/a is reserved for numbers the card is supposed to have. Everything not
glance-worthy — load averages, clock, disk read/write/busy, uptime, GPU power and the
per-label temp breakdown, filesystems, processes — folds into a collapsed "More detail"
<details>: a simpler surface, not a less complete page (the table keeps its Temp column;
this was a promotion, not a de-duplication).
A Δ<window> label is a measurement, never a config value: sources/host.py sends the window
the estimator actually used as the payload's window_seconds, and /system's footnote prints
those (a range when the hosts disagree) instead of the poll interval, which only coincides with
them at the defaults. /llm's hero .sub prints the rate cell's own window through the same
provenance() tag, and the hero's title carries the exact source string — "78 tok/s" must
always say whether it was scraped, measured Δcounter/Δt, or estimated (llm_monitor's
setThroughput idiom; dropping the tag to look cleaner is a regression, not a simplification).
The /llm page reads per engine (phase 04, decided in
.agents/phases/complete/04_llm_chart_readability/01_chart_set_decision.md, laid out by phase
07): a row of two Dash.hero() throughput numbers (decode, prompt — deliberately uncoloured,
because 0 tok/s on an idle server is healthy and severity.py has no throughput row) and the
latency rows, each a mean with a mono p95 … foot (no hover; p95 needs no stored series) and a
sparkline() fixed at 160px so rows line up. Rows are engine-scoped by the only flag on both
components: gufo never grows a TTFT mystery, vLLM never a spec-accept hole.
The stats strip (#strip, phase 07 task 03) is the summary; the panels are the detail, and no
row is printed in both — the UPS hero row's "one value drawn twice" rule, widened to two
components. One .card.stat per configured engine between the fleet block and the panels: the
reachability dot, the engine name and the age stamp in the head (same distinction every card
draws — the dot speaks reachability, the numbers never do), then the KV-cache bar — the one
cell on the page with a verdict, so the fill and a breach's text take their colour from the
payload's severity through .bar.ok/.warn/.fail and the ratio→percent conversion the panel's
cell used to make, with ok staying quiet ink and a missing gauge an empty bar plus n/a — then
prompt tok/s / decode tok/s with their Δ<window> tags, total tokens (lifetime, so its
source is the tooltip), the sessions this engine reports, prefix cache hit, spec accept
(gufo) and queue time with its p95 … foot (vLLM). STRIP_ROWS is the whole table and its
key is the cellValue() key, so every number on it is a cell of /api/llm/snapshot: no
field was invented in sources/llm.py for this card, and there is deliberately no peak-ctx
row (phase assumption). The session row is engine-shaped — vLLM publishes running/waiting
counts, gufo's is slots busy, counted from the sessions.list rows /slots answered
(is_processing, the shape pinned by tests/slots_contract.py); when /slots did not answer
there are no rows to count, so it falls back to the published sessions.active carrying the
API's own sessions.detail sentence rather than reporting 0 busy slots from no data (rule 2).
preemptions / device_lost are alarm chips that exist only while non-zero (.chip.fail,
container emptied and hidden at zero — the rule is the presence of an incident, never a graded
threshold) and they live on this card, not in a permanent cell; the lifetime token counter left
the panel's footnote line for the strip's row. One /api/llm/history request per refresh
carries every chart and spark series (HISTORY_SERIES = the four chart series +
SPARK_CELLS; the endpoint answers unknown names as empty series on purpose), and the two series
phase 04 added (llm.tpot_seconds vLLM-only, llm.spec_accept_rate gufo-only) stay stored
additively in sources/llm.py HISTORY_METRICS. Above the strip sits the fleet block
(#fleet, phase 07: "I'm missing the aggregate throughput at the top") — ALL ENGINES, a
reachability dot, an
N of M engines tag, the note sums only engines answering now, then running / waiting /
prompt tok/s / decode tok/s / total tokens. FLEET_FIELDS in page-llm.js is a list of
fleet_totals() keys and nothing else: the page sums no number of its own, a dead engine is
dropped from every total presented as current (its own stale-labelled panel still shows its
last good values), and the tag counts the answering engines, names the missing one in its
tooltip and takes the shared .stale dim while the sums are partial. It hides below two
engines — a cross-engine sum of one engine duplicates that engine's own panel — and it
carries no severity colour anywhere: fleet_totals has no severity to render (the
summed-KV bar is gone for that reason) and severity.py rates no throughput or queue depth,
so the numbers stay ink and only the dot speaks reachability. fleet.seams — the API's own
note on how the two engines count prompt tokens differently — is the prompt sum's tooltip
rather than another line of ink. The
four trend charts stay but at height 110 below the panels, each with an ariaLabel. Panels
sit at their own height (#engines carries .items-start): a two-hero gufo card next to a
latency-row vLLM one otherwise grows a blank bottom, and a blank panel reads as "no data". An
engine that leaves the payload leaves both components (pruneEngines), never a stale card in
either grid.
The slot grid (llm_monitor's, phase 07 task 04) belongs to the gufo panel, under its
sessions (GET /slots) label. One card per row of sessions.list, keyed by the row's own
id in state.slots: built once by slotCard(), patched in place afterwards, and reordered
by appendChild of the existing node so DOM order stays slot order — rebuilding a busy slot's
card every 2 s poll re-announces the whole card to assistive tech for a difference of a few
tokens. SLOT_FIELDS is the whole table and each cell names exactly one /slots key
(path: 'next_token' reads next_token[0], where the capture puts n_remain); slotValue()
is the only place a row is indexed, so tests/test_api.py can resolve every printed field
against tests/fixtures/slots_gufo.json and a rename fails there rather than printing dashes
or a plausible wrong number. No per-slot speed cell: the capture publishes no per-slot rate key
and a page-side Δn_decoded/Δt would be a measurement with no estimator, no Δ<window> label and
no source string (rule 2) — the engine's rate lives on its strip card and panel. llama.cpp's
-1 sentinels print a word or nothing: id_task: -1 is idle, n_remain: -1 is n/a,
never a negative countdown and never the 0 that reads as one token from done. The busy marker
is a border, a glyph (○/●) and the word processing in the brand --accent, never an
ok|warn|fail token — a slot generating tokens is the job (phase 06's GPU reasoning), so
severity.py rates no slot and the stylesheet section contains no verdict token. The empty
state is a sentence, never a card of nothing, and it distinguishes the two cases: no /slots
answer prints the API's own sessions.detail, an answer with no rows prints engine reports 0
slots; sessions.source is the gate. The note under the grid stays because a queued
(deferred) request is not in /slots — the waiting row counts those. vLLM's panel gets one
line (exposes no per-slot JSON) with sessions.detail as its tooltip and no grid: a
component of permanent n/as for a server that cannot answer is the dead-cell anti-pattern.
The gate is the engine name (SLOT_ENGINES), not row count, so an engine that has not answered
yet still shows its label and its honest empty sentence.
Charts belong to the host they chart. /system has two sections — the host panels and the
transition events — and nothing else: the fleet-wide chart stack, the nine-figure fleet-totals
panel and the host/metric detail picker are deleted, along with the #chart-* / #detail-* ids and
the select / label.field styling only those pickers used. Each panel stacks its own
.host-charts — utilisation (CPU, memory, worst disk), network (in/out), then GPU busy — where
the GPU slot is created and destroyed with host.gpus, so gateway shows neither a phantom card
nor a hole. That stack is collapsed behind a per-host Charts <details> (phase 06: "only
visible if expanded… there will be lots of hosts"): the default /system view is the hero row,
the summary carries the window label the stack will draw (the chips themselves stay in the page
head, one control per page), and drawHostCharts() is the single place that decides whether to
paint — so nothing is built or drawn while the stack is shut (LineChart inside a
display:none container measures 0 px, and rebuilding three charts per host every poll for
plots nobody sees is the waste this removes). The history request is deliberately unchanged:
one per host per refresh, naming every series the panel could draw, and its response is cached
per host so the first expand paints from data in hand rather than waiting a poll; a cached
response from another window is re-fetched instead of being drawn under the wrong label.
Expanding one panel must not stretch its collapsed siblings into blank cards, so #hosts
carries the existing .grid.items-start. One /api/system/history request per host per refresh
(hosts=<that host>&metrics=<that panel's series>&range=…, pinned by
test_every_system_chart_is_drawn_inside_its_own_host_panel and the collapse by
test_system_history_charts_are_collapsed_behind_a_toggle): unknown metrics come back as
empty series on purpose, so a panel asks for every series it could draw and asks once. A legend
here names metrics (or one GPU's model), never hosts — the panel heading already says who this
is, and a legend that grew a second host is the bug this replaced. A host that stops reporting
loses its panel, its Charts toggle and its charts (pruneHosts deletes the card plus the
payload/chart/history maps), never a chart of nothing or an orphan control in the grid. Per-core
visuals are gone from /system and renderCores is deleted from charts.js with its last
caller.
drawHostChart() shades a chart only when one series has points — two or three filled areas
stack into one block and bury the quieter line, which is how a idle CPU would hide behind a busy
memory plot; the cpu.usage_pct@coreN series is still stored and still queryable from
/api/system/history — removing the plot is not removing the data.
Known gaps / next work
- UPS commands work, and the account can now cut power — the app's gates are the control.
Three defects were cleared on 2026-10-17, each verified against real hardware. (a)
NutClient.login()sentUSER/PASS, which the deployed upsd 2.8.5 does not implement — each answeredERR UNKNOWN-COMMAND, so_read_line()raised andPOST /api/ups/commandcould only 502 beforeinstcmd(), credentials or not. It now sendsUSERNAME/PASSWORD, the pair the server's ownHELPadvertises (HELP VER PROTVER GET LIST SET INSTCMD LOGIN LOGOUT USERNAME PASSWORD STARTTLS), pinned bytest_login_uses_the_verbs_upsd_2_8_5_answers_to— the fake upsd has noUSER/PASSbranch, so a regression answersERR UNKNOWN-COMMANDexactly like the real thing. (b) 12 of the 23 commands the two real UPSes advertise had noCOMMANDSentry, andavailable_commands()drops anything the catalog misses — the page silently offered less than the hardware does. Every advertised command is catalogued now, each with a risk tier:test_every_command_the_hardware_advertises_is_cataloguedfails if a capture gains a command the catalog does not know, andtest_every_command_that_can_cut_power_is_in_the_dangerous_tierkeepsload.off,driver.killpowerand theshutdown.*sequence in the tier that typesDANGERand is refused on battery.shutdown.stopis the exception the name hides — it cancels a shutdown, so it issafe, and the test says so in words. (c)[dashboards]in/etc/ups/upsd.usersisinstcmds = allat the operator's request, so this app may send every advertised command on both UPSes, power-cutting ones included..env.dev(mode0600, gitignored) carries the credentials. What remains to know:- Proven live, and reversibly.
beeper.enablereturnedOKandups.beeper.statusreadenabledon the APC afterwards;driver.killpowerwithout the wordDANGERstill answers 422 and never reaches the wire. A battery test has deliberately not been run — it puts real load on a battery things are plugged into, which stays the operator's call. enabled: truecannot mean "this account may send it".available_commands()filters onLIST CMD, which upsd serves unfiltered, and NUT has no call for a user's privileges — so the wire cannot tell the page what the account is granted. Withinstcmds = allthe two agree by configuration rather than by check, which is precisely why the risk tiers and the typedDANGERare load-bearing and not decoration.- Pre-existing upsd log noise: every upsd start logs
Unknown upsmon type =(20 occurrences over 12 starts, oldest before this work). Harmless —upsmonlogs into both UPSes right after it — but unexplained. Editingupsd.usersat all: never grantactions = -, and never write it blind — the runningupsmonauthenticates as its own account from that same file for both UPSes, so dropping it means no orderly power-down. Copy the untouched sections from the live bytes andcmpthem before writing;teetruncates in place, so owner and mode survive.
- Proven live, and reversibly.
- GPU coverage is AMD only.
host_agent.gpus()reads amdgpu DRM sysfs (gpu_busy_percent,mem_info_vram_*, hwmon temps, clocks). That covers the current fleet (R9700 + Strix Halo iGPU; gateway has none). An NVIDIA host would report no GPU at all — needs anvidia-smi/NVML or DCGM path, and a fixture from a real card before the field map is written. - UPS history depth. Events and the numeric series are stored; long-window battery health (capacity fade over months) has the rollups but no dedicated view.
Locked decisions (and why)
- SQLite, not Prometheus/Grafana. Handful of boxes, 1–10s 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 (GPU model strings, per-mount usage, temperature labels) versus
~1500 series and a re-labeling layer; no dependency to install,
ProtectSystem=strictfriendly, and read-only by construction.scripts/capture-node-exporter.shremains to compare ground truth if that is ever revisited. - Our own NUT client, not
pynut/upsc. The protocol is ~10 line commands, the third-party libs are subprocess-based or stalled, and we need instant commands with explicit gating. - In-house Prometheus parser. Only two upstreams use the format; keeps
prometheus_clientout of the dependency set. - Port 8090, inherited from the
llm_monitorprototype this started from: 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)
The first three are exactly what bash .agents/validate.sh (the phase gate) checks, so no
phase may be called done on a subset of them.
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 · fixtures re-captured if an upstream contract changed ·
this file and README.md updated when a rule, command, gap, or fleet fact changed.