Compare commits

..
10 Commits
Author SHA1 Message Date
ducoterra c5689a8bdd chore(agent): drop the verification notes of the completed phases 03-05
The three NOTES files recorded the browser-verification evidence for phases that are now in
.agents/phases/complete/; the conclusions they captured already live in AGENTS.md's UI
conventions. Their content stays retrievable from git history.

LLM-Generated: true
2026-10-17 12:28:05 -04:00
ducoterra 9e732c1f87 chore(agent): commit the validation gate the phase pipeline runs
AGENTS.md and PLAN.md already told every phase to `bash .agents/validate.sh`, but the script
itself was never tracked, so a fresh clone could not run the gate at all.

Four checks: `uv run pytest --cov --cov-fail-under=94`, `uv run ruff check src tests agent`,
`uv run pyright`, and a zero-dependency `node --check` of every served JS. Plus repo hygiene
(tracked files matching .gitignore, log drops/key material, non-placeholder secrets in the
tracked env templates) and an advisory `uv pip list --outdated`.

Two rules are baked in because breaking them made a false green: run the project toolchain
through `uv run` (a bare `python3`/`ruff` on PATH tests an interpreter nobody deploys and an
older linter than the pyproject floor), and treat a missing tool as a failure rather than an
`if command -v` skip.

LLM-Generated: true
2026-10-17 12:27:52 -04:00
ducoterra 046dc57b26 chore(agent): add phase 11 — single-admin login for the dashboards
Eight tasks to put a session gate in front of every page and API route: the auth primitives
(PBKDF2 verifier, HMAC-signed stateless session, session-bound CSRF token, login throttle,
origin check) in a new `auth.py`, the verifier in the store plus fail-closed startup, the
default-deny middleware with an explicit public list, `POST /api/auth/password` with session
rotation, a login page, a `/settings` page, and the dev-config/docs/browser verification pass.

Stdlib hashlib/hmac only — no new dependency, so no PLAN.md anchor moves. `/api/health`
stays public on purpose (the Containerfile HEALTHCHECK and dev-server.sh depend on it), and
one test enumerates every route and demands a session.

LLM-Generated: true
2026-10-17 12:27:40 -04:00
ducoterra 71900d903a chore(agent): add phase 10 — dev dials the fleet directly
Six tasks that make dev the prod topology: the documented `ops`-installable agent sudoers
grant, installing the agents as systemd services, the upsd ACL for the host and laptop
ranges, `.env.dev` pointing at the real hosts (token in the URL userinfo, `NUT_PORT=3051`),
retiring `scripts/dev-tunnels.sh`, and a live + browser verification pass.

No dashboard Python changes — deployment, config, scripts and docs only.

LLM-Generated: true
2026-10-17 12:27:27 -04:00
ducoterra 9015931860 docs: record the live UPS command state after real-hardware verification
AGENTS.md gap 1 said the buttons were read-only in practice because no upsd.users account
had `instcmds`. That is no longer true: the login verbs, the command catalog, and the
`[dashboards]` account have each been fixed and verified against both real UPSes.

The rewrite keeps the parts that stay load-bearing: `instcmds = all` means the account may
send every advertised command including the power-cutting ones, and `available_commands()`
filters `LIST CMD` (which upsd serves unfiltered) — NUT has no call for a user's
privileges, so the risk tiers and the typed `DANGER` are the control, not decoration. Also
records what was proven reversibly (`beeper.enable` -> OK, `driver.killpower` refused
without `DANGER`), why a battery test was not run, and the rules for editing upsd.users
without dropping the account `upsmon` authenticates as.

README: `instcmds` is one command per line per `man 5 upsd.users`, the fleet runs
`instcmds = all`, and authentication uses `USERNAME`/`PASSWORD`.

LLM-Generated: true
2026-10-17 12:27:11 -04:00
ducoterra 9041683179 fix(ups): offer every advertised command and gate its aliases too
Two halves of the same bug: the page offered less than the hardware does, and the gate
matched only the name it rendered.

`available_commands()` drops anything `COMMANDS.get()` misses, and 12 of the 23 commands
the two real UPSes advertise had no entry (`beeper.on/off`, `load.on/off.delay`,
`shutdown.default/reboot/stayoff`, `driver.reload*`, `driver.killpower`,
`test.panel.stop`) — an invisible button the operator had no way to learn about. Every one
is catalogued now with a risk tier, and the power-cutting ones are pinned to
`RISK_DANGEROUS` (`shutdown.stop` is the exception the name hides: it cancels a shutdown).

Both real drivers advertise `beeper.enable` *and* `beeper.on`, `beeper.disable` *and*
`beeper.off`, so rendering every advertised name drew "Beeper on" twice. `COMMAND_ALIASES`
collapses one action to one entry (named for a name this driver answers, so a driver that
only implements the alias still gets its button) and records the other names in `also`.

The POST gate resolved the request through that table instead of comparing the literal
string: previously `beeper.off` walked past a `disabled` verdict recorded against
`beeper.disable` — the same action, and the gate is the control, not the button.

LLM-Generated: true
2026-10-17 12:27:02 -04:00
ducoterra 1f20a5e57f fix(nut): log in with the verbs upsd 2.8.5 actually answers to
`NutClient.login()` sent `USER`/`PASS`. The deployed upsd 2.8.5 implements neither —
its own `HELP` advertises `USERNAME`/`PASSWORD` — so each line answered
`ERR UNKNOWN-COMMAND`, `_read_line()` raised, and `POST /api/ups/command` could only
ever 502 before `instcmd()`, credentials or not.

Values go out unquoted: upsd takes the rest of the line literally, so quoting them
would make the quotes part of the credential.

`ERROR_HINTS["ACCESS-DENIED"]` blamed the upsd.conf ACL/LISTEN lines, but that code is
only reachable on an authenticated session (an unauthenticated client gets
`ERR USERNAME-REQUIRED`), so the cause is the account's `instcmds` grant. The hint now
names the one thing the operator can change.

The fake upsd in tests has no `USER`/`PASS` branch, so a regression answers
`ERR UNKNOWN-COMMAND` exactly like the real server, and its `PASSWORD` branch checks the
secret so a wrong-credential refusal is expressible.

LLM-Generated: true
2026-10-17 12:26:47 -04:00
ducoterra 2460a0adf5 chore(agent): phase roadmap from TODO.md, 4 phases
06 system_page_gpu_verdicts — GPU stops carrying a verdict, GPU temp on the
   hero row, per-host history charts collapsed behind a toggle (TODO L3/L4/L9)
07 llm_monitor_style_pages — /slots schema pinned from the live server,
   broken slot table removed, fleet totals block, stats strip, slot grid
   (full restyle; TODO L5)
08 events_paging_and_copy — event feeds scrollable + paginated, flavour text
   stripped, rule pinned in AGENTS.md (TODO L6/L7)
09 disks_smartctl — smartctl physical-disk collection in the host agent,
   5-minute disks source/poller/store, /api/disks + severity rows, /disks
   page + nav tab + overview entry (TODO L8)

LLM-Generated: true
2026-10-17 11:39:29 -04:00
ducoterra 34854d32af phase: 05_ups_hero_battery_glyph
**Phase 05 final pass — all criteria met; whole project 100 % green.**

Verified (live upsd + isolated simulator):
- Real `apc`/`cyberpower`: Charge/Load/Runtime are the card's three largest elements (38.4 px vs 15.2 px next), coloured only by API `severity` — live right now 100 % ok / 79.0 % **warn** / 5m **fail**; glyph fill = charge (100/100 viewBox units, `--ok` token), `<title>` "apc: Charge 100 percent, on line"; SIMULATED hidden; battery °C `n/a` uncoloured; every button disabled with its reason; `confirm_text: DANGER` on power-cutting commands; **0 non-GET requests** → `data/logs/ups-verify-live-{card,full}.png`.
- Simulator on `:8091` (own `/tmp` DB, killed after): caution dialog → Cancel, dangerous dialog → Run disabled until `DANGER` → Cancel, **0 POSTs**; one deliberate quick test *to the sim only* flipped the card to `OB` (glyph `stroke-dasharray="7 5"`, "on battery", health "discharging, on battery") then `OL CHRG` (bolt) then `OL` → `ups-verify-sim-{dialog-caution,dialog-danger,onbattery-button,onbattery-page,charging-button}.png`; glyph matrix `ups-glyph-*.png` (100/mid/low/unknown/empty/unrated, dark/light/200 %) present; `grep -c "function battery"` = 1.

Defects found in earlier work and fixed:
1. **`poller.py` could stop polling in silence** — 4 of 6 dev-server sources had frozen `attempts` (ages 12–38 min) while `/api/health` still said `ok: true`: the store-write half of `run_once` was outside the `try`, and a dead task's exception is never retrieved. Now `_commit()` moves `last_ok` only after the write lands, `_store_refused()` reports it (`ok:false`, real reason, one `store-error` event + one `recording` on recovery, payload kept), and `_loop()` catches anything that escapes and keeps polling — 4 new tests in `tests/test_poller.py`.
2. **`nut_sim.py` (modified by this phase) sat at 83 % coverage**; its whole command path was untested. Added tests (every advertised command implemented, unknown → `ValueError`, alarms → card + `assess()` fail, `NUT_SIMULATE` truthy rule) and fixed two model lies: `beeper.*` answered OK while changing nothing (now publishes `ups.beeper.status` enabled/muted/disabled) and a finished self-test never wrote `ups.test.result` (aborted writes none). → 100 %.
3. `nut_sim.from_env()` was dead code duplicating `app._truthy`; `app.py` now builds the simulator through it, so the enable gate lives once.

Tests / lint (exact): `bash .agents/validate.sh` → **EXIT=0** — `uv run pytest --cov --cov-fail-under=94` **543 passed, 8 skipped, coverage 97.91 %**; `uv run ruff check src tests agent` clean; `uv run pyright` 0 errors; `node --check` clean; repo hygiene clean. `uv run pytest -m live --live` **with `.env.dev` sourced → 8 passed** (one earlier run: 2 transient `9121/snapshot timed out` against the 4 s `HOST_TIMEOUT`; three later runs 8/8). `uv pip list --outdated` empty. Dev server restarted on the final code: `health ok`, 6/6 sources fresh.

Docs: AGENTS.md — invariant 4 extended ("nothing may stop polling in silence" + store-refusal semantics), `poller.py`/`nut_sim.py` responsibility lines, 533→543 and 96.9 %→97.9 %, "~97 %"→"~98 %". README.md — poller bullet gained the same guarantee, Testing gained "source `.env.dev` before `-m live --live`" (bare runs skip 7 of 8 live tests, which reads like a pass), Battery-test section gained the `deploy/nut-sudoers` install line and the read-before-write warning.

Notes/deviations: no live instant command was ever sent to real hardware (the four gates leave them disabled — AGENTS gap 1 unchanged); the on-battery proof was driven through the real button against `NUT_SIMULATE=1`, which is stronger than the NOTES hand-driven states. `NOTES-verify.md` under `todo/` left untouched per instructions. Next phase: **none** — 05 is the last in the PLAN.md roadmap.
2026-10-17 10:27:23 -04:00
ducoterra f44a617ef9 phase: 04_llm_chart_readability
**Phase 04 final verification pass — all tasks already in `complete/`; verified + fixed 3 defects found in verification.**

- Verified `/llm` live (both engines, gufo-only instance on :8099, `llm_monitor` on :8095) in a real browser; dev server left running/healthy.
- Completed task-04's truncated layout fix: `#engines` now carries `.items-start` (the CSS rule existed but nothing used it) — gufo 544 px vs vLLM 715 px, no blank card; pinned by `test_llm_engine_panels_are_not_stretched_to_their_tallest_sibling`.
- Fixed: event rows said the subject twice ("gufo gufo is answering again"), and `/ups` mangled mid-message names ("sent to apc: OK" → "sent to  OK") → shared `Dash.eventText()` (strips `UpstreamError: ` + a *leading* name only, mirroring `alerts._clean_error`) used by `/system`, `/llm`, `/ups`; new test.
- Fixed: count sparklines autoscaled rollup averages (queue max 0.1379) into full-height spikes → `sparkline({minSpan})`, `minSpan: 1` on sessions/queued (spike 30 px → 4 px); assertions added.
- Tests: `bash .agents/validate.sh` → **EXIT=0** (`uv run pytest --cov --cov-fail-under=94`: **485 passed, 7 skipped**, coverage **96.87%**; `ruff check src tests agent`: All checks passed; `pyright`: 0 errors; `node --check` all static JS clean; repo hygiene clean; `uv pip list --outdated` empty). `uv run pytest -m live --live` → **7 passed**.
- Completion criteria: heroes largest per engine ✔ (38.4 px vs 16.8 px row values); sparkline per cell + selected window ✔ (5/5 gufo, 7/7 vLLM; 6 h chip moved KV max 53 %→58 %); mean+p95 without hover ✔ (243 / 48.8 / 285 ms feet); provenance tag ✔ (`Δ10.0s`/`Δ8.0s` + `title`=exact source); single-engine ✔ (1 panel, totals hidden, zero red); screenshots ✔ `data/logs/llm-verify-{both-engines,charts-and-slots,single-engine,monitor-reference}.png` + `NOTES-chart-set.md`; pytest/cov/ruff/pyright ✔; earlier phases unchanged-behaviour ✔ (all 01–03 tests pass).
- Deviation (documented, not code): throughput heroes stay **neutral ink** — `severity.py` has no throughput row and 0 tok/s idle is healthy; colour comes from rated cells (KV) + reachability. Kept per `NOTES-chart-set`.
- Deviation: could not append the task-04 written verdict to `NOTES-chart-set.md` (it lives in `todo/`, which I must not edit). Verdict: ours reads better than `:8095` — bigger throughput numbers, per-cell shape over the selected window, percentiles inline; `llm_monitor` only sparklines decode+KV and has no window control.
- Docs: AGENTS.md updated (485 test count, `sparkline` `minSpan` rule, `Dash.eventText` event-row rule, `/llm` panel `items-start`); README reviewed in full — commands, config table (all `config.py` vars present), architecture bullets, page descriptions verified accurate; no other inconsistencies found.
- Note for the harness: `.agents/phases/todo/03…/NOTES-verify.md` and `todo/04…/NOTES-chart-set.md` are untracked notes left behind by their phases (task files moved, notes did not).
- Next pending phase: `05_ups_hero_battery_glyph`.
2026-10-17 07:03:58 -04:00
113 changed files with 6217 additions and 378 deletions
@@ -1,66 +0,0 @@
# Verification notes — `/system` on every host shape
**Date:** 2026-10-17 (dev loop: tunnels 9121/9122/9123 + upsd 3494 all OK; dev-server
`/api/health` all six sources `ok` before and after verification)
## What was checked, on live data
Verified against `http://127.0.0.1:8090/system` with all three real agents answering, in a
real browser (headless Chromium/Playwright), DOM assertions plus screenshots.
| Host (shape) | Result | Screenshot |
| --- | --- | --- |
| `driveripper` (discrete Radeon AI PRO R9700) | GPU hero: `3 %` ok, `.sub` = "Radeon AI PRO R9700 · VRAM 30.5 GiB / 31.9 GiB · pool 33 %"; util/net/GPU charts all present inside the panel | `system-driveripper.png` (dark), `system-driveripper-light.png` |
| `deskwork` (Strix Halo APU, two memory pools) | GPU hero busy went 82 % ok → 85 % warn → 99 % fail across the session, so threshold colour visibly moves; `.sub` = "Radeon 8060S Graphics · VRAM 172.7 MiB / 512.0 MiB · pool 78 %" — small dedicated VRAM and high shared pool read as two facts, not a contradiction; MEM 91 % and TEMP ~83 °C amber | `system-deskwork.png` |
| `gateway` (no GPU, no CPU temp sensor) | CPU TEMP `n/a` with **no** severity class; GPU slot = `n/a` + "No GPU reported by this agent", uncoloured; **no** GPU chart and no hole where one would be | `system-gateway.png` (dark), `system-gateway-light.png` |
Full-page shapes: `system-shapes-dark.png`, `system-shapes-light.png`.
Failure path: stopped only the gateway tunnel (same commands `dev-tunnels.sh stop` runs,
scoped to the 9123 entry; remote agent stopped too). After 15 poll failures the panel kept
its last-good heroes (MEM 32 %, DISK 61 %, NET rates — nothing zeroed, nothing `n/a`), its
dot turned red, the header read `stale 2.1m`, and the card dimmed via `.stale`. The other
two panels stayed green ("one dead source must not blink another"). Store recorded exactly
one `unreachable` and, after `./scripts/dev-tunnels.sh start`, one `reachable` event.
Screenshot: `system-stale-gateway.png` (+ `system-stale-overview.png` full page). Restored;
`dev-tunnels.sh status` and `/api/health` back to all-OK.
Accessibility pass:
- hero values are real selectable text (`div.value > span.num/span.unit`, no SVG paths) —
asserted in-page for every hero on all three panels;
- colour is never the only signal: each hero prints the number, the NET slot is
deliberately colourless, staleness reads as text (`updated Ns` / `stale 2.1m`);
- charts — **defect found**: the SVGs were anonymous (`aria-label` absent on container and
svg). Fixed: `LineChart` gained an `ariaLabel` option that sets `role="img"` +
`aria-label` on the `<svg>`, and every `/system` chart now names its host and metrics
(e.g. "driveripper — utilisation over the selected window: CPU, memory and worst disk in
per cent"). Pinned by `tests/test_api.py::test_every_system_chart_names_itself_for_assistive_tech`.
The `/llm` and `/ups` charts can adopt the same option in phases 04/05.
Both themes checked via the header toggle (severity colours come from theme tokens; the
light-theme deskwork fail-red and warn-amber read clearly — see
`system-shapes-light.png`, `system-gateway-light.png`). Note for the next shooter: set
`prefers-color-scheme` or seed `localStorage['dash-theme']`; Playwright defaults to light,
which silently mislabels a "dark" screenshot.
`curl "localhost:8090/api/system/history?hosts=driveripper&metrics=cpu.usage_pct@core7&range=1h"`
→ 354 points: dropping the per-core plot did not drop the data.
## Defects found and fixed during verification
1. **Unlabelled charts** (above) — fixed in `charts.js` + `page-system.js`, test-pinned,
re-screenshotted.
2. **`/system` Events panel was permanently empty** — the poller stores transition events
under the *dashboard* name (`system`) while the page queried `/api/events?source=host`,
so the feed said "No host transitions recorded yet" even with the deliberate gateway
outage in the store. Fixed to `source=system` (with a comment on the naming trap);
`tests/test_api.py::test_system_event_feed_queries_the_source_transitions_are_stored_under`
pins it; the re-shot full-page screenshots show the feed populated with the outage and
recovery. Pre-existing since the first commit, not introduced by phase 03.
## After the fixes
`bash .agents/validate.sh` → exit 0: 460 passed + 7 skipped, coverage 96.86 % (≥ 94),
ruff + pyright clean, `node --check` clean. `uv run pytest -m live --live` → 7 passed.
Console stayed error-free throughout.
@@ -0,0 +1,42 @@
# Phase 06 — The system page stops grading GPU usage, shows GPU temp, and tucks charts away
**Source:** `TODO.md` L3, L4, L9 — "GPU verdicts + system-page cleanup": "High gpu usage is not a warning or error, these are LLM servers and that's expected" · "The historical charts on the system resources page should only be visible if expanded" · "Pull GPU temp for the system resources tab where you can."
**Story:** n/a
**Context:** `src/dashboards/severity.py` (`THRESHOLDS` — the only place thresholds live), `src/dashboards/sources/host.py` (per-GPU `severity` dict + `gpu.*` samples), `tests/card_contract.py` / `tests/test_system_contract.py` (the pinned card contract), `src/dashboards/web/static/page-system.js` (hero row, `gpuSub()`, the per-host `.host-charts` stack), `src/dashboards/web/static/dash.css`
## Objective
`/system` watches LLM servers, where a busy GPU is the job, not a fault: the GPU card stops
carrying any verdict (muted, never green and never red), the GPU temperature the agent
already reports moves up onto the hero row, and the per-host history charts collapse behind
a "Charts" toggle so the default view is current stats only.
## Dependencies
- `05_ups_hero_battery_glyph` (complete) — the shared `.hero` vocabulary and the per-host
chart stack this phase reworks; nothing in this phase may fight its conventions.
## Tasks
1. `01_drop_gpu_severity_verdicts.md` — remove `gpu.busy_pct` / `gpu.mem_used_pct` from the verdict table; the GPU card renders uncoloured; the contract pins the *absence*.
2. `02_gpu_temp_on_hero_row.md` — surface `gpus[].temp_c` (already in the payload) on the GPU hero's `.sub` line.
3. `03_collapse_host_history_charts.md` — wrap each host's `.host-charts` in a collapsed-by-default toggle; charts draw lazily on expand.
## Testing & Quality
- Unit: `tests/test_severity.py`, `tests/test_host_source.py`, `tests/test_system_contract.py`
(all three captures — discrete GPU, APU, no GPU — plus over HTTP) must pin the new
no-verdict shape; `tests/test_api.py` pins the chart-toggle markup on served bytes.
- Coverage: **>90%** on new/modified code, ≥94% overall gate maintained.
- Browser (DoD): dev server live at `127.0.0.1:8090`, screenshot per change in `data/logs/` —
a busy GPU (driveripper's R9700 under load) renders uncoloured, the temp is on the hero
row, charts are collapsed by default and draw on expand.
## Completion Criteria
- [ ] `classify("gpu.busy_pct", 99)` is `None` and no page script colours a GPU number from a threshold
- [ ] `uv run pytest` green; coverage gate (94%) holds
- [ ] `uv run ruff check src tests agent && uv run pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] screenshots in `data/logs/` show the three behaviours on live data
- [ ] `AGENTS.md` severity section no longer claims GPU rows exist
@@ -0,0 +1,51 @@
# Task 01 — GPU usage and VRAM stop carrying a verdict
**Phase:** `06_system_page_gpu_verdicts` · **Source:** `TODO.md:3` — "High gpu usage is not a warning or error, these are LLM servers and that's expected"
**Story:** n/a
## Objective
Remove the GPU verdict entirely: a 95 %-busy card on an LLM server is the job, not a
warning, so the GPU card prints its numbers uncoloured at every utilisation.
## Work
1. `src/dashboards/severity.py` — delete the `gpu.busy_pct` and `gpu.mem_used_pct` rows
from `THRESHOLDS`. No verdict at all — not a raised threshold: "high usage is expected"
applies to every level, and the module's own rule is that a metric the table does not
know classifies to `None`, never `ok`.
2. `src/dashboards/sources/host.py` — stop classifying the GPU rows: drop the `busy_pct` /
`mem_used_pct` classifications from the per-GPU `severity` dict (remove the dict when it
becomes empty), leaving the `gpu.*` **samples** untouched — the history series stay
stored and queryable; removing the verdict is not removing the data.
3. `tests/card_contract.py` — pin the absence, the way it already pins `network`'s
colourlessness: the GPU card paths (`gpus[].model/busy_pct/vram_*/mem_used_pct`) stay,
the verdict paths go. `tests/test_system_contract.py` follows (all three captures + HTTP).
4. `tests/test_severity.py` — assert `classify("gpu.busy_pct", 99)` /
`classify("gpu.mem_used_pct", 99)` return `None`; keep the boundary-ordering test
consistent with the smaller table. `tests/test_host_source.py` — the GPU row no longer
carries a `severity` dict.
5. `src/dashboards/web/static/page-system.js` — the GPU hero renders without `severity` and
without the utilisation bar (a bar under a verdictless number is a verdict by shape);
drop the `data-sev="gpus[].severity.busy_pct"` hook. `n/a` stays uncoloured as before.
6. `AGENTS.md` — the `severity.py` section lists `gpus[].severity` among the rated cells;
update it so the file and the code agree (DoD: rules and fleet facts travel with the
change).
- ASSUMPTION: "no verdict at all (muted)" was the operator's confirmed reading of the item
— the card shows busy % / VRAM with no colour class at any level, rather than "only fail
above a new ceiling". If the operator later wants a ceiling, it is one table row back.
## Testing & Quality
- Unit: `tests/test_severity.py` (no GPU row in the table; `None` verdicts),
`tests/test_host_source.py` (payload shape), `tests/test_system_contract.py` (the pinned
contract over the three captures + over HTTP).
- Coverage: **>90%** on this task's modified code; overall gate (94%) maintained.
## Completion Criteria
- [ ] `uv run pytest` green with the updated contract
- [ ] a live busy GPU (driveripper, R9700) renders its numbers with no colour class and no bar
- [ ] `gpu.busy_pct@N` still answers from `/api/system/history` (data intact, verdict gone)
- [ ] `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,44 @@
# Task 02 — GPU temperature on the hero row
**Phase:** `06_system_page_gpu_verdicts` · **Source:** `TODO.md:9` — "Pull GPU temp for the system resources tab where you can."
**Story:** n/a
## Objective
The GPU temperature is already collected end-to-end (agent `gpus[].temps` → payload
`gpus[].temp_c`, max over the card's hwmon labels, and a `gpu.temp_c@N` sample) but the
hero row never shows it — it sits in the collapsed "More detail" table. Move it up so the
one number that matters while a card is busy is glanceable.
## Work
1. `src/dashboards/web/static/page-system.js` — `gpuSub()`: append the temperature to the
GPU hero's `.sub` line when `gpu.temp_c != null` (e.g.
`VRAM 31.2 GB / 32 GB · pool 98% · 63 °C`); when the agent cannot read it (the APU
build on deskwork or a card with no hwmon input), the sub line simply does not print a
temperature — `n/a` is reserved for numbers the card is supposed to have, and "where you
can" is the item's own wording.
2. Keep the "More detail" table exactly as-is (it carries per-label temps, power, and both
memory pools); this is a surface change, not a dedup.
3. No backend changes: `tests/fixtures/agent_deskwork.json` / `agent_driveripper.json`
already carry `gpus[].temps`, so the hermetic page tests can exercise both the
with-temp and without-temp paths.
- ASSUMPTION: the payload's `temp_c` (agent's max over hwmon labels) is "the" GPU temp —
the same max the agent already computes — so no new field, no new metric name, and the
`gpu.temp_c@N` history series keeps its existing name.
## Testing & Quality
- Unit: `node --check` on `page-system.js`; served-page assertions in `tests/test_api.py`
if the markup structure changes.
- Coverage: **>90%** on modified code (mostly JS; the Python side is unchanged).
- Browser (DoD): screenshot in `data/logs/` with live data — driveripper's R9700 shows a
temperature on the hero row; deskwork's APU row does not print a bogus one.
## Completion Criteria
- [ ] GPU hero `.sub` includes `· <n> °C` on hosts whose agent reports temps
- [ ] hosts without a readable sensor show no temperature (not `0 °C`, not red)
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
- [ ] screenshot in `data/logs/` against live data
@@ -0,0 +1,49 @@
# Task 03 — Per-host history charts collapse behind a toggle
**Phase:** `06_system_page_gpu_verdicts` · **Source:** `TODO.md:4` — "The historical charts on the system resources page should only be visible if expanded. I only want to see current stats to keep things clean. There will be lots of hosts and historical charts make things look messy."
**Story:** n/a
## Objective
The default `/system` view is the hero row (current stats) only. Each host's `.host-charts`
stack (utilisation, network, GPU busy) moves behind a collapsed "Charts" toggle, so a
fleet that grows to many hosts reads clean at a glance — and a host that stops reporting
still loses its panel and charts (`pruneHosts`) exactly as today.
## Work
1. `src/dashboards/web/static/page-system.js` — wrap each host's `.host-charts` in a
native `<details>` (collapsed by default; `summary` reads "Charts" and carries the
window chip so the expanded state still says which window is drawn). Create the
`LineChart` instances **lazily on first expand** (the `state.charts` map already keys
them) and re-run the draw pass on refresh **only for hosts whose charts are open** —
drawing into a collapsed container is wasted work, and the page's one-
`/api/system/history`-request-per-host discipline is unchanged (the request still names
every series the panel could draw; only the paint is deferred).
2. `pruneHosts()` keeps destroying chart instances with the panel; add the `details`
element to the removal so a dead host leaves no orphan toggle.
3. `src/dashboards/web/static/dash.css` — style `summary` as a quiet toggle in the existing
vocabulary (no new palette, no `.sev-*` family); the collapsed stack takes no height.
4. `tests/test_api.py` — pin the served markup: one `<details>` per host panel, collapsed
by default (no `open` attribute), summary text "Charts"; the orphan-`id` rule keeps
passing (delete no rule that a live selector still uses).
5. `node --check` on both scripts.
- ASSUMPTION: the toggle is **per host** (not one global switch) and **collapsed by
default** — "only visible if expanded" is the item's own wording, and per-host matches
the page's one-panel-per-host discipline; the window chips (5 m … 30 d) stay where they
are and keep driving the same single history request.
## Testing & Quality
- Unit: `tests/test_api.py` served-bytes assertions (details present, closed by default).
- Coverage: **>90%** on modified code; overall gate (94%) maintained.
- Browser (DoD): screenshot in `data/logs/` — collapsed by default on a multi-host page,
one host expanded with its three charts drawn, a second refresh while expanded repaints.
## Completion Criteria
- [ ] default load shows hero rows only; no chart pixels before a toggle is opened
- [ ] expanding a host draws its charts on the first open and on every later refresh while open
- [ ] a pruned (dead) host removes its toggle along with its panel and charts
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,55 @@
# Phase 07 — The LLM page looks like llm_monitor again
**Source:** `TODO.md` L5 — "make this look like the llm_monitor project": "The LLM dashboard is nothing like ~/Experimental/llm_monitor. I'm missing the 'slots' tables (I really liked that). I'm missing the aggregate throughput at the top. Basically, make this look like the llm_monitor project. The gufo slots table that exists isn't even working and I want it gone anyway."
**Story:** n/a
**Context:** `~/Projects/Experimental/llm_monitor/static/index.html` (the reference: fleet-totals section ~L710, shared stats strip ~L743, gufo slot cards `buildSlotCard`/`renderSlots` ~L1179, `/slots` passthrough with `/v1/slots` fallback ~L750), `src/dashboards/web/pages/llm.html` + `src/dashboards/web/static/page-llm.js` (phase-04 layout: per-engine heroes, `CELLS` sparkline list, alarm chips, four trend charts), `src/dashboards/sources/llm.py` (`fleet_totals()` ~L327 — already sums decode/prompt tok/s, running/waiting, token totals; `_sessions()` passes gufo's raw `/slots` rows through as `sessions.list`), `scripts/capture-fixtures.sh` (today captures engine `/metrics` only — **no `/slots` capture exists**, which is why the slot table never matched the live shape)
## Objective
Port `llm_monitor`'s visual structure onto the LLM page — the aggregate fleet block at the
top, the per-engine stats strip, the slot-card grid — while keeping what phase 04 and the
locked UI conventions own: the per-engine panel (two throughput heroes, compact sparkline
cells, alarm chips, the four half-height trend charts) and every provenance/severity rule.
The broken gufo slot table is gone and its replacement is fed by a `/slots` shape pinned
from the live server, never from memory.
`llm_monitor` is the design reference, not a dependency: a separate app, nothing imported.
- ASSUMPTION (phase, confirmed with the operator): "full restyle" = port the three
llm_monitor structures above (aggregate block, stats strip, slot grid). The per-engine
panel and chart set stay; `llm_monitor`'s peak-ctx cell does **not** come over because
the payload publishes no peak-context number — a shape is not a verdict, and a missing
number prints `n/a`, never a borrowed one.
## Dependencies
- `06_system_page_gpu_verdicts` (todo) — same web assets (`dash.css`, `app.js`, page
scripts); land in that order so the CSS token vocabulary is settled first.
- `04_llm_chart_readability` (complete) — the layout this phase reworks; its NOTES
(`NOTES-chart-set.md`) and provenance/severity conventions bind.
## Tasks
1. `01_slots_schema_and_table_removal.md` — delete the broken slot table; extend `capture-fixtures.sh` to record gufo's live `/slots` and pin the shape in a fixture + contract test.
2. `02_fleet_totals_block.md` — render the aggregate block at the top from the existing `fleet_totals()` payload (hidden below two engines, as today).
3. `03_engine_stats_strip.md` — port the shared stats strip: one card per engine (KV bar, tok/s, sessions; gufo extras; alarm chips), fed only by payload fields that already exist.
4. `04_slot_grid.md` — the llm_monitor slot grid: one keyed card per gufo slot, updated in place, from `sessions.list`.
## Testing & Quality
- Hermetic: `tests/test_api.py` key-path pins (fleet block, strip, grid markup on served
bytes), `node --check` on every touched script, fixture-driven `sessions.list` shape test.
- Live: `uv run pytest -m live --live` — the `/slots` fixture test re-asserts against the
running gufo (`deskwork:8731`), and the page renders with both engines live and with a
single engine (one clean half, not a red panel).
- Browser (DoD): side-by-side with `llm_monitor` on its own port against the same live
engines; screenshots in `data/logs/` (two-engine, single-engine, gufo-busy states).
- Coverage: **>90%** on new/modified code, ≥94% overall gate maintained.
## Completion Criteria
- [ ] the old `#slots-card` table is gone from HTML, CSS and JS (and from any test that pinned it)
- [ ] `tests/fixtures/slots_gufo.json` exists, is produced by `./scripts/capture-fixtures.sh`, and the contract test fails if the live shape drifts
- [ ] top aggregate block shows summed decode/prompt tok/s, running/waiting, token totals — hidden below two engines
- [ ] stats strip + slot grid render on live data; single-engine page is one clean half
- [ ] `bash .agents/validate.sh` green; screenshots in `data/logs/`
@@ -0,0 +1,52 @@
# Task 01 — Remove the broken slot table; pin the /slots schema from the live server
**Phase:** `07_llm_monitor_style_pages` · **Source:** `TODO.md:5` — "The gufo slots table that exists isn't even working and I want it gone anyway."
**Story:** n/a
## Objective
Delete the slot table that has never matched gufo's live `/slots` shape, and replace
"trust a memory of an upstream" (rule 7) with a recorded ground truth: a fixture captured
from the running gufo, pinned by a contract test.
## Work
1. `scripts/capture-fixtures.sh` — add a gufo `/slots` capture: dial
`${GUFO_BASE_URL%/}/slots` with the `/v1/slots` fallback (the same order
`llm_monitor`'s passthrough at ~L750 uses — a server that only exposes the alias must
still be capturable), save verbatim as `tests/fixtures/slots_gufo.json`. Skip cleanly
(with a note) when `GUFO_BASE_URL` is unset, matching the script's existing engine
handling.
2. Delete the broken table end-to-end: `#slots-card` / `#slots-empty` / `#slots-table`
markup in `src/dashboards/web/pages/llm.html`, `renderSlots()` and the dead helpers in
`src/dashboards/web/static/page-llm.js`, and the CSS rules in `dash.css` that only that
table used (the orphan rule for both `id`s and selectors — `tests/test_api.py` enforces
both). Any test that pinned the table's markup or its guessed field names
(`slot.state`, `slot.n_prompt`, `slot.decode_per_second`, …) gets the pin removed or
turned into the new fixture-based assertion.
3. `tests/` — add the shape contract: a table of the `/slots` field paths the slot grid
(task 04) will read, asserted against `tests/fixtures/slots_gufo.json` hermetically and,
in a `live`-marked test, against the running gufo — the same device as
`tests/card_contract.py`. `tests/test_llm_source.py` keeps asserting that
`sessions.list` is the passthrough (raw rows, no re-mapping in `sources/llm.py`'s
`_sessions()` — the grid, not the source, decides what a slot field means).
4. `./scripts/capture-fixtures.sh` — run it against the live fleet and commit the new
fixture (serials/PII scrubbed, same as the existing captures).
- ASSUMPTION: the fixture is the *live* shape — whatever the running gufo answers today —
not a README's; if the live server is idle (zero busy slots) at capture time, the
capture still records the idle slot rows, which is the shape the grid must handle.
## Testing & Quality
- Unit: fixture-based shape test (hermetic), `node --check` on `page-llm.js`,
`tests/test_api.py` orphan-`id`/orphan-selector rules still pass after the deletion.
- Live: the `live`-marked shape test against `deskwork:8731`.
- Coverage: **>90%** on modified code; overall gate (94%) maintained.
## Completion Criteria
- [ ] no `#slots-table` / `#slots-card` / `renderSlots` remains in HTML, JS or CSS
- [ ] `tests/fixtures/slots_gufo.json` is in the repo and reproducible via `./scripts/capture-fixtures.sh`
- [ ] the shape contract fails when the fixture (or the live server) drifts
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,54 @@
# Task 02 — Aggregate throughput block at the top
**Phase:** `07_llm_monitor_style_pages` · **Source:** `TODO.md:5` — "I'm missing the aggregate throughput at the top."
**Story:** n/a
## Objective
The page leads with the fleet: one block above the engine panels with the summed numbers
llm_monitor's `#fleet` section shows — decode/prompt tok/s, running/waiting, token totals,
engines answering — which `fleet_totals()` in `sources/llm.py` already computes over the
engines that answered. Phase 04 retired the summed display to counts only; this task brings
the sums back in llm_monitor's shape.
## Work
1. `src/dashboards/web/pages/llm.html` + `page-llm.js` — replace the current `#totals`
card (counts only) with the llm_monitor-style fleet block: status dot, "all engines",
an `N engines · …` tag, the note "sums only engines answering now", and the
`running / waiting / prompt tok/s / decode tok/s / total tokens` row, rendered from
`fleet_totals()` via the existing payload (`/api/llm/snapshot` → `fleet`).
Provenance discipline stays: the sums carry no severity (throughput has no
good/bad — the same rule phase 04 wrote), and a dead engine's last-good values
contribute nothing to a total presented as current (`engines_live` vs `engines_total`
makes that visible, exactly as `fleet_totals()`'s docstring promises).
2. Keep the existing visibility rule — hidden below two engines (a cross-engine sum of one
engine duplicates that engine's own panel; the page comment at `#totals` says so and
`llm_monitor` hides the same way).
3. `tests/test_api.py` — pin the `fleet` key paths the block reads
(`engines_total`, `engines_live`, `decode_tokens_per_second`,
`prompt_tokens_per_second`, `requests_active`, `requests_waiting`, `tokens_total`) with
and without targets configured, and the served-markup presence/absence with one vs two
engines.
4. `dash.css` — block styling from the existing token vocabulary (`.metrics`/`.metric`
patterns already in the file; no new palette).
- ASSUMPTION: no backend change — `fleet_totals()` already sums exactly the numbers the
block prints; if a wanted number turns out to be missing from the payload, it stays
`n/a` rather than inventing a new metric (rule 2).
## Testing & Quality
- Unit: `tests/test_api.py` (key paths + markup, one vs two engines), `node --check`.
- Live: `uv run pytest -m live --live` with both engines; the block sums the two live
panels' numbers (a hand-check against the per-engine values on the same screen).
- Browser (DoD): screenshots in `data/logs/` — two-engine (block visible, sums correct),
single-engine (block absent, one clean half).
- Coverage: **>90%** on modified code; overall gate (94%) maintained.
## Completion Criteria
- [ ] the top block prints summed decode/prompt tok/s, running/waiting, token totals from `fleet_totals()`
- [ ] hidden below two engines; a dead engine dims the tag and contributes nothing to the sums
- [ ] no severity colour anywhere on the block
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,54 @@
# Task 03 — Per-engine stats strip
**Phase:** `07_llm_monitor_style_pages` · **Source:** `TODO.md:5` — "Basically, make this look like the llm_monitor project." (the shared stats strip, `index.html` ~L743)
**Story:** n/a
## Objective
Port llm_monitor's shared stats strip — one card per engine between the fleet block and
the engine panels — so the read-across-the-room numbers (throughput, KV pressure,
sessions, the gufo extras) sit in one row instead of being scattered inside the panels.
The panels keep their detail; the strip is the summary.
## Work
1. `src/dashboards/web/pages/llm.html` + `page-llm.js` — one stat card per configured
engine: status dot + name + timestamp, the KV-cache bar (the one rated cell — colour
from the payload's `severity`, the existing `scale: 100` ratio→percent conversion),
prompt tok/s and decode tok/s (with the provenance tags, uncoloured), total tokens,
and the session counts (gufo: active slots via `sessions.list` length/`active`; vLLM:
running/waiting from its count metrics).
2. Gufo-only extras on the gufo card, vLLM-only rows on the vLLM card — the existing
`only` scoping in `CELLS` is the model: prefix-cache hit %, spec-accept %
(gufo), queue time (vLLM). **No peak-ctx cell** — the payload publishes no
peak-context number (phase assumption; a missing number prints `n/a` or the row is
absent, never a borrowed one).
3. `preemptions` / `device_lost` remain **alarm chips that exist only while non-zero**
(phase-04 rule, `.chip.fail` pattern) — on the strip card, not in a permanent cell.
4. `dash.css` — the strip is a new layout of existing vocabulary (`.card`, `.metrics`,
the KV bar already has its tokens); no new palette, no `.sev-*` family.
5. `tests/test_api.py` — pin the strip's served markup (one card per engine, gufo/vLLM
scoping) and the payload field paths it reads; `node --check`.
6. The engine panels keep their hero row and the sparkline cells the strip *does not*
show (latency means + p95s, queue mean); cells that end up rendered in **both** strip
and panel get demoted from the panel to the strip — one value, one home (the
"one value drawn twice" rule from the UPS hero row).
- ASSUMPTION: the strip renders whatever the payload already sends — no new field in
`sources/llm.py` — so a single-engine deployment shows one card and the hidden
fleet block, and nothing on the strip is ever a number the upstream did not publish.
## Testing & Quality
- Unit: `tests/test_api.py` (markup + field paths), `node --check`.
- Live: both engines, then single-engine (one clean card, no red half).
- Browser (DoD): screenshot in `data/logs/` — strip visible, KV bar coloured from the
payload verdict, alarm chip present only in a non-zero state.
- Coverage: **>90%** on modified code; overall gate (94%) maintained.
## Completion Criteria
- [ ] one card per configured engine, between the fleet block and the panels, with KV bar + tok/s + sessions
- [ ] no value printed twice (strip vs panel); gufo/vLLM rows scoped correctly
- [ ] alarm chips absent at zero, present and red while non-zero
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,53 @@
# Task 04 — The gufo slot grid
**Phase:** `07_llm_monitor_style_pages` · **Source:** `TODO.md:5` — "I'm missing the 'slots' tables (I really liked that)."
**Story:** n/a
## Objective
Bring back what the operator liked about llm_monitor: one card per gufo slot, keyed and
created once, updated in place on refresh — fed by `sessions.list` (the raw `/slots`
passthrough whose shape task 01 pinned in `tests/fixtures/slots_gufo.json`).
## Work
1. `src/dashboards/web/pages/llm.html` + `page-llm.js` — implement the grid with
llm_monitor's `buildSlotCard`/`renderSlots` idiom (~L1179/L1209): a `.slot` card per
slot id (keyed `Map`, created once, patched in place — a busy slot does not get
rebuilt and re-announced on every poll), the card top shows the slot id + model, and
the per-slot fields come **only from the pinned fixture's shape** (state/busy
marker, prompt + generated token counts, cache share, per-slot speed — the exact keys
the fixture records; a field the live shape does not carry prints `n/a`, never a
guessed fallback chain like the old table's).
2. The grid lives in the gufo section under the "sessions (GET /slots)" label; the
empty state is the honest one (no slots answered → the panel says so, it does not
render a card of nothing). A queued (deferred) request is **not** in `/slots` — the
existing note under the grid stays (it is information, not flavour).
3. vLLM section: keep the one-line note that vLLM exposes no per-slot JSON and its
metrics are the equivalent of the session grid — no grid, no fake rows.
4. `dash.css` — `.slot` / `.slot-top` / `.slot.busy` styling ported to the existing token
vocabulary (busy state is a border/glyph, not a new palette entry); `node --check`.
5. `tests/test_api.py` — pin the grid markup on served bytes (container, empty state,
label); the fixture-driven render path is covered by the shape contract from task 01.
- ASSUMPTION: the slot card reads the fixture's fields one-to-one (no `a ?? b` fallback
chains — the old table's `slot.n_prompt ?? slot.prompt_tokens` style is exactly the
guessing that made it "not even working"); if a live capture shows two spellings of one
fact, the contract names the canonical one and the other is not rendered.
## Testing & Quality
- Unit: fixture-driven render test (the pinned shape through `renderSlots`-equivalent
logic, via served-bytes assertions), `node --check`.
- Live: gufo with at least one active request (a card with real numbers) and an idle
server (cards show idle, no invented speed).
- Browser (DoD): screenshots in `data/logs/` — idle, one busy slot, multiple busy slots;
refresh in place (cards do not flicker/rebuild).
- Coverage: **>90%** on modified code; overall gate (94%) maintained.
## Completion Criteria
- [ ] one keyed card per gufo slot, created once and updated in place across refreshes
- [ ] every printed field traces to a key in `tests/fixtures/slots_gufo.json` (no fallback chains)
- [ ] idle server: honest empty/idle state; vLLM: note only, no grid
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,44 @@
# Phase 08 — Event feeds page; the flavour text goes
**Source:** `TODO.md` L6, L7 — "page the event tables; strip the flavour text": "All the events tables need to be scrollable and paginated. They are causing the page to be massive." · "Get rid of most of the flavor text and add a note to AGENTS.md to not add it unless necessary. It just comes off as LLM generated slop, not helpful information."
**Story:** n/a
**Context:** the three `ul.events` feeds (`src/dashboards/web/pages/system.html` L58, `llm.html` L103, `ups.html` L70-72), `Dash.eventText()` in `src/dashboards/web/static/app.js`, the page-head sub-lines with the quoted flavour text (`system.html` L45, `index.html` L73), `AGENTS.md` → *UI conventions*, `tests/test_api.py` (served-bytes pins, orphan-`id` rule)
## Objective
A dead host's 500-event history must not make `/system` take a page to scroll: every
event feed becomes a bounded, scrollable, paginated window. The descriptive copy that
explains the app to the operator ("agent-reported values; rates are measured over the Δ
window…") goes away page by page, and `AGENTS.md` records the rule so the slop does not
crawl back in.
## Dependencies
- `07_llm_monitor_style_pages` (todo) — same page files and `app.js`/`dash.css`; this
phase lands on the restructured `/llm` page, not the phase-04 one.
- `06_system_page_gpu_verdicts` (todo) — rewrites `/system`'s panel structure; event-feed
work must not fight it for the same markup.
## Tasks
1. `01_paginate_event_feeds.md` — one shared pager: the three `ul.events` become scrollable containers with a 50-per-page window and a prev/next + count footer; no API change.
2. `02_strip_flavor_text.md` — audit all four pages and the page scripts for descriptive copy, delete what is not functional, and pin the rule in `AGENTS.md`'s UI conventions.
## Testing & Quality
- Hermetic: `tests/test_api.py` served-bytes pins (pager markup, no removed flavour
strings still present), `node --check` on touched scripts, the orphan-`id` rule
(the pager introduces `id`s — every one must be referenced).
- Live: `uv run pytest -m live --live` — the event endpoints still answer; a feed with
more than 50 stored events (the real event table has history) paginates instead of
dumping.
- Browser (DoD): screenshots in `data/logs/` — a long feed bounded and scrollable, page
2 reachable, the removed sub-lines gone from the page heads.
- Coverage: **>90%** on modified code, ≥94% overall gate maintained.
## Completion Criteria
- [ ] all three feeds scroll in a bounded container and paginate 50-per-page with a count footer
- [ ] no removed flavour string survives in served HTML or JS
- [ ] `AGENTS.md` carries the flavour-text rule under UI conventions
- [ ] `bash .agents/validate.sh` green; screenshots in `data/logs/`
@@ -0,0 +1,56 @@
# Task 01 — Event feeds: scrollable and paginated
**Phase:** `08_events_paging_and_copy` · **Source:** `TODO.md:6` — "All the events tables need to be scrollable and paginated. They are causing the page to be massive."
**Story:** n/a
## Objective
Every event feed (`/system`, `/llm`, `/ups`) renders a bounded, scrollable window over the
events instead of a growing `ul` that makes the page take a scroll to read. One shared
helper, three call sites — a pager that only works on one page is the bug this shape
prevents.
## Work
1. `src/dashboards/web/static/app.js` — add `Dash.eventsPager(el, events, opts)`: renders
the visible page of `events` into the feed's `ul` (keeping the existing
`Dash.eventText()` row rendering and per-row subject-once rule untouched), with a
footer: `1–50 of N` and prev/next controls (disabled at the ends, `aria`-labelled).
Page size defaults to 50; the feed container is the scrollable element, the page
stays short.
2. Call sites — `page-system.js`, `page-llm.js`, `page-upts.js` → `page-ups.js`: each
replaces its direct `ul#events` population with the pager (the "loading…" / empty /
not-configured states keep their current behaviour; pagination only applies when there
is more than one page of data). On a refresh that brings new events, the pager resets
to the first page (an operator reading page 3 of stale history should not silently land
on page 3 of the new set).
3. `src/dashboards/web/static/dash.css` — `.events` gets a bounded height (scrollable
container, existing token vocabulary); the pager footer styled as a quiet `.faint`
strip. No new palette.
4. No API change: `/api/events` keeps answering exactly what it does (server-side list,
no `page`/`limit` params added — the feeds are page-local and the endpoint's
contract is pinned by `tests/test_api.py`).
5. `tests/test_api.py` — pin the served markup (the three feeds keep their `id`s; the
pager's new `id`s/`aria-*` are all referenced — the orphan rule stays green), and
`node --check` on all touched scripts.
- ASSUMPTION: **client-side** pagination (50-per-page, scrollable container) is the
confirmed reading — the event tables grow with history and the server already serves
the list; a server-side `page`/`limit` API change would re-open a pinned endpoint
contract for a page-local problem.
## Testing & Quality
- Unit: `tests/test_api.py` (markup pins), `node --check`; a fixture-events render check
(page 1 of 3 pages, footer counts, disabled prev on page 1).
- Live: `uv run pytest -m live --live` — event endpoints unchanged.
- Browser (DoD): screenshot in `data/logs/` — a feed with >50 events bounded and
scrollable, page 2 showing rows 51–100.
- Coverage: **>90%** on modified code; overall gate (94%) maintained.
## Completion Criteria
- [ ] all three feeds paginate 50-per-page with a `1–50 of N` footer and prev/next
- [ ] the page height no longer grows with event history; the container scrolls
- [ ] refresh resets to page 1; loading/empty/not-configured states unchanged
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,56 @@
# Task 02 — Strip the flavour text; pin the rule in AGENTS.md
**Phase:** `08_events_paging_and_copy` · **Source:** `TODO.md:7` — "Get rid of most of the flavor text and add a note to AGENTS.md to not add it unless necessary. It just comes off as LLM generated slop, not helpful information. Like 'agent-reported values; rates are measured over the Δ window in the footer, and each panel's charts cover only that host' or 'every number is scraped from the box it belongs to · null renders as n/a'"
**Story:** n/a
## Objective
The pages stop narrating themselves. Copy that explains the app's own design to the
operator ("agent-reported values; rates are measured over the Δ window…", "every number is
scraped from the box it belongs to · null renders as n/a") is deleted; copy that is
*functional* — a source tag, a `Δ10.0s` provenance, a "not configured" explanation, the
SIMULATED badge, an error that says why a panel is empty — stays. `AGENTS.md` records
the rule so it is a convention, not a mood.
## Work
1. Audit, page by page: `src/dashboards/web/pages/index.html`, `system.html`, `llm.html`,
`ups.html` and the `src/dashboards/web/static/page-*.js` / `app.js` strings. The two
lines the TODO quotes (the `system.html` L45 sub-line, the `index.html` L73 sub-line)
are confirmed deletions; every other candidate is judged on one test: *would the
operator act differently without it?* No → delete. Kept by default: the `Δwindow`
footnotes (measurements, rule: "a Δ label is a measurement, never a config value"),
the per-rate provenance tags ("scraped / measured Δcounter/Δt / estimated" — dropping a
tag is a regression, not a simplification), `n/a`-and-why states, the "queued (deferred)
requests are not in /slots" note, and any string a test pins as functional.
2. Delete the confirmed flavour from markup *and* from JS that injects the same copy;
a sub-line that becomes empty removes its container, not an empty `<span>`.
3. `tests/test_api.py` — where a deleted string was pinned, update the pin; add an
assertion that the two quoted strings (and the other confirmed deletions) are **absent**
from served bytes, so the slop cannot creep back in silently.
4. `AGENTS.md` → *UI conventions* — add the rule: **Flavour text is not helpful
information.** Pages do not narrate their own data pipeline or design; copy exists
only when the operator needs it to act (a why-this-panel-is-empty, a provenance tag,
a measurement label). The two deleted lines are the cited examples. (DoD: AGENTS.md
updates with rule changes — this is the task that does it, not the conversion.)
5. `node --check` on every touched script.
- ASSUMPTION: "most of the flavour text" = the audit rule above (act-differently test),
with the two quoted lines as the floor of deletions — the operator's examples, not the
ceiling. If a borderline string survives the audit, it survives because it is a
measurement or a why, and the audit notes say which.
## Testing & Quality
- Unit: `tests/test_api.py` (absence pins for the deleted strings; served-bytes shape
unchanged otherwise), `node --check`.
- Browser (DoD): screenshot in `data/logs/` — the four page heads without the removed
sub-lines; no empty containers.
- Coverage: **>90%** on modified code; overall gate (94%) maintained.
## Completion Criteria
- [ ] the two quoted lines (and the rest of the audit's deletions) are gone from served HTML and JS
- [ ] functional copy (provenance tags, Δ footnotes, empty-state explanations) intact
- [ ] `AGENTS.md` carries the flavour-text rule with the deleted lines as cited examples
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,52 @@
# Phase 09 — A /disks page for physical disk health (smartctl)
**Source:** `TODO.md` L8 — "a truly physical disk monitoring page": "Add a 'disks' tab dedicated to smartctl and per-disk metrics. Note: not per-partition, but truly a physical disk monitoring page that collects data about all my physical disks across my fleet (should only be deskwork and driveripper right now)."
**Story:** n/a
**Context:** `agent/host_agent.py` (physical whole disks via `/sys/block`, ~L400 — already partition-free; stdlib-only, `ProtectSystem=strict`), `src/dashboards/config.py` (env only, sources default to unset), `src/dashboards/poller.py` (one task per source, stale/last-good, events under the dashboard name), `src/dashboards/store.py` (WAL, rollups raw→1m→1h, retention — rule 9), `src/dashboards/api.py` (GETs, 404-when-unconfigured without dialling), the three existing dashboards + `/` overview (a new tab joins the nav and the overview's "which of the three" table)
## Objective
A fourth dashboard: `/disks` — one card per **physical** disk (the agent's whole-
`/sys/block` enumeration, never partitions), each carrying `smartctl` health: overall
health, temperature, power-on hours, and the sector/wear alarms. Its own `disks` source
and poller task at a slow 5-minute cadence (SMART is not a per-second signal), its own
API (`/api/disks/snapshot`, `/api/disks/history`), rollups and retention, and a page that
reads `n/a` for what `smartctl` cannot see — never red for a missing binary.
- ASSUMPTION (phase, confirmed): the agent reads SMART **directly** (`smartctl` as the
agent user, e.g. `disk` group) — no sudo — and `DISKS_INTERVAL_SECONDS` defaults to
**300** s. `smartctl` absent or a disk unreadable → that disk's `smart` is `null`
with an error entry; the page says "smartctl not available" dimmed (invariants 3/4:
unconfigured is not broken; a dead source must not blink another).
## Dependencies
- `08_events_paging_and_copy` (todo) — sequence only: it settles `app.js`/`dash.css`
(the pager + copy audit) that the new page builds on; no hard code dependency.
## Tasks
1. `01_agent_smartctl_collection.md` — `host_agent.py` gains a `smartctl` pass over its existing whole-disk list (per-device `smartctl -i -A -H`, stdlib `subprocess`, timeout-bounded); fixture from the real disks; deploy docs.
2. `02_disks_source_poller_store.md` — the `disks` source (payload + `disk.*@dev` samples), its own poller job at `DISKS_INTERVAL_SECONDS` (default 300), rollups + retention in `store.py`.
3. `03_disks_api_and_config.md` — `/api/disks/snapshot` + `/api/disks/history`, config wiring, `404 {"configured": false}` without dialling, severity rows for the health/temperature metrics.
4. `04_disks_page_and_overview.md` — the `/disks` page (per-disk cards, alarm chips, per-host history charts), nav tab + overview entry + header status dot.
## Testing & Quality
- Hermetic: fixture-driven agent tests (`tests/test_agent.py` + recorded `smartctl`
output), `tests/test_disks_source.py` (new), `tests/test_api.py` key-path pins (with
and without config), `node --check`.
- Live: `uv run pytest -m live --live` — the agent on deskwork/driveripper answers
`smart` fields for its real disks; **live tests must not write** (no `smartctl -t`, no
self-test on hardware something is plugged into).
- Browser (DoD): screenshots in `data/logs/` — per-disk cards on live data, the
"smartctl not available" state, the overview with the new tab.
- Coverage: **>90%** on new/modified code, ≥94% overall gate maintained.
## Completion Criteria
- [ ] `GET /api/disks/snapshot` answers per-disk `smart` data for every physical disk the agents report; `404 {"configured": false}` with no agents configured, without dialling
- [ ] `/api/disks/history` serves the `disk.*@dev` series through the existing rollups
- [ ] the `/disks` page renders one card per physical disk (never per-partition) with health, temp, power-on hours and non-zero alarm chips
- [ ] a host without `smartctl` (or a denied read) shows dimmed `n/a` cards, never a red page
- [ ] `bash .agents/validate.sh` green; screenshots in `data/logs/`
@@ -0,0 +1,71 @@
# Task 01 — The agent collects smartctl per physical disk
**Phase:** `09_disks_smartctl` · **Source:** `TODO.md:8` — "…collects data about all my physical disks across my fleet" (the collection half)
**Story:** n/a
## Objective
`agent/host_agent.py` answers SMART data for every physical disk it already enumerates
(whole `/sys/block` devices — the existing partition-free list), via a direct,
timeout-bounded `smartctl` call, without ever breaking the agent: no binary, a denied
read, or a wedgy device degrades that disk's `smart` to `null` with an `errors[]`
entry and leaves the rest of the snapshot intact.
## Work
1. `agent/host_agent.py` — a `smart()` pass inside the existing disk collection: for each
physical whole disk (the existing `/sys/block` enumeration, skipping the same
virtual/`dm-*` entries it already skips), run `smartctl -i -A -H <device>` via stdlib
`subprocess` with a per-call timeout (ASSUMPTION: **5 s per device**; the whole pass is
bounded so a wedgy disk cannot stall the snapshot). Parse, from the recorded output:
`model`/`serial` (from `-i`, falling back to the agent's existing disk identity when
smartctl's identity block is absent), `overall_health` (OK/FAILED), `temp_c`
(air-temperature line, absent on many SSDs → `None`), `power_on_hours`,
`reallocated_sector_ct`, `current_pending_sector_ct`, `offline_uncorrectable` (media
errors), `percentage_used` (SSD wear). Attach as `smart: {…}` per disk row in the
`GET /snapshot` docstring and payload.
2. Failure semantics: `smartctl` missing → **one** `errors[]` entry per snapshot (not one
per disk), every disk's `smart` is `null`; a single denied/wedgy device → only that
disk's `smart` is `null` with its own error entry. The agent's `/health` stays up:
SMART is a best-effort add-on, exactly like the existing per-section `_safe()`
handling.
3. Stdlib-only is kept (no new agent dependency; `subprocess` is stdlib, consistent with
the locked "our own stdlib host agent" decision). `GET /info` is unchanged (smartctl
is a per-snapshot capability, not identity).
4. Fixtures (rule 7 — never trust a memory of an upstream): record `smartctl -i -A -H`
output from the **real** disks on deskwork and driveripper (one representative SSD and
one HDD if the fleet has both) into `tests/fixtures/smartctl_<dev>.txt`, captured by
an extension of `scripts/capture-fixtures.sh` (same script, same PII/serial scrubbing
as the existing captures — a serial scrubbed in a fixture is not a serial in the
browser). The parse tests assert against these verbatim captures; a `smartctl` version
drift that breaks a field fails the fixture test before a dashboard does.
5. `tests/test_agent.py` — a fixture-rooted collector case: happy path (fields present),
binary-missing (one error entry, all `smart: null`), single-device failure (only that
disk null), non-physical device skipped.
6. Deployment docs: `deploy/install-agent.sh` (or its README section) and `README.md` note
the requirement — `smartmontools` installed and the agent user able to read SMART
(e.g. `disk` group); absence is a documented graceful degradation, not an install
failure (DoD: fleet facts travel with the change).
- ASSUMPTION: **direct read, no sudo** — `smartctl` runs as the agent user (confirmed
with the operator); if a host's permissions deny it, that host's disks degrade to the
"smartctl not available" state, and the fix is a group grant, not a sudoers entry
(command-scoped sudo exists for NUT, but SMART reads do not need root and the agent's
`ProtectSystem=strict` unit stays as-is).
## Testing & Quality
- Unit: `tests/test_agent.py` against the recorded fixtures (all four failure/happy modes
above); the real-`/proc` test keeps touching only the box running pytest (existing
policy — a box without `smartctl` exercises the binary-missing path, which is itself
a valid assertion).
- Live: `uv run pytest -m live --live` — the live agents' snapshots carry `smart` fields
for the real disks (read-only; no `smartctl -t` anywhere in the suite).
- Coverage: **>90%** on the agent's new code; overall gate (94%) maintained.
## Completion Criteria
- [ ] `GET /snapshot` disk rows carry `smart` (model/serial/health/temp/power-on/sectors/wear) on a host with a readable `smartctl`
- [ ] missing binary → one error entry + `smart: null` everywhere; one bad device → only that disk null; agent `/health` stays up
- [ ] `tests/fixtures/smartctl_*.txt` in the repo, reproducible via `./scripts/capture-fixtures.sh`, serials scrubbed
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,66 @@
# Task 02 — The `disks` source, its slow poller job, and the store's rollups
**Phase:** `09_disks_smartctl` · **Source:** `TODO.md:8` — the storage + polling half of the physical-disk dashboard
**Story:** n/a
## Objective
`disks` becomes a first-class source: one payload per host (the physical disks and their
`smart` fields), one `disk.*@dev` sample set per poll, its **own** asyncio poller task at
a slow 5-minute cadence — SMART is not a per-second signal, and a dead `disks` job must
age its numbers in the UI exactly like any other source (invariant 4), with the store's
existing rollups and retention doing the history work (rule 9).
## Work
1. `src/dashboards/config.py` — `DISKS_INTERVAL_SECONDS` (default **300**; env-only, the
existing pattern). The `disks` source is *configured* when at least one host agent
target is configured — it reads the same `/snapshot` the `system` source does, so it
has no targets of its own; with no host targets it is unconfigured and 404s without
dialling (invariant 3).
2. `src/dashboards/sources/disks.py` (new) — snapshot → payload per host: the physical
disk rows (device, model, size, rotation from the agent's existing `disks` fields)
plus `smart` (task 01's fields, `None` where the agent had nothing — the page prints
`n/a`, never `0`), plus a `smartctl_available` flag per host so the page can say
*why* a card is `n/a` instead of leaving it a mystery. Samples (dotted, `@dev` stream
suffix, the existing naming scheme — the history API's key):
`disk.health_ok@<dev>` is **not** a series (it is a verdict, not a number); the
series are `disk.temp_c@<dev>`, `disk.power_on_hours@<dev>`,
`disk.reallocated@<dev>`, `disk.pending@<dev>`, `disk.uncorrectable@<dev>`,
`disk.percentage_used@<dev>` (a count/percent metric, not a rate — no `Δ` window
applies). Events under the **dashboard** name `disks` (the phase-03 lesson: a page
filtering `/api/events` by source gets an empty feed otherwise).
3. `src/dashboards/poller.py` — register the job (one task per source, its own timeout
and health record; the 300 s cadence means the stale threshold — 3× its own interval —
is 15 min, and `alerts.py`'s existing ok-but-stale `warn` rule covers it with no
change: the alert reads the job's own interval).
4. `src/dashboards/store.py` — the new series follow the existing rollup path
(raw → 1m → 1h) and retention; at a 300 s cadence the raw rows are 288/day/host/disk —
the retention story is the existing one, but the task must verify the rollup window
arithmetic handles a 300 s raw resolution (a 1-minute rollup bucket straddles two
polls; the existing "latest wins" semantics must keep holding, asserted by a test).
5. `tests/test_disks_source.py` (new) — fixture-snapshot → payload/samples (fields present,
`null` where the agent had none, `smartctl_available` false → all `n/a`), the 300 s
cadence in the job registration, the unconfigured-without-dialling 404 path, the
rollup-bucket test.
- ASSUMPTION: `power_on_hours` and the sector counters are stored as **counters-as
reported** (monotonic by nature) — no `Δ` rate is derived; a rate the upstream does not
publish is `n/a` (rule 2). `disk.health_ok` (OK/FAILED) lives in the payload only;
colour comes from `severity.classify` on the numeric rows (task 03).
## Testing & Quality
- Unit: `tests/test_disks_source.py` (the full matrix above), `tests/test_poller.py`
additions (the job's own timeout/health, a failed poll keeps the last good payload and
marks stale — the invariant-4 behaviour on the new job), `tests/test_store.py`
(rollup/retention on the new series at 300 s resolution).
- Coverage: **>90%** on new/modified code; overall gate (94%) maintained.
## Completion Criteria
- [ ] `uv run pytest` green with the new source/job/store tests
- [ ] with no host targets, the `disks` job is unconfigured and 404s without dialling
- [ ] a failed `disks` poll ages the payload (stale in the UI) instead of zeroing it
- [ ] 1-minute rollup buckets are correct at 300 s raw resolution (asserted, not assumed)
- [ ] `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,62 @@
# Task 03 — The disks API, config wiring, and the severity rows
**Phase:** `09_disks_smartctl` · **Source:** `TODO.md:8` — the API half of the physical-disk dashboard
**Story:** n/a
## Objective
`/api/disks/snapshot` and `/api/disks/history` answer like every other dashboard's
endpoints (GETs only, 404-when-unconfigured, redacted error strings, the health record in
`/api/health`), and the numbers that *are* worth a verdict get one row in
`severity.py` — the only place thresholds live — while the alarm counters get none
(a non-zero sector count is an incident, not a graded number: presence, like the
`/llm` preemption chips, never a threshold).
## Work
1. `src/dashboards/api.py` — `GET /api/disks/snapshot` (per-host: disk rows + `smart` +
`smartctl_available`) and `GET /api/disks/history` (the `disk.*@dev` series through
the existing history mechanism; unknown names answer as empty series on purpose, the
same contract `/api/system/history` and `/api/llm/history` have). No POST anywhere on
this surface (invariant 1 — the one write in the app stays `POST /api/ups/command`).
2. `404 {"configured": false}` with **no dialling** when no host agent is configured
(invariant 3), asserted with a test that would catch a client being constructed before
the gate. `UpstreamError` messages carry redacted URLs only (rule 6 — the existing
`Target.display_url` path; the disks source reuses the host targets' redaction, so
nothing new leaves the process with a token).
3. `src/dashboards/severity.py` — the new rows (ASSUMPTION — proposed bounds, pinned by
test, adjustable in one place without touching the page):
- `disk.temp_c`: warn **50**, fail **70** (a HDD spinning at 70 °C is a fire; an SSD
without a sensor simply has no value and gets no verdict — no measurement, no
verdict);
- `disk.percentage_used`: warn **80**, fail **100** (a 100 %-used SSD is a
replacement, not a warning).
The sector counters (`disk.reallocated`, `disk.pending`,
`disk.uncorrectable`) get **no row**: non-zero is an alarm chip, zero is the
absence of one (never green).
4. `src/dashboards/alerts.py` — no change expected: the ok-but-stale `warn` and the
dead-source `fail` rules read the job's own interval and reachability, and the
unconfigured-never-alerts rule covers the unconfigured case. **If** a test shows the
new job needs nothing, say so in the report; an alert with its own threshold is a
bug (the module is a pure projection).
5. `src/dashboards/app.py` — assembly wiring (settings/store injection, so tests build an
app with an in-memory DB and no targets, the existing pattern).
6. `tests/test_api.py` — key-path pins for `/api/disks/snapshot` (with and without
targets; a configured job with no payload yet lists the hosts as stubs, `n/a`
everywhere — the same stub shape `/api/system/snapshot` has), `/api/disks/history`
(known + unknown series), the 404-without-dialling case, and `/api/health` carrying
the new job's record.
## Testing & Quality
- Unit: `tests/test_api.py` (the pins above), `tests/test_severity.py` (the two new rows,
boundary ordering, the counter metrics deliberately absent from `THRESHOLDS`),
`tests/test_alerts.py` (the new job's stale/dead/unconfigured states).
- Coverage: **>90%** on modified code; overall gate (94%) maintained.
## Completion Criteria
- [ ] `curl /api/disks/snapshot` answers per-host disk + smart data (or `404 {"configured": false}` with no dial)
- [ ] `/api/disks/history` serves the `disk.*@dev` series; unknown names → empty series
- [ ] `classify("disk.temp_c", 75)` is `fail`, `classify("disk.reallocated", 1)` is `None` (presence, not a verdict)
- [ ] `bash .agents/validate.sh` green
@@ -0,0 +1,74 @@
# Task 04 — The /disks page, the nav tab, and the overview entry
**Phase:** `09_disks_smartctl` · **Source:** `TODO.md:8` — "Add a 'disks' tab dedicated to smartctl and per-disk metrics" (the page half)
**Story:** n/a
## Objective
The fourth dashboard: `/disks` — one card per **physical** disk (the agent's whole-
`/sys/block` enumeration; a partition is never a card), led by what the operator needs
while a disk is acting up: health, temperature, power-on hours, and the sector alarms —
plus per-host history charts under the same discipline as `/system` (collapsed by
default, phase 06; one history request per host per refresh; charts belong to the host
they chart).
## Work
1. `src/dashboards/web/pages/disks.html` + `src/dashboards/web/static/page-disks.js`
(new) — the page, in the existing vocabulary (masthead nav + a `Disks` tab with
`aria-current` on itself, `.card`, `.heroes`-free layout — the disk card leads with
the disk identity, not a big number):
- **per-disk card**: host + device + model (+ size / rotation from the agent's
existing fields), a health line (OK/FAILED from `smart.overall_health`, coloured
from the payload's severity verdict — a FAILED health is `fail` by the existing
`disk.percentage_used`/`temp_c` rows or the explicit health field; **never** a
verdict the API did not send), temperature, power-on hours, percentage-used where
the device has it (HDDs may not — `n/a`, uncoloured);
- **alarm chips that exist only while non-zero** (the `/llm` `.chip.fail` idiom):
reallocated, pending, uncorrectable — a disk at zero shows no chip row at all,
because a permanent "0 pending sectors" row trains the eye to ignore it;
- **`smartctl` unavailable** (per host, `smartctl_available: false`): the host's
cards render dimmed (`.stale`) with `n/a` and a one-line why — "smartctl not
available on <host>" — never a red panel (invariant 3);
- **per-host chart section** (phase-06 discipline): collapsed `<details>` "Charts" by
default, lazy-drawn `LineChart`s for `disk.temp_c@<dev>` (and
`disk.percentage_used@<dev>` where present) through **one**
`/api/disks/history` request per host per refresh naming every series it could draw;
a host that stops reporting loses its cards *and* charts (`pruneHosts` pattern).
2. `src/dashboards/web/pages/*.html` — the `Disks` tab joins the masthead nav on all
four pages (the orphan-`id` rule: every new `id` is referenced); the overview's
"which dashboard do you need" table gains the `/disks` row
("are any of my physical disks failing, and which one") and the header's
per-source status dot picks up the `disks` job.
3. `src/dashboards/web/pages/index.html` + `page-index.js` — `/api/summary` is the
overview's single call; add the `disks` payload to its contract **additively**
(the existing key paths stay pinned — `tests/test_api.py` fails if a summary key
retires), and the Fleet/Warnings panels treat the `disks` source like the others
(an alert from `alerts.py` needs nothing new; a dead `disks` job is one line).
4. `src/dashboards/web/static/dash.css` — the disk-card + chip styling from existing
tokens (no new palette, no `.sev-*` family); `node --check` on every touched script.
5. `tests/test_api.py` — pin the served markup (nav tab on all four pages, overview row,
the page's card/empty/stale states) and the `/api/summary` additive key path.
- ASSUMPTION: the page's hero per card is **identity + health**, not a single big number
(a disk has no one-number story the way a UPS has charge %); the numbers that matter
(temp, hours, wear) are equal-size `.metric` cells, and the verdicts come only from
task 03's `severity` rows — `n/a` where the device does not publish the field.
## Testing & Quality
- Hermetic: `tests/test_api.py` (markup + summary contract), `node --check`, the
orphan-`id`/orphan-selector rules.
- Live: `uv run pytest -m live --live` (read-only); the real fleet today has physical
disks on deskwork and driveripper — the page must show both hosts' cards.
- Browser (DoD): screenshots in `data/logs/` — live cards on both hosts, the
"smartctl not available" state (a host with the binary removed, or a `smartctl_available:
false` fixture), collapsed charts expanded, the overview with the new tab + row.
- Coverage: **>90%** on new/modified code; overall gate (94%) maintained.
## Completion Criteria
- [ ] `/disks` renders one card per physical disk on live data (never per-partition), with health/ temp/hours/wear and non-zero-only alarm chips
- [ ] a `smartctl`-less host is dimmed with a one-line why — never red
- [ ] nav tab on all four pages; the overview's table lists `/disks`; the header dot tracks the job
- [ ] `bash .agents/validate.sh` green; screenshots in `data/logs/`
@@ -0,0 +1,104 @@
# Phase 10 — Dev dials the fleet directly (no more SSH tunnels)
**Source:** user request (2026-10) — "I don't want this separation of architectures. My laptop can
talk to these hosts directly via local network or vpn already. Just have dev work like prod."
**Story:** n/a
**Context:** `AGENTS.md` (dev workflow: `scripts/dev-tunnels.sh`, `DEV_TUNNELS`/`DEV_FORWARDS`),
`deploy/install-agent.sh`, `deploy/nut-sudoers`, `scripts/dev-server.sh`,
`scripts/capture-fixtures.sh`, `tests/test_live_fleet.py`, `.env.dev.example`, `.env.example`,
`README.md`
## Objective
Dev becomes the prod topology: each host's agent runs as a **systemd service** (installed by
`ops` through a documented minimal sudoers grant), bound to the address the dashboard dials,
token in the URL userinfo; driveripper's upsd ACLs the host and laptop IP ranges on port 3051;
`.env.dev` points at the real hosts with no `DEV_TUNNELS`/`DEV_FORWARDS`; `scripts/dev-tunnels.sh`
is retired. The dashboard's Python is untouched — this phase changes deployment, config, scripts
and docs, and proves parity with the live suite plus browser screenshots.
## Decisions (user, 2026-10)
- The agent is a **systemd service, installed by the `ops` user** (not root). The repo documents
the **simplest possible working sudoers addition** in `deploy/agent-sudoers`, and the user adds
it to each host.
- The upsd ACL allows the **host's IP range and the laptop's IP range** — both real ranges,
derived at install time and commented in `.env.dev` so they can be re-derived when the laptop's
IP changes.
## Precondition (user action, before task 02)
The sudoers line needs root, and the command-scoped sudo on driveripper does not include
`visudo`. The user adds the one line from `deploy/agent-sudoers` on each host. If it is missing
when task 02 runs, that task must **stop cleanly and print the exact instructions** — never loop,
never wait, never attempt to edit sudoers itself.
## Dependencies
- `05_ups_hero_battery_glyph` (complete) — the last UI phase; its pages are what this phase
re-verifies against live data after the transport change.
- `06_system_page_gpu_verdicts`, `07_llm_monitor_style_pages`, `08_events_paging_and_copy`,
`09_disks_smartctl` (todo) — independent: this phase changes transport and ops, not payloads or
page behaviour, and they do not depend on the transport.
## Tasks
1. `01_sudoers_grant_and_installer_hint.md` — `deploy/agent-sudoers` (the minimal working grant
+ install instructions, in the `nut-sudoers` house style); `install-agent.sh` run without
root prints the exact line to add.
2. `02_install_agents_on_fleet.md` — on all three hosts: kill the ad-hoc agent, stage the
installer, install the systemd service bound to the dialed address, capture the printed
tokens, verify from the laptop.
3. `03_upsd_acl_for_laptop.md` — driveripper: append ACL lines for the host range and the laptop
range to `upsd.conf` (read-first, `cmp`-after, never blind-write), restart `nut-server`, verify
the NUT wire from the laptop.
4. `04_env_dev_direct_dial.md` — stop the tunnels; rewrite the local `.env.dev` to direct targets
(userinfo tokens, `NUT_HOST`/`NUT_PORT=3051`); restart the dev server; sources ok.
5. `05_retire_tunnels_update_scripts.md` — delete `scripts/dev-tunnels.sh`; rewrite
`.env.dev.example` direct-shaped; fix `capture-fixtures.sh`, the live suite's docstring and
skip messages, `ups_contract.py`, the `alerts.py` docstring, `test_config.py`, `.env.example`'s
NUT note, and the dev-workflow sections of `README.md`/`AGENTS.md`.
6. `06_verify_direct_dial_live.md` — full live verification: `/api/health` ok over direct dial,
`uv run pytest -m live --live` green, browser screenshots of `/` + `/system` + `/ups` + `/llm`
on live data, no tunnel port needed on the laptop.
## Testing & Quality
- The Python changes in this phase are test-file strings and one docstring — no new logic. The
full suite must stay 100% green and overall coverage must not drop (gate: `--cov-fail-under=94`).
- `bash -n` on `deploy/install-agent.sh` and `scripts/capture-fixtures.sh` after their edits.
- The real proof of this phase is the live path: `uv run pytest -m live --live` against the
directly-dialed fleet (the same production code paths over the prod-shaped transport) plus a
browser screenshot per `AGENTS.md` rule 10.
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in
the project, not just the phase's — pre-existing failures included) and the coverage gate in
`.agents/validate.sh` holds.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands
after this phase — fixing every inconsistency found, **including pre-existing ones not caused
by the phase** (including any remaining mention of `dev-tunnels.sh`, `DEV_TUNNELS`,
`DEV_FORWARDS`, `9121`/`9122`/`9123` or `3494`).
- Task 05 updates `README.md`/`AGENTS.md` immediately for the user-visible dev-workflow change.
- `PLAN.md` is locked and must not be edited: its Data-Model note ("forwarded to
`127.0.0.1:3494` in dev") goes stale after this phase — flag it in the final pass's report to
the user, and leave the file untouched.
## Completion Criteria
- [ ] `systemctl status dashboards-agent` is active on all three hosts, each bound to the address
the dashboard dials, answering `/health` (unauthenticated) and `/snapshot` (bearer) from the
laptop
- [ ] driveripper's upsd answers `LIST UPS` from the laptop on 3051 — not via the host's loopback
- [ ] `git grep -n "dev-tunnels\|DEV_TUNNELS\|DEV_FORWARDS\|9121\|9122\|9123\|3494"` matches
nothing outside `.agents/phases/complete/`
- [ ] `./scripts/dev-server.sh status` shows every source `ok` over direct dial
- [ ] `uv run pytest -m live --live` (with `.env.dev` sourced) is green
- [ ] `data/logs/` holds `phase10-*.png` screenshots of `/`, `/system`, `/ups`, `/llm` on live
data over direct dial
- [ ] full test suite **100% passing**, coverage gate (≥94%) holds, `ruff` + `pyright` clean
- [ ] `README.md` and `AGENTS.md` describe the direct-dial dev workflow and mention no tunnels
(verified in the final pass)
- [ ] no behavior change in completed phases: the four pages render the same payloads as before
the transport change
@@ -0,0 +1,59 @@
# Task 01 — The agent sudoers grant and the installer's hint
**Phase:** `10_dev_direct_dial` · **Story:** n/a
## Objective
A user with the command-scoped `ops` sudo (no root) can install `deploy/install-agent.sh`
because the repo documents the single sudoers line that makes it work, and running the
installer without root tells you that exact line instead of the generic "run as root".
## Work
1. `deploy/agent-sudoers` (new) — house style: follow `deploy/nut-sudoers` (header with the
`visudo -cf` check-first install steps, a "WHY THESE ARE NEEDED" block, scope notes, the
grant, a "deliberately NOT granted" block). Content:
- **Why:** the agent is read-only by construction and every non-`/health` request needs the
bearer token, so the only privileged operation is installing its files and unit. The grant
is the whole script path, run as root, with no argument restriction — the flags
(`--bind`, `--port`, `--status`, `--uninstall`, `--rotate-token`) are non-sensitive, and
the documented flow never puts a token on argv (the installer generates one and prints it
once).
- **Canonical staging path:** the host has no repo checkout, so the host gets
`deploy/` + `agent/` staged to `~/agent-install/` (task 02 does the staging). The grant
line is therefore stable:
```
ops ALL=(root) NOPASSWD: /home/ops/agent-install/deploy/install-agent.sh
```
- **Install steps** (per host, the same two-stage check as `nut-sudoers`):
`sudo visudo -cf deploy/agent-sudoers` (adapt the path first), then install to
`/etc/sudoers.d/dashboards-agent` with `install -m 0440 -o root -g root`, and
`sudo visudo -cf` on the installed copy.
- **Deliberately NOT granted:** `visudo`/`vim`/`sh` (no self-amendment of the grant),
`systemctl *` other than what the script itself already needs as root, `cat` of
`/etc/dashboards-agent.token` (the token stays root-readable; the installer prints it
once), and the NUT instant-command clients.
2. `deploy/install-agent.sh` — replace the `need_root` failure message for the non-root case:
when `id -u != 0`, print (a) the exact NOPASSWD line for the script's **resolved absolute
path** (so a checkout at a different path still works), (b) the two install steps from
`deploy/agent-sudoers`, and (c) a pointer to `deploy/agent-sudoers`. Keep the exit code
(1) and the behaviour when already root unchanged. `bash -n` must stay clean.
## Testing & Quality
- `bash -n deploy/install-agent.sh` clean.
- Run the script **as the current (non-root) user** locally: `bash deploy/install-agent.sh
--bind 127.0.0.1` must exit 1 and print the hint with this checkout's absolute path; the
printed line must match the file's canonical line apart from the path (diff them in
`data/logs/NOTES-sudoers.md`).
- No Python changes this task; the full suite must stay green.
## Completion Criteria
- [ ] `deploy/agent-sudoers` exists, passes `visudo -cf` syntax locally, and names the canonical
staging path and the exact grant line
- [ ] non-root `bash deploy/install-agent.sh --bind 127.0.0.1` prints the exact line to add and
exits 1
- [ ] root behaviour unchanged (the script still refuses nothing new; `need_root` logic intact)
- [ ] `bash -n` clean on the script; `uv run pytest` 100% green; `ruff` + `pyright` clean
- [ ] README.md/AGENTS.md unchanged yet (task 05 is where the docs change)
@@ -0,0 +1,66 @@
# Task 02 — Install the agents as systemd services on the fleet
**Phase:** `10_dev_direct_dial` · **Story:** n/a
**Precondition (user action):** the one line from `deploy/agent-sudoers` is installed on each
host (root-only; the scoped sudo does not include `visudo`). If it is missing on a host, this
task must stop cleanly for that host and leave the exact instructions in
`data/logs/NOTES-agent-install.md` — never loop, never wait, never edit sudoers.
## Objective
Each of the three hosts (`driveripper`, `deskwork`, `gateway`) runs `dashboards-agent` as an
active systemd service — the prod shape — bound to the address the dashboard will dial, with
its bearer token captured for `.env.dev`. The old ad-hoc tunnel agent is gone.
## Work
1. Once, on the laptop: `./scripts/dev-tunnels.sh stop` (kills the local ssh legs; the remote
ad-hoc agents are orphans and are killed in step 2).
2. Per host (`ssh driveripper-ops | deskwork-ops | gateway-ops`):
- `ssh <host> "pkill -f 'host_agent.py --port 9120' || true"` — the orphaned ad-hoc agent
from the old dev flow (it runs as `ops`, so `ops` can kill it). Leave the
`~/lab-agent/` files on disk; they are simply no longer used.
- Stage the installer: `ssh <host> "mkdir -p ~/agent-install"` then
`rsync -a --delete deploy/ agent/ <host>:~/agent-install/`.
- Check the grant: `ssh <host> "sudo -n ~/agent-install/deploy/install-agent.sh --status"`.
On a permission failure: append to `data/logs/NOTES-agent-install.md` the host, the exact
line from `deploy/agent-sudoers` (with this host's staging path), and the two `visudo`
install steps; mark the host **BLOCKED**, continue with the next host, and end the task
with the blocker visible in the summary. Do not retry or wait.
- Install: `ssh <host> "sudo ~/agent-install/deploy/install-agent.sh --bind <addr> --port 9120"`.
- `<addr>` is the address the dashboard will dial. Derive it: the host's name from
`.env.dev` (the `*.reeselink.com` names used by the engines, or the LAN name the user
uses), resolved on the laptop (`getent hosts <name>`), then confirmed present on the
host (`ssh <host> "ip -o addr"`). If resolution is ambiguous, use `0.0.0.0` — the
installer requires a token off-loopback, which the flow always provides.
- The installer prints the new token exactly once on first install. Record it for task 04
(`data/logs/NOTES-agent-install.md` may hold `host:port + unit state` only — **no
tokens** in NOTES; the tokens go into `.env.dev` in task 04, which is `0600` and
gitignored, the same place `NUT_PASSWORD` already lives).
- Verify **from the laptop** (a host-side check could pass via loopback):
`curl -sf http://<host>:9120/health` → `{"ok": true}`, and
`curl -sf -H "Authorization: Bearer <token>" http://<host>:9120/snapshot | head -c 200`
shows the `cpu` object.
- `ssh <host> "systemctl status dashboards-agent --no-pager" | head -5` → `active (running)`.
3. If a firewall on any host blocks 9120 from the laptop (the `curl` in step 2 fails while
`systemctl status` is active), record it in `data/logs/NOTES-agent-install.md` with the host
and the port, and mark the host BLOCKED-with-details — the user's scoped sudo set may not
cover firewall edits on every host, and this task does not guess.
## Testing & Quality
- No Python changes; `uv run pytest` must stay 100% green (this is an ops task — its "tests"
are the verifications above).
- `data/logs/NOTES-agent-install.md` records per host: bind address, unit state, verification
outcome, and — for any BLOCKED host — the exact user instructions. No secrets in the file.
## Completion Criteria
- [ ] all three hosts: `dashboards-agent` active, verified from the laptop (or a host is
BLOCKED with the exact user instructions in `data/logs/NOTES-agent-install.md`)
- [ ] the old ad-hoc agent is dead on each host (the pkill in step 2 ran; no `9120` loopback
listener remains)
- [ ] tokens captured for task 04 (in the NOTES file only as host:port — the values go straight
into `.env.dev` there)
- [ ] `uv run pytest` 100% green; `ruff` + `pyright` clean
@@ -0,0 +1,54 @@
# Task 03 — upsd ACL for the host and laptop IP ranges
**Phase:** `10_dev_direct_dial` · **Story:** n/a
## Objective
driveripper's upsd (port 3051) accepts the NUT wire from the laptop over the network, using
the user-decided ranges — the host's IP range and the laptop's IP range — so the UPS source
can dial `NUT_HOST=<driveripper-addr> NUT_PORT=3051` directly. No `upsd.users` account is
touched, so `upsmon`'s orderly power-down is preserved.
## Work
1. Read first (the `NUT_READ` sudo grant on driveripper covers `cat /etc/ups/*`):
`ssh driveripper-ops "sudo cat /etc/ups/upsd.conf" > data/logs/upsd.conf.before`.
Confirm the file's current ACL lines (NUT 2.8 refuses non-local connections by default, so
there are likely none).
2. Derive the two ranges:
- **Laptop address as seen from driveripper:** `ssh driveripper-ops "ss -tn state
established '( sport = :22 )'"` — the source IP of this ssh session.
- **Host range:** driveripper's LAN subnet (`ssh driveripper-ops "ip -o addr"` — the
interface prefix, e.g. a `/24`).
3. Build the new file as `upsd.conf.before` **plus** exactly two appended NUT 2.8 ACL lines
(`ACL <ip-or-net> <netmask>`; a single host is mask `255.255.255.255`), write it back with
the granted `tee`, then prove the diff is exactly those two lines — the same read-first /
`cmp`-after care `AGENTS.md` mandates for `upsd.users` (never a blind write):
```
cat data/logs/upsd.conf.new | ssh driveripper-ops "sudo tee /etc/ups/upsd.conf"
ssh driveripper-ops "sudo cat /etc/ups/upsd.conf" | diff - data/logs/upsd.conf.before
```
4. `ssh driveripper-ops "sudo systemctl restart nut-server"` (the `NUT_WRITE` grant). `upsmon`
re-authenticates from `upsd.users`, which was not modified.
5. Firewall: `ssh driveripper-ops "sudo firewall-cmd --list-ports"` — if a firewall is active
and 3051 is not reachable from the laptop, add a rule limited to the laptop address (the
scoped sudo includes `firewall-cmd *` "only if upsd must serve the LAN"); record what was
added in `data/logs/NOTES-upsd-acl.md`. If no firewall is active, record that.
6. Verify **from the laptop** (a host-side check passes via loopback and proves nothing):
`printf 'LIST UPS\n' | nc -w 5 <driveripper-addr> 3051` → `BEGIN LIST UPS`. Task 04's
`/api/health` is the app-level proof.
## Testing & Quality
- No Python changes; `uv run pytest` must stay 100% green.
- `data/logs/NOTES-upsd-acl.md` records the two ACL lines, the diff output (must show exactly
the two added lines), the firewall state, and the laptop-side verification result.
## Completion Criteria
- [ ] `diff` between `upsd.conf.before` and the live file shows exactly the two ACL lines
- [ ] `nut-server` restarted cleanly (`systemctl status nut-server` active)
- [ ] from the laptop, `printf 'LIST UPS\n' | nc -w 5 <driveripper-addr> 3051` answers
`BEGIN LIST UPS` (with both UPS names following, or at least the BEGIN marker)
- [ ] no `upsd.users` modification (the diff is limited to `upsd.conf`)
- [ ] `uv run pytest` 100% green; `ruff` + `pyright` clean
@@ -0,0 +1,44 @@
# Task 04 — `.env.dev` goes direct
**Phase:** `10_dev_direct_dial` · **Story:** n/a
## Objective
The dev configuration dials the fleet the prod way: real host addresses with bearer tokens in
URL userinfo, upsd directly on 3051, and no `DEV_TUNNELS`/`DEV_FORWARDS` — with the dev server
showing every source healthy over the new transport.
## Work
1. `./scripts/dev-tunnels.sh stop` (idempotent; task 02 may have done it already).
2. Edit the local `.env.dev` (keep its `0600` mode; it is gitignored):
- delete the `DEV_TUNNELS` and `DEV_FORWARDS` lines
- `HOST_TARGETS="driveripper=http://<token>@<addr>:9120 deskwork=http://<token>@<addr>:9120
gateway=http://<token>@<addr>:9120"` — the addresses exactly as task 02 verified them,
the tokens task 02 captured.
- `NUT_HOST=<driveripper-addr>`, `NUT_PORT=3051`; keep `NUT_USER` / `NUT_PASSWORD` /
`NUT_ALLOW_ACTIONS` unchanged.
- `GUFO_BASE_URL` / `VLLM_BASE_URL` stay (already direct); `DASH_PORT` stays.
- add a short comment: where the upsd ACL lines live (`/etc/ups/upsd.conf` on
driveripper) and that a changed laptop IP means re-adding one ACL line (task 03's
method).
3. `./scripts/dev-server.sh restart`, then `./scripts/dev-server.sh status` — every source
`ok`. A host that was BLOCKED in task 02 may still be down; that is expected and must be
recorded, not "fixed" with a tunnel.
4. Watch the transition: a host that was tunnel-served should log an unreachable/ok (or
stale/ok) transition in `/api/events` — the poller's normal bookkeeping. Confirm the
transitions are the only new events and no source is stuck `DOWN`.
## Testing & Quality
- `uv run pytest` 100% green (no Python changes this task).
- `.env.dev` keeps mode `0600` and contains no token in any file git tracks.
## Completion Criteria
- [ ] `.env.dev` has no `DEV_TUNNELS`/`DEV_FORWARDS` and every target is a real address with
userinfo token (hosts) or direct `host:3051` (NUT)
- [ ] `./scripts/dev-server.sh status` shows every reachable source `ok` over direct dial
- [ ] `curl -s localhost:8090/api/system/snapshot` shows live data for every installed host
(a BLOCKED host is absent or stale — recorded, not hidden)
- [ ] the dev server shows no `DOWN` source that was healthy before the switchover
@@ -0,0 +1,69 @@
# Task 05 — Retire the tunnel machinery and update scripts and docs
**Phase:** `10_dev_direct_dial` · **Story:** n/a
## Objective
Everything that exists to serve the old architecture goes away or gets rewritten: the tunnel
script is deleted, the example env file is direct-shaped, and no tracked file mentions the
tunnel ports or variables any more.
## Work
1. `git rm scripts/dev-tunnels.sh` — the user decided against the separation; git history
keeps the script if it is ever needed.
2. `scripts/capture-fixtures.sh` — drop the "needs the tunnels up" line; the hard-coded
`HOSTS=(driveripper:9121 deskwork:9122 gateway:9123)` becomes a parse of `HOST_TARGETS`
from `.env.dev` (source it the way `dev-server.sh` does: `set -a; . .env.dev; set +a`,
then split `name=url` pairs and use the URL as-is, token userinfo and all — the app's own
`config.parse_targets` shows the shape). The NUT line already uses `$NUT_HOST`.
`bash -n` must stay clean.
3. `tests/test_live_fleet.py` — module docstring: replace the `./scripts/dev-tunnels.sh
start` line of the dev loop with `set -a; . .env.dev; set +a` (direct dial); both skip
messages become `pytest.skip("HOST_TARGETS is unset")`.
4. `tests/ups_contract.py` — the message that mentions `DEV_FORWARDS` (around L89): the UPS
hero row is contracted against the real drivers over direct dial; reword, drop the
forward reference.
5. `src/dashboards/alerts.py` — the docstring example `http://h:9121/snapshot` (around L138):
update to the direct-dial shape (`http://<host>:9120/snapshot`). Docstring only.
6. `tests/test_config.py` — the `HOST_TARGETS` monkeypatch `deskwork=http://127.0.0.1:9122`
(around L100): cosmetic, update to the direct shape (e.g.
`deskwork=http://tok@deskwork.example.net:9120`); the test stays hermetic.
7. `.env.example` — the NUT section's "reach it with an ssh forward or a tailnet address, or
bind a LAN address only if a firewall rule limits it": add the now-default third option —
ACL the dashboard's source range in `upsd.conf` (NUT refuses non-local connections by
default).
8. `.env.dev.example` — full rewrite, direct-shaped: the agent installed per host via
`deploy/agent-sudoers` + `deploy/install-agent.sh` (bound to the dialed address, token in
the URL userinfo), `NUT_HOST`/`NUT_PORT` pointing at the real upsd (keep the `ss -ltn` /
`nc 'LIST UPS'` port-discovery note — still the right first move), the upsd-ACL note, no
`DEV_TUNNELS`/`DEV_FORWARDS`, RFC 2606 placeholder hostnames (the file's existing
convention), and the "never commit this file" warning.
9. `README.md` — the dev section (around L40–49 and L275): replace the tunnel lines with the
direct-dial flow — the agent install (the `deploy/agent-sudoers` grant, the staged
installer, the token into `HOST_TARGETS` userinfo), the upsd ACL, `.env.dev`,
`./scripts/dev-server.sh restart`. State plainly that dev is the prod topology.
10. `AGENTS.md` — the fleet + Commands sections: the agents are systemd services installed by
`ops` via the `deploy/agent-sudoers` grant (bound to the dialed address, token in
userinfo); upsd serves the LAN for the ACLed ranges; the `dev-tunnels.sh` lines out of the
Commands block; "Nothing listens on the hosts in dev" is the opposite of the truth now and
must go. Keep the port-3051 discovery note (it is still why `3493` looks dead to a
default client).
## Testing & Quality
- `uv run pytest` 100% green (the edits are strings/docstrings; the suite must catch any
regression), `ruff` + `pyright` clean, `bash -n scripts/capture-fixtures.sh` clean.
- `git grep -n "dev-tunnels\|DEV_TUNNELS\|DEV_FORWARDS\|9121\|9122\|9123\|3494" --` must
match nothing outside `.agents/phases/complete/` (and this phase's own todo files, which
describe the change).
## Completion Criteria
- [ ] `scripts/dev-tunnels.sh` is deleted from the working tree
- [ ] `git grep` for the tunnel names/ports is clean (per Testing & Quality)
- [ ] `.env.dev.example` is direct-shaped and passes the same "copy and edit" workflow as
before
- [ ] `README.md` and `AGENTS.md` describe the direct-dial dev workflow with no tunnel
references (the phase's final pass re-checks both project-wide)
- [ ] full test suite 100% passing; `ruff` + `pyright` clean
@@ -0,0 +1,52 @@
# Task 06 — Verify the direct-dial fleet end to end
**Phase:** `10_dev_direct_dial` · **Story:** n/a
## Objective
Prove the claim this phase exists for — dev works like prod: every source healthy over the
direct transport, the live suite green against the real fleet, and the four pages rendering
live data in a browser, with no tunnel port existing on the laptop.
## Work
1. `./scripts/dev-server.sh restart`, then `curl -s localhost:8090/api/health` — every source
`ok` (a host BLOCKED in task 02 is the only acceptable `DOWN`, and it must be the one
recorded in `data/logs/NOTES-agent-install.md`).
2. Prove the laptop needs no tunnel port:
`ss -ltn | grep -E ':(9121|9122|9123|3494)\s'` → empty (the old tunnel ports are gone),
while the host sources in `/api/health` are still `ok`.
3. `set -a; . .env.dev; set +a; uv run pytest -m live --live` — green (or the normal
per-source skips; a source that is configured and reachable must not skip).
4. Browser verification (`AGENTS.md` rule 10) — screenshots to `data/logs/` as
`phase10-*.png`, all against live data over direct dial:
- `/` — the fleet table shows all three hosts with live numbers (any BLOCKED host dimmed
`n/a`, as designed), alerts consistent with the source states
- `/system` — three live host cards (CPU/MEM/TEMP/DISK/GPU/NET heroes populated where the
hardware reports it; gateway's absent GPU stays an uncoloured `n/a`)
- `/ups` — both real UPS cards, live charge/load/runtime, **no `SIMULATED` badge**
- `/llm` — the live engine(s) with throughput and latency cells
5. `data/logs/NOTES-verify.md` — what was verified, which hosts are BLOCKED (if any) with a
pointer to the task-02 notes, and the `PLAN.md` staleness flag: its Data-Model note
("forwarded to `127.0.0.1:3494` in dev", around L94) no longer describes reality —
**report it to the user; do not edit `PLAN.md`** (locked).
6. The full gate: `bash .agents/validate.sh`.
## Testing & Quality
- The live suite is read-only by policy — nothing in this task sends an instant command,
restarts a service, or writes to an upstream.
- `uv run pytest` (hermetic) 100% green; coverage gate (≥94%) holds; `ruff` + `pyright`
clean.
## Completion Criteria
- [ ] `/api/health`: every reachable source `ok` over direct dial; no `DOWN` source that was
healthy before the phase
- [ ] no tunnel port (9121/9122/9123/3494) listening on the laptop
- [ ] `uv run pytest -m live --live` green against the directly-dialed fleet
- [ ] `data/logs/phase10-*.png` exists for `/`, `/system`, `/ups`, `/llm`, showing live data
- [ ] `data/logs/NOTES-verify.md` written, including the `PLAN.md` staleness flag for the user
- [ ] `bash .agents/validate.sh` passes
- [ ] no behavior change in completed phases (same payloads, same pages — only the transport
underneath changed)
@@ -0,0 +1,110 @@
# Phase 11 — One admin, one password: the dashboards stop being public
**Source:** operator request in chat, 2026-10-17 — "password protect this dashboard for a single admin user… changeable once logged in, configured initially with an env var, not required to change… for local testing the default admin password should be `testpass`."
**Story:** n/a
**Context:** `src/dashboards/app.py` (route table, middleware, `PAGES`), `src/dashboards/api.py` (`AppContext`, `/api/config`, the one `POST /api/ups/command`), `src/dashboards/config.py` (env-only settings), `src/dashboards/store.py` (the existing `meta` table), `src/dashboards/web/static/app.js` (`fetchJSON`, `initChrome`), `.env.example` / `.env.dev.example`, `README.md` → *Configuration*, `AGENTS.md` → *Non-negotiable rules*
## Objective
Every page and every `/api` route except a small, explicit public list requires a signed-in
admin session; the admin password is seeded from `DASH_ADMIN_PASSWORD` on first start, is
changeable from a new `/settings` page, and does **not** have to be changed. The server refuses
to start when it has neither a stored password nor the env var — there is no way to run this
thing open by accident.
## Derived scope (from the operator, in chat)
- **Fail closed.** `DASH_ADMIN_PASSWORD` unset **and** no stored verifier → the process exits
with an operator-readable reason. `testpass` is the local-testing password (dev config +
the hermetic suite), never a production fallback.
- **`/api/health` stays public** on purpose — the Containerfile `HEALTHCHECK` and
`./scripts/dev-server.sh status` depend on it. Its shape must not change, and nothing new
may be added to it without treating it as a public endpoint.
- **Most secure, least likely to break** was the instruction for sessions/CSRF → stdlib
`hashlib`/`hmac` only: PBKDF2-HMAC-SHA256 verifier, HMAC-signed stateless session cookie,
a session-bound CSRF token on every write, and a default-deny gate. **No new dependency**,
so no `PLAN.md` anchor moves (`itsdangerous` + Starlette `SessionMiddleware` was the
alternative the operator declined by asking for "least likely to break").
## Dependencies
- `01_severity_language` … `05_ups_hero_battery_glyph` (complete) — the pages, the shared
`.masthead` chrome and `Dash.fetchJSON` that this phase wraps; nothing here may change what
they render once a session exists.
- `06_system_page_gpu_verdicts`, `07_llm_monitor_style_pages`, `08_events_paging_and_copy`,
`09_disks_smartctl` (todo) — **no** dependency in either direction. This phase touches
`app.py`, `config.py`, `store.py`, `app.js` and new files; those phases touch
`severity.py`, `sources/*`, page scripts. If they run first, re-read the `create_app(` call
sites in `tests/` before task 03 — that is the only overlap that can conflict.
- `10_dev_direct_dial` (todo) — **no functional dependency, four shared files**: it rewrites
`.env.dev.example`, edits `.env.example`'s NUT section, rewrites the local `.env.dev`, and
rewrites the dev sections of `README.md`/`AGENTS.md`. Run either order, with two rules: apply
this phase's `.env*` additions by **appending to whatever the file says at the time** (never
restore a `DEV_TUNNELS`/`DEV_FORWARDS` line that 10 deleted), and never delete a
`DASH_ADMIN_PASSWORD` line 10 touches. Whichever phase runs second re-checks the diff of the
other's doc edits rather than overwriting them.
## Tasks
1. `01_auth_primitives.md` — `src/dashboards/auth.py`: password hash/verify (self-describing PBKDF2), session mint/read, CSRF token, login throttle, origin check. Pure logic, no HTTP.
2. `02_verifier_config_fail_closed.md` — `store.get_meta`/`set_meta`/`delete_meta`, the four `DASH_*` auth settings, and `ensure_admin_verifier()` wired into the lifespan so a dashboard with no password anywhere refuses to start.
3. `03_session_gate_and_public_surface.md` — the default-deny middleware, `/login` route, `/api/auth/login|logout|status`, CSRF enforcement on every write, `fetchJSON` attaching the token and bouncing to `/login` on 401, plus the test that enumerates **every** route and demands a session.
4. `04_change_password.md` — `POST /api/auth/password`: current-password check, verifier replace, session-secret rotation (every other browser is out), event row, throttle reset.
5. `05_login_page.md` — `pages/login.html` + `static/page-login.js`: password field, inline accessible errors, lockout message, no CDN, theme parity with the other pages.
6. `06_settings_page.md` — `/settings`: change-password form, sign-out, session age; a `⚙` link in the masthead of all four dashboards.
7. `07_dev_config_docs_browser_verification.md` — env templates, the operator's gitignored `.env.dev`, README + AGENTS.md, then live browser proof with screenshots and the leak checks (`/api/summary` 401 anonymous, `/system` 303, `/api/health` 200).
## Testing & Quality
- Unit: `tests/test_auth.py` for every primitive — wrong password, tampered cookie, expired
cookie, cookie signed with a rotated secret, CSRF token from another session, throttle
backoff and reset, `needs_rehash` when the configured cost rises.
- Integration: `tests/test_auth_api.py` — anonymous 401 on every `/api/*` data route, 303 →
`/login?next=…` for HTML, login success/malfailure/lockout over HTTP, logout kills the
cookie, change-password invalidates the *old* session and the *other* browser's,
`POST /api/ups/command` refuses without the CSRF header even **with** a valid session.
- The default-deny guard: `test_every_route_that_is_not_public_requires_a_session` walks
`create_app().routes` and asserts nothing outside the public list answers anonymously. A
route added by a future phase that forgets the gate fails here, not in production.
- `tests/test_api.py` keeps passing unmodified in intent: `build_client()` logs in once with
`testpass`, so the ~200 existing HTTP assertions still assert what they assert. The nine
direct `create_app(Settings(...))` call sites in `tests/` get the same password.
- Coverage: **>90%** on new/modified code (`auth.py` should be near 100%); the overall gate
(94%, currently ~98%) must hold.
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test
in the project, not just this phase's), including `uv run pytest -m live --live`.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands
after this phase — fixing every inconsistency found, **including pre-existing ones not
caused by this phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible
(setup, commands, configuration, features, conventions). This phase is user-visible in the
strongest sense: the first thing an operator meets is a login prompt.
## Completion Criteria
- [ ] `curl -si http://127.0.0.1:8090/system` → `303` to `/login?next=/system`; `curl -s
http://127.0.0.1:8090/api/summary` → `401 {"ok": false, …}`; `curl -s
http://127.0.0.1:8090/api/config` → `401`; `curl -s http://127.0.0.1:8090/api/health` →
`200` (the one deliberate public data route)
- [ ] login with `testpass` in a browser reaches `/`; a wrong password shows an inline error
and, after 5 failures, the lockout message with `Retry-After` honoured
- [ ] `/settings` changes the password; the same browser is signed out and gets back in with
the new one; a second browser (or a saved cookie) is rejected with 401
- [ ] with `DASH_ADMIN_PASSWORD` unset **and** no `auth.verifier` row, `uv run dashboards`
exits non-zero with a message naming the variable and the reset command
- [ ] `./scripts/dev-server.sh status` and the Containerfile `HEALTHCHECK` still work
unauthenticated (`/api/health` unchanged)
- [ ] `uv run pytest` green, `uv run pytest --cov --cov-fail-under=94` green,
`uv run ruff check src tests agent && uv run pyright` clean, `bash .agents/validate.sh`
green
- [ ] screenshots in `data/logs/`: `auth-login.png`, `auth-login-rejected.png`,
`auth-locked-out.png`, `auth-signed-in-overview.png`, `auth-settings.png` (AGENTS.md
invariant 10 — a UI change is screenshotted, not claimed)
- [ ] `README.md` documents the four new `DASH_*` variables, the reset procedure, and the fact
that a UI change wins over the env var; `AGENTS.md` carries the new invariant naming the
public surface; both are accurate and consistent with the whole project (final pass)
- [ ] no behavior change in completed phases: every payload, hero card, chart and gate the
five completed phases shipped still renders identically **once signed in**
@@ -0,0 +1,112 @@
# Task 01 — Auth primitives: hashes, session tokens, CSRF tokens, throttle
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "password protect this dashboard for a single admin user"
**Story:** n/a
## Objective
`src/dashboards/auth.py`: every authentication decision as a pure, unit-testable function with
no HTTP and no I/O — password hash/verify, session token mint/read, CSRF token mint/verify,
login throttle, origin check. Nothing here knows about FastAPI; task 03 wires it.
## Work
1. New `src/dashboards/auth.py`, module docstring stating the design and **why** it is
hand-rolled (stdlib only — the same reasoning as `prom.py` and the NUT wire client; no
`itsdangerous`, no `bcrypt`, no session store to operate). Constants at the top:
```python
ADMIN_USER = "admin" # single admin; there is no user table and none is coming
SESSION_COOKIE = "dash_session"
CSRF_HEADER = "X-CSRF-Token"
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
PBKDF2_ITERATIONS = 600_000 # OWASP's PBKDF2-HMAC-SHA256 figure; DASH_PBKDF2_ITERATIONS overrides
PBKDF2_ALGO = "pbkdf2_sha256"
```
2. Password verifier — self-describing so the cost can rise later without a migration:
- `hash_password(password: str, *, iterations: int = PBKDF2_ITERATIONS) -> str` →
`pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>`, salt `secrets.token_bytes(16)`,
`hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)`.
- `verify_password(password: str, encoded: str) -> bool` — parses defensively and returns
`False` for `""`, a wrong field count, a non-numeric iteration count, an unknown algo
name, odd-length hex, or a length mismatch. **It must never raise**: a corrupt `meta`
row has to read as "wrong password", not a 500 on the login page. Compare with
`hmac.compare_digest`.
- `needs_rehash(encoded: str, *, iterations: int) -> bool` — true when the stored cost (or
algo) is below the configured one, so a login can transparently upgrade the hash.
- No password, hash, salt or session token may ever appear in a log line, an exception
message, or a return value used for display. Name this in the docstring: AGENTS.md
invariant 6 now covers the admin's own credential.
3. Sessions — stateless and signed, so there is no session table and no cleanup job:
- `new_secret() -> str` → `secrets.token_hex(32)`. Stored by task 02 under the meta key
`auth.session_secret`; rotating it invalidates every issued cookie, which is how changing
the password signs everyone else out.
- `@dataclass(frozen=True) Session: username: str; expires_at: float; token: str`
- `mint_session(secret: str, *, username: str = ADMIN_USER, hours: float = 720.0,
now: float | None = None) -> str` → `b64url(f"{username}|{expires_at:.0f}")` + `"."` +
`b64url(hmac_sha256(secret, payload))`. Use `base64.urlsafe_b64encode` and strip padding;
`hmac.new(secret.encode(), payload.encode(), hashlib.sha256)`.
- `read_session(secret: str, token: str, *, now: float | None = None) -> Session | None` —
signature first (constant-time), then `expires_at > now`. Returns `None` for: `""`,
no dot, non-base64 payload, payload without `|`, non-numeric expiry, an expired session,
a token signed by a rotated secret, and a payload whose *username* was tampered with.
- `mint_csrf(secret: str, session_token: str) -> str` →
`hmac_sha256(secret, "csrf|" + session_token).hexdigest()` — bound to the session, so a
token stolen from one session cannot authorise another's write, and it needs no expiry
of its own because the session has one.
- `verify_csrf(secret: str, session_token: str, provided: str | None) -> bool` —
`compare_digest`, `False` on `None`/empty.
4. Login throttle — in-process memory, because there is one admin and one uvicorn process:
- `class LoginThrottle: def __init__(self, *, max_attempts: int = 5, base_seconds: float =
60.0, max_seconds: float = 3600.0)`
- `retry_after(key: str, *, now: float | None = None) -> float` → seconds still blocked,
`0.0` when allowed.
- `failure(key: str, *, now: float | None = None) -> float` → the backoff just applied.
Exponential from `base_seconds` per key, capped at `max_seconds`; a key that gets a
successful login is cleared.
- `success(key: str) -> None`, and `prune(now)` (or an inline length guard) so a spoofed
swarm of source addresses cannot grow the dict without bound.
- Take `now` as an injectable seconds value on every method (`time.monotonic` at the call
site in task 03) — a test must not sleep 60 s to prove the backoff works.
- ASSUMPTION: the key is the socket peer address. Behind nginx every login shares one
bucket, so a determined attacker can lock the operator out of the dashboard (not of the
UPS, not of the data) for at most `max_seconds`. Accepted for a single-admin box; it goes
in `AGENTS.md` → Known gaps rather than becoming an `X-Forwarded-For` knob that is
trivially spoofed when someone forgets to trust the proxy.
5. Origin / Sec-Fetch gate as pure string functions (unit-testable without a `Request`):
- `origin_matches(origin: str, own_origin: str, allowed: Sequence[str] = ()) -> bool`
- `sec_fetch_site_allowed(value: str | None) -> bool` — allow `None`, `"same-origin"`,
`"none"`; reject `"cross-site"`, `"cross-origin"`, `"same-site"`(unknown → reject).
Anything unrecognised is rejected: a browser that invents a new value is a browser doing
something we did not reason about.
## Testing & Quality
- `tests/test_auth.py` (new) — the exhaustive negative table above. Minimum:
round-trip hash/verify, wrong password, unicode password (`"pässwörd"` — proves the utf-8
encode), five malformed encodings all `False` with no exception, `needs_rehash` on a
1_000-iteration hash against the default cost, session round-trip, tampered username,
flipped signature byte, truncated token, expired session (via `now`), cookie from a rotated
secret, CSRF ok/wrong-session/missing, throttle allows 5, blocks the 6th with
`retry_after > 0`, backoff doubles and caps at `max_seconds`, `success()` clears,
`origin_matches` accepts its own origin and the allow-list and rejects
`http://evil.example`, `sec_fetch_site_allowed("cross-site") is False`.
- Cover `auth.py` to ~100% — every branch here is a security decision, and a branch no test
reaches is a decision nobody checked.
- Coverage: **>90%** on this task's new code; overall gate (94%) maintained.
## Completion Criteria
- [ ] `uv run pytest tests/test_auth.py -v` green
- [ ] `python -c "from dashboards import auth; h=auth.hash_password('testpass'); print(h)"`
prints a `pbkdf2_sha256$600000$…` string and `auth.verify_password('testpass', h)` is
`True` while `verify_password('wrong', h)` and `verify_password('testpass', 'junk')` are
`False`
- [ ] `grep -n "print(\|logger\|logging" src/dashboards/auth.py` shows nothing that could
echo a credential
- [ ] no new dependency in `pyproject.toml` / `uv.lock` (`uv pip list --outdated` stays empty)
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,119 @@
# Task 02 — Where the password lives: `meta`, the four settings, and failing closed
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "configured initially with an env var (but should not be required to change)"
**Story:** n/a
## Objective
Make the credential durable and the startup honest: the verifier and the signing secret live in
the SQLite `meta` table, the environment seeds them once, and a server with neither a stored
verifier nor `DASH_ADMIN_PASSWORD` **refuses to start** instead of serving the fleet open.
## Work
1. `src/dashboards/store.py` — three small public helpers over the `meta` table that already
exists (`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)`), each under
`self._lock`:
- `get_meta(key: str) -> str | None`
- `set_meta(key: str, value: str) -> None` (`INSERT OR REPLACE`)
- `delete_meta(key: str) -> int` (returns rowcount; the reset procedure needs it and a
test should prove it removes only the named key)
Route `stats()`'s `last_maintain` read through `get_meta()` so there is one path to the
table. Leave `maintain()`'s write inline — it is inside that method's transaction.
2. `src/dashboards/config.py` — four new `Settings` fields beside the server block, using the
existing `_env` / `_num` / `_flag` / `_int` helpers (field defaults are read once at import;
that is the established pattern, see the module docstring):
- `admin_password: str = _env("DASH_ADMIN_PASSWORD")` — the **seed**. `_env()` strips, so
`DASH_ADMIN_PASSWORD=` means *unset*, never "the password is empty".
- `session_hours: float = _num("DASH_SESSION_HOURS", 720)` — 30 days; a homelab dashboard
that logs the operator out every shift change gets bypassed, which is worse.
- `cookie_secure: bool = _flag("DASH_COOKIE_SECURE")` — off by default because dev is
`http://127.0.0.1`; task 03 also marks the cookie `Secure` when the request arrived over
`X-Forwarded-Proto: https`.
- `pbkdf2_iterations: int = _int("DASH_PBKDF2_ITERATIONS", PBKDF2_ITERATIONS)` — import the
constant from `.auth` (`auth.py` imports nothing from this package, so there is no cycle).
The knob exists so the hermetic suite can hash in microseconds instead of 0.3 s; the
comment must say that, and must not suggest lowering it in production.
3. `src/dashboards/auth.py` — the loader that turns settings + `meta` into request-time state.
Declare a `MetaStore` `Protocol` (`get_meta` / `set_meta` / `delete_meta`) so this module
still imports nothing from `config`/`store`:
```python
@dataclass(frozen=True)
class AuthState:
verifier: str # encoded PBKDF2, never the password
secret: str # session-signing key
session_hours: float
iterations: int
seeded: bool # True when this start wrote the verifier row
env_ignored: bool # True when a stored verifier beat DASH_ADMIN_PASSWORD
```
- `load_auth_state(store: MetaStore, *, admin_password: str, iterations: int =
PBKDF2_ITERATIONS, session_hours: float = 720.0, now: float | None = None) -> AuthState`
- `verifier = store.get_meta("auth.verifier")`.
- If absent and `admin_password` → `hash_password(...)`, `set_meta("auth.verifier", …)`,
`seeded=True`.
- If absent and no `admin_password` → raise `RuntimeError` with the exact operator text:
name `DASH_ADMIN_PASSWORD`, name the meta key, and give the reset command
(`sqlite3 <db> "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')"`).
Fail closed here — not "start open with a warning": the domain is public and the fleet's
hostnames and UPS controls are behind it.
- `secret = store.get_meta("auth.session_secret")` or generate with `new_secret()` and
persist it. Persisting it is what makes a restart not invalidate every browser.
- `env_ignored = bool(admin_password) and not seeded`. When true,
`logging.getLogger("dashboards.auth").warning()` **once**, at startup, with the words:
`DASH_ADMIN_PASSWORD is ignored — the password set in /settings wins; delete the
'auth.verifier' row to reset from the environment`. Never log the value, the hash, or
any prefix of either.
- `update_verifier(store: MetaStore, state: AuthState, new_password: str) -> AuthState` —
hashes, writes both `auth.verifier` and a **fresh** `auth.session_secret`, returns the new
state (task 04 uses it; the rotation is what signs out every other browser). Empty /
whitespace-only `new_password` raises `ValueError` — an empty password must never be
storable.
4. `src/dashboards/app.py` — build the state during lifespan startup, before `start()`, for
both the owned-store and injected-store branches, and hang it off the context:
`ctx.auth = load_auth_state(store, admin_password=settings.admin_password,
iterations=settings.pbkdf2_iterations, session_hours=settings.session_hours)`.
`AppContext` (in `api.py`) gains `auth: Any = None` (typed as `AuthState | None`; import the
dataclass — that is how it stays honest under pyright). Task 03 is what *enforces* it; this
task only guarantees it exists or the process does not come up.
Startup failure must be loud and non-zero: a `RuntimeError` raised in the lifespan does that
for `uvicorn dashboards.app:app` and `uv run dashboards` alike. Do **not** swallow it.
5. Tests:
- `tests/test_store.py` — `get_meta` `None` for a missing key, round-trip, `set_meta`
replaces, `delete_meta` returns 1 for an existing key / 0 for a missing one and leaves the
other keys alone.
- `tests/test_config.py` — the four new fields pick up their env vars and their defaults;
`DASH_ADMIN_PASSWORD=` is `""`.
- `tests/test_auth.py` (or a new `tests/test_auth_bootstrap.py` if 01's file is getting
long) — seeds on first call and writes both rows; second call with `admin_password=""`
reuses the stored verifier (so a deployment can drop the env var after first boot);
`admin_password=""` **and** no row raises `RuntimeError` naming both the variable and the
key; a stored verifier makes a conflicting env password `env_ignored=True` and does **not**
overwrite the row; a corrupt `auth.session_secret`… (state the behaviour you choose:
regenerate and persist is fine, since the effect is "everyone signs in again", which is a
login prompt, not a lockout); `update_verifier` changes both rows and the new state's
secret, and refuses an empty password.
- `tests/test_api.py` — one test that `create_app(Settings(db_path=db), store)` (no
password anywhere) fails to start, and that with `admin_password="testpass"` it starts.
## Testing & Quality
- Coverage **>90%** on `auth.py`, `config.py`, `store.py`, `app.py` changes; overall gate
(94%) maintained.
- Prove the fail-closed branch and the seeded branch each run — a security default that no test
exercises is a rumour.
## Completion Criteria
- [ ] `uv run pytest tests/test_store.py tests/test_config.py tests/test_auth.py -v` green
- [ ] starting the real dev server with `DASH_ADMIN_PASSWORD` unset and a scratch
`DASH_DB=/tmp/auth-probe.sqlite3` exits non-zero and prints the message naming the
variable and the reset command (paste the line into the commit message)
- [ ] the same command with `DASH_ADMIN_PASSWORD=testpass` starts, and
`sqlite3 /tmp/auth-probe.sqlite3 "SELECT key FROM meta"` lists `auth.verifier` and
`auth.session_secret`
- [ ] the stored hash in `meta` is a `pbkdf2_sha256$…` string; `grep -c testpass` in that DB
is 0
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,163 @@
# Task 03 — The gate: default-deny middleware, `/login` route, `/api/auth/*`
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "password protect this dashboard"
**Story:** n/a
## Objective
Put every route behind the session except four deliberately public ones, with the login,
logout and status endpoints that create and end it, and make the browser scripts carry the
credentials so no page has to think about auth.
## Work
1. `src/dashboards/app.py` — state the public surface **next to the route table** it guards:
```python
# Everything else requires a signed-in admin. This list is the whole attack surface, so
# adding a path here is a security decision, not a convenience: /api/health is here
# because the Containerfile HEALTHCHECK and ./scripts/dev-server.sh status poll it
# unauthenticated, and it is the only data route that stays public.
PUBLIC_PATHS = frozenset({"/login", "/api/auth/login", "/api/health"})
PUBLIC_PREFIXES = ("/static/",)
```
Add `"/login": "login.html"` to `PAGES`. Task 05 supplies the file; until then serve the
existing shape it will get (a minimal page is fine to create here and flesh out in 05 — do
**not** leave `/login` 404, or the gate redirects to a dead end).
2. Add the gate as an `@app.middleware("http")` in `create_app()`, beside the existing
`no_store_for_api`:
- `ctx = getattr(request.app.state, "ctx", None)`; no `ctx` → `503 {"ok": false, "detail":
"startup did not complete"}` for non-public paths (the app that failed to build its
context must not serve pages with `ctx.auth = None`).
- public path → straight through.
- session: `request.cookies.get(auth.SESSION_COOKIE)`, or `Authorization: Bearer <token>`
(so `curl -H "Authorization: Bearer …"` can script the API — document it in README).
Read with `auth.read_session(ctx.auth.secret, token)`.
- no/invalid session →
- `GET`/`HEAD` with `text/html` in `Accept` and a path not starting with `/api` →
`303` to `/login?next=<safe next>`;
- everything else → `401 {"ok": false, "detail": "sign in required", "login": "/login"}`.
- `safe_next(raw)` — keep it only if it starts with `/`, does not start with `//`, and
contains no `://`; else drop it. That is the open-redirect check; a `?next=` that sends
the operator to someone else's site would be a phishing kit with a homelab theme.
- `GET`/`HEAD` with a valid session → set `request.state.session = session` and continue.
- non-`GET`/`HEAD` (everything that can write, `POST` included) additionally:
- origin check with `auth.origin_matches(origin, own_origin, allowed)` where `own_origin`
is `f"{request.url.scheme}://{request.url.netloc}"` and `allowed` comes from
`DASH_ALLOWED_ORIGINS` — **no such setting is added**: same-origin is the only policy
this app needs, so pass `()`. Reject when `Origin` is present and mismatched, or when
`Sec-Fetch-Site` is present and `auth.sec_fetch_site_allowed()` rejects it;
- CSRF: required when the caller authenticated with the **cookie** (a bearer caller
cannot be forged by another site — that is what the header buys), validated with
`auth.verify_csrf(ctx.auth.secret, session.token, request.headers.get(auth.CSRF_HEADER))`;
- failures → `403 {"ok": false, "detail": "cross-origin request refused"}` /
`403 {"ok": false, "detail": "missing or invalid CSRF token"}`.
- **every response the gate produces carries `NO_STORE`** (the gate may short-circuit
before `no_store_for_api` runs, depending on middleware order — do not depend on the
order, pin the header in a test).
3. `POST /api/auth/login` (public; put it in a new `auth_router()` in
`src/dashboards/api.py`, included in `app.py` alongside the other routers, prefix `/api/auth`):
- body `{"password": "<string>"}` — there is no username field (single admin;
`auth.ADMIN_USER`). An absent/empty password is `422`, not a hash comparison.
- throttle first: `key = request.client.host if request.client else "unknown"`;
`retry_after = ctx.login_throttle.retry_after(key)`; blocked → `429` with
`Retry-After: <ceil>` and `detail` naming the lockout seconds — no "wrong password"
lie, and no hint about which account.
- `auth.verify_password(password, ctx.auth.verifier)`:
- no → `throttle.failure(key)`, log an event
(`Event(now, "auth", "admin", "login-denied", "warn", "sign-in refused (attempt N)")`)
and `401 {"ok": false, "detail": "wrong password"}` — one message for every failure
kind, because there is one account and the response must not rank failures.
`alerts.py` only turns `kind == "unreachable"` events into overview rows and the page
feeds filter by `source`, so an `auth` event cannot blink the overview — say so in the
code comment so nobody "fixes" it by hiding the event.
- yes → `throttle.success(key)`, `auth.mint_session(...)`, set the cookie
(`httponly=True`, `samesite="lax"`, `path="/"`, `max_age=int(hours*3600)`,
`secure=True` when `settings.cookie_secure` **or** the request's first
`X-Forwarded-Proto` value is `https` — marking it `Secure` for an https visit is safe
even if the header lies, because it only ever tightens), and answer
`{"ok": true, "username": "admin", "expires_at": ts, "csrf": <token>, "next": "/safe"}`.
Never echo the password, the verifier, or the session token in any other field.
- `GET /login` while already holding a valid session → `303` to `/` (or to a safe `next`).
4. Protected auth routes on the same router:
- `GET /api/auth/status` → `{"ok": true, "authenticated": true, "username": "admin",
"expires_at": ts, "seconds_left": n, "csrf": auth.mint_csrf(secret, session.token),
"session_hours": …}`. This is the only place the CSRF token is published; it is inside
the session for a reason.
- `POST /api/auth/logout` (session + CSRF required — a plain link would be a CSRF that
signs the operator out) → clears the cookie (`delete_cookie(path="/")`) and answers
`{"ok": true}`.
5. `src/dashboards/api.py` — `AppContext` gains `login_throttle: Any = None` next to the
`auth` field from task 02; `app.py` constructs one `auth.LoginThrottle()` per app (per
process, which is the documented model). Do not create it per request: a new throttle per
request is a throttle that never trips.
6. `src/dashboards/web/static/app.js` — one place learns the token, every request uses it:
- module-level `authState = null` + `async function ensureAuth()` that fetches
`/api/auth/status` once (memoise the promise, not just the result, or 4 page scripts race
four status calls), caching `{csrf}`;
- `fetchJSON(url, options)` attaches `X-CSRF-Token` for every method outside
`GET`/`HEAD`, awaiting `ensureAuth()` first;
- on `401`, `window.location.assign('/login?next=' + encodeURIComponent(location.pathname))`
and throw once — guard with a module flag so a 2 s poll loop whose session expired does
not spam the login page with redirects;
- keep the exported surface additive (`app.js`'s `return { … }` list): export `authStatus`
(the cached object) because task 06's `/settings` page prints the session expiry.
7. Tests — `tests/test_auth_api.py` (new) plus the plumbing this task forces on the suite:
- `tests/auth_client.py` (new helper module, imported the way `tests/card_contract.py`
is): `ADMIN_PASSWORD = "testpass"`, `sign_in(client, password=ADMIN_PASSWORD)` that posts
the login, asserts 200, and stashes the token on the client
(`client.headers[auth.CSRF_HEADER] = body["csrf"]`). Setting it on the TestClient is what
keeps the 12 existing `client.post(` assertions in `tests/test_api.py` unedited — they get
cookies *and* the CSRF header for free.
- `tests/test_api.py::build_client` logs in through that helper. The seven other
`create_app(` sites in `tests/test_api.py` (search `grep -n "create_app(" tests/`; there
are also two in `tests/test_system_contract.py` and `tests/test_ups_contract.py`) each get
`admin_password=ADMIN_PASSWORD` **and** a `sign_in()`, or their assertions read 401s.
- gate behaviour: every `/api/*` data route (at least `/api/config`, `/api/summary`,
`/api/events`, `/api/metrics`, `/api/system/snapshot`, `/api/system/history`,
`/api/llm/snapshot`, `/api/ups/snapshot`, `/api/ups/commands`) answers `401` with that
exact body shape anonymously; `GET /`, `/system`, `/llm`, `/ups` with `Accept:
text/html` → `303` with `Location` starting `/login?next=`; `/api/health`, `/login`,
`/static/dash.css` stay reachable anonymously; every gate response has
`cache-control: no-store`.
- `test_every_route_that_is_not_public_requires_a_session` — build the app, walk
`app.routes`, derive each path (`getattr(r, "path", "")`; a `Mount` counts as its prefix),
and assert: anything outside `PUBLIC_PATHS`/`PUBLIC_PREFIXES` is `401`/`303` for anonymous
`GET` **and** `POST`. This is the test that protects the next phase that adds a route.
- `?next=` handling: `/nope` passes through, `//evil.example` and
`http://evil.example` are dropped to `/` (assert the `Location` has no `evil` in it).
- login: wrong password 401 `wrong password`; empty 422; success sets a `Set-Cookie`
containing `HttpOnly`, `SameSite=lax` and `Max-Age`, and returns a usable `csrf`;
`X-Forwarded-Proto: https` adds `Secure`; `DASH_COOKIE_SECURE=1` adds it without the
header; 6th bad attempt from the same client → 429 with `Retry-After` and the event row
exists in `/api/events?source=auth` once signed in.
- CSRF: `POST /api/ups/command` with a valid session and **no** `X-CSRF-Token` → 403, and
prove it never reached the wire (the scripted `ScriptedNutClient` in `tests/test_api.py`
records nothing); with the header it behaves exactly as it did before this phase
(`DANGER` still required for `driver.killpower`); `Origin: http://evil.example` → 403 on
login and on a write; `Sec-Fetch-Site: cross-site` → 403; a bearer-token caller can write
without the CSRF header but not without the token.
- logout kills the cookie (subsequent `/api/summary` → 401).
- the served-pages tests in `tests/test_api.py` that scrape page scripts still pass — the
orphan-`id` test and the two-panel/hero pins must stay green without weakening them.
## Testing & Quality
- Coverage **>90%** on `app.py`, `api.py`, `auth.py` changes; every branch of the gate
(public, sessionless HTML, sessionless JSON, bad-session write, CSRF-missing,
origin-mismatch, no-ctx) needs a test — this module is the entire perimeter.
- `node --check src/dashboards/web/static/app.js` is part of `.agents/validate.sh`; run it.
## Completion Criteria
- [ ] `uv run pytest tests/test_auth_api.py -v` green, including the route-enumeration test
- [ ] `curl -si http://127.0.0.1:8090/system | head -5` shows `303` + `Location: /login?next=/system`
- [ ] `curl -s http://127.0.0.1:8090/api/summary` → the 401 JSON; `curl -s
http://127.0.0.1:8090/api/health` → 200; `curl -s -o /dev/null -w '%{http_code}'
http://127.0.0.1:8090/static/dash.css` → 200
- [ ] `curl -s -X POST -H 'content-type: application/json' -d '{"password":"testpass"}'
http://127.0.0.1:8090/api/auth/login` returns `ok: true` with a `csrf`, and the returned
token used as `X-CSRF-Token` + the cookie makes `POST /api/ups/command` behave as before
- [ ] `uv run pytest` fully green (all ~550 tests, not just the auth ones); `ruff` + `pyright`
clean; `bash .agents/validate.sh` green
@@ -0,0 +1,87 @@
# Task 04 — Change the password from the API, and mean it for every session
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "the password should be changeable once logged in"
**Story:** n/a
## Objective
`POST /api/auth/password`: prove the current password, replace the verifier, rotate the signing
secret so the change takes effect everywhere at once. Optional in every sense — nothing forces
a change, no expiry, no first-login interstitial.
## Work
1. `src/dashboards/api.py` — add to `auth_router()`:
`POST /api/auth/password`, body `{"current": str, "new": str}` (session **and**
`X-CSRF-Token` required by task 03's gate):
- Validate `new` before touching anything: non-empty after `.strip()`, at least
`MIN_PASSWORD_LENGTH = 8`, and different from `current` → otherwise `422` with a `detail`
that says which. No composition rules (symbols/digits requirements produce sticky notes,
not passwords); length is the part that does work.
- Throttle the *current* password guesses on a **separate key** —
`f"password:{request.client.host}"` on the same `LoginThrottle` instance — so the
change-password form is not an unthrottled oracle for the old password once a cookie is
stolen. Blocked → `429` with `Retry-After`, same as login.
- `auth.verify_password(current, ctx.auth.verifier)`; wrong → `throttle.failure(that key)`
and `403 {"ok": false, "detail": "current password is wrong"}`. Nothing else changes and
an event is logged (`source="auth"`, `kind="password-denied"`, `severity="warn"`).
- Right → `auth.update_verifier(store, ctx.auth, new)` (task 02: writes `auth.verifier` and
a fresh `auth.session_secret`), assign `ctx.auth = new_state` (`AppContext` is a plain
mutable dataclass), `throttle.success()`, log
`Event(now, "auth", "admin", "password-changed", "warn", "admin password changed")`, and
respond with a **fresh cookie minted from the new secret** plus
`{"ok": true, "changed": true, "expires_at": ts, "csrf": <new token>,
"hint": "other browsers are now signed out"}`.
Cookie attributes identical to login (including the `X-Forwarded-Proto` /
`DASH_COOKIE_SECURE` rule).
- Rotating the secret is the whole point: sessions are stateless, so without it a stolen
cookie would outlive the password that "revoked" it. Say that in a comment — the next
reader will wonder why a password change touches the signing key.
2. Also in `auth_router()`: make login upgrade a cheap hash transparently. After a successful
`verify_password`, if `auth.needs_rehash(ctx.auth.verifier,
iterations=settings.pbkdf2_iterations)` → re-hash with the configured cost and write it
(keep the secret — this is not a logout). Without this, an operator who raises
`DASH_PBKDF2_ITERATIONS` never actually gets the new cost.
3. Order-of-operations guard: the response must be built from `new_state`, never from the old
`ctx.auth`. A response signed with the old secret is a browser that dies on its next
request — the exact bug this task can introduce.
4. Tests (extend `tests/test_auth_api.py`):
- wrong `current` → 403, and the `auth.verifier` string in the DB is byte-identical to
before; five wrong tries then a **correct** one is still `429` (proves the separate
throttle key actually throttles);
- `new` empty / whitespace / 7 chars / equal to `current` → 422 and both `meta` rows
unchanged;
- success → `auth.verifier` and `auth.session_secret` both changed; `verify_password`
confirms new-yes/old-no against the stored string; the **old** cookie now gets `401` from
`/api/summary`; the cookie in the response works and its returned `csrf` authorises
`POST /api/ups/command`; a second browser's cookie (captured earlier in the test) gets
`401`;
- restart semantics: after a change, rebuild the app from the same DB with
`admin_password="testpass"` in settings → login with the **new** password works and
`testpass` does not (the env var is a seed, not an override);
- rehash: seed a verifier at `iterations=1000`, start with `pbkdf2_iterations=2000`, log in
→ 200 and the stored string now says `pbkdf2_sha256$2000$…`, and the same password still
works afterwards;
- the `auth` events exist and are visible via `/api/events?source=auth`, and `alerts.build`
does **not** turn them into overview alerts (assert the summary's `alerts` list is
unchanged by them — invariant: a login failure is a fact for the feed, not a red panel).
5. `/api/config` and `/api/health` must not gain any auth detail: `/api/health` is public, and
"the admin password was changed at T" is not something to serve to an anonymous probe. The
fact lives in the event feed (authenticated) and in `/settings`.
## Testing & Quality
- Coverage **>90%** on the new route and `auth.update_verifier`; every rejection path above is
a test, not a comment.
- The suite's existing `/api/ups/command` tests (the `ScriptedNutClient` ones) must stay green
unchanged in behaviour.
## Completion Criteria
- [ ] `uv run pytest tests/test_auth_api.py -v` green for the eight scenarios above
- [ ] by hand on the dev server: sign in with `testpass`, change it to something else with
`curl`, and confirm the old cookie now gets `401` while the returned one works
- [ ] `sqlite3 data/dashboards.sqlite3 "SELECT value FROM meta WHERE key='auth.verifier'"`
still prints only an encoded PBKDF2 string — no plaintext, anywhere
- [ ] `uv run pytest` green; `ruff` + `pyright` clean; `bash .agents/validate.sh` green
@@ -0,0 +1,83 @@
# Task 05 — The login page
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "password protect this dashboard"
**Story:** n/a
## Objective
A single-purpose page at `/login` that asks for the admin password, says honestly why it
refused, and lands the operator on `/` (or the page they were heading to). Hand-rolled HTML +
one small script, no CDN, dark/light parity with the dashboards.
## Work
1. `src/dashboards/web/pages/login.html` — modelled on the other four pages' `<head>`
(`/static/dash.css`, the same inline SVG favicon, `data-theme="dark"` so `app.js` can flip
it from `localStorage` before paint):
- title `Homelab · sign in`; the `.masthead` brand link, but **no** `.tabs` nav — the tabs
lead to pages that would bounce straight back here, and a nav of links that all redirect
is a lie about what is available.
- one `.card` in `main`: `<h1>Sign in</h1>`, a `.sub` line naming what this protects ("the
fleet's system, engine and battery dashboards — including the UPS controls"), then a form:
`<label for="admin-password">Admin password</label>` +
`<input id="admin-password" type="password" autocomplete="current-password" required>` +
`<button class="action" id="sign-in" type="submit">Sign in</button>`.
- a `<p id="login-error" class="login-error" role="alert" hidden>` for refusals and
`<p id="login-note" class="faint mono" hidden>` for the lockout countdown.
- scripts: `/static/app.js` then `/static/page-login.js`, `defer`.
- **no** username field: one admin, and a field that is always the same word teaches the
operator that half the form is theatre (`auth.ADMIN_USER` is the server's business).
2. `src/dashboards/web/static/page-login.js` — IIFE in the house style (see
`page-index.js`), no framework:
- `submit` handler → `POST /api/auth/login` with
`Dash.fetchJSON` (it adds the JSON accept header and turns a non-2xx into a thrown
`Error` with `status`/`body`); body `{password}`;
- on success `location.assign(body.next || '/')`;
- on `401` show `Wrong password.` and select the field's contents (fixing a typo without
retyping is the whole UX of a login form);
- on `429` show the `detail` plus the `Retry-After` seconds ticking down, and disable the
button until it reaches 0 — a submit that will be refused should not look pressable;
- on any other failure print the server's `detail` verbatim through the escape path the app
already uses for text (`textContent`, never `innerHTML`);
- honour `?next=` **only** if it starts with `/` and not `//` (the same rule the server
enforces; a client-side copy is defence in depth, and the server's check is the one that
counts);
- if the browser already holds a valid session, `GET /api/auth/status` succeeds → redirect
to `/` immediately (task 03 also does this server-side; this is what stops a flash of the
form).
3. `src/dashboards/web/static/dash.css`:
- extend the existing `input[type="text"]` rule (line ~642) to
`input[type="text"], input[type="password"]` — do not duplicate it;
- add `.login-card { max-width: 26rem; margin: 3rem auto; }` and `.login-error { color:
var(--fail); }`, `.login-card form { display: grid; gap: .6rem; }`, plus
`.login-card label { font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em;
color: var(--text-faint); }` (the same caption vocabulary as `.hero .label`);
- only existing `:root` tokens (`--fail`, `--text-faint`, `--accent`, …) —
`tests/test_api.py` fails if a rule reaches for a token `:root` does not declare, and if a
`.sev-*` family appears.
4. Keep the invariant: nothing external. `tests/test_api.py` already asserts the pages load no
foreign asset — extend that assertion to `/login` (same test, one more path).
## Testing & Quality
- `tests/test_api.py` (the served-HTML section) — `/login` is served anonymously with
`HTTP/1.1 200`, contains `id="admin-password"`, `type="password"`,
`autocomplete="current-password"`, `role="alert"`, and **no** `/system`-style `.tabs`; no
`http://` or `https://` asset reference in the served bytes.
- Orphan-`id` pin: every new `id` (`admin-password`, `sign-in`, `login-error`, `login-note`)
must be referenced by `page-login.js` (or the CSS) — that test fails on a dead `id`, so name
what you use and use what you name.
- `node --check src/dashboards/web/static/page-login.js` (part of `validate.sh`).
- Coverage: no new Python; the served-bytes assertions are the tests. Do not add a JS test
runner (a locked anchor).
## Completion Criteria
- [ ] anonymous `GET /login` → 200 with the form; anonymous `GET /` → 303 to `/login?next=/`
- [ ] in the dev server: wrong password shows `Wrong password.` inline with no page reload;
`testpass` lands on `/` and the header status dots go green
- [ ] five wrong passwords then a correct one shows the lockout with a counting-down
`Retry-After` and a disabled button; it clears and the next attempt works
- [ ] `/login?next=/ups` after a successful sign-in lands on `/ups`
- [ ] dark and light both readable (screenshot both, or one plus a note in `NOTES-*.md`)
- [ ] `uv run pytest` green; `ruff` + `pyright` clean; `bash .agents/validate.sh` green
@@ -0,0 +1,89 @@
# Task 06 — `/settings`: change the password, sign out, see when the session ends
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "the password should be changeable once logged in"
**Story:** n/a
## Objective
A fifth page the operator reaches from the masthead: the session facts, a working
change-password form, and a sign-out. It has to survive the secret rotation from task 04
without leaving the browser holding a stale CSRF token.
## Work
1. `src/dashboards/app.py` — add `"/settings": "settings.html"` to `PAGES`. It lands behind the
gate with no extra code (task 03's route-enumeration test proves it); do not add it to the
public list.
2. `src/dashboards/web/pages/settings.html` — same `<head>`/masthead as the other pages, `.tabs`
nav **without** `aria-current` (Settings is not one of the four dashboards), and in `main`:
- **Session** panel: `<dl>` with the signed-in name, the expiry as a local timestamp *and*
"in 29d 14h" via `Dash.duration(seconds_left)`, and `<button class="action"
id="sign-out" type="button">Sign out</button>`.
- **Change password** panel: three fields — current (`autocomplete="current-password"`),
new (`autocomplete="new-password"`, `id="new-password"`), confirm
(`autocomplete="new-password"`, `id="confirm-password"`) — each with a real `<label
for=…>` and an `aria-describedby` hint naming the 8-character floor, a `<button
class="action" id="change-password">Change password</button>`, and one
`<p id="settings-result" role="status" aria-live="polite">` for the outcome.
- A static `.sub` paragraph stating the precedence rule in the app's own voice: the password
was seeded from `DASH_ADMIN_PASSWORD` at first start, changing it here takes precedence,
the env var never overwrites a password set here, and resetting from the environment means
deleting the `auth.verifier` row and restarting. (This is the sentence that stops the
"I edited the env file and nothing happened" support call.)
- No `#nav-status` — `Dash.initChrome()` already guards its absence.
3. `src/dashboards/web/static/app.js`:
- `resetAuth()` — clears the memoised `authState`/promise added in task 03, exported in the
`return { … }` list. Needed twice: after a password change (the secret rotated, so the
cached `csrf` is now invalid) and after logout. Forgetting this is the bug where the
operator changes the password and then every UPS button 403s until they reload.
- also call it when a `401` bounces to `/login` (the flag from task 03 already stops the
redirect storm).
4. `src/dashboards/web/static/page-settings.js`:
- on load `Dash.fetchJSON('/api/auth/status')` → fill the session panel;
- sign-out → `POST /api/auth/logout` → `Dash.resetAuth()` → `location.assign('/login')`;
- change password → local checks first (new ≥ 8 chars, `new === confirm`, current non-empty),
reported inline without a round-trip; then
`POST /api/auth/password` with `{current, new}`; on success write
"Password changed. Other browsers are signed out." into `#settings-result`,
`Dash.resetAuth()` (so the next write re-reads the fresh `csrf`), clear all three fields
(`form.reset()` plus explicit blanking — password managers autofill them), and keep the
session cookie the response set; on `403` print "current password is wrong", on `422`
print the server's `detail`, on `429` the lockout text — every message via `textContent`;
- after a successful change, verify once by calling `/api/auth/status` again so the panel
cannot show a session the server no longer honours.
5. Masthead on all four dashboard pages (`index.html`, `system.html`, `llm.html`, `ups.html`) —
one link in `.masthead-right`, before the theme toggle:
`<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>`.
**No `id`** (the orphan-`id` test in `tests/test_api.py` flags ids nothing references — an
`<a href>` needs none), and it must sit outside `.tabs` so the pinned tab structure of the
four dashboards is untouched. Add `.ghost` styling for `a` alongside the existing
`button.ghost` rule (extend its selector to `a.ghost` rather than duplicating; existing
tokens only).
6. `src/dashboards/web/static/page-login.js` — a sign-in that carries `?next=/settings` is a
normal path once the session dies on the settings page; task 05's `next` handling covers it,
just verify it in the browser.
## Testing & Quality
- `tests/test_api.py` (served-bytes section, signed-in client) —
`GET /settings` is `200` and contains `id="new-password"`, `autocomplete="new-password"`,
`id="sign-out"`, `role="status"`; anonymous `GET /settings` is `401`/`303` (already covered by
the enumeration test — do not duplicate it there, but assert it once here for the page);
`test_every_dashboard_page_links_to_settings` over the four page paths; `/settings` carries no
external asset; the orphan-`id` test still passes with the four new ids.
- `node --check` for `page-settings.js` and `app.js` (in `validate.sh`).
- No new Python except the one `PAGES` line, so coverage moves only through `app.py`'s existing
route tests; keep the overall gate (94%) where it is.
## Completion Criteria
- [ ] in a browser: the `⚙ Account` link appears on all four dashboards and opens `/settings`
- [ ] signing out from `/settings` returns to `/login`, and `/system` afterwards bounces to
`/login` rather than rendering an empty page of `n/a`s
- [ ] changing the password on `/settings` shows the success line, keeps the operator signed
in, and a UPS button on `/ups` (e.g. beeper disable → confirm) still works **without a
page reload** — that is `resetAuth()` doing its job
- [ ] the same change in a second browser tab signed in earlier shows the 401 → `/login` path
- [ ] `/settings` renders correctly in dark and light; screenshots
`data/logs/auth-settings.png` and `data/logs/auth-account-link.png`
- [ ] `uv run pytest` green; `ruff` + `pyright` clean; `bash .agents/validate.sh` green
@@ -0,0 +1,125 @@
# Task 07 — Dev config, the two docs, and proof in a browser against live data
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "for local testing the default admin password should be `testpass`"
**Story:** n/a
## Objective
Make `testpass` the password a developer types locally without ever putting it in a tracked
template, document the whole thing for the operator, and prove the gate in a real browser on
live fleet data (AGENTS.md invariant 10).
## Work
1. `.env.example` — new `Server` block lines, production-shaped and placeholder-only:
```
# Admin password. Seeds the SQLite verifier on first start; after that the password set
# in /settings wins and this line is ignored (delete the 'auth.verifier' row to reset).
# With neither this variable nor a stored verifier the app REFUSES to start.
DASH_ADMIN_PASSWORD=change-me
DASH_SESSION_HOURS=720
# 1 marks the session cookie Secure. Set it in deployment (TLS/nginx); the app also sets
# it automatically for requests that arrive with X-Forwarded-Proto: https.
DASH_COOKIE_SECURE=0
# Cost of the PBKDF2 hash. Lowering it weakens the one thing standing between the network
# and your fleet; it exists so the test suite can hash fast.
DASH_PBKDF2_ITERATIONS=600000
```
2. `.env.dev.example` — `export DASH_ADMIN_PASSWORD=change-me`, and the local-testing value in
prose **on a comment line**, e.g. `# Local throwaway value: the hermetic suite and every
dev box here use the word the tests sign in with (see tests/auth_client.py).`
- **The trap to avoid:** `.agents/validate.sh` scans every tracked env *template* and fails
the phase if a `*_PASSWORD=` / `*_TOKEN=` / `*_SECRET=` / `*_KEY=` line carries anything
but an allow-listed placeholder (`changeme`, `change-me`, `replace-me*`, `secret`,
`placeholder`, `dummy`, `example`, `x+`, empty). **`testpass` is not on that list**, so
`DASH_ADMIN_PASSWORD=testpass` in a tracked template fails the gate — correctly. Do not
edit `validate.sh` to let it through; the fix is a placeholder in the template and the
real word in the gitignored file. Comment lines are not matched (the pattern is anchored
at `^` and cannot start at `#`), which is why prose is safe and an assignment is not.
3. The developer's own `.env.dev` (gitignored, mode `0600`) — append
`export DASH_ADMIN_PASSWORD=testpass` if no `DASH_ADMIN_PASSWORD` line exists. Append with
`printf '…\n' >> .env.dev` (`tee` truncates in place; the mode is 0600 for a reason) and do
not reorder or reformat the rest of that file — it carries real upsd credentials.
Never print its contents into a log, a commit message, or a screenshot.
4. Optional one-line ergonomics in `scripts/dev-server.sh`'s `start()` — after the success
`echo`, add `echo " sign in with DASH_ADMIN_PASSWORD from .env.dev"`. `start`'s readiness
probe and `status` both use `/api/health`, which stayed public, so **no auth logic belongs in
this script** and no password belongs in it either.
5. `README.md`:
- **Quickstart** — after `cp .env.dev.example .env.dev`, one line: local dev signs in with
the dev file's password; the app refuses to start without one.
- New **Signing in** section (before *Configuration*): one admin; the four public paths and
why `/api/health` is one of them (container `HEALTHCHECK`, `dev-server.sh status`);
password seeded from `DASH_ADMIN_PASSWORD`, changed in `/settings`, never forced;
sessions are HMAC-signed cookies (`DASH_SESSION_HOURS`, default 30 days) invalidated
together by a password change; the reset procedure, verbatim:
`sqlite3 data/dashboards.sqlite3 "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')"`;
scripting the API with `Authorization: Bearer <session token>`;
deployment note — set the password in the container env file (the deployment repo's quadlet
env template), set `DASH_COOKIE_SECURE=1` behind the TLS/nginx front, and note that nginx
config/HSTS live in the deployment repo like every other service here.
- **Configuration table** — the four new variables with their defaults, in the same
two-column style as the existing rows.
6. `AGENTS.md`:
- a new numbered rule in **Non-negotiable rules** — *"One admin, one password, four public
paths."* Everything else is behind the session; the public list is
`/login`, `/api/auth/login`, `/api/health`, `/static/*`; the verifier is PBKDF2 in the
SQLite `meta` table and the env var is a **seed, never an override**; no stored verifier
and no env var means the process refuses to start; a password change rotates the signing
secret so every browser is out; the login POST and every write are origin/CSRF-checked;
and `/api/health` is public by decision, which means nothing sensitive may be added to it
without re-deciding.
- `auth.py` in the one-line-per-responsibility architecture list (pure primitives: PBKDF2
verifier, signed sessions, CSRF tokens, login throttle — stdlib only, no new dependency).
- Architecture/API prose: the four dashboards and `/settings` now require a session.
- **Known gaps** — three entries: the login throttle keys the socket peer address, so behind
nginx a lockout is global (at worst `max_seconds`; accepted for a single admin);
no second factor and no per-user accounts by design; the throttle is per-process, so
running more than one uvicorn worker multiplies the buckets (and the session/CSRF design
itself is worker-safe because it is stateless).
- `PLAN.md`'s Architectural Anchors table has no auth row. This phase adds no dependency, so
it stays inside the existing anchors — but note in the phase `NOTES-*.md` that the operator
may want to ratify "in-house stdlib auth (PBKDF2 + HMAC cookie)" as an anchor; this skill
and this phase must not edit `PLAN.md`.
7. Live browser verification (dev server on `http://127.0.0.1:8090`, tunnels up, live data):
- `./scripts/dev-server.sh restart`, then confirm `./scripts/dev-server.sh status` still
prints per-source `ok`/`DOWN` **without** credentials;
- screenshots into `data/logs/`: `auth-login.png`, `auth-login-rejected.png`,
`auth-locked-out.png`, `auth-signed-in-overview.png`, `auth-settings.png`,
`auth-account-link.png` (the last two from task 06 — retake if a pixel moved);
- leak checks, recorded in `NOTES-verify.md` with the actual status codes:
`curl -si localhost:8090/system` (303 → `/login?next=/system`),
`curl -s localhost:8090/api/summary` (401), `/api/config` (401), `/api/events` (401),
`/api/system/history?...` (401), `/api/health` (200),
`curl -s localhost:8090/login | grep -c 'http'` (0 external refs);
- once signed in, walk all five pages and confirm the five completed phases look exactly as
they did before: the `/system` hero rows, the `/llm` sparklines and latency feet, the `/ups`
glyph and heroes, the overview's two panels, the `Δ<window>` footnotes;
- `uv run pytest -m live --live` — the live suite talks to the agents, engines and upsd
directly rather than to the dashboard, so it must be unaffected; run it and say so.
8. `NOTES-auth.md` in the phase directory — what was verified how (which screenshot shows
which behaviour), the `curl` outputs, the exact `meta` keys written, and anything the operator
must do in the deployment repo (set `DASH_ADMIN_PASSWORD`, set `DASH_COOKIE_SECURE=1`).
## Testing & Quality
- `bash .agents/validate.sh` green — this is the task where the tracked-template secret gate
bites, so run it before declaring the docs done.
- `git status --porcelain` must show no `.env` / `.env.dev` change staged (both are ignored) and
no screenshot except the `data/logs/` drops the repo already ignores.
- Coverage: no code change expected; the gate (94%) must not move.
## Completion Criteria
- [ ] `.env.example` and `.env.dev.example` are placeholder-clean and `bash .agents/validate.sh`
passes the template scan (quote the passing line in the commit message)
- [ ] `.env.dev` has the dev password, still mode `0600`, still containing its real NUT
credentials untouched
- [ ] `./scripts/dev-server.sh restart && ./scripts/dev-server.sh status` works unauthenticated
- [ ] all six screenshots exist in `data/logs/` and show live data, not the simulator
- [ ] the leak-check list above returns exactly the codes listed, recorded in `NOTES-auth.md`
- [ ] README's *Signing in* section and four config rows exist; `AGENTS.md` has the new
invariant, the `auth.py` line, and the three known gaps
- [ ] `uv run pytest` and `uv run pytest -m live --live` both green;
`uv run ruff check src tests agent && uv run pyright` clean; `bash .agents/validate.sh`
green
@@ -0,0 +1,13 @@
**Phase 04 final verification pass — all tasks already in `complete/`; verified + fixed 3 defects found in verification.**
- Verified `/llm` live (both engines, gufo-only instance on :8099, `llm_monitor` on :8095) in a real browser; dev server left running/healthy.
- Completed task-04's truncated layout fix: `#engines` now carries `.items-start` (the CSS rule existed but nothing used it) — gufo 544 px vs vLLM 715 px, no blank card; pinned by `test_llm_engine_panels_are_not_stretched_to_their_tallest_sibling`.
- Fixed: event rows said the subject twice ("gufo gufo is answering again"), and `/ups` mangled mid-message names ("sent to apc: OK" → "sent to OK") → shared `Dash.eventText()` (strips `UpstreamError: ` + a *leading* name only, mirroring `alerts._clean_error`) used by `/system`, `/llm`, `/ups`; new test.
- Fixed: count sparklines autoscaled rollup averages (queue max 0.1379) into full-height spikes → `sparkline({minSpan})`, `minSpan: 1` on sessions/queued (spike 30 px → 4 px); assertions added.
- Tests: `bash .agents/validate.sh` → **EXIT=0** (`uv run pytest --cov --cov-fail-under=94`: **485 passed, 7 skipped**, coverage **96.87%**; `ruff check src tests agent`: All checks passed; `pyright`: 0 errors; `node --check` all static JS clean; repo hygiene clean; `uv pip list --outdated` empty). `uv run pytest -m live --live` → **7 passed**.
- Completion criteria: heroes largest per engine ✔ (38.4 px vs 16.8 px row values); sparkline per cell + selected window ✔ (5/5 gufo, 7/7 vLLM; 6 h chip moved KV max 53 %→58 %); mean+p95 without hover ✔ (243 / 48.8 / 285 ms feet); provenance tag ✔ (`Δ10.0s`/`Δ8.0s` + `title`=exact source); single-engine ✔ (1 panel, totals hidden, zero red); screenshots ✔ `data/logs/llm-verify-{both-engines,charts-and-slots,single-engine,monitor-reference}.png` + `NOTES-chart-set.md`; pytest/cov/ruff/pyright ✔; earlier phases unchanged-behaviour ✔ (all 01–03 tests pass).
- Deviation (documented, not code): throughput heroes stay **neutral ink** — `severity.py` has no throughput row and 0 tok/s idle is healthy; colour comes from rated cells (KV) + reachability. Kept per `NOTES-chart-set`.
- Deviation: could not append the task-04 written verdict to `NOTES-chart-set.md` (it lives in `todo/`, which I must not edit). Verdict: ours reads better than `:8095` — bigger throughput numbers, per-cell shape over the selected window, percentiles inline; `llm_monitor` only sparklines decode+KV and has no window control.
- Docs: AGENTS.md updated (485 test count, `sparkline` `minSpan` rule, `Dash.eventText` event-row rule, `/llm` panel `items-start`); README reviewed in full — commands, config table (all `config.py` vars present), architecture bullets, page descriptions verified accurate; no other inconsistencies found.
- Note for the harness: `.agents/phases/todo/03…/NOTES-verify.md` and `todo/04…/NOTES-chart-set.md` are untracked notes left behind by their phases (task files moved, notes did not).
- Next pending phase: `05_ups_hero_battery_glyph`.
@@ -0,0 +1,46 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 14%]
........................................................................ [ 29%]
........................................................................ [ 43%]
......................................sssssss........................... [ 58%]
........................................................................ [ 73%]
........................................................................ [ 87%]
............................................................ [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 367 22 94% 118, 132, 138, 150, 156, 168, 176, 181, 252-253, 255, 414, 458, 470, 505, 538, 542, 595, 599-600, 605, 617
src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2109 66 97%
Required test coverage of 94% reached. Total coverage: 96.87%
485 passed, 7 skipped, 1 warning in 44.38s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1 @@
Now I have everything verified. Writing the decision note:
@@ -0,0 +1 @@
error: assistant error: litellm.APIConnectionError: APIConnectionError: OpenAIException - Response payload is not completed: <TransferEncodingError: 400, message='Not…
@@ -0,0 +1,46 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 15%]
........................................................................ [ 30%]
........................................................................ [ 45%]
...........................sssssss...................................... [ 60%]
........................................................................ [ 75%]
........................................................................ [ 90%]
............................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 226
src/dashboards/sources/nut.py 367 22 94% 118, 132, 138, 150, 156, 168, 176, 181, 252-253, 255, 414, 458, 470, 505, 538, 542, 595, 599-600, 605, 617
src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2109 66 97%
Required test coverage of 94% reached. Total coverage: 96.87%
472 passed, 7 skipped, 1 warning in 43.29s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,7 @@
**Task 01 complete — chart-set decision note written.**
- **Implemented:** `.agents/phases/todo/04_llm_chart_readability/NOTES-chart-set.md` — keep/hero/sparkline-cell/conditional-demote/charts decisions with every claim cited by path+line from both `llm_monitor/static/index.html` (drawSpark L1092, right-aligned growth L1158–1163, fixedMax L1356/L1420, setThroughput L1303, historyLen L1003, percentile feet L950/955/960, slot cards L1179/L1209, single-engine hide L1500–1504) and this repo (`page-llm.js` CELLS L16–33 + `severityFor` sites L194–195/L307, `charts.js` sparkline L348, `llm.py` HISTORY_METRICS L113/SEVERITY_CELLS L99, `severity.py` L46–57); colour rules = KV rated via payload severity (75/90), queue depth & throughput explicitly unrated → colourless; sparkline contract for task 02; flagged one additive Python delta (store `llm.tpot_seconds`, `llm.spec_accept_rate` so no cell is a permanent "no history" slot).
- **Tests/lint:** `uv run pytest` exit 0 (all passed, 7 live-skipped) · `--cov --cov-fail-under=94` → TOTAL 97% · `uv run ruff check src tests agent` clean · `uv run pyright` 0 errors · `node --check` all `web/static/*.js` clean.
- **Docs:** no doc impact — note-only task, no user-visible change yet; `git status` shows only the new note (plus pre-existing untracked harness files).
- **Notable decisions:** throughput heroes stay neutral-ink (phase checklist's "colour-coded" reconciled with the no-invented-threshold rule via the `/system` NET precedent; noted for task 04); fleet-totals KV bar removed and the card hidden below two engines; `HISTORY_METRICS` extension marked as the phase's single Python exception for task 03 to implement with tests.
- **Next pending task:** `02_sparkline_upgrade.md`.
@@ -0,0 +1,46 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 15%]
........................................................................ [ 30%]
........................................................................ [ 45%]
...........................sssssss...................................... [ 60%]
........................................................................ [ 75%]
........................................................................ [ 90%]
............................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 226
src/dashboards/sources/nut.py 367 22 94% 118, 132, 138, 150, 156, 168, 176, 181, 252-253, 255, 414, 458, 470, 505, 538, 542, 595, 599-600, 605, 617
src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2109 66 97%
Required test coverage of 94% reached. Total coverage: 96.87%
472 passed, 7 skipped, 1 warning in 43.32s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,10 @@
All green. Task complete.
**Report**
- **Implemented** — `DashCharts.sparkline()` upgraded to the `drawSpark` contract (task 02) in `src/dashboards/web/static/charts.js`: new opts `fixedMax` (data clamps to a fixed `[0, ceiling]` axis + top/bottom scale marks, never axis-to-data rescale), `align: 'right'` + `span` (newest pinned right, growth leftwards over real time), `labels: {current, max}` foot via caller's `format`, `severity` (stroke from `--ok/--warn/--fail` tokens; null → faint `--text-faint` stroke, no fill), `minPoints` (default 2 → "no history"), `ariaLabel`; dependency-free SVG, no animation/transition; `LineChart` untouched (single diff hunk in the sparkline region).
- **Tests** — added `test_sparkline_ships_the_fixed_scale_and_verdict_options` to `tests/test_api.py` (served-bytes pin: option semantics, clamp expression, `x1 - o.span`, token colours, `minPoints: 2`, no `.sev-*`, no transitions).
- **Verification** — `uv run pytest`: **473 passed, 7 skipped**; `--cov-fail-under=94`: **96.87%**; `ruff` + `pyright` clean; `node --check` clean; `bash .agents/validate.sh` passes. Live suite `uv run pytest -m live --live`: **7 passed**. Browser synthetic check on live `/llm`: 10/10 PASS incl. `fixedMax: 100` keeps data 5–10 at y≈29/34 (autoscaled control hits y≈2), 0/1-point → "no history", align-right first x=160.8/190 — screenshot at `data/logs/sparkline-upgrade-task02.png`. Criterion grep on served file: 14 (≥3).
- **Docs** — AGENTS.md UI conventions gained the shared sparkline contract paragraph; no README impact (no setup/command/API change).
- **Decisions** — unrated spark defaults to faint grey (task-file rule) over NOTES-chart-set's `--series-1`; callers wanting orange pass `color`/`severity`. `align:'right'` anchors at the newest sample's ts (task's "newest pinned right"), with `span` setting the window. No new Python, no CSS/HTML changes.
- **Next pending task**: `03_llm_layout_simplified.md`.
@@ -0,0 +1,46 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 15%]
........................................................................ [ 30%]
........................................................................ [ 45%]
............................sssssss..................................... [ 60%]
........................................................................ [ 75%]
........................................................................ [ 90%]
................................................ [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 226
src/dashboards/sources/nut.py 367 22 94% 118, 132, 138, 150, 156, 168, 176, 181, 252-253, 255, 414, 458, 470, 505, 538, 542, 595, 599-600, 605, 617
src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2109 66 97%
Required test coverage of 94% reached. Total coverage: 96.87%
473 passed, 7 skipped, 1 warning in 43.62s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,18 @@
All green. Final state verified; dev server stays up on http://127.0.0.1:8090 with the new page live.
## Report — Task 03 `llm_layout_simplified` (phase 04)
**Implemented**
- `web/pages/llm.html`: totals card shrunk to engines-answering/active/queued and hidden below 2 engines; summed decode/prompt/KV/tokens + seam note dropped; four charts moved *below* the engine panels at half height.
- `page-llm.js` rewritten: `CELLS` flags `hero/spark/alarm` drive markup — two `Dash.hero()` throughput heroes per engine (neutral ink, `Δ<window>` provenance in `.sub`, exact `source` in `title`); compact `.cells` rows with right-aligned tabular values, per-cell `sparkline()` (align:'right'+span, fixedMax 100 on ratio cells, `max` foot, KV coloured from payload `severity` via `severityFrom`); TTFT/TPOT/queue-time show mean + `p95 …` foot line; `preemptions`/`device_lost` render only as `.chip.fail` when non-zero (absent at zero); engine-scoped rows (`only:`) so gufo has no latency mystery; tokens lifetime demoted to footnote; **one** batched `/api/llm/history` request feeds charts + all sparks; both `severityFor(…,75,90)` call sites deleted.
- `sources/llm.py`: additive `HISTORY_METRICS` `tpot_seconds` (vLLM) + `spec_accept_rate` (gufo) per NOTES-chart-set.
- `dash.css`: `.cells/.cell` (label | tabular value | 160px spark column, p95 foot, KV verdict classes, alarm chip row, narrow-screen fallback).
- Fixed two defects found only against live data: hero `cellValue` key mapping and `drawChart` legend-sibling null crash.
**Tests/lint** — `bash .agents/validate.sh` exit 0: **483 passed, 7 skipped**, coverage 96.87 % (≥94), ruff + pyright clean, `node --check` clean. New pins: LLM payload-colour, hero row, spark/p95 contract, one-request + `HISTORY_METRICS` round-trip, alarm-chip rule, totals-card shape, chart height/aria; `test_severity_for_is_kept_as_the_documented_fallback` narrowed to `/ups`. Live: `uv run pytest -m live --live` → 7 passed. Browser (both engines live): heroes/sparks/p95 verified; mocked payload in devtools → red chips at non-zero, absent at zero; single-engine instance (`VLLM_BASE_URL=` unset, :8091, throwaway DB) → one clean panel, totals hidden, no dead panel. Screenshots: `data/logs/llm-layout-both-engines.png`, `llm-alarm-chips-mock.png`, `llm-single-engine.png`. One tiny vLLM chat request (16 tok) was sent to make latency rows non-empty — read-only class of traffic, same kind llm_monitor watches.
**Docs** — AGENTS.md (`/llm` layout convention + `severityFor` now only `/ups` remains) and README (`web/` section `/llm` description) updated.
**Deviations** — Totals card also hides below 2 engines (llm_monitor rule cited by NOTES); heroes neutral per the phase-01 colour language (documented in NOTES + AGENTS.md).
**Next pending task:** `04_side_by_side_verification.md` (same phase).
@@ -0,0 +1,46 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 14%]
........................................................................ [ 29%]
........................................................................ [ 44%]
....................................sssssss............................. [ 58%]
........................................................................ [ 73%]
........................................................................ [ 88%]
.......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 367 22 94% 118, 132, 138, 150, 156, 168, 176, 181, 252-253, 255, 414, 458, 470, 505, 538, 542, 595, 599-600, 605, 617
src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2109 66 97%
Required test coverage of 94% reached. Total coverage: 96.87%
483 passed, 7 skipped, 1 warning in 47.36s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1 @@
Now let me fix the layout void I measured (the shorter engine card stretched to the taller one, leaving 172 px of empty card):
@@ -0,0 +1,46 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 14%]
........................................................................ [ 29%]
........................................................................ [ 44%]
....................................sssssss............................. [ 58%]
........................................................................ [ 73%]
........................................................................ [ 88%]
.......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 367 22 94% 118, 132, 138, 150, 156, 168, 176, 181, 252-253, 255, 414, 458, 470, 505, 538, 542, 595, 599-600, 605, 617
src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2109 66 97%
Required test coverage of 94% reached. Total coverage: 96.87%
483 passed, 7 skipped, 1 warning in 45.25s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,16 @@
**Phase 05 final pass — all criteria met; whole project 100 % green.**
Verified (live upsd + isolated simulator):
- Real `apc`/`cyberpower`: Charge/Load/Runtime are the card's three largest elements (38.4 px vs 15.2 px next), coloured only by API `severity` — live right now 100 % ok / 79.0 % **warn** / 5m **fail**; glyph fill = charge (100/100 viewBox units, `--ok` token), `<title>` "apc: Charge 100 percent, on line"; SIMULATED hidden; battery °C `n/a` uncoloured; every button disabled with its reason; `confirm_text: DANGER` on power-cutting commands; **0 non-GET requests** → `data/logs/ups-verify-live-{card,full}.png`.
- Simulator on `:8091` (own `/tmp` DB, killed after): caution dialog → Cancel, dangerous dialog → Run disabled until `DANGER` → Cancel, **0 POSTs**; one deliberate quick test *to the sim only* flipped the card to `OB` (glyph `stroke-dasharray="7 5"`, "on battery", health "discharging, on battery") then `OL CHRG` (bolt) then `OL` → `ups-verify-sim-{dialog-caution,dialog-danger,onbattery-button,onbattery-page,charging-button}.png`; glyph matrix `ups-glyph-*.png` (100/mid/low/unknown/empty/unrated, dark/light/200 %) present; `grep -c "function battery"` = 1.
Defects found in earlier work and fixed:
1. **`poller.py` could stop polling in silence** — 4 of 6 dev-server sources had frozen `attempts` (ages 12–38 min) while `/api/health` still said `ok: true`: the store-write half of `run_once` was outside the `try`, and a dead task's exception is never retrieved. Now `_commit()` moves `last_ok` only after the write lands, `_store_refused()` reports it (`ok:false`, real reason, one `store-error` event + one `recording` on recovery, payload kept), and `_loop()` catches anything that escapes and keeps polling — 4 new tests in `tests/test_poller.py`.
2. **`nut_sim.py` (modified by this phase) sat at 83 % coverage**; its whole command path was untested. Added tests (every advertised command implemented, unknown → `ValueError`, alarms → card + `assess()` fail, `NUT_SIMULATE` truthy rule) and fixed two model lies: `beeper.*` answered OK while changing nothing (now publishes `ups.beeper.status` enabled/muted/disabled) and a finished self-test never wrote `ups.test.result` (aborted writes none). → 100 %.
3. `nut_sim.from_env()` was dead code duplicating `app._truthy`; `app.py` now builds the simulator through it, so the enable gate lives once.
Tests / lint (exact): `bash .agents/validate.sh` → **EXIT=0** — `uv run pytest --cov --cov-fail-under=94` **543 passed, 8 skipped, coverage 97.91 %**; `uv run ruff check src tests agent` clean; `uv run pyright` 0 errors; `node --check` clean; repo hygiene clean. `uv run pytest -m live --live` **with `.env.dev` sourced → 8 passed** (one earlier run: 2 transient `9121/snapshot timed out` against the 4 s `HOST_TIMEOUT`; three later runs 8/8). `uv pip list --outdated` empty. Dev server restarted on the final code: `health ok`, 6/6 sources fresh.
Docs: AGENTS.md — invariant 4 extended ("nothing may stop polling in silence" + store-refusal semantics), `poller.py`/`nut_sim.py` responsibility lines, 533→543 and 96.9 %→97.9 %, "~97 %"→"~98 %". README.md — poller bullet gained the same guarantee, Testing gained "source `.env.dev` before `-m live --live`" (bare runs skip 7 of 8 live tests, which reads like a pass), Battery-test section gained the `deploy/nut-sudoers` install line and the read-before-write warning.
Notes/deviations: no live instant command was ever sent to real hardware (the four gates leave them disabled — AGENTS gap 1 unchanged); the on-battery proof was driven through the real button against `NUT_SIMULATE=1`, which is stronger than the NOTES hand-driven states. `NOTES-verify.md` under `todo/` left untouched per instructions. Next phase: **none** — 05 is the last in the PLAN.md roadmap.
@@ -0,0 +1,47 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 13%]
........................................................................ [ 26%]
........................................................................ [ 39%]
..................................................ssssssss.............. [ 52%]
........................................................................ [ 65%]
........................................................................ [ 78%]
........................................................................ [ 91%]
............................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 107 6 94% 50-51, 56, 58, 60, 127
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 373 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 476, 488, 523, 556, 560, 617-618, 623, 635
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2151 45 98%
Required test coverage of 94% reached. Total coverage: 97.91%
543 passed, 8 skipped, 1 warning in 47.58s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,29 @@
All checks green. Final verification summary:
```
uv run pytest → 518 passed, 8 skipped (live)
uv run pytest --cov --cov-fail-under=94 → 96.97% (sources/nut.py 95%)
uv run ruff check src tests agent → All checks passed
uv run pyright → 0 errors
node --check web/static/*.js → clean
uv run pytest -m live --live → 8 passed (real apc + cyberpower, engines, fleet)
bash .agents/validate.sh → EXIT=0 · uv pip list --outdated → empty
```
**Implemented**
- `tests/fixtures/nut_apc.vars` / `nut_cyberpower.vars`: verbatim `LIST UPS`/`LIST VAR`/`LIST CMD` replies from the real upsd (device serials deterministically scrubbed), recorded by the extended `scripts/capture-fixtures.sh` (new UPS section + serial scrubber) — no hand-typed variable table.
- `tests/ups_contract.py`: `HERO_FIELDS` / `UPS_SHAPES` / `parse_capture()` (uses the client's own wire splitter) / `assert_ups_hero_contract()` — the `/ups` twin of `card_contract.py`, shared with the live suite.
- `tests/test_ups_contract.py` (33 tests): every hero/glyph path on both real drivers; `battery_temperature` `None` (never 0) + `unknown` check; charge 100/45/15 → ok/warn/fail; runtime 3600/400/200 → ok/warn/fail and absent → `None`; served-JS proof `runtimeText(null)` returns the app's `naText` (`n/a`, not `0s`) and runs before any formatter; glyph state table (`ol`/`full`/`chg`/`dchg`), "full + on line never says charging", both on-battery routes, and a guard that `page-ups.js` never re-parses NUT flags.
- `tests/test_live_fleet.py::test_ups_hero_fields_are_present_on_the_real_batteries` — same table against live upsd, read-only.
- Live check after `dev-server restart`: both `apc` and `cyberpower` serve `severity` for charge/load/runtime, `battery_temperature: null`, `charger_status: null`, `simulated: false`.
**Defects found and fixed** (`sources/nut.py`)
- `battery_runtime` borrowed `battery.runtime.low` (a shutdown *threshold*) as the estimate — the hero would print a confident "5m left". It is now `battery.runtime` only; the threshold stays readable at `battery_runtime_low`.
- `charger_status` is normalised to the lowercase `CHARGER_STATUS` keys (as `states()`/`on_battery` already compare), so a driver answering `FULL` can't cost the glyph its state; `nut.py`'s protocol docstring now shows the verbatim 2.8.5 framing (`VAR apc ups.status "OL"` — quoting is optional).
- `page-ups.js`: `runtimeText()` returns `Dash.naText` instead of a duplicated literal (verified behaviourally with a throwaway node vm run: `n/a` / `10m` / `1h 32m`; no JS test added, per the locked anchor).
**Docs**: AGENTS.md (nut.py responsibilities incl. the runtime/charger rules, real-hardware quirks incl. "neither driver publishes `battery.charger.status`", UPS capture fixtures + test-count/command lines, testing-policy bullet for `ups_contract.py`) and README.md (fixture capture commands + UPS contract paragraph) updated.
**Notable decisions**: fixtures over hand-written vars (rule 7, and the drivers genuinely disagree — `battery.mfr.date: CPS`, `battery.date 2001/09/25` with slashes, only CyberPower exports `output.voltage`); no screenshots — task 01 changes no visuals, browser proof is task 04's mandate.
**Next pending task**: `05_ups_hero_battery_glyph/02_battery_glyph.md` (`DashCharts.battery()`).
@@ -0,0 +1,47 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 13%]
........................................................................ [ 27%]
........................................................................ [ 41%]
......................................ssssssss.......................... [ 54%]
........................................................................ [ 68%]
........................................................................ [ 82%]
........................................................................ [ 95%]
...................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 368 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 466, 478, 513, 546, 550, 607-608, 613, 625
src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2110 64 97%
Required test coverage of 94% reached. Total coverage: 96.97%
518 passed, 8 skipped, 1 warning in 44.57s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,18 @@
**Implemented** (task `05_ups_hero_battery_glyph/02_battery_glyph.md`)
- `src/dashboards/web/static/charts.js` — `DashCharts.battery(container, {charge, severity, state, label, decorative})`: horizontal cell (rounded shell + nub) in one `<svg>`; `<rect>` fill = `charge/100` of the interior, clipped to the plate so 0 % leaves an empty shell; clamped 0–100 and never padded (verified: 2 % → 2.00 of 100 units, −5/1000/null → nothing); colour only from `cssVar('--ok'|'--warn'|'--fail')`, unrated measured charge = faint grey at half strength (shape, not verdict), unknown = no fill.
- State shapes readable without colour: solid shell `online`, bolt `charging`, dashed outline `onbattery`, dash pattern *through* the body `unknown`; an unknown state word degrades to `unknown`, never "on line".
- Accessible by default: `role="img"` + `<title>` + `aria-label` ("apc: Charge 100 percent, on line"), label escaped; documented opt-out `decorative: true` → `aria-hidden` (chosen: labelled, since the glyph says what the markup doesn't).
- Scales by container (`viewBox` + `preserveAspectRatio`, measured 210×91.9 = the 128:56 ratio); no animation/transition at all; exported in `window.DashCharts`.
- `dash.css` — only the `.battery` box (`width:100%; max-width:210px`, `svg{width:100%;height:auto}`).
- 6 new static-asset tests in `tests/test_api.py` (export, fill-honesty/clipping, token-only colour, state shapes, a11y, scaling/no-motion).
**Tests / lint / coverage**
- `uv run pytest` → **524 passed, 8 skipped**; `uv run pytest --cov --cov-fail-under=94` → **96.97 %**; `uv run ruff check src tests agent` → passed; `uv run pyright` → 0 errors; `node --check src/dashboards/web/static/*.js` → clean; `uv run pytest -m live --live` → **8 passed**; `bash .agents/validate.sh` → **EXIT=0**.
- Phase criterion: `curl -s localhost:8090/static/charts.js | grep -c "function battery"` → **1**.
- Browser matrix (live `/ups`, real upsd + synthetic inputs) in `data/logs/`: `ups-glyph-{100-ok-online,60-ok-charging,35-warn-onbattery,12-fail-onbattery,02-fail-onbattery,00-empty-onbattery,unknown,87-unrated-online}.png` + `ups-glyph-matrix-{dark,light}.png` + `ups-glyph-matrix-zoom200-light.png`; every state distinguishable in monochrome, both themes and 200 % zoom checked; all four pages still render live data after the shared-file change.
**Docs**: AGENTS.md (new UI-conventions paragraph for the glyph: fill-is-charge, state shapes, a11y choice, no animation, page derives `state`) and README.md (`web/` bullet) updated.
**Decisions**: unrated-but-measured charge draws a grey fill rather than an empty shell ("0 %" ≠ "not rated"); no fill transition added, so `prefers-reduced-motion` holds by construction; no notes file written in `todo/` (harness owns those). Dev server restarted, `/api/health` ok.
**Next pending task**: `05_ups_hero_battery_glyph/03_ups_hero_row.md`.
@@ -0,0 +1,47 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 13%]
........................................................................ [ 27%]
........................................................................ [ 40%]
............................................ssssssss.................... [ 54%]
........................................................................ [ 67%]
........................................................................ [ 81%]
........................................................................ [ 94%]
............................ [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 368 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 466, 478, 513, 546, 550, 607-608, 613, 625
src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2110 64 97%
Required test coverage of 94% reached. Total coverage: 96.97%
524 passed, 8 skipped, 1 warning in 46.62s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,16 @@
All green. Final report:
**Implemented** (`05_ups_hero_battery_glyph/03_ups_hero_row.md`)
- `page-ups.js`: `upsCard()` now opens with `.ups-hero` = battery glyph (`data-k="glyph"`) + three `.hero` slots — **Charge %**, **Load %**, **Runtime left** — with `.sub` context (`24 V nominal · 27.3 V`, `128 W of 900 W` only when both `realpower` figures exist, `at the current load`); line in / output / battery V / battery A / battery °C demoted to a visible-but-muted `.ups-electrical` row (not deleted). States chips, `SIMULATED` badge, health checks, command groups, NUT dump and the whole `#confirm-dialog`/`DANGER` flow untouched.
- `renderCard()`: three `Dash.hero()` calls coloured only from `severity.charge_pct|load_pct|battery_runtime`; **`severityFor()` deleted from this file** (no page script calls it anywhere now); runtime prose still only from `runtimeText()`, unmeasured → `null` so `hero()` prints faint, uncoloured `n/a`.
- `glyphState()` (on_battery → `chg` → `dchg`/`rec` → online tokens → an `ok`-rated `states[]` chip → `unknown`) + `drawGlyph()`, which repaints only when charge/verdict/state change and passes `decorative: true`. `dash.css`: `.ups-hero` (glyph column 112–156px, 140px hero track floor, stacks under 620px) and `.ups-electrical`.
- Tests: 6 new in `tests/test_api.py` (hero shape/order + `data-sev` hooks, payload-only colour, no bar on charge, secondary row demoted/muted/`n/a` faint, glyph-state mapping, repaint-only-on-change, row layout/stack) and `test_severity_for_is_kept_as_the_documented_fallback` rewritten to fail if any page re-invents a threshold.
- Browser (live apc + cyberpower, both themes, both real and synthetic states): `data/logs/ups-hero-row-{live,states}-{dark,light}.png`, `ups-hero-row-narrow.png`, `ups-hero-row-danger-dialog.png`. Measured: no clipped/overlapping numbers, glyph node identical across two polls, zero card/hero layout shift, `Battery °C` = faint `n/a` on both UPSes; dialog verified Cancel (caution) and the `DANGER` gate (Run disabled until typed) with a fetch spy recording **0 POSTs**.
**Verification**: `bash .agents/validate.sh` EXIT=0 → `530 passed, 8 skipped`, coverage `96.97%` (floor 94), `ruff` All checks passed, `pyright` 0 errors, `node --check` clean; `uv run pytest -m live --live` → `8 passed`; `uv pip list --outdated` empty.
**Docs**: AGENTS.md — `severityFor` now "no page script calls it any more", new `/ups` hero-row convention (no bar on charge, sub rules, demoted electrical row, glyph-state mapping incl. "`full` is on line, not charging", repaint key, `decorative: true`), test count 518→530. README.md — `/ups` card described in the `web/` bullet.
**Decisions/deviations**: task text said `charger_status in ('dchg',) → 'charging'` — that's discharging, so `chg` charges and `dchg`/`rec` draw the on-battery shell; because **neither real driver publishes `battery.charger.status`**, the online case falls back to a state chip the API rated `ok` (`nut.states()` reserves `ok` for on-line/fully-charged) rather than re-parsing `ups.status` in JS (banned by `tests/test_ups_contract.py`). Documented remainder: a ≥10 h runtime (`10h 05m`, 161px) can reach past its 140px track into the row's right padding — it's the last slot, so it never lands on another number, and shortening the pinned prose was rejected. Dialog wiring was exercised with the buttons' `disabled` attribute removed client-side: the live API gates every command off (AGENTS gap 1 — no `actions` user in `upsd.users`), so no live config was touched and no command sent. Pre-existing, untouched: the shared masthead overflows below ~620px on every page (verified identically on `/system`). Mid-verification the dev server's UPS source alternated twice between real upsd and `NUT_SIMULATE=1` (another process); the final screenshots were re-taken after confirming `simulated=false`, `24 V nominal`, `battery_temperature=null`.
**Next pending task**: `05_ups_hero_battery_glyph/04_verify_real_batteries.md`.
@@ -0,0 +1,47 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 13%]
........................................................................ [ 26%]
........................................................................ [ 40%]
..................................................ssssssss.............. [ 53%]
........................................................................ [ 66%]
........................................................................ [ 80%]
........................................................................ [ 93%]
.................................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 368 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 466, 478, 513, 546, 550, 607-608, 613, 625
src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2110 64 97%
Required test coverage of 94% reached. Total coverage: 96.97%
530 passed, 8 skipped, 1 warning in 44.89s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,17 @@
All green, environment restored. Final report:
**Implemented (`05_ups_hero_battery_glyph/04_verify_real_batteries.md`)**
- Verified both real UPSes (`data/logs/ups-real-apc.png`, `ups-real-cyberpower.png`, both read): apc 100 %/48.0 %/10m green, age 5.3 y **warn**, temp `n/a`; cyberpower 100 %/14.0 % (`127 W of 900 W`)/56m, age **unknown** (`mfr.date: CPS` never became an age); `SIMULATED` absent on real reads.
- Five simulator states screenshotted with `NUT_SIMULATE=1` + `NUT_ALLOW_ACTIONS=0`, `SIMULATED` badge visible in each: `ups-sim-100-online/60-charging(bolt)/35-onbattery(amber)/12-low(OB LB, red)/nacharge(n/a, uncoloured, no fake runtime)` + full-page `ups-sim-12-low-page.png` (banner + transition events).
- **Defects found & fixed:** (1) `nut_sim.py` never reported `chg`/`CHRG` (and claimed `ol` while `OB`), making the glyph's bolt state unreachable in dev — now on-line-below-float = `OL CHRG`/`chg`, on-battery = `OB`/`dchg`; (2) `UpsReading.states()` said the same word twice (charger token + subsumed flag) — now one chip per fact. 3 new pinning tests in `tests/test_nut.py`.
- Stale path: stopped only the 3494 forward → `ups-stale-lastgood.png` shows last-good heroes + `stale 75s` + red dot/banner, nothing zeroed; forward restored, all sources ok.
- DANGER gate: exercised with genuinely-enabled buttons on the sim run (`NUT_ALLOW_ACTIONS=1` — Cancel-only, no command sent): caution dialog + Cancel, dangerous dialog Run-disabled→type `DANGER`→enabled→Cancel; **0 POSTs**, sim `ups.test.result` untouched (`ups-dialog-caution.png`, `ups-dialog-danger-typed.png`).
- Wrote `.agents/phases/todo/05_ups_hero_battery_glyph/NOTES-verify.md` (each shot ↔ state, defect writeup, note on pre-existing port-3494 probe events).
**Tests/lint:** `bash .agents/validate.sh` → EXIT=0 (`533 passed, 8 skipped`; coverage 96.98 % ≥ 94; ruff clean; pyright 0 errors; `node --check` clean). `uv run pytest -m live --live` → 8 passed. No deps moved.
**Docs:** AGENTS.md — nut_sim status semantics (`chg` reachable, `OB` never `ol`), one-chip-per-fact in `states()`, test count 530→533. README: no change needed (`NUT_SIMULATE` wording already accurate).
**Deviations:** the dialog pass ran with `NUT_ALLOW_ACTIONS=1` (sim only) because with `0` the buttons are legitimately disabled and cannot open the dialog; five-state pass kept `0` as instructed. No live instant command sent.
**Next pending task:** none in phase 05 — task 04 was the phase's last; harness may now complete/commit the phase.
@@ -0,0 +1,47 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 13%]
........................................................................ [ 26%]
........................................................................ [ 39%]
..................................................ssssssss.............. [ 53%]
........................................................................ [ 66%]
........................................................................ [ 79%]
........................................................................ [ 93%]
..................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 28
src/dashboards/alerts.py 175 0 100%
src/dashboards/api.py 193 0 100%
src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131
src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
src/dashboards/config.py 113 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 139 1 99% 226
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
src/dashboards/rates.py 37 1 97% 51
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/host.py 199 1 99% 147
src/dashboards/sources/llm.py 150 1 99% 228
src/dashboards/sources/nut.py 373 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 476, 488, 523, 556, 560, 617-618, 623, 635
src/dashboards/sources/nut_sim.py 124 21 83% 152, 178-196, 237
src/dashboards/store.py 178 2 99% 243-244
------------------------------------------------------------------
TOTAL 2117 64 97%
Required test coverage of 94% reached. Total coverage: 96.98%
533 passed, 8 skipped, 1 warning in 45.33s
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
# .agents/validate.sh — validation gate for the phased-execution pipeline.
#
# A unit is only moved to .agents/phases/complete/ if this script exits 0,
# and a phase is only COMMITTED if its final pass exits 0 — so this script
# is the last line of defense for "no phase commits unless 100% passing".
# It MUST run the project's FULL test suite (every test, not a subset) plus
# its linter, so a failure anywhere in the project blocks the commit.
#
# Adapted to THIS project's real gate (AGENTS.md "Definition of done", PLAN.md
# "Validation / Verification Workflow"):
#
# uv run pytest --cov hermetic full suite, coverage floor 94%
# uv run ruff check src tests agent
# uv run pyright
#
# Two rules this script had to learn the hard way:
#
# * Run the PROJECT toolchain, not whatever is on PATH. `python3 -m pytest` here
# resolves to /usr/bin/python3 + ~/.local site-packages (tests/conftest.py puts
# src/ on sys.path, so it passes while testing an interpreter nobody deploys),
# and a bare `ruff` on PATH is older than the `ruff>=0.17.1` floor in
# pyproject.toml. `uv run` is the only invocation that matches the docs.
# * Never skip a check silently. `if command -v ruff; then ruff check .; fi`
# exits 0 when the linter is missing — a gate that can pass without linting is
# not a gate. A missing tool is a FAILURE here; the checks are listed by name.
set -uo pipefail
rc=0
# Run from the repo root no matter where the harness invoked us, so `src`,
# `tests`, `agent` and the pytest/ruff/pyright configs all mean the same tree.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" || { echo "validation FAILED: cannot cd to $ROOT"; exit 1; }
# --- the Python gate ----------------------------------------------------------
# uv is the documented entry point (`uv sync --python 3.14`); if it is gone the
# venv is still usable directly, and only a total absence of both is fatal.
if command -v uv >/dev/null 2>&1; then
RUN=(uv run)
elif [[ -x .venv/bin/pytest && -x .venv/bin/ruff && -x .venv/bin/pyright ]]; then
RUN=(.venv/bin/)
echo "note: uv not on PATH — using .venv/bin directly" >&2
else
echo "validation FAILED: no uv and no complete .venv (pytest+ruff+pyright)." >&2
echo "Run 'uv sync --python 3.14'. Refusing to guess a system interpreter:" >&2
echo "that path skips the pinned toolchain and can pass on the wrong stack." >&2
exit 1
fi
echo "== uv run pytest --cov (hermetic: no network, live tests skip) =="
# --cov-fail-under is the floor from PLAN.md (overall must not drop below 94%);
# the project floor in AGENTS.md is 80%, and we are at 95%.
"${RUN[@]}" pytest --cov --cov-fail-under=94 || rc=1
echo "== uv run ruff check src tests agent =="
"${RUN[@]}" ruff check src tests agent || rc=1
echo "== uv run pyright =="
# pyright's `include` in pyproject.toml is src+tests on purpose: agent/host_agent.py
# is stdlib-only and must stay runnable on the fleet's Python 3.10, so it is linted
# (ruff, above) but not type-checked against 3.14 stubs.
"${RUN[@]}" pyright || rc=1
# --- static assets: syntax only, no toolchain added ---------------------------
# There is no JS build step or JS linter (a locked anchor), so a broken page is
# only caught in a browser. `node --check` is a zero-dependency syntax gate that
# catches the class of error a stray brace makes; a missing node is a visible
# skip, never a silent pass of a check that ran nothing.
if command -v node >/dev/null 2>&1; then
echo "== node --check src/dashboards/web/static/*.js =="
for js in src/dashboards/web/static/*.js; do
node --check "$js" || rc=1
done
else
echo "note: node not on PATH — skipping the JS syntax check (browser verification only)" >&2
fi
# --- dependency currency (advisory, not a gate) -------------------------------
# AGENTS.md rule 8 checks this after touching dependencies; making it a hard gate
# would block an unrelated phase on an upstream release that landed five minutes
# ago, so it is loud in the log instead of silent.
if command -v uv >/dev/null 2>&1 && [[ -f uv.lock ]]; then
outdated="$(uv pip list --outdated 2>/dev/null | grep -vE '^(Package|---|$)' || true)"
if [[ -n "$outdated" ]]; then
echo "advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):" >&2
printf ' %s\n' "$outdated" >&2
fi
fi
# --- repo hygiene (language-agnostic; runs in every project) ------------------
# Scoped staging commits everything the phase touched, so a file that is not
# ignored gets committed — including raw log drops and key material. These
# gates make the phase fail instead. Fix by untracking, keeping the working
# copy: git rm --cached <file>
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
# 1. A tracked file that matches a .gitignore rule should never be tracked.
junk="$(git ls-files -i -c --exclude-standard 2>/dev/null || true)"
if [[ -n "$junk" ]]; then
echo "repo hygiene: TRACKED files match .gitignore rules (untrack with 'git rm --cached <file>'):" >&2
printf ' %s\n' "$junk" >&2
rc=1
fi
# 2. Personal-data / key-material file SHAPES that must never be tracked,
# even in a project with no .gitignore rule for them.
DANGEROUS='(^|/)[^/]*\.log$|(^|/)[^/]*\.jsonl$|(^|/)\.env($|\.)|\.(pem|key|crt|cer|p12|pfx|keystore|jks)$|(^|/)(id_rsa|id_ecdsa|id_ed25519)(\.pub)?$|(^|/)(credentials|service-account|client_secret)([-a-z0-9]*)\.json$'
# Project allow-list (this repo's deliberate, documented choice — see .gitignore):
# the env *templates* `.env.example` / `.env.dev.example` are tracked on purpose,
# because `cp .env.example .env` is the setup documented in README.md and every
# real `.env` / `.env.dev` stays git-ignored. A template is documentation, not key
# material — and 2b below keeps that claim honest, so this allow-list cannot smuggle
# a live credential past the gate. Nothing else is allow-listed: logs, key material,
# and real dot-env files still fail here.
SAFE_TEMPLATES='(^|/)\.env[^/]*\.(example|template|sample)$'
bad="$(git ls-files | grep -E "$DANGEROUS" | grep -vE "$SAFE_TEMPLATES" || true)"
if [[ -n "$bad" ]]; then
echo "repo hygiene: TRACKED files look like log drops / key material (untrack with 'git rm --cached <file>' and add a .gitignore rule):" >&2
printf ' %s\n' "$bad" >&2
rc=1
fi
# 2b. A tracked env template may only carry placeholders: every
# *_PASSWORD|*_TOKEN|*_SECRET|*_KEY assignment must be empty or a placeholder
# word (AGENTS.md rule 6 — "never leak a credential", not even into a sample).
PLACEHOLDER='^(|changeme|change-me|replace-me[-a-z0-9]*|secret|placeholder|dummy|example|example\.net|x+)$'
tmpl_hits=""
while IFS= read -r f; do
[[ -z "$f" ]] && continue
while IFS= read -r line; do
val="${line#*=}"
val="${val%%#*}"
val=${val//[[:space:]]/}
val=${val//\"/}
val=${val//\'/}
[[ "${val,,}" =~ $PLACEHOLDER ]] && continue
tmpl_hits+=" $f: $line"$'\n'
done < <(grep -inE '^[[:space:]]*(export[[:space:]]+)?[A-Za-z0-9_]*(PASSWORD|TOKEN|SECRET|KEY)[A-Za-z0-9_]*=' "$f" 2>/dev/null || true)
done < <(git ls-files | grep -E "$SAFE_TEMPLATES" || true)
if [[ -n "$tmpl_hits" ]]; then
echo "repo hygiene: tracked env TEMPLATE carries a non-placeholder secret (move the real value to the git-ignored .env):" >&2
printf '%s' "$tmpl_hits" >&2
rc=1
fi
# 3. Project-specific content scan for personal data inside tracked files
# (client IPs, account ids, ...). Empty by default — fill it in per
# project, and allow-list documentation ranges (RFC 5737 / 4193 / 3849,
# 127.0.0.0/8, RFC1918) so fixtures do not trip it. Format: one ERE.
PII_PATTERNS="${PII_PATTERNS:-}"
if [[ -n "$PII_PATTERNS" ]]; then
hits="$(git ls-files -z | xargs -0 grep -IHnE "$PII_PATTERNS" 2>/dev/null || true)"
if [[ -n "$hits" ]]; then
echo "personal-data gate: TRACKED files match PII_PATTERNS — redact them:" >&2
printf ' %s\n' "$hits" >&2
rc=1
fi
fi
fi
if [[ $rc -ne 0 ]]; then
echo "validation FAILED (see output above)"
fi
exit "$rc"
+186 -36
View File
@@ -52,12 +52,19 @@ page still renders.
`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.
4. **One dead source must not blink another.** 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 number `n/a`) because
dropping the row would turn a dead agent into "never configured". Transitions write
events; repeated failure writes one line, not 500.
4. **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 number `n/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 in
`error`, `last_ok` frozen so the page ages the numbers, one `store-error` event then one
`recording` event 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/health` went on calling them `ok` — a frozen source that looks healthy is the
one failure mode worse than an outage.
5. **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.
@@ -107,7 +114,10 @@ Responsibilities, one line each:
rates so the agent never remembers who polled it last.
- `config.py` — env only, no config file format, no CLI flags. `.env` is 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.
- `poller.py` — job loop, health records, stale/last-good semantics, transition events. The
success path records (payload → samples → events) and only *then* moves `last_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/events` by `host` gets an empty feed forever
(found on `/system` during phase 03 browser verification).
@@ -120,12 +130,30 @@ Responsibilities, one line each:
`VLLM_HISTOGRAMS`), measured Δ rates, prefill speed as
`mean(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), `COMMANDS` catalog with risk tiers, `available_commands()` filtering,
kelvin-vs-celsius normalisation.
a mystery score), `COMMANDS` catalog 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, and `charger_status` normalised to the `CHARGER_STATUS`
keys (the same lowercase token `states()` and `on_battery` already compare), and `states()`
publishes one chip per fact — a driver exporting both `battery.charger.status` and the
legacy flag that says the same word gets "charging, online", not "charging, online,
charging". `battery_runtime`
comes from `battery.runtime` **only** — `battery.runtime.low` is a shutdown threshold, not
time left, so it lives at `battery_runtime_low` and 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 says `simulated`.
Its modelled `battery.mfr.date` is computed relative to today (not a fixed string that
ages), so a fresh simulator reads healthy and amber keeps meaning something.
ages), so a fresh simulator reads healthy and amber keeps meaning something. Its status
follows the real drivers: a below-float pack on mains reports `OL 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 is `OB`/`dchg`, never `ol`. Every command it advertises is implemented and
visible in a variable a real driver publishes — the beeper ones set `ups.beeper.status`
(`enabled`/`muted`/`disabled`), and a test window that ends writes `ups.test.result: PASSED`
while an aborted one writes nothing; the load/shutdown commands answer `OK` without modelling
an outlet, because a faked power cut in the history is indistinguishable from a real one.
`from_env()` is the single place the `NUT_SIMULATE` truthy rule lives — `app.py` builds 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`/`INVERTED` per metric,
@@ -167,9 +195,11 @@ Responsibilities, one line each:
`Dash.hero(el, {label, value, unit, digits, severity, sub, bar})` renders/patches a
`.hero` big 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; converting its remaining call sites is phases 04–05's job
(`/` was converted in phase 02, `/system` in phase 03 — those pages colour only from the
`severity` verdicts the API sends, and no `severityFor(` call survives in their scripts).
values the API does not rate, and **no page script calls it any more** — `/` was converted
in phase 02, `/system` in phase 03, `/llm` in phase 04 and `/ups` in phase 05, so every
colour on all four pages comes from a `severity` verdict the API sent
(`tests/test_api.py` fails if a page script names it again). A value the API should rate
gets a row in `severity.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
@@ -196,9 +226,14 @@ That scheme is the history API's key — treat it as a public interface.
`ups.description`, the operator's label — the driver's own `identity.model` answers
`GX1500U`, so the description is what identifies the box).
Real-hardware quirks the parsers already handle: `battery.mfr.date` is the useful date
(`battery.date` is often an unset placeholder like `2001-09-25`), CyberPower reports
`battery.mfr.date: CPS` (not a date → `unknown`, never a fake age), neither driver reports
a battery temperature, and `ups.test.result: No test initiated` is the factory state on both.
(`battery.date` is often an unset placeholder — `2001/09/25`, slashes and all, on the APC),
CyberPower reports `battery.mfr.date: CPS` (not a date → `unknown`, never a fake age),
**neither driver reports a battery temperature or the NUT 2.8 `battery.charger.status`**
(so the battery glyph's state has to come from `on_battery`, and `charger_status` must stay
optional), and `ups.test.result: No test initiated` is the factory state on both.
Both UPSes are recorded verbatim (`LIST UPS`/`LIST VAR`/`LIST CMD`, serials scrubbed) as
`tests/fixtures/nut_<ups>.vars` by `./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 in `deploy/nut-sudoers` (additions are listed there for the
work that is still blocked).
@@ -215,11 +250,11 @@ cp .env.dev.example .env.dev # dev-only: ssh tunnels + live targets
./scripts/dev-server.sh status # per-source ok/DOWN
./scripts/dev-server.sh logs
uv run pytest # hermetic: 472 passed + 7 live-skipped, no network
uv run pytest --cov # coverage gate (96.9%; floor 94%)
uv run pytest # hermetic: 543 passed + 8 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
./scripts/capture-fixtures.sh # re-record ground truth (agents + UPS captures)
bash .agents/validate.sh # the phase gate: the four checks above (pytest
# --cov --cov-fail-under=94, ruff, pyright, plus a
@@ -252,7 +287,14 @@ service here.
`test_every_host_serves_the_hero_card_contract` asserts the same table against the live
agents — so a rename fails a test, and a card that shows `n/a` cannot be "fixed" by
inventing a field. `network` carries no `severity` on purpose (throughput has no good/bad),
and the contract pins its absence.
and the contract pins its absence. `tests/ups_contract.py` is the same device for the `/ups`
hero row and battery glyph (`HERO_FIELDS`, `UPS_SHAPES`, `assert_ups_hero_contract`):
`tests/test_ups_contract.py` drives it from `tests/fixtures/nut_<ups>.vars` — verbatim
`LIST UPS`/`LIST VAR`/`LIST CMD` replies from the two real UPSes — and
`test_ups_hero_fields_are_present_on_the_real_batteries` drives it against live upsd. The
captures are what make the driver disagreements testable: one of them answers
`battery.mfr.date: CPS`, neither exports `battery.temperature` or
`battery.charger.status`.
- `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
@@ -260,7 +302,7 @@ service here.
- The agent's real-`/proc` test (`tests/test_agent.py`) touches only the box running pytest,
so it stays in the default run and is *not* marked `live`.
- Coverage gate: 80% is the repo floor, the phase gate in `.agents/validate.sh` enforces
94% (`--cov-fail-under`), and ~97% is where we are; keep it there. New/modified Python
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 use `create_app()` with an in-memory DB,
never a real target, and never a real upstream: `/api/ups/command`'s send is proven against
a scripted `nut.NutClient` (`ScriptedNutClient` in `tests/test_api.py`) because the live
@@ -302,6 +344,69 @@ muted unit, mono `.sub` footnote (`Δ10.0s`, "at current load"), coloured by the
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` passes `minSpan: 1` on sessions and queued so its axis is never
finer than one request.
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
@@ -316,7 +421,29 @@ simpler surface, not a less complete page.
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.
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, `NOTES-chart-set.md`): 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), then a compact `.cells`
list — label left, tabular value right, a `sparkline()` fixed at 160px so rows line up, and a
mono `p95 …` foot under every latency mean (no hover; p95 needs no stored series). Rows are
engine-scoped by the `only` flag: gufo never grows a TTFT mystery, vLLM never a spec-accept
hole. `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); lifetime token counters are a quiet footnote line. **One `/api/llm/history` request
per refresh** carries every chart *and* spark series (`HISTORY_SERIES`; the endpoint answers
unknown names as empty series on purpose), and the two series that addition required
(`llm.tpot_seconds` vLLM-only, `llm.spec_accept_rate` gufo-only) are stored additively in
`sources/llm.py` HISTORY_METRICS. The engine-totals card kept only engines-live/active/queued
and **hides below two engines** — a cross-engine sum of one engine duplicates that engine's own
panel, and the summed-KV bar went because `fleet_totals` carries no `severity` to render. 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 5-row gufo panel next to a 7-row
vLLM one otherwise grows a blank bottom, and a blank panel reads as "no data".
**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
@@ -339,20 +466,43 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
## Known gaps / next work
1. **UPS instant commands are read-only in practice.** Reads work against real upsd (see
above), but every button is gated off with "NUT_USER / NUT_PASSWORD must name a user with
`actions` in upsd.users" because no such user exists yet. Creating one safely needs a
*read* of `/etc/ups/upsd.users` first — `sudo tee` can write the file but overwriting it
blind would delete the users upsmon already authenticates as, which can take the box down
at the wrong moment. `deploy/nut-sudoers` is the installable ruleset that makes that
read (and only a scoped write + reload) possible — it carries the
`ops ALL=(root) NOPASSWD: NUT_READ, NUT_WRITE` grant and is installed with
`sudo install -m 0440 -o root -g root deploy/nut-sudoers /etc/sudoers.d/dashboards-nut`,
but it is **repo-side only so far**: the live UPS cards still answer "NUT_USER /
NUT_PASSWORD must name a user with `actions` in upsd.users" (verified through
`/api/ups/snapshot`). Once it is installed: read `upsd.users`, add `[dashboards]` with
`instcmds` limited to the battery tests, put the credentials in `.env.dev`, and the
buttons come alive. Never grant `actions = -`, and never write the file blind.
1. **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()` sent `USER`/`PASS`, which the deployed
upsd 2.8.5 does not implement — each answered `ERR UNKNOWN-COMMAND`, so `_read_line()`
raised and `POST /api/ups/command` could only 502 before `instcmd()`, credentials or not.
It now sends `USERNAME`/`PASSWORD`, the pair the server's own `HELP` advertises
(`HELP VER PROTVER GET LIST SET INSTCMD LOGIN LOGOUT USERNAME PASSWORD STARTTLS`), pinned
by `test_login_uses_the_verbs_upsd_2_8_5_answers_to` — the fake upsd has no `USER`/`PASS`
branch, so a regression answers `ERR UNKNOWN-COMMAND` exactly like the real thing.
(b) 12 of the 23 commands the two real UPSes advertise had **no `COMMANDS` entry**, and
`available_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_catalogued` fails if a capture gains a
command the catalog does not know, and
`test_every_command_that_can_cut_power_is_in_the_dangerous_tier` keeps `load.off`,
`driver.killpower` and the `shutdown.*` sequence in the tier that types `DANGER` and is
refused on battery. `shutdown.stop` is the exception the name hides — it *cancels* a
shutdown, so it is `safe`, and the test says so in words.
(c) `[dashboards]` in `/etc/ups/upsd.users` is `instcmds = all` at the operator's request,
so this app may send every advertised command on both UPSes, power-cutting ones included.
`.env.dev` (mode `0600`, gitignored) carries the credentials.
What remains to know:
- **Proven live, and reversibly.** `beeper.enable` returned `OK` and `ups.beeper.status`
read `enabled` on the APC afterwards; `driver.killpower` without the word `DANGER` still
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: true` cannot mean "this account may send it".** `available_commands()`
filters on `LIST 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. With
`instcmds = all` the two agree by configuration rather than by check, which is precisely
why the risk tiers and the typed `DANGER` are 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 — `upsmon` logs into both
UPSes right after it — but unexplained.
Editing `upsd.users` at all: never grant `actions = -`, and never write it blind — the
running `upsmon` authenticates 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
and `cmp` them before writing; `tee` truncates in place, so owner and mode survive.
2. **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
+60 -9
View File
@@ -132,7 +132,9 @@ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ──
* `poller.py` — every source gets its own asyncio task, its own health record, and its own
error handling. A dead host keeps its last good values and is labelled stale; it never
blanks or blocks another source.
blanks or blocks another source. And it never quietly *stops*: the task is that source's only
poller, so a SQLite write that gets refused is reported (`ok: false`, the real reason, the
numbers aging, one line in the event log) instead of freezing the source under a green dot.
* `store.py` — raw samples roll up to 1-minute and 1-hour averages and the raw rows are
pruned, so a year of history is a few hundred MB rather than a disk emergency.
* `severity.py` — the single green/amber/red table (`THRESHOLDS`, plus `INVERTED` for the
@@ -162,7 +164,13 @@ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ──
A big colour-coded number is `Dash.hero(el, {...})` on the
`.hero` / `.hero-bar` styles, coloured by the `severity` in the payload via
`Dash.severityFrom()` — the only colour vocabulary is `ok` / `warn` / `fail`, and a value
with no verdict renders `n/a` with no colour at all. Each `/system` host is one panel led
with no verdict renders `n/a` with no colour at all. A battery is
`DashCharts.battery(el, {charge, severity, state, label})`: one inline SVG whose fill *is* the
charge — clamped to 0–100, never rounded up, clipped to the plate so 0 % is an empty shell —
coloured only by the payload verdict, with a shape per state (solid shell on line, bolt
charging, dashed outline on battery, a dash through the body when there is no reading) and a
`<title>` / `aria-label` that names it ("apc: Charge 100 percent, on line") unless the number
beside it already does. Each `/system` host is one panel led
by exactly six of them — CPU %, MEM %, TEMP, DISK % (mount underneath), GPU busy (VRAM and
the APU pool in the footnote), and NET on two lines (↓ in / ↑ out, never coloured: a byte
rate has no good/bad). Under the numbers, **each panel charts only its own host**: utilisation
@@ -174,16 +182,32 @@ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ──
load, clock, disk I/O, uptime, GPU power/temp, filesystems, processes — folds into a collapsed
"More detail" disclosure. Each panel pulls its series in **one** `/api/system/history` request
(`hosts=<that host>&metrics=…&range=…`), and a host that stops reporting takes its panel and
its charts off the page.
its charts off the page. The LLM page (`/llm`) is per engine too: two big
throughput numbers (decode, prompt — uncoloured, because 0 tok/s on an idle server is
healthy) with their provenance tag (`Δ10.0s`, scraped, estimated), then a compact list of
metric rows — sessions, queue, KV pressure (the only rated cell), prefix/spec percentages,
and TTFT/TPOT/queue-time means with a `p95 …` foot line — each carrying its own sparkline
over the selected window. Preemptions and GPU-context losses render only as red alarm chips
while non-zero; the token counter is a footnote. The four trend charts sit below the panels
at half height, and one `/api/llm/history` request per refresh feeds all of it. With a
single engine configured the totals card hides and the page is one clean half — never a red
panel for a server you do not run. Each UPS card (`/ups`) opens with its battery glyph and
three big numbers — **charge %**, **load %**, **runtime left** — coloured from the card's
`severity` dict, with the electrical readings (line in, output, battery volts and amps,
battery °C) kept below them in smaller muted type because they are what you read *while* the
UPS is on battery. Health checks, the battery-test buttons (with their `DANGER`
confirmation) and the NUT variable dump are unchanged, just second.
## Testing
```bash
uv run pytest # unit + API tests, fully mocked, no network
uv run pytest --cov # coverage gate
set -a; . ./.env.dev; set +a # the live suite reads the same env as the app
uv run pytest -m live --live # optional: real fleet / engines / upsd
uv run ruff check src tests agent && uv run pyright # lint + types
./scripts/capture-fixtures.sh # re-record ground-truth captures
./scripts/capture-fixtures.sh # re-record ground truth: host agents + UPS replies
./scripts/capture-fixtures.sh --with-llm # ... and the engines' /metrics while they are up
```
Test philosophy: **real captures as fixtures, mocks for behaviour.** Anything that must
@@ -191,15 +215,23 @@ match an upstream (`/proc` layouts, vLLM metric names, NUT protocol framing) is
against a verbatim capture in `tests/fixtures/`; behaviour that needs a failure — upsd
refusing a command, an engine returning 500, a host rebooting — is a fake. The `live` tests
(`tests/test_live_fleet.py`) are the bridge: they SKIP unless you pass `--live`, each one
also skips when its source is unset, they drive the production code paths (`collectors.*`,
`NutMonitor.collect`) against whatever hardware is reachable, and they never write — a
battery test on live hardware is not a unit test.
also skips when its source is unset (**source `.env.dev` first** — run bare and they all skip
quietly, which reads exactly like a pass), they drive the production code paths
(`collectors.*`, `NutMonitor.collect`) against whatever hardware is reachable, and they never
write — a battery test on live hardware is not a unit test.
One more file is worth knowing about: `tests/card_contract.py` is the table of
`/api/system/snapshot` paths the system page's per-host cards are allowed to read. It is
checked against all three captured host shapes (discrete GPU, APU, no GPU), over HTTP, and
again against the live fleet, so a card can never be "fixed" by inventing a number the agent
does not report.
does not report. `tests/ups_contract.py` does the same job for the UPS page's hero row and
battery glyph (`charge %`, `load %`, `runtime left`, `battery_voltage`, the glyph's
`charger_status` + `on_battery`, the card header), against the two real UPSes — recorded
verbatim as `tests/fixtures/nut_<ups>.vars` by `./scripts/capture-fixtures.sh`, with the
device serials scrubbed — and again against live upsd. The captures earn their place: the
CyberPower answers `battery.mfr.date: CPS`, the APC's `battery.date` is a factory
placeholder, and **neither** publishes `battery.temperature` or `battery.charger.status`, so
a hand-written variable table would have tested nobody's memory.
## Battery-test buttons
@@ -217,9 +249,28 @@ not `actions = -`:
# /etc/ups/upsd.users
[dashboards]
password = "..."
instcmds = test.battery.start.quick test.battery.start.deep test.battery.stop
instcmds = test.battery.start.quick
instcmds = test.battery.start.deep
instcmds = test.battery.stop
```
One command per `instcmds` line — that is what `man 5 upsd.users` specifies ("to specify
multiple commands, use multiple instances of the instcmds field"), and a comma-separated list
on one line is not that. `instcmds = all` is the alternative, and the fleet runs that: every
command the drivers advertise, including the ones that cut output power. Nothing about the
gates changes — the risk tier, the typed `DANGER`, `NUT_ALLOW_ACTIONS` and the on-battery
refusal are what prevent a click from becoming an outage, because upsd gives the dashboard no
way to ask what an account is allowed to send.
upsd authenticates with `USERNAME` + `PASSWORD` (see `HELP` on your own server for the verbs
it accepts — `USER`/`PASS` are not among them in 2.8.5); a user needs `instcmds`, never
`actions = -`.
`deploy/nut-sudoers` is the ruleset that lets the `ops` account read (and scoped-write) those
files: `sudo install -m 0440 -o root -g root deploy/nut-sudoers /etc/sudoers.d/dashboards-nut`.
Read `upsd.users` before you write it — `upsmon` authenticates from the same file, so
overwriting it blind can take the box down at the wrong moment.
Bind upsd to loopback (`LISTEN 127.0.0.1` in `upsd.conf`) and reach it with an ssh forward
(`DEV_FORWARDS`) or a tailnet address. `NUT_SIMULATE=1` stands in for upsd during
development; everything it produces is badged `SIMULATED`, and it is never the default.
+93 -5
View File
@@ -35,6 +35,76 @@ for entry in "${HOSTS[@]}"; do
fi
done
# --- UPSes (NUT upsd) ---------------------------------------------------------
# The two real UPSes disagree in ways a hand-written VAR table cannot: the APC's
# `battery.date` is the factory placeholder `2001/09/25` (its `battery.mfr.date` is the real
# one), the CyberPower answers `battery.mfr.date: CPS`, and neither exports
# `battery.temperature` or `battery.charger.status`. `tests/test_ups_contract.py` pins the
# UPS hero row against these replies, so they are recorded from the wire, not typed from
# memory (AGENTS.md rule 7). Skipped when NUT_HOST is unset, so a laptop with no upsd
# reachable still re-records the rest.
if [ -n "${NUT_HOST:-}" ]; then
if python3 - "$FIX" "$NUT_HOST" "${NUT_PORT:-3493}" <<'PY'
import pathlib
import socket
import sys
fix, host, port = pathlib.Path(sys.argv[1]), sys.argv[2], int(sys.argv[3])
HEADER = ("# Verbatim upsd reply (LIST UPS / LIST VAR / LIST CMD), recorded by\n"
"# ./scripts/capture-fixtures.sh — one `VAR`/`CMD` line per protocol line.\n"
"# Volatile values (ups.load, battery.runtime, the voltages) move every poll,\n"
"# so a diff here on re-capture is expected; the contract asserts shape, units\n"
"# and which variables the drivers omit, never those numbers.")
class Upsd:
"""Raw socket on purpose: the fixture is the wire, not a parse of it."""
def __init__(self, host: str, port: int) -> None:
self.handle = socket.create_connection((host, port), timeout=8).makefile("rwb")
def line(self) -> str:
raw = self.handle.readline()
if not raw:
raise EOFError("upsd closed the connection")
return raw.decode(errors="replace").rstrip("\r\n")
def ask(self, command: str) -> list[str]:
self.handle.write((command + "\n").encode())
self.handle.flush()
first = self.line()
if not first.startswith("BEGIN LIST"):
raise RuntimeError(f"{command!r} -> {first!r}")
rows: list[str] = []
while True:
row = self.line()
if row.startswith("END LIST"):
return rows
rows.append(row)
upsd = Upsd(host, port)
for row in upsd.ask("LIST UPS"):
if not row.startswith("UPS"):
continue
name = row.split()[1].strip('"')
body = [HEADER, row,
*upsd.ask(f'LIST VAR "{name}"'),
*upsd.ask(f'LIST CMD "{name}"')]
path = fix / f"nut_{name}.vars"
path.write_text("\n".join(body) + "\n")
print(f" ✓ nut_{name}.vars ({len(body) - 1} protocol lines)")
PY
then
: # per-UPS lines above
else
echo " ✗ nut_*.vars — upsd at $NUT_HOST:${NUT_PORT:-3493} unreachable (scripts/dev-tunnels.sh start?)"
failed=1
fi
else
echo " (skipped UPS captures — NUT_HOST unset; see .env.dev.example)"
fi
if [ "${1:-}" = "--with-llm" ]; then
if [ ${#ENGINES[@]} -eq 0 ]; then
echo " ! no GUFO_BASE_URL / VLLM_BASE_URL set (see .env.dev) — nothing to capture"
@@ -60,10 +130,12 @@ if grep -rlE "Authorization|Bearer |://[^/]*:[^/@]*@" "$FIX" >/dev/null 2>&1; th
failed=1
fi
# Two things leak out of a real capture that nobody notices: LUKS/partition UUIDs (which
# identify *your* disks, and appear in `device` strings as /dev/mapper/luks-<uuid>) and the
# FQDN a host thinks it has. They are replaced deterministically so re-capturing does not
# churn the fixture, and the tests keep the same shapes to assert against.
# Three things leak out of a real capture that nobody notices: LUKS/partition UUIDs (which
# identify *your* disks, and appear in `device` strings as /dev/mapper/luks-<uuid>), the
# FQDN a host thinks it has, and a UPS's `ups.serial`/`device.serial` (which identifies the
# physical unit, and is echoed back in the card's variable dump). They are replaced
# deterministically so re-capturing does not churn the fixture, and the tests keep the same
# shapes to assert against.
python3 - "$FIX" <<'PY'
import pathlib
import re
@@ -73,11 +145,15 @@ uuid_re = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{
SYNTHETIC = "00000000-0000-4000-8000-" # already-scrubbed markers: re-running must not churn
fix = pathlib.Path(sys.argv[1])
changed = []
# upsd quotes a field only when it has to, so both `VAR "apc" "ups.serial" "x"` and
# `VAR apc ups.serial "x"` are real wire shapes; the value is always quoted.
serial_re = re.compile(r'(^VAR\s+\S+\s+"?(?:device|ups)\.serial"?\s+")([^"\n]*)(")', re.M)
for path in sorted(fix.iterdir()):
if path.suffix not in (".json", ".txt"):
if path.suffix not in (".json", ".txt", ".vars"):
continue
text = path.read_text()
seen: dict[str, str] = {}
serials: dict[str, str] = {}
def swap(match: re.Match[str]) -> str:
value = match.group(0)
@@ -91,6 +167,18 @@ for path in sorted(fix.iterdir()):
# "hostname": "gw.lab.example.com" -> "gw": the dashboard's host key is the target name
# from HOST_TARGETS, never whatever the box calls itself
text = re.sub(r'("hostname": "[^"]*?)\.[^"]*(")', r"\1\2", text)
def scrub_serial(match: re.Match[str]) -> str:
value = match.group(2)
body, pad = value.rstrip(), value[len(value.rstrip()):]
if body.startswith("SCRUBBEDSERIAL"):
return match.group(0)
if body not in serials:
serials[body] = f"SCRUBBEDSERIAL{len(serials) + 1}"
# trailing spaces are a real quirk of the apc driver — keep the padding verbatim
return match.group(1) + serials[body] + pad + match.group(3)
text = serial_re.sub(scrub_serial, text)
if text != path.read_text():
path.write_text(text)
changed.append(path.name)
+6 -3
View File
@@ -446,9 +446,12 @@ def ups_router() -> APIRouter:
if advice["risk"] == nut.RISK_DANGEROUS and confirm != "DANGER":
raise HTTPException(422, f"{advice['label']} can cut power: send "
f"confirm=\"DANGER\" to prove you read the warning")
if any(item["command"] == command and not item["enabled"] for item in card["commands"]):
blocked = next(item for item in card["commands"] if item["command"] == command)
raise HTTPException(409, blocked["reason"] or f"{command} is not available")
# Resolved through the alias table: `beeper.off` and `beeper.disable` are one action,
# and only one of them is listed. Matching the literal name would let the unlisted
# alias past a `disabled` verdict — on battery, that is a power cut with a typo.
entry = nut.resolve_command(card["commands"], command)
if entry is not None and not entry["enabled"]:
raise HTTPException(409, entry["reason"] or f"{command} is not available")
try:
if monitor.simulated:
+3 -7
View File
@@ -26,7 +26,7 @@ from .config import Settings
from .poller import Hub
from .rates import RateEstimator
from .sources.host import HostCollector
from .sources.nut_sim import NutSimulator
from .sources.nut_sim import from_env as simulator_from_env
from .store import SampleStore
WEB = Path(__file__).resolve().parent / "web"
@@ -47,7 +47,7 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
host_collector = HostCollector(rates)
nut_monitor: NutMonitor | None = None
if settings.nut_enabled:
simulator = NutSimulator() if _truthy(os.environ.get("NUT_SIMULATE")) else None
simulator = simulator_from_env(os.environ.get("NUT_SIMULATE"))
nut_monitor = NutMonitor(settings, simulator=simulator)
hub = Hub(store)
@@ -64,10 +64,6 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
return ctx, store, hub.start
def _truthy(value: str | None) -> bool:
return (value or "").strip().lower() in ("1", "true", "yes", "on")
def create_app(settings: Settings | None = None, store: SampleStore | None = None) -> FastAPI:
"""`store` is for tests: when supplied it is not created and not closed here."""
settings = settings or DEFAULT_SETTINGS
@@ -88,7 +84,7 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
hub = Hub(store)
nut_monitor = None
if settings.nut_enabled:
simulator = NutSimulator() if _truthy(os.environ.get("NUT_SIMULATE")) else None
simulator = simulator_from_env(os.environ.get("NUT_SIMULATE"))
nut_monitor = NutMonitor(settings, simulator=simulator)
for job in host_jobs(list(settings.host_targets), client,
HostCollector(rates), settings.host_interval):
+60 -7
View File
@@ -9,6 +9,10 @@ Rules this enforces, because violating them is what makes dashboards untrustwort
(`stale 12s` in the UI) instead of showing zeros or `n/a` everywhere.
* **Transitions are events, spam is not.** ok→fail and fail→ok write to the event log;
500 consecutive failures write one line.
* **The loop is the source's only poller, so nothing may kill it.** A bookkeeping failure — a
SQLite write refused by lock contention, a full disk, anything — is a *reported* failure,
never a dead task: four dev-server sources once stopped advancing `attempts` while
`/api/health` went on calling them `ok`, and only the growing `age_seconds` said so.
"""
from __future__ import annotations
@@ -92,6 +96,7 @@ class Job:
self.grace_failures = grace_failures
self.state = SourceState(key=key, dashboard=dashboard, label=label, configured=enabled)
self._down_logged = False
self._store_down_logged = False
self._stop = asyncio.Event()
self._task: asyncio.Task[None] | None = None
self._inflight = False
@@ -127,9 +132,24 @@ class Job:
finally:
self._inflight = False
self.state.ok = True
self.state.successes += 1
try:
self._commit(store, result, now, was_ok)
except Exception as exc: # noqa: BLE001 - a refusing store must not kill the loop
self._store_refused(store, exc, now)
return False
return True
def _commit(self, store: SampleStore, result: Result, now: float, was_ok: bool) -> None:
"""Record a good poll: payload, samples, its events, then mark the source good.
That order is the point. `last_ok` moves only once the write landed, so a source whose
history is being refused ages out on the page (and alerts) instead of looking current
forever with charts that silently stop drawing.
"""
self.state.payload = result.payload
self.state.samples_written += store.add_many(result.samples)
for event in result.events:
store.log_event(event)
# Recovery is news only if we ever said it was down: on a fresh start every source
# would otherwise log "answering again", which reads like an outage happened.
if self._down_logged or (not was_ok and self.state.failures):
@@ -139,18 +159,51 @@ class Job:
+ (f" after {count} failure{'s' if count != 1 else ''}"
if count else "")))
self._down_logged = False
if self._store_down_logged:
self._store_down_logged = False
store.log_event(Event(now, self.dashboard, self.label, "recording",
"info", f"{self.label} is recording history again"))
self.state.ok = True
self.state.successes += 1
self.state.consecutive_failures = 0
self.state.last_ok = now
self.state.error = None
self.state.samples_written += store.add_many(result.samples)
for event in result.events:
store.log_event(event)
return True
def _store_refused(self, store: SampleStore, exc: Exception, now: float) -> None:
"""Bookkeeping for a store that refused an otherwise good poll.
The upstream answered, so the freshest payload we have is kept — but the source is not
`ok` and its age keeps growing: history that is not landing means every chart past the
raw window is quietly empty, and a green dot over that is the failure this app exists
to avoid. Nothing here may raise: the caller's task *is* this source's poller, so the
event is attempted, and if the store is what broke, that failure is logged instead.
"""
self.state.ok = False
self.state.failures += 1
self.state.consecutive_failures += 1
self.state.error = f"{type(exc).__name__}: {exc} (the poll succeeded; the store refused it)"
logger.exception("job %s could not record a good poll", self.key)
if self._store_down_logged:
return
self._store_down_logged = True
try:
store.log_event(Event(now, self.dashboard, self.label, "store-error", "error",
self.state.error))
except Exception:
# The store is what is broken; say it out loud rather than die trying to say it.
logger.exception("job %s could not even log that the store refused it", self.key)
async def _loop(self, store: SampleStore) -> None:
while not self._stop.is_set():
started = time.perf_counter()
await self.run_once(store)
try:
await self.run_once(store)
except Exception:
# `run_once` handles its own failures, so escaping here is a bug. Letting it
# propagate would freeze the source with a healthy-looking health record and no
# further polls, which is exactly how this guard was earned.
logger.exception("job %s raised outside its own error handling; still polling",
self.key)
remaining = self.interval - (time.perf_counter() - started)
try:
await asyncio.wait_for(self._stop.wait(), timeout=max(0.2, remaining))
+2
View File
@@ -118,8 +118,10 @@ HISTORY_METRICS: dict[str, tuple[str, ...]] = {
"requests_waiting": ("requests_deferred", "num_requests_waiting"),
"ttft_seconds": ("time_to_first_token_seconds__mean",),
"ttft_p95_seconds": ("time_to_first_token_seconds__p95",),
"tpot_seconds": ("time_per_output_token_seconds__mean",), # vLLM only
"queue_seconds": ("request_queue_time_seconds__mean",),
"prefix_cache_hit_ratio": ("prefix_cache_hit_ratio", "prompt_cache_hit_ratio"),
"spec_accept_rate": ("spec_accept_rate",), # gufo only
}
+133 -13
View File
@@ -14,15 +14,20 @@ Verified against NUT 2.8.5 (`/usr/bin/upsc` on driveripper). Protocol shape:
-> LIST UPS
<- BEGIN LIST UPS
<- UPS "apc" : APC Smart-UPS
<- UPS apc "APC Back-UPS RS 1500MS (USB)"
<- END LIST UPS
-> LIST VAR apc
-> LIST VAR "apc"
<- BEGIN LIST VAR apc
<- VAR "ups.status" "OL"
<- VAR apc ups.status "OL"
<- END LIST VAR apc
-> LIST CMD apc ; <- BEGIN LIST CMD apc / CMD test.battery.start.quick / END LIST CMD apc
-> LIST CMD apc ; <- BEGIN LIST CMD apc / CMD apc test.battery.start.quick / END LIST CMD apc
bad -> ERR ACCESS-DENIED (auth or permission problem)
ok -> OK ...
Those lines are verbatim NUT 2.8.5 (`tests/fixtures/nut_apc.vars`, recorded by
`scripts/capture-fixtures.sh`): upsd quotes a field only when it has to — a value with a
space always is, a bare name usually is not — which is why `_split_fields` honours quotes
instead of splitting on whitespace.
"""
from __future__ import annotations
@@ -49,8 +54,13 @@ class NutError(Exception):
#: upsd error tokens -> what the operator should actually do about them.
ERROR_HINTS = {
"ACCESS-DENIED": "upsd refused: this client is not in an ACL that allows it (upsd.conf `ACL`/"
"`LISTEN`, or the user lacks the privilege in upsd.users)",
# Reached only on an *authenticated* session — an unauthenticated client gets
# ERR USERNAME-REQUIRED instead. So the cause is the account's privileges, never the
# upsd.conf ACL/LISTEN lines that used to be blamed here, and the sentence has to name
# the one thing the operator can actually change.
"ACCESS-DENIED": "upsd accepted the login but the account may not send this: an instant "
"command needs its own `instcmds` line in upsd.users (or `instcmds = all`), "
"and nut-server must restart to pick the change up",
"INVALID-PASSWORD": "wrong NUT_PASSWORD for NUT_USER (upsd.users)",
"INVALID-USERNAME": "NUT_USER is not defined in upsd.users",
"INVALID-ARGUMENT": "malformed command or unknown UPS",
@@ -176,12 +186,20 @@ class NutClient:
raise NutError(f"{kind} list too long", code="UNEXPECTED")
async def login(self) -> bool:
"""Authenticate for actions. Returns False when no credentials are configured."""
"""Authenticate for actions. Returns False when no credentials are configured.
`USERNAME` + `PASSWORD`, never `USER`/`PASS`. Captured from the deployed upsd 2.8.5,
whose own `HELP` answers `Commands: HELP VER PROTVER GET LIST SET INSTCMD LOGIN LOGOUT
USERNAME PASSWORD STARTTLS` and replies `ERR UNKNOWN-COMMAND` to both `USER` and
`PASS` — with those verbs the login raised here and `POST /api/ups/command` could only
ever 502, credentials or not. Values go out **unquoted**: upsd takes the rest of the
line literally, so quoting them would make the quotes part of the credential.
"""
if not self.username or not self.password:
return False
await self._send(f"USER {self.username}")
await self._send(f"USERNAME {self.username}")
await self._read_line()
await self._send(f"PASS {self.password}")
await self._send(f"PASSWORD {self.password}")
await self._read_line()
return True
@@ -300,7 +318,10 @@ VAR_MAP: dict[str, tuple[str, ...]] = {
"battery_current": ("battery.current",),
"battery_temperature": ("battery.temperature",),
"battery_type": ("battery.type",),
"battery_runtime": ("battery.runtime", "battery.runtime.low"),
# `battery.runtime.low` is the *shutdown threshold*, not time remaining. Listing it here
# made a UPS that publishes no estimate read as "5 m left" — the biggest number on the
# card, and an invented one (AGENTS.md invariant 2). It stays readable as itself below.
"battery_runtime": ("battery.runtime",),
"battery_runtime_low": ("battery.runtime.low",),
"battery_date": ("battery.mfr.date", "battery.date"),
"test_result": ("battery.test.result", "ups.test.result"),
@@ -386,21 +407,31 @@ class UpsReading:
def states(self) -> list[dict[str, str]]:
"""Human state list. The 2.8 `battery.charger.status` wins over legacy ups.status
when both exist because it distinguishes trim/boost/full/recalibration."""
when both exist because it distinguishes trim/boost/full/recalibration.
Winning means the flag the token already says is dropped, not appended beside it:
a driver exporting both `chg` and `OL CHRG` renders "charging, online", not
"charging, online, charging" — a word said twice on a chip row reads as two events.
"""
out: list[dict[str, str]] = []
charger = (self.get("charger_status") or "").strip().lower()
if charger and charger in CHARGER_STATUS:
label, severity = CHARGER_STATUS[charger]
out.append({"id": charger, "label": label, "severity": severity})
seen = {entry["label"] for entry in out}
raw = " ".join(self.status_flags)
if raw:
if raw in STATE_FLAGS:
label, severity, detail = STATE_FLAGS[raw]
out.append({"id": raw, "label": label, "severity": severity, "detail": detail})
if label not in seen:
out.append({"id": raw, "label": label, "severity": severity, "detail": detail})
else:
for flag in self.status_flags:
if flag in STATE_FLAGS:
label, severity, detail = STATE_FLAGS[flag]
if label in seen:
continue
seen.add(label)
out.append({"id": flag, "label": label, "severity": severity, "detail": detail})
return out
@@ -644,6 +675,8 @@ COMMANDS: dict[str, dict[str, str]] = {
"help": "Aborts a running battery test and returns to line power."},
"test.panel.start": {"label": "Panel test", "risk": RISK_SAFE,
"help": "Lights and beeps the front panel for a few seconds."},
"test.panel.stop": {"label": "Stop panel test", "risk": RISK_SAFE,
"help": "Ends a front-panel test."},
"test.system.start": {"label": "Full system self-test", "risk": RISK_CAUTION,
"help": "Vendor self-test; behaviour varies by model and may move "
"the load to battery."},
@@ -651,6 +684,12 @@ COMMANDS: dict[str, dict[str, str]] = {
"beeper.disable": {"label": "Beeper off", "risk": RISK_SAFE,
"help": "Silences audible alarms. Faults are still reported in this dashboard."},
"beeper.mute": {"label": "Beeper mute", "risk": RISK_SAFE, "help": "Silence the current alarm only."},
# `beeper.on`/`beeper.off` are what the APC and CyberPower drivers actually advertise;
# `beeper.enable`/`beeper.disable` are the names other drivers use. Both pairs are real,
# so both are catalogued — a command the driver offers must never go missing from the page.
"beeper.on": {"label": "Beeper on", "risk": RISK_SAFE, "help": "Re-enable audible alarms."},
"beeper.off": {"label": "Beeper off", "risk": RISK_SAFE,
"help": "Silences audible alarms. Faults are still reported in this dashboard."},
"calibrate.start": {
"label": "Start calibration",
"risk": RISK_DANGEROUS,
@@ -662,8 +701,15 @@ COMMANDS: dict[str, dict[str, str]] = {
"help": "Clears the recorded input voltage extremes."},
"load.on": {"label": "Energise output", "risk": RISK_CAUTION,
"help": "Turns the outlets on."},
"load.on.delay": {"label": "Energise output after delay", "risk": RISK_CAUTION,
"help": "Turns the outlets on after the driver's shutdown delay. "
"Cancels a pending power-down on some models."},
"load.off": {"label": "Cut output power", "risk": RISK_DANGEROUS,
"help": "Kills power to everything plugged in, immediately, on battery or not."},
"load.off.delay": {"label": "Cut output power after delay", "risk": RISK_DANGEROUS,
"help": "Cuts outlet power after the driver's shutdown delay. Attached "
"systems are not asked to shut down first — this is a hard cut "
"on a timer."},
"load.reboot": {"label": "Power-cycle outlets", "risk": RISK_DANGEROUS,
"help": "Cuts and restores output power. Everything attached reboots."},
"shutdown.return": {"label": "Graceful shutdown, then restore", "risk": RISK_DANGEROUS,
@@ -673,6 +719,32 @@ COMMANDS: dict[str, dict[str, str]] = {
"help": "Shuts attached systems down and stays off."},
"shutdown.stop": {"label": "Cancel shutdown", "risk": RISK_SAFE,
"help": "Aborts a pending shutdown sequence."},
"shutdown.stayoff": {"label": "Shut down and stay off", "risk": RISK_DANGEROUS,
"help": "Cuts output power after the delay and does not bring it back "
"when utility returns."},
"shutdown.default": {"label": "Default shutdown sequence", "risk": RISK_DANGEROUS,
"help": "The driver's own shutdown sequence — usually the same as "
"shutdown.return, but the order and timing are the driver's "
"choice, not yours."},
"shutdown.reboot": {"label": "Shut down, then power-cycle", "risk": RISK_DANGEROUS,
"help": "Shuts attached systems down, cuts power, and restores it after "
"the delay. Everything attached comes back on its own."},
# Driver-level commands. `driver.reload` re-reads the driver's configuration; it interrupts
# status reporting for a moment but leaves the load alone. `driver.killpower` does not.
"driver.reload": {"label": "Reload driver config", "risk": RISK_CAUTION,
"help": "Makes the driver re-read ups.conf. Status reporting blips for a "
"few seconds; output power is unaffected."},
"driver.reload-or-error": {"label": "Reload config, exit on error", "risk": RISK_CAUTION,
"help": "Reloads the driver configuration and stops the driver if "
"it is invalid — this UPS then stops reporting until it is "
"started again."},
"driver.reload-or-exit": {"label": "Reload config, or exit", "risk": RISK_CAUTION,
"help": "Reloads the driver configuration, exiting if the reload "
"cannot be done."},
"driver.killpower": {"label": "Kill power now", "risk": RISK_DANGEROUS,
"help": "Cuts inverter output immediately with no shutdown sequencing "
"and no delay — the fastest way to take everything attached "
"down hard."},
}
@@ -680,6 +752,39 @@ def command_advice(command: str) -> dict[str, str] | None:
return COMMANDS.get(command)
# Driver names that mean the same action. Both real drivers advertise `beeper.enable` AND
# `beeper.on`, and `beeper.disable` AND `beeper.off`, so offering every advertised command
# drew "Beeper on" and "Beeper off" twice each. Both names stay in `COMMANDS` — a driver that
# offers only one of them must still get a button — but one action gets one entry, named for a
# name this driver actually answers, in the preference order below.
COMMAND_ALIASES: tuple[tuple[str, ...], ...] = (
("beeper.enable", "beeper.on"),
("beeper.disable", "beeper.off"),
)
def alias_group(command: str) -> tuple[str, ...]:
"""Every driver name for the same action, preferred first. Just itself if not an alias."""
for group in COMMAND_ALIASES:
if command in group:
return group
return (command,)
def resolve_command(commands: list[dict[str, Any]], command: str) -> dict[str, Any] | None:
"""The offered entry a command name refers to, aliases included.
The POST gate compares the request against the command list. Matching on `command` alone
would let an alias the page never rendered (`beeper.off`) walk past a `disabled` verdict
recorded against the name the page did render (`beeper.disable`) — the same action, and
the gate is the control, not the button.
"""
for item in commands:
if item["command"] == command or command in (item.get("also") or []):
return item
return None
def available_commands(advertised: list[str], *, allow_actions: bool,
read_only: bool, has_credentials: bool,
on_battery: bool = False) -> list[dict[str, Any]]:
@@ -687,12 +792,21 @@ def available_commands(advertised: list[str], *, allow_actions: bool,
Filtering server-side (not just hiding buttons) is the point: the response says
`enabled: false` plus `reason`, so the page never offers a button that 403s.
One entry per *action*: where a driver advertises both names of an alias group, the
preferred name is emitted and the others ride along in `also`, so the page cannot draw the
same button twice while `resolve_command()` still refuses the alias when the action is off.
"""
out: list[dict[str, Any]] = []
advertised_set = set(advertised)
for name in advertised:
advice = COMMANDS.get(name)
if advice is None:
continue
group = alias_group(name)
preferred = next(candidate for candidate in group if candidate in advertised_set)
if name != preferred:
continue # already represented by the preferred name of this action
reason = None
if read_only:
reason = "the server is in DASH_READ_ONLY mode"
@@ -710,6 +824,9 @@ def available_commands(advertised: list[str], *, allow_actions: bool,
"enabled": reason is None,
"reason": reason,
"confirm_text": None if advice["risk"] != RISK_DANGEROUS else "DANGER",
# other names this driver answers for the same action; the page shows one button,
# and POSTing any of them hits this same entry's verdict
"also": [other for other in advertised if other in group and other != name],
})
order = {RISK_SAFE: 0, RISK_CAUTION: 1, RISK_DANGEROUS: 2}
out.sort(key=lambda item: (order[item["risk"]], item["label"]))
@@ -721,6 +838,9 @@ def payload(reading: UpsReading, *, now: float | None = None, **assess_kwargs: A
now = time.time() if now is None else now
health = assess(reading, now=now, **assess_kwargs)
numbers = {key: reading.number(key) for key in VAR_MAP}
# Normalised to the `CHARGER_STATUS` keys, the way `states()` and `on_battery` already
# read it: a driver that publishes "FULL" must not become a glyph state nobody labelled.
charger = (reading.get("charger_status") or "").strip().lower() or None
# charts must not plot 300 "°C"; the raw string stays visible in the variable dump
for key in ("battery_temperature", "ups_temperature"):
if numbers.get(key) is not None:
@@ -738,7 +858,7 @@ def payload(reading: UpsReading, *, now: float | None = None, **assess_kwargs: A
},
"ts": reading.ts,
"status": reading.get("status"),
"charger_status": reading.get("charger_status"),
"charger_status": charger,
"states": reading.states(),
"alarms": reading.alarms,
"on_battery": reading.on_battery,
+48 -14
View File
@@ -6,11 +6,17 @@ putting a real load on battery. It is never silent about what it is: the payload
`simulated: true` and the page shows a watermark on every simulated number.
The model is deliberately small and physical rather than random:
* charge sits at float (100%) while on line, drains at a Peukert-ish rate when on battery
* charge climbs back to float while on line — reporting `CHRG`/`chg` until it gets there,
the way a real driver does — and drains at a Peukert-ish rate when on battery
* runtime estimates scale with charge and fall off steeply with load (as they do for real
VRLA packs, which is why the number jumps around while the load moves)
* a quick battery test moves to OB for ~12s and then reports PASSED, so the whole
command → state change → event-log path is exercised
* the state a command changes is visible in the variables a real driver publishes — `OB`
plus `ups.test.result`/`ups.test.date` for the tests, `ups.beeper.status` for the beeper
— so the dev page shows an effect instead of only an `OK` toast. The load/shutdown
commands answer `OK` without modelling an outlet: there is no load here to cut, and a
faked power cut in the history is indistinguishable from a real one.
"""
from __future__ import annotations
@@ -18,7 +24,6 @@ from __future__ import annotations
import math
import time
from dataclasses import dataclass, field
from typing import Any
from .nut import UpsReading
@@ -59,13 +64,21 @@ class SimUps:
driver: str = "usbhid-ups"
_test_until: float = 0.0
_deep_until: float = 0.0
_beeper: bool = True
_result_pending: bool = False # a test is running; report its outcome when the window closes
_beeper: str = "enabled" # NUT's `ups.beeper.status`: enabled | muted | disabled
_alarms: list[str] = field(default_factory=list)
def tick(self, now: float) -> None:
"""Advance the model by wall-clock time since the last tick."""
# a running self-test pulls the load from the wall, whichever test it is
was_testing = self.on_battery
self.on_battery = now < self._test_until or now < self._deep_until
if was_testing and not self.on_battery and self._result_pending:
self._result_pending = False
# A driver writes the outcome when the test window closes, and `ups.test.result`
# is what the "Last self-test" health check reads: without this the simulated pack
# comes back on line while the card still quotes the result it had before the test.
self.test_result = "PASSED"
def step(self, dt: float) -> None:
dt = max(0.0, min(dt, 60.0))
@@ -93,13 +106,23 @@ class SimUps:
def vars(self, now: float) -> dict[str, str]:
load_pct = self.load_w / self.nominal_w * 100.0
flags = ["OL"] if not self.on_battery else (["OB"] + (["LB"] if self.charge < 20 else []))
if self.charge >= 99.5 and not self.on_battery:
flags.append("CHRG" if self.charge < 99.9 else "")
status = " ".join(part for part in flags if part)
charger = "ol" if not self.on_battery else ("dchg" if self.charge < 99.5 else "ol")
if self.charge >= 99.9 and not self.on_battery:
# Status and charger status must agree with each other *and* with a real driver.
# A below-float pack on mains is charging — APC prints exactly `OL CHRG` there —
# and a battery being drawn from is never "online": `ol`/`full` while `OB` is the
# contradiction `assess()` would have to guess about. Reporting plain `ol`/`OL`
# during the recharge made the page's charging state (the glyph's bolt, keyed on
# `charger_status == "chg"`) unreachable in the simulator, and that is a state the
# operator has to be able to see in dev.
if self.on_battery:
flags = ["OB"] + (["LB"] if self.charge < 20 else [])
charger = "dchg"
elif self.charge >= 99.9:
flags = ["OL"]
charger = "full"
else:
flags = ["OL", "CHRG"]
charger = "chg"
status = " ".join(flags)
volts = (self.battery_nominal * (0.86 + 0.14 * self.charge / 100.0)
if self.on_battery else self.battery_nominal * 1.045)
line = 121.4 + 1.1 * math.sin(now / 90.0)
@@ -109,6 +132,7 @@ class SimUps:
"ups.serial": self.serial,
"ups.firmware": self.firmware,
"ups.status": status,
"ups.beeper.status": self._beeper,
"battery.charger.status": charger,
"ups.load": f"{load_pct:.1f}",
"ups.realpower": f"{self.load_w:.1f}",
@@ -152,23 +176,29 @@ class SimUps:
if command == "test.battery.start.quick":
self._test_until = now + 12.0
self.on_battery = True
self._result_pending = True
self.test_date = time.strftime("%Y-%m-%d", time.gmtime(now))
return "OK Start Quick Battery Test"
if command == "test.battery.start.deep":
self._deep_until = now + 90.0
self.on_battery = True
self._result_pending = True
return "OK Start Deep Battery Test"
if command in ("test.battery.stop", "calibrate.stop"):
self._test_until = self._deep_until = 0.0
self.on_battery = False
self._result_pending = False # an aborted test reports nothing, not a pass
return "OK Stop Test"
if command == "test.panel.start":
return "OK Start Panel Test"
if command == "beeper.enable":
self._beeper = True
self._beeper = "enabled"
return "OK Beeper enabled"
if command in ("beeper.disable", "beeper.mute"):
self._beeper = False
if command == "beeper.disable":
self._beeper = "disabled"
return "OK Beeper disabled"
if command == "beeper.mute":
self._beeper = "muted"
return "OK Beeper muted"
if command == "calibrate.start":
self._deep_until = now + 180.0
@@ -221,6 +251,10 @@ class NutSimulator:
raise KeyError(f"unknown simulated UPS {ups_name!r}")
def from_env(value: str | None) -> Any:
"""`NUT_SIMULATE=1` -> a simulator; anything else -> None (talk to real upsd)."""
def from_env(value: str | None) -> NutSimulator | None:
"""`NUT_SIMULATE=1` -> a simulator; anything else -> None (talk to real upsd).
The one place the enable rule lives: `app.py` builds the monitor with this, so the truthy
set cannot drift between the assembly and the model it swaps in.
"""
return NutSimulator() if (value or "").strip().lower() in ("1", "true", "yes", "on") else None
+33 -35
View File
@@ -36,52 +36,29 @@
<div id="banners" class="stack" style="margin-bottom:.85rem"></div>
<section class="card flat" aria-label="Engine totals">
<!-- Engine totals, hidden until a second engine exists: a cross-engine sum of one
engine only duplicates that engine's own panel. What stays here is what no panel
says — how many engines are answering, and the session/queue counts across them.
Summed decode/prompt/KV left with phase 04: per-engine is the honest view, and
averaging a Strix Halo iGPU with a Threadripper is not one number. -->
<section class="card flat" id="totals" aria-label="Engine totals" hidden>
<div class="body">
<div class="metrics">
<div class="metric"><div class="label">Engines answering</div>
<div class="value"><span id="f-live">n/a</span><span class="unit" id="f-total"></span></div></div>
<div class="metric"><div class="label">Decode (sum)</div>
<div class="value"><span id="f-decode">n/a</span><span class="unit">tok/s</span></div></div>
<div class="metric"><div class="label">Prompt (sum)</div>
<div class="value"><span id="f-prompt">n/a</span><span class="unit">tok/s</span></div></div>
<div class="metric"><div class="label">Active sessions</div>
<div class="value" id="f-active">n/a</div></div>
<div class="metric"><div class="label">Queued</div>
<div class="value" id="f-waiting">n/a</div></div>
<div class="metric"><div class="label">Tokens lifetime</div>
<div class="value"><span id="f-tokens">n/a</span><span class="unit"></span></div></div>
<div class="metric"><div class="label" title="highest KV-cache use across engines right now, not a window peak">KV cache now</div>
<div class="value"><span id="f-kv">n/a</span><span class="unit">%</span></div>
<div class="bar" style="margin-top:.3rem"><i></i></div></div>
</div>
<div class="faint" id="seam-note" style="margin-top:.5rem;font-size:11.5px"></div>
</div>
</section>
<section class="grid cols-2" style="margin-top:.85rem" aria-label="Throughput charts">
<article class="card">
<header><h3>Decode throughput</h3><span class="spacer"></span>
<span class="faint">tok/s · every engine, summed slots</span></header>
<div class="body"><div class="chart" id="chart-decode"></div><div class="legend" id="legend-decode"></div></div>
</article>
<article class="card">
<header><h3>Prompt / prefill throughput</h3><span class="spacer"></span>
<span class="faint">tok/s · newly computed tokens</span></header>
<div class="body"><div class="chart" id="chart-prompt"></div><div class="legend" id="legend-prompt"></div></div>
</article>
<article class="card">
<header><h3>KV / prefix cache pressure</h3><span class="spacer"></span><span class="faint">% of cache in use</span></header>
<div class="body"><div class="chart" id="chart-kv"></div><div class="legend" id="legend-kv"></div></div>
</article>
<article class="card">
<header><h3>Sessions &amp; queue</h3><span class="spacer"></span><span class="faint">running vs waiting</span></header>
<div class="body"><div class="chart" id="chart-queue"></div><div class="legend" id="legend-queue"></div></div>
</article>
</section>
<!-- The page leads with the engine panels: two throughput heroes per engine, a compact
sparkline cell list, and the gufo slot table below. The four trend charts follow,
half-height — supporting evidence, not the main event. -->
<section style="margin-top:.85rem">
<div class="grid cols-2" id="engines"></div>
<div class="grid cols-2 items-start" id="engines"></div>
<div class="empty-state" id="engines-empty" hidden>
<h3>No engines configured</h3>
<p>Set <code>GUFO_BASE_URL</code> (llama.cpp / gufo) and/or <code>VLLM_BASE_URL</code>.
@@ -90,6 +67,27 @@
</div>
</section>
<section class="grid cols-2" style="margin-top:.85rem" aria-label="Trend charts">
<article class="card">
<header><h3>Decode throughput</h3><span class="spacer"></span>
<span class="faint">tok/s</span></header>
<div class="body"><div class="chart" id="chart-decode"></div><div class="legend" id="legend-decode"></div></div>
</article>
<article class="card">
<header><h3>Prompt / prefill throughput</h3><span class="spacer"></span>
<span class="faint">tok/s</span></header>
<div class="body"><div class="chart" id="chart-prompt"></div><div class="legend" id="legend-prompt"></div></div>
</article>
<article class="card">
<header><h3>KV / prefix cache pressure</h3><span class="spacer"></span><span class="faint">% in use</span></header>
<div class="body"><div class="chart" id="chart-kv"></div><div class="legend" id="legend-kv"></div></div>
</article>
<article class="card">
<header><h3>Sessions &amp; queue</h3><span class="spacer"></span><span class="faint">running vs waiting</span></header>
<div class="body"><div class="chart" id="chart-queue"></div><div class="legend" id="legend-queue"></div></div>
</article>
</section>
<section class="grid cols-2" style="margin-top:.85rem">
<article class="card" id="slots-card">
<header><h3>gufo slots</h3><span class="spacer"></span>
@@ -107,8 +105,8 @@
</section>
<footer class="pagefoot">
<span>each cell carries its provenance: scraped gauge, <span class="mono">Δ10s</span> measured
counter, or lifetime estimate</span>
<span>each throughput number carries its provenance: scraped gauge, <span class="mono">Δ10s</span>
measured counter, or lifetime estimate</span>
<span class="spacer" style="margin-inline-start:auto"></span>
<span class="mono" id="foot-note"></span>
</footer>
+27 -4
View File
@@ -70,6 +70,28 @@ const Dash = (() => {
const naText = 'n/a';
/**
* The text of an event-feed row: the subject said **once**.
*
* `poller.py` writes messages that name their own subject ("gufo is answering again
* after 1 failure"), and every feed prints the host as its own chip beside the message,
* so printing the stored words verbatim reads as two problems — the same reasoning
* `alerts._clean_error()` applies to the overview's alert list, including the drop of
* `UpstreamError: ` (this app has one upstream error type, so its name is never a fact).
* A leading `host:` goes too, but only at the front: the UPS rows used to `replace()`
* the name anywhere, which mangled "Quick battery test sent to apc: OK". Presentation
* only — the stored event and `/api/events` keep every word.
*/
function eventText(event) {
const text = String((event && event.message) || '');
const host = String((event && event.host) || '');
const out = text.replace(/^UpstreamError:\s+/, '');
if (!host) return out;
const esc = host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const stripped = out.replace(new RegExp('^' + esc + '\\s*[:\\-]?\\s*'), '');
return stripped.trim() ? stripped : out;
}
// ---- DOM helpers --------------------------------------------------------
/** Write text only when it changed: avoids killing transitions and focus. */
function setTxt(el, value) {
@@ -110,9 +132,9 @@ const Dash = (() => {
*
* Thresholds live in `src/dashboards/severity.py` and travel in the payload as
* `severity`; `severityFrom()` below is how a page reads them. This function survives
* only because `page-llm.js` and `page-ups.js` still call it with their own numbers
* (phases 04–05 convert them page by page, so each page's change is reviewable on its
* own — `/` was converted in phase 02, `/system` in phase 03). Do not add a new call
* only because `page-ups.js` still calls it with its own numbers (phase 05 converts it,
* so each page's change is reviewable on its own — `/` was converted in phase 02,
* `/system` in phase 03, `/llm` in phase 04). Do not add a new call
* site: a threshold written in JS is a threshold that can disagree with the other pages.
*/
function severityFor(value, warnAt, failAt) {
@@ -444,7 +466,8 @@ const Dash = (() => {
return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt,
setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindPause,
staleness, markStale, severityFor, severityFrom, hero, ageSeconds, naText };
staleness, markStale, severityFor, severityFrom, hero, ageSeconds, naText,
eventText };
})();
document.addEventListener('DOMContentLoaded', () => Dash.initChrome());
+232 -18
View File
@@ -344,31 +344,245 @@ function renderLegend(container, series, format, showPeak) {
}).join('');
}
/** Small non-interactive sparkline for cards where a full chart would shout. */
/**
* Small non-interactive sparkline for cards where a full chart would shout.
*
* Same honesty rules as `LineChart`, at 160×34: fewer than `minPoints` points say
* "no history" (never a flat zero line, never a confident line through one point), and
* labels quote the data, not the padded axis — a flat window reports the one real value.
*
* opts (all optional; omitting them keeps the autoscaled, full-width line this function
* has always drawn):
* - height: plot height in px (default 34; the box is the point of a sparkline)
* - color: explicit stroke/fill colour; wins over `severity`
* - severity: payload verdict ('ok'|'warn'|'fail', or a `{severity}` cell) → stroke from
* that theme token (`cssVar('--ok')` etc.). Anything else — null, '', an
* unknown word — draws **no colour**: a faint grey stroke and no fill,
* because an unrated series is a shape, not a verdict (an unknown number
* must never look green)
* - min / max: explicit y bounds (today's behaviour)
* - minSpan: smallest y span an autoscaled axis may have, in the series' own units.
* For counts (sessions, queued) pass 1: a 1-minute rollup averages 8 seconds
* of one waiting request down to 0.14, and an axis that shrinks to fit turns
* that into a full-height spike — the twin of the rescale fixedMax fixes. An
* all-zero window still pads to the mid-line, so "nothing queued" reads flat
* - fixedMax: known ceiling for percent-like metrics — plot against [min ?? 0, fixedMax]
* instead of rescaling to the data, so an 8 % KV window that once held a
* 92 % spike reads the same as one that did not. Adds top/bottom scale marks
* so "the line is at a third" says a third *of what*; values clamp to the
* axis rather than drawing off-box
* - align: 'right' pins the newest sample to the right edge and grows the line
* leftwards as history fills in (llm_monitor's drawSpark). With `span` a
* partly-filled window occupies only the right of the plot — and x stays
* time, not sample index, so the shape keeps meaning *when*. Default 'left'
* spans the data domain (today's behaviour)
* - span: x window in seconds for align:'right' (default: the oldest sample)
* - labels: `{current, max}` → the small `now / max` foot drawSpark prints, formatted
* with `format`; `max` is the window maximum of the data, never the fixed
* axis
* - format: value → string for the labels and scale marks (default DEFAULT_FORMAT)
* - minPoints: finite points required before a line is drawn (default 2)
* - ariaLabel: names the spark for assistive tech; the generated fallback quotes values
*
* No animation and no CSS transition at all — `prefers-reduced-motion` is honoured by
* construction, and a moving squiggle competes with the number it annotates.
*/
function sparkline(container, points, opts) {
const o = Object.assign({ height: 34, color: cssVar('--series-1'), min: null, max: null }, opts || {});
const pts = (points || []).filter((p) => p && Number.isFinite(p.value));
const o = Object.assign({
height: 34, color: null, severity: null, min: null, max: null, fixedMax: null,
minSpan: null, align: 'left', span: null, labels: null, format: DEFAULT_FORMAT,
minPoints: 2, ariaLabel: null,
}, opts || {});
const fmt = typeof o.format === 'function' ? o.format : DEFAULT_FORMAT;
const pts = (points || [])
.map((p, i) => (p && Number.isFinite(p.value)
? { ts: Number.isFinite(p.ts) ? p.ts : i, value: p.value }
: null))
.filter(Boolean)
.sort((a, b) => a.ts - b.ts);
const w = Math.max(60, container.clientWidth || 160);
const h = o.height;
container.innerHTML = '';
if (pts.length < 2) {
if (pts.length < Math.max(1, o.minPoints)) {
container.innerHTML = '<div class="faint mono" style="font-size:11px">no history</div>';
return;
}
const xs = pts.map((p) => p.ts), ys = pts.map((p) => p.value);
const x0 = Math.min(...xs), x1 = Math.max(...xs);
let lo = o.min !== null ? o.min : Math.min(...ys);
let hi = o.max !== null ? o.max : Math.max(...ys);
if (lo === hi) { const pad = Math.abs(lo) * 0.1 || 1; lo -= pad; hi += pad; }
const sx = (ts) => ((ts - x0) / (x1 - x0 || 1)) * (w - 2) + 1;
const sy = (v) => h - 2 - ((v - lo) / (hi - lo)) * (h - 4);
const ys = pts.map((p) => p.value);
const dataMax = Math.max(...ys);
let lo, hi;
if (o.fixedMax != null) {
lo = o.min != null ? o.min : 0;
hi = Math.max(o.fixedMax, lo); // never an inverted axis
} else {
lo = o.min !== null ? o.min : Math.min(...ys);
hi = o.max !== null ? o.max : Math.max(...ys);
if (lo === hi) { const pad = Math.abs(lo) * 0.1 || 1; lo -= pad; hi += pad; }
// the axis never gets finer than one thing (see `minSpan` above)
if (o.minSpan > 0 && hi - lo < o.minSpan) hi = lo + o.minSpan;
}
const first = pts[0];
const last = pts[pts.length - 1];
let x0 = first.ts;
const x1 = last.ts;
if (o.align === 'right' && o.span > 0) x0 = x1 - o.span;
// Marks live in a left gutter so they never sit on the line; a 160px box has the pixels.
const marksOn = o.fixedMax != null;
const padL = marksOn ? 24 : 1;
const inner = { l: padL, r: 1, t: 2, b: 2 };
const plotW = Math.max(10, w - inner.l - inner.r);
const plotH = Math.max(6, h - inner.t - inner.b);
const sx = (ts) => {
const span = (x1 - x0) || 1;
const clamped = Math.min(Math.max(ts, x0), x1);
return inner.l + ((clamped - x0) / span) * plotW;
};
const sy = (v) => {
const c = o.fixedMax != null ? Math.min(Math.max(v, lo), hi) : v;
return inner.t + plotH - ((c - lo) / ((hi - lo) || 1)) * plotH;
};
let sev = o.severity;
if (sev && typeof sev === 'object') sev = sev.severity;
const sevColor = sev === 'ok' ? cssVar('--ok')
: sev === 'warn' ? cssVar('--warn')
: sev === 'fail' ? cssVar('--fail') : null;
const stroke = o.color || sevColor || cssVar('--text-faint');
const filled = Boolean(o.color || sevColor);
const d = pts.map((p, i) => `${i ? 'L' : 'M'}${sx(p.ts).toFixed(1)} ${sy(p.value).toFixed(1)}`).join(' ');
const svg = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" preserveAspectRatio="none"
role="img" aria-label="trend from ${lo.toFixed(2)} to ${hi.toFixed(2)}">
<path d="${d} L${sx(x1).toFixed(1)} ${h} L${sx(x0).toFixed(1)} ${h} Z" fill="${o.color}" opacity=".14"/>
<path d="${d}" fill="none" stroke="${o.color}" stroke-width="1.4"/></svg>`;
container.innerHTML = svg;
const baseY = (inner.t + plotH).toFixed(1);
const area = `<path d="${d} L${sx(last.ts).toFixed(1)} ${baseY} L${sx(first.ts).toFixed(1)} ${baseY} Z" fill="${stroke}" opacity=".14"/>`;
const baseline = `<line x1="${inner.l}" x2="${(inner.l + plotW).toFixed(1)}" y1="${baseY}" y2="${baseY}" stroke="${stroke}" opacity=".22"/>`;
let marks = '';
if (marksOn) {
// top/bottom of the *axis* only — enough to read the line as a fraction of the ceiling
const mk = (v, y) => `<text x="2" y="${y}" font-size="8" fill="${cssVar('--text-faint')}" dominant-baseline="${y < h / 2 ? 'hanging' : 'alphabetic'}">${escapeHtml(fmt(v))}</text>`;
marks = mk(hi, inner.t + 7) + mk(lo, h - inner.b);
}
const aria = o.ariaLabel ||
`trend from ${fmt(first.value)} to ${fmt(last.value)}, max ${fmt(dataMax)}`;
let foot = '';
if (o.labels && (o.labels.current || o.labels.max)) {
const bits = [];
if (o.labels.current) bits.push(`now ${fmt(last.value)}`);
if (o.labels.max) bits.push(`max ${fmt(dataMax)}`);
foot = `<div class="faint mono" style="font-size:10px;line-height:1.25">${escapeHtml(bits.join(' / '))}</div>`;
}
container.innerHTML = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" preserveAspectRatio="none"
role="img" aria-label="${escapeHtml(aria)}">${filled ? area : ''}${baseline}
<path d="${d}" fill="none" stroke="${stroke}" stroke-width="1.4"/>${marks}</svg>${foot}`;
}
window.DashCharts = { LineChart, niceTicks, renderLegend, sparkline, cssVar,
CHART_COLORS, DEFAULT_FORMAT, escapeHtml };
/**
* An inline-SVG battery whose fill *is* the charge, for the `/ups` hero row.
*
* `battery(container, {charge, severity, state, label, decorative})`
* - `charge`: 0-100, or `null` when the driver does not publish it. Clamped to the scale,
* and **never rounded up to visible**: 2 % fills 2 % of the interior, because a
* reassuring full-looking battery above a flat cell is a lie (the same rule that makes
* an absent number print `n/a` instead of `0`).
* - `severity`: the payload verdict (`'ok'|'warn'|'fail'`, or a `{severity}` cell) → the
* fill and outline take that theme token through `cssVar()`. Anything else is *no
* verdict*: a muted outline, and a measured charge drawn faint and grey — a shape, not
* a judgement (`sparkline`'s unrated rule, applied to the one thing here that is a
* shape). A measured charge never renders as an empty shell; "0 %" and "not rated" are
* different sentences and only one of them is an alarm.
* - `state`: `'online' | 'onbattery' | 'charging' | 'unknown'` — the page derives it from
* `charger_status` + `on_battery` (see `tests/ups_contract.py`), never by re-parsing
* NUT flags here. Each state has a *shape*, so the glyph reads in monochrome and to a
* screen reader, not only in colour:
* online solid shell
* charging solid shell + a bolt over the plate
* onbattery **dashed** shell outline
* unknown no fill, a dash pattern **through** the body
* - `label`: names this battery ("apc") in the accessible name. No percent text is drawn
* inside the glyph: the hero number next to it already says it, and a card that prints
* one value twice is the pattern this app avoids everywhere else.
*
* Accessible **by default**: `role="img"` + `<title>` + `aria-label` built from the inputs
* ("apc: Charge 100 percent, on line"), which is the choice this component makes — the
* glyph carries information the markup does not repeat in words. A page that prints the
* same sentence beside it passes `decorative: true` to make the `<svg>` `aria-hidden`
* instead of saying it twice; the `<title>` stays for the hover tooltip.
*
* Scales with its container: one `viewBox` + `preserveAspectRatio`, `width:100%` and
* `height:auto` (the `.battery` rule in `dash.css` caps it), so the hero row gets a big
* cell on a desktop and a small one on a phone with no redraw and no ResizeObserver.
* No animation and no transition at all — the fill is re-drawn per render, and a bar that
* slides competes with the number it annotates, so `prefers-reduced-motion` holds by
* construction. Colours are resolved from the tokens at draw time, so a theme switch
* repaints with the next render (every page re-renders on its own poll).
*/
let batterySeq = 0;
const BATTERY_STATES = ['online', 'onbattery', 'charging', 'unknown'];
const BATTERY_STATE_LABELS = { online: 'on line', onbattery: 'on battery',
charging: 'charging', unknown: 'state unknown' };
function battery(container, opts) {
const el = typeof container === 'string' ? document.querySelector(container) : container;
if (!el) return null;
const o = Object.assign({ charge: null, severity: null, state: 'unknown', label: null,
decorative: false }, opts || {});
// Geometry in viewBox units, chosen so 1 unit of fill == 1 % of charge.
const W = 128;
const H = 56;
const shell = { x: 2, y: 8, w: 110, h: 40, r: 9, stroke: 3 };
const inner = { x: 7, y: 12, w: 100, h: 32, r: 4 };
const nub = { x: shell.x + shell.w + 3, y: 20, w: 9, h: 16, r: 3 };
const raw = Number(o.charge);
const measured = o.charge != null && isFinite(raw);
const pctValue = measured ? Math.max(0, Math.min(100, raw)) : null;
let sev = o.severity;
if (sev && typeof sev === 'object') sev = sev.severity;
const ink = sev === 'ok' ? cssVar('--ok')
: sev === 'warn' ? cssVar('--warn')
: sev === 'fail' ? cssVar('--fail') : null;
const stroke = ink || cssVar('--text-muted');
const state = BATTERY_STATES.indexOf(o.state) >= 0 ? o.state : 'unknown';
const clipId = `dash-battery-${(batterySeq += 1)}`;
const dashed = state === 'onbattery' ? ' stroke-dasharray="7 5"' : '';
let fillRect = '';
if (pctValue != null && pctValue > 0) {
const w = (pctValue / 100) * inner.w;
fillRect = `<rect x="${inner.x}" y="${inner.y}" width="${w.toFixed(2)}" height="${inner.h}"` +
` fill="${ink || cssVar('--text-faint')}"${ink ? '' : ' opacity=".5"'}/>`;
}
// A dash *through* the body: the cell exists, the reading does not.
const noReading = state === 'unknown'
? `<line x1="${inner.x + 5}" y1="${inner.y + inner.h / 2}" x2="${inner.x + inner.w - 5}"` +
` y2="${inner.y + inner.h / 2}" stroke="${cssVar('--text-faint')}" stroke-width="3"` +
` stroke-linecap="round" stroke-dasharray="5 6"/>`
: '';
const bolt = state === 'charging'
? `<path d="M13 2 L3 14 h7 l-1 8 L19 10 h-7 z" transform="translate(43.8 13.6) scale(1.2)"` +
` fill="${ink || cssVar('--text-muted')}" stroke="${cssVar('--bg-raised')}"` +
` stroke-width="1.6" stroke-linejoin="round"/>`
: '';
const chargePhrase = pctValue == null ? 'Charge unknown' : `Charge ${Math.round(pctValue)} percent`;
const statePhrase = BATTERY_STATE_LABELS[state];
const spoken = `${chargePhrase}, ${statePhrase}`;
const name = o.label ? `${o.label}: ${spoken}` : spoken;
// `decorative` is the opt-out described above; labelled is the default.
const a11y = o.decorative ? 'aria-hidden="true"'
: `role="img" aria-label="${escapeHtml(name)}"`;
el.classList.add('battery');
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"
preserveAspectRatio="xMidYMid meet" ${a11y}>
<title>${escapeHtml(name)}</title>
<defs><clipPath id="${clipId}"><rect x="${inner.x}" y="${inner.y}" width="${inner.w}"
height="${inner.h}" rx="${inner.r}"/></clipPath></defs>
<rect x="${shell.x}" y="${shell.y}" width="${shell.w}" height="${shell.h}" rx="${shell.r}"
fill="none" stroke="${stroke}" stroke-width="${shell.stroke}"${dashed}/>
<rect x="${nub.x}" y="${nub.y}" width="${nub.w}" height="${nub.h}" rx="${nub.r}" fill="${stroke}"/>
<g clip-path="url(#${clipId})">
<rect x="${inner.x}" y="${inner.y}" width="${inner.w}" height="${inner.h}" fill="${cssVar('--bg-sunken')}"/>
${fillRect}
</g>${noReading}${bolt}</svg>`;
return el;
}
window.DashCharts = { LineChart, niceTicks, renderLegend, sparkline, battery, cssVar,
CHART_COLORS, DEFAULT_FORMAT, escapeHtml };

Some files were not shown because too many files have changed in this diff Show More