Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5689a8bdd | ||
|
|
9e732c1f87 | ||
|
|
046dc57b26 | ||
|
|
71900d903a | ||
|
|
9015931860 | ||
|
|
9041683179 | ||
|
|
1f20a5e57f | ||
|
|
2460a0adf5 | ||
|
|
34854d32af | ||
|
|
f44a617ef9 |
@@ -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`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+46
@@ -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 ==
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Now I have everything verified. Writing the decision note:
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
error: assistant error: litellm.APIConnectionError: APIConnectionError: OpenAIException - Response payload is not completed: <TransferEncodingError: 400, message='Not…
|
||||||
+46
@@ -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 ==
|
||||||
+7
@@ -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`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+46
@@ -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 ==
|
||||||
+10
@@ -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`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+46
@@ -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 ==
|
||||||
+18
@@ -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).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+46
@@ -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 ==
|
||||||
+1
@@ -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):
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+46
@@ -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.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+47
@@ -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 ==
|
||||||
+29
@@ -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()`).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+47
@@ -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 ==
|
||||||
+18
@@ -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`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+47
@@ -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 ==
|
||||||
+16
@@ -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`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+47
@@ -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 ==
|
||||||
+17
@@ -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.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+47
@@ -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 ==
|
||||||
Executable
+163
@@ -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"
|
||||||
@@ -52,12 +52,19 @@ page still renders.
|
|||||||
`404 {"configured": false}` **without dialling**, and the page says "not configured".
|
`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
|
A red panel for a server the operator never installed destroys the signal from the
|
||||||
panels that matter.
|
panels that matter.
|
||||||
4. **One dead source must not blink another.** Each source is its own asyncio task with its
|
4. **One dead source must not blink another — and nothing may stop polling in silence.** Each
|
||||||
own timeout and health record (`poller.py`). A failed poll keeps the last good payload and
|
source is its own asyncio task with its own timeout and health record (`poller.py`). A
|
||||||
marks it stale in the UI instead of zeroing it — on `/` too, where a host that has never
|
failed poll keeps the last good payload and marks it stale in the UI instead of zeroing it —
|
||||||
answered still gets a fleet row (`unreachable: true`, dimmed, every number `n/a`) because
|
on `/` too, where a host that has never answered still gets a fleet row
|
||||||
dropping the row would turn a dead agent into "never configured". Transitions write
|
(`unreachable: true`, dimmed, every number `n/a`) because dropping the row would turn a dead
|
||||||
events; repeated failure writes one line, not 500.
|
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;
|
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
|
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.
|
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.
|
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
|
- `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.
|
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
|
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
|
source (`host`) — a page filtering `/api/events` by `host` gets an empty feed forever
|
||||||
(found on `/system` during phase 03 browser verification).
|
(found on `/system` during phase 03 browser verification).
|
||||||
@@ -120,12 +130,30 @@ Responsibilities, one line each:
|
|||||||
`VLLM_HISTOGRAMS`), measured Δ rates, prefill speed as
|
`VLLM_HISTOGRAMS`), measured Δ rates, prefill speed as
|
||||||
`mean(prompt tokens)/mean(prefill time)`, session list from gufo `/slots`.
|
`mean(prompt tokens)/mean(prefill time)`, session list from gufo `/slots`.
|
||||||
- `sources/nut.py` — upsd client, `UpsReading`, `VAR_MAP`, `assess()` (explicit checks, not
|
- `sources/nut.py` — upsd client, `UpsReading`, `VAR_MAP`, `assess()` (explicit checks, not
|
||||||
a mystery score), `COMMANDS` catalog with risk tiers, `available_commands()` filtering,
|
a mystery score), `COMMANDS` catalog with risk tiers, `COMMAND_ALIASES` + `resolve_command()`
|
||||||
kelvin-vs-celsius normalisation.
|
(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
|
- `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`.
|
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
|
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).
|
- `prom.py` — Prometheus text-exposition parser (in-house; only for engines we don't own).
|
||||||
- `rates.py` — Δ/window estimator, keyed by resolved series name.
|
- `rates.py` — Δ/window estimator, keyed by resolved series name.
|
||||||
- `severity.py` — the only place thresholds live: `THRESHOLDS`/`INVERTED` per metric,
|
- `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
|
`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
|
`.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
|
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
|
values the API does not rate, and **no page script calls it any more** — `/` was converted
|
||||||
(`/` was converted in phase 02, `/system` in phase 03 — those pages colour only from the
|
in phase 02, `/system` in phase 03, `/llm` in phase 04 and `/ups` in phase 05, so every
|
||||||
`severity` verdicts the API sends, and no `severityFor(` call survives in their scripts).
|
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`,
|
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
|
`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
|
`ups.description`, the operator's label — the driver's own `identity.model` answers
|
||||||
`GX1500U`, so the description is what identifies the box).
|
`GX1500U`, so the description is what identifies the box).
|
||||||
Real-hardware quirks the parsers already handle: `battery.mfr.date` is the useful date
|
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.date` is often an unset placeholder — `2001/09/25`, slashes and all, on the APC),
|
||||||
`battery.mfr.date: CPS` (not a date → `unknown`, never a fake age), neither driver reports
|
CyberPower reports `battery.mfr.date: CPS` (not a date → `unknown`, never a fake age),
|
||||||
a battery temperature, and `ups.test.result: No test initiated` is the factory state on both.
|
**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
|
- 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
|
set we rely on is documented in `deploy/nut-sudoers` (additions are listed there for the
|
||||||
work that is still blocked).
|
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 status # per-source ok/DOWN
|
||||||
./scripts/dev-server.sh logs
|
./scripts/dev-server.sh logs
|
||||||
|
|
||||||
uv run pytest # hermetic: 472 passed + 7 live-skipped, no network
|
uv run pytest # hermetic: 543 passed + 8 live-skipped, no network
|
||||||
uv run pytest --cov # coverage gate (96.9%; floor 94%)
|
uv run pytest --cov # coverage gate (97.9%; floor 94%)
|
||||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||||
uv run ruff check src tests agent && uv run pyright
|
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
|
bash .agents/validate.sh # the phase gate: the four checks above (pytest
|
||||||
# --cov --cov-fail-under=94, ruff, pyright, plus a
|
# --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
|
`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
|
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),
|
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
|
- `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
|
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
|
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,
|
- 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`.
|
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
|
- 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,
|
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
|
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
|
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
|
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.
|
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
|
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).
|
`.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
|
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
|
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
|
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
|
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
|
**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
|
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
|
## Known gaps / next work
|
||||||
|
|
||||||
1. **UPS instant commands are read-only in practice.** Reads work against real upsd (see
|
1. **UPS commands work, and the account can now cut power — the app's gates are the control.**
|
||||||
above), but every button is gated off with "NUT_USER / NUT_PASSWORD must name a user with
|
Three defects were cleared on 2026-10-17, each verified against real hardware. (a) `NutClient.login()` sent `USER`/`PASS`, which the deployed
|
||||||
`actions` in upsd.users" because no such user exists yet. Creating one safely needs a
|
upsd 2.8.5 does not implement — each answered `ERR UNKNOWN-COMMAND`, so `_read_line()`
|
||||||
*read* of `/etc/ups/upsd.users` first — `sudo tee` can write the file but overwriting it
|
raised and `POST /api/ups/command` could only 502 before `instcmd()`, credentials or not.
|
||||||
blind would delete the users upsmon already authenticates as, which can take the box down
|
It now sends `USERNAME`/`PASSWORD`, the pair the server's own `HELP` advertises
|
||||||
at the wrong moment. `deploy/nut-sudoers` is the installable ruleset that makes that
|
(`HELP VER PROTVER GET LIST SET INSTCMD LOGIN LOGOUT USERNAME PASSWORD STARTTLS`), pinned
|
||||||
read (and only a scoped write + reload) possible — it carries the
|
by `test_login_uses_the_verbs_upsd_2_8_5_answers_to` — the fake upsd has no `USER`/`PASS`
|
||||||
`ops ALL=(root) NOPASSWD: NUT_READ, NUT_WRITE` grant and is installed with
|
branch, so a regression answers `ERR UNKNOWN-COMMAND` exactly like the real thing.
|
||||||
`sudo install -m 0440 -o root -g root deploy/nut-sudoers /etc/sudoers.d/dashboards-nut`,
|
(b) 12 of the 23 commands the two real UPSes advertise had **no `COMMANDS` entry**, and
|
||||||
but it is **repo-side only so far**: the live UPS cards still answer "NUT_USER /
|
`available_commands()` drops anything the catalog misses — the page silently offered less
|
||||||
NUT_PASSWORD must name a user with `actions` in upsd.users" (verified through
|
than the hardware does. Every advertised command is catalogued now, each with a risk tier:
|
||||||
`/api/ups/snapshot`). Once it is installed: read `upsd.users`, add `[dashboards]` with
|
`test_every_command_the_hardware_advertises_is_catalogued` fails if a capture gains a
|
||||||
`instcmds` limited to the battery tests, put the credentials in `.env.dev`, and the
|
command the catalog does not know, and
|
||||||
buttons come alive. Never grant `actions = -`, and never write the file blind.
|
`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
|
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
|
(`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
|
fleet (R9700 + Strix Halo iGPU; gateway has none). An NVIDIA host would report no GPU at
|
||||||
|
|||||||
@@ -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
|
* `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
|
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
|
* `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.
|
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
|
* `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
|
A big colour-coded number is `Dash.hero(el, {...})` on the
|
||||||
`.hero` / `.hero-bar` styles, coloured by the `severity` in the payload via
|
`.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
|
`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
|
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
|
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
|
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
|
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
|
"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
|
(`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
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest # unit + API tests, fully mocked, no network
|
uv run pytest # unit + API tests, fully mocked, no network
|
||||||
uv run pytest --cov # coverage gate
|
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 pytest -m live --live # optional: real fleet / engines / upsd
|
||||||
uv run ruff check src tests agent && uv run pyright # lint + types
|
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
|
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
|
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
|
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
|
(`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.*`,
|
also skips when its source is unset (**source `.env.dev` first** — run bare and they all skip
|
||||||
`NutMonitor.collect`) against whatever hardware is reachable, and they never write — a
|
quietly, which reads exactly like a pass), they drive the production code paths
|
||||||
battery test on live hardware is not a unit test.
|
(`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
|
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
|
`/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
|
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
|
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
|
## Battery-test buttons
|
||||||
|
|
||||||
@@ -217,9 +249,28 @@ not `actions = -`:
|
|||||||
# /etc/ups/upsd.users
|
# /etc/ups/upsd.users
|
||||||
[dashboards]
|
[dashboards]
|
||||||
password = "..."
|
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
|
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
|
(`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.
|
development; everything it produces is badged `SIMULATED`, and it is never the default.
|
||||||
|
|||||||
@@ -35,6 +35,76 @@ for entry in "${HOSTS[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
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 [ "${1:-}" = "--with-llm" ]; then
|
||||||
if [ ${#ENGINES[@]} -eq 0 ]; then
|
if [ ${#ENGINES[@]} -eq 0 ]; then
|
||||||
echo " ! no GUFO_BASE_URL / VLLM_BASE_URL set (see .env.dev) — nothing to capture"
|
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
|
failed=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Two things leak out of a real capture that nobody notices: LUKS/partition UUIDs (which
|
# 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>) and the
|
# identify *your* disks, and appear in `device` strings as /dev/mapper/luks-<uuid>), the
|
||||||
# FQDN a host thinks it has. They are replaced deterministically so re-capturing does not
|
# FQDN a host thinks it has, and a UPS's `ups.serial`/`device.serial` (which identifies the
|
||||||
# churn the fixture, and the tests keep the same shapes to assert against.
|
# 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'
|
python3 - "$FIX" <<'PY'
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
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
|
SYNTHETIC = "00000000-0000-4000-8000-" # already-scrubbed markers: re-running must not churn
|
||||||
fix = pathlib.Path(sys.argv[1])
|
fix = pathlib.Path(sys.argv[1])
|
||||||
changed = []
|
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()):
|
for path in sorted(fix.iterdir()):
|
||||||
if path.suffix not in (".json", ".txt"):
|
if path.suffix not in (".json", ".txt", ".vars"):
|
||||||
continue
|
continue
|
||||||
text = path.read_text()
|
text = path.read_text()
|
||||||
seen: dict[str, str] = {}
|
seen: dict[str, str] = {}
|
||||||
|
serials: dict[str, str] = {}
|
||||||
|
|
||||||
def swap(match: re.Match[str]) -> str:
|
def swap(match: re.Match[str]) -> str:
|
||||||
value = match.group(0)
|
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
|
# "hostname": "gw.lab.example.com" -> "gw": the dashboard's host key is the target name
|
||||||
# from HOST_TARGETS, never whatever the box calls itself
|
# from HOST_TARGETS, never whatever the box calls itself
|
||||||
text = re.sub(r'("hostname": "[^"]*?)\.[^"]*(")', r"\1\2", text)
|
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():
|
if text != path.read_text():
|
||||||
path.write_text(text)
|
path.write_text(text)
|
||||||
changed.append(path.name)
|
changed.append(path.name)
|
||||||
|
|||||||
@@ -446,9 +446,12 @@ def ups_router() -> APIRouter:
|
|||||||
if advice["risk"] == nut.RISK_DANGEROUS and confirm != "DANGER":
|
if advice["risk"] == nut.RISK_DANGEROUS and confirm != "DANGER":
|
||||||
raise HTTPException(422, f"{advice['label']} can cut power: send "
|
raise HTTPException(422, f"{advice['label']} can cut power: send "
|
||||||
f"confirm=\"DANGER\" to prove you read the warning")
|
f"confirm=\"DANGER\" to prove you read the warning")
|
||||||
if any(item["command"] == command and not item["enabled"] for item in card["commands"]):
|
# Resolved through the alias table: `beeper.off` and `beeper.disable` are one action,
|
||||||
blocked = next(item for item in card["commands"] if item["command"] == command)
|
# and only one of them is listed. Matching the literal name would let the unlisted
|
||||||
raise HTTPException(409, blocked["reason"] or f"{command} is not available")
|
# 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:
|
try:
|
||||||
if monitor.simulated:
|
if monitor.simulated:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from .config import Settings
|
|||||||
from .poller import Hub
|
from .poller import Hub
|
||||||
from .rates import RateEstimator
|
from .rates import RateEstimator
|
||||||
from .sources.host import HostCollector
|
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
|
from .store import SampleStore
|
||||||
|
|
||||||
WEB = Path(__file__).resolve().parent / "web"
|
WEB = Path(__file__).resolve().parent / "web"
|
||||||
@@ -47,7 +47,7 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
|
|||||||
host_collector = HostCollector(rates)
|
host_collector = HostCollector(rates)
|
||||||
nut_monitor: NutMonitor | None = None
|
nut_monitor: NutMonitor | None = None
|
||||||
if settings.nut_enabled:
|
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)
|
nut_monitor = NutMonitor(settings, simulator=simulator)
|
||||||
|
|
||||||
hub = Hub(store)
|
hub = Hub(store)
|
||||||
@@ -64,10 +64,6 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
|
|||||||
return ctx, store, hub.start
|
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:
|
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."""
|
"""`store` is for tests: when supplied it is not created and not closed here."""
|
||||||
settings = settings or DEFAULT_SETTINGS
|
settings = settings or DEFAULT_SETTINGS
|
||||||
@@ -88,7 +84,7 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
|
|||||||
hub = Hub(store)
|
hub = Hub(store)
|
||||||
nut_monitor = None
|
nut_monitor = None
|
||||||
if settings.nut_enabled:
|
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)
|
nut_monitor = NutMonitor(settings, simulator=simulator)
|
||||||
for job in host_jobs(list(settings.host_targets), client,
|
for job in host_jobs(list(settings.host_targets), client,
|
||||||
HostCollector(rates), settings.host_interval):
|
HostCollector(rates), settings.host_interval):
|
||||||
|
|||||||
@@ -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.
|
(`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;
|
* **Transitions are events, spam is not.** ok→fail and fail→ok write to the event log;
|
||||||
500 consecutive failures write one line.
|
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
|
from __future__ import annotations
|
||||||
@@ -92,6 +96,7 @@ class Job:
|
|||||||
self.grace_failures = grace_failures
|
self.grace_failures = grace_failures
|
||||||
self.state = SourceState(key=key, dashboard=dashboard, label=label, configured=enabled)
|
self.state = SourceState(key=key, dashboard=dashboard, label=label, configured=enabled)
|
||||||
self._down_logged = False
|
self._down_logged = False
|
||||||
|
self._store_down_logged = False
|
||||||
self._stop = asyncio.Event()
|
self._stop = asyncio.Event()
|
||||||
self._task: asyncio.Task[None] | None = None
|
self._task: asyncio.Task[None] | None = None
|
||||||
self._inflight = False
|
self._inflight = False
|
||||||
@@ -127,9 +132,24 @@ class Job:
|
|||||||
finally:
|
finally:
|
||||||
self._inflight = False
|
self._inflight = False
|
||||||
|
|
||||||
self.state.ok = True
|
try:
|
||||||
self.state.successes += 1
|
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.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
|
# 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.
|
# would otherwise log "answering again", which reads like an outage happened.
|
||||||
if self._down_logged or (not was_ok and self.state.failures):
|
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 ''}"
|
+ (f" after {count} failure{'s' if count != 1 else ''}"
|
||||||
if count else "")))
|
if count else "")))
|
||||||
self._down_logged = False
|
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.consecutive_failures = 0
|
||||||
self.state.last_ok = now
|
self.state.last_ok = now
|
||||||
self.state.error = None
|
self.state.error = None
|
||||||
self.state.samples_written += store.add_many(result.samples)
|
|
||||||
for event in result.events:
|
def _store_refused(self, store: SampleStore, exc: Exception, now: float) -> None:
|
||||||
store.log_event(event)
|
"""Bookkeeping for a store that refused an otherwise good poll.
|
||||||
return True
|
|
||||||
|
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:
|
async def _loop(self, store: SampleStore) -> None:
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
await self.run_once(store)
|
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)
|
remaining = self.interval - (time.perf_counter() - started)
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self._stop.wait(), timeout=max(0.2, remaining))
|
await asyncio.wait_for(self._stop.wait(), timeout=max(0.2, remaining))
|
||||||
|
|||||||
@@ -118,8 +118,10 @@ HISTORY_METRICS: dict[str, tuple[str, ...]] = {
|
|||||||
"requests_waiting": ("requests_deferred", "num_requests_waiting"),
|
"requests_waiting": ("requests_deferred", "num_requests_waiting"),
|
||||||
"ttft_seconds": ("time_to_first_token_seconds__mean",),
|
"ttft_seconds": ("time_to_first_token_seconds__mean",),
|
||||||
"ttft_p95_seconds": ("time_to_first_token_seconds__p95",),
|
"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",),
|
"queue_seconds": ("request_queue_time_seconds__mean",),
|
||||||
"prefix_cache_hit_ratio": ("prefix_cache_hit_ratio", "prompt_cache_hit_ratio"),
|
"prefix_cache_hit_ratio": ("prefix_cache_hit_ratio", "prompt_cache_hit_ratio"),
|
||||||
|
"spec_accept_rate": ("spec_accept_rate",), # gufo only
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+132
-12
@@ -14,15 +14,20 @@ Verified against NUT 2.8.5 (`/usr/bin/upsc` on driveripper). Protocol shape:
|
|||||||
|
|
||||||
-> LIST UPS
|
-> LIST UPS
|
||||||
<- BEGIN LIST UPS
|
<- BEGIN LIST UPS
|
||||||
<- UPS "apc" : APC Smart-UPS
|
<- UPS apc "APC Back-UPS RS 1500MS (USB)"
|
||||||
<- END LIST UPS
|
<- END LIST UPS
|
||||||
-> LIST VAR apc
|
-> LIST VAR "apc"
|
||||||
<- BEGIN LIST VAR apc
|
<- BEGIN LIST VAR apc
|
||||||
<- VAR "ups.status" "OL"
|
<- VAR apc ups.status "OL"
|
||||||
<- END LIST VAR apc
|
<- 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)
|
bad -> ERR ACCESS-DENIED (auth or permission problem)
|
||||||
ok -> OK ...
|
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
|
from __future__ import annotations
|
||||||
@@ -49,8 +54,13 @@ class NutError(Exception):
|
|||||||
|
|
||||||
#: upsd error tokens -> what the operator should actually do about them.
|
#: upsd error tokens -> what the operator should actually do about them.
|
||||||
ERROR_HINTS = {
|
ERROR_HINTS = {
|
||||||
"ACCESS-DENIED": "upsd refused: this client is not in an ACL that allows it (upsd.conf `ACL`/"
|
# Reached only on an *authenticated* session — an unauthenticated client gets
|
||||||
"`LISTEN`, or the user lacks the privilege in upsd.users)",
|
# 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-PASSWORD": "wrong NUT_PASSWORD for NUT_USER (upsd.users)",
|
||||||
"INVALID-USERNAME": "NUT_USER is not defined in upsd.users",
|
"INVALID-USERNAME": "NUT_USER is not defined in upsd.users",
|
||||||
"INVALID-ARGUMENT": "malformed command or unknown UPS",
|
"INVALID-ARGUMENT": "malformed command or unknown UPS",
|
||||||
@@ -176,12 +186,20 @@ class NutClient:
|
|||||||
raise NutError(f"{kind} list too long", code="UNEXPECTED")
|
raise NutError(f"{kind} list too long", code="UNEXPECTED")
|
||||||
|
|
||||||
async def login(self) -> bool:
|
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:
|
if not self.username or not self.password:
|
||||||
return False
|
return False
|
||||||
await self._send(f"USER {self.username}")
|
await self._send(f"USERNAME {self.username}")
|
||||||
await self._read_line()
|
await self._read_line()
|
||||||
await self._send(f"PASS {self.password}")
|
await self._send(f"PASSWORD {self.password}")
|
||||||
await self._read_line()
|
await self._read_line()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -300,7 +318,10 @@ VAR_MAP: dict[str, tuple[str, ...]] = {
|
|||||||
"battery_current": ("battery.current",),
|
"battery_current": ("battery.current",),
|
||||||
"battery_temperature": ("battery.temperature",),
|
"battery_temperature": ("battery.temperature",),
|
||||||
"battery_type": ("battery.type",),
|
"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_runtime_low": ("battery.runtime.low",),
|
||||||
"battery_date": ("battery.mfr.date", "battery.date"),
|
"battery_date": ("battery.mfr.date", "battery.date"),
|
||||||
"test_result": ("battery.test.result", "ups.test.result"),
|
"test_result": ("battery.test.result", "ups.test.result"),
|
||||||
@@ -386,21 +407,31 @@ class UpsReading:
|
|||||||
|
|
||||||
def states(self) -> list[dict[str, str]]:
|
def states(self) -> list[dict[str, str]]:
|
||||||
"""Human state list. The 2.8 `battery.charger.status` wins over legacy ups.status
|
"""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]] = []
|
out: list[dict[str, str]] = []
|
||||||
charger = (self.get("charger_status") or "").strip().lower()
|
charger = (self.get("charger_status") or "").strip().lower()
|
||||||
if charger and charger in CHARGER_STATUS:
|
if charger and charger in CHARGER_STATUS:
|
||||||
label, severity = CHARGER_STATUS[charger]
|
label, severity = CHARGER_STATUS[charger]
|
||||||
out.append({"id": charger, "label": label, "severity": severity})
|
out.append({"id": charger, "label": label, "severity": severity})
|
||||||
|
seen = {entry["label"] for entry in out}
|
||||||
raw = " ".join(self.status_flags)
|
raw = " ".join(self.status_flags)
|
||||||
if raw:
|
if raw:
|
||||||
if raw in STATE_FLAGS:
|
if raw in STATE_FLAGS:
|
||||||
label, severity, detail = STATE_FLAGS[raw]
|
label, severity, detail = STATE_FLAGS[raw]
|
||||||
|
if label not in seen:
|
||||||
out.append({"id": raw, "label": label, "severity": severity, "detail": detail})
|
out.append({"id": raw, "label": label, "severity": severity, "detail": detail})
|
||||||
else:
|
else:
|
||||||
for flag in self.status_flags:
|
for flag in self.status_flags:
|
||||||
if flag in STATE_FLAGS:
|
if flag in STATE_FLAGS:
|
||||||
label, severity, detail = STATE_FLAGS[flag]
|
label, severity, detail = STATE_FLAGS[flag]
|
||||||
|
if label in seen:
|
||||||
|
continue
|
||||||
|
seen.add(label)
|
||||||
out.append({"id": flag, "label": label, "severity": severity, "detail": detail})
|
out.append({"id": flag, "label": label, "severity": severity, "detail": detail})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -644,6 +675,8 @@ COMMANDS: dict[str, dict[str, str]] = {
|
|||||||
"help": "Aborts a running battery test and returns to line power."},
|
"help": "Aborts a running battery test and returns to line power."},
|
||||||
"test.panel.start": {"label": "Panel test", "risk": RISK_SAFE,
|
"test.panel.start": {"label": "Panel test", "risk": RISK_SAFE,
|
||||||
"help": "Lights and beeps the front panel for a few seconds."},
|
"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,
|
"test.system.start": {"label": "Full system self-test", "risk": RISK_CAUTION,
|
||||||
"help": "Vendor self-test; behaviour varies by model and may move "
|
"help": "Vendor self-test; behaviour varies by model and may move "
|
||||||
"the load to battery."},
|
"the load to battery."},
|
||||||
@@ -651,6 +684,12 @@ COMMANDS: dict[str, dict[str, str]] = {
|
|||||||
"beeper.disable": {"label": "Beeper off", "risk": RISK_SAFE,
|
"beeper.disable": {"label": "Beeper off", "risk": RISK_SAFE,
|
||||||
"help": "Silences audible alarms. Faults are still reported in this dashboard."},
|
"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.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": {
|
"calibrate.start": {
|
||||||
"label": "Start calibration",
|
"label": "Start calibration",
|
||||||
"risk": RISK_DANGEROUS,
|
"risk": RISK_DANGEROUS,
|
||||||
@@ -662,8 +701,15 @@ COMMANDS: dict[str, dict[str, str]] = {
|
|||||||
"help": "Clears the recorded input voltage extremes."},
|
"help": "Clears the recorded input voltage extremes."},
|
||||||
"load.on": {"label": "Energise output", "risk": RISK_CAUTION,
|
"load.on": {"label": "Energise output", "risk": RISK_CAUTION,
|
||||||
"help": "Turns the outlets on."},
|
"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,
|
"load.off": {"label": "Cut output power", "risk": RISK_DANGEROUS,
|
||||||
"help": "Kills power to everything plugged in, immediately, on battery or not."},
|
"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,
|
"load.reboot": {"label": "Power-cycle outlets", "risk": RISK_DANGEROUS,
|
||||||
"help": "Cuts and restores output power. Everything attached reboots."},
|
"help": "Cuts and restores output power. Everything attached reboots."},
|
||||||
"shutdown.return": {"label": "Graceful shutdown, then restore", "risk": RISK_DANGEROUS,
|
"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."},
|
"help": "Shuts attached systems down and stays off."},
|
||||||
"shutdown.stop": {"label": "Cancel shutdown", "risk": RISK_SAFE,
|
"shutdown.stop": {"label": "Cancel shutdown", "risk": RISK_SAFE,
|
||||||
"help": "Aborts a pending shutdown sequence."},
|
"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)
|
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,
|
def available_commands(advertised: list[str], *, allow_actions: bool,
|
||||||
read_only: bool, has_credentials: bool,
|
read_only: bool, has_credentials: bool,
|
||||||
on_battery: bool = False) -> list[dict[str, Any]]:
|
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
|
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.
|
`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]] = []
|
out: list[dict[str, Any]] = []
|
||||||
|
advertised_set = set(advertised)
|
||||||
for name in advertised:
|
for name in advertised:
|
||||||
advice = COMMANDS.get(name)
|
advice = COMMANDS.get(name)
|
||||||
if advice is None:
|
if advice is None:
|
||||||
continue
|
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
|
reason = None
|
||||||
if read_only:
|
if read_only:
|
||||||
reason = "the server is in DASH_READ_ONLY mode"
|
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,
|
"enabled": reason is None,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"confirm_text": None if advice["risk"] != RISK_DANGEROUS else "DANGER",
|
"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}
|
order = {RISK_SAFE: 0, RISK_CAUTION: 1, RISK_DANGEROUS: 2}
|
||||||
out.sort(key=lambda item: (order[item["risk"]], item["label"]))
|
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
|
now = time.time() if now is None else now
|
||||||
health = assess(reading, now=now, **assess_kwargs)
|
health = assess(reading, now=now, **assess_kwargs)
|
||||||
numbers = {key: reading.number(key) for key in VAR_MAP}
|
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
|
# charts must not plot 300 "°C"; the raw string stays visible in the variable dump
|
||||||
for key in ("battery_temperature", "ups_temperature"):
|
for key in ("battery_temperature", "ups_temperature"):
|
||||||
if numbers.get(key) is not None:
|
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,
|
"ts": reading.ts,
|
||||||
"status": reading.get("status"),
|
"status": reading.get("status"),
|
||||||
"charger_status": reading.get("charger_status"),
|
"charger_status": charger,
|
||||||
"states": reading.states(),
|
"states": reading.states(),
|
||||||
"alarms": reading.alarms,
|
"alarms": reading.alarms,
|
||||||
"on_battery": reading.on_battery,
|
"on_battery": reading.on_battery,
|
||||||
|
|||||||
@@ -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.
|
`simulated: true` and the page shows a watermark on every simulated number.
|
||||||
|
|
||||||
The model is deliberately small and physical rather than random:
|
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
|
* 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)
|
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
|
* a quick battery test moves to OB for ~12s and then reports PASSED, so the whole
|
||||||
command → state change → event-log path is exercised
|
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
|
from __future__ import annotations
|
||||||
@@ -18,7 +24,6 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .nut import UpsReading
|
from .nut import UpsReading
|
||||||
|
|
||||||
@@ -59,13 +64,21 @@ class SimUps:
|
|||||||
driver: str = "usbhid-ups"
|
driver: str = "usbhid-ups"
|
||||||
_test_until: float = 0.0
|
_test_until: float = 0.0
|
||||||
_deep_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)
|
_alarms: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
def tick(self, now: float) -> None:
|
def tick(self, now: float) -> None:
|
||||||
"""Advance the model by wall-clock time since the last tick."""
|
"""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
|
# 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
|
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:
|
def step(self, dt: float) -> None:
|
||||||
dt = max(0.0, min(dt, 60.0))
|
dt = max(0.0, min(dt, 60.0))
|
||||||
@@ -93,13 +106,23 @@ class SimUps:
|
|||||||
|
|
||||||
def vars(self, now: float) -> dict[str, str]:
|
def vars(self, now: float) -> dict[str, str]:
|
||||||
load_pct = self.load_w / self.nominal_w * 100.0
|
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 []))
|
# Status and charger status must agree with each other *and* with a real driver.
|
||||||
if self.charge >= 99.5 and not self.on_battery:
|
# A below-float pack on mains is charging — APC prints exactly `OL CHRG` there —
|
||||||
flags.append("CHRG" if self.charge < 99.9 else "")
|
# and a battery being drawn from is never "online": `ol`/`full` while `OB` is the
|
||||||
status = " ".join(part for part in flags if part)
|
# contradiction `assess()` would have to guess about. Reporting plain `ol`/`OL`
|
||||||
charger = "ol" if not self.on_battery else ("dchg" if self.charge < 99.5 else "ol")
|
# during the recharge made the page's charging state (the glyph's bolt, keyed on
|
||||||
if self.charge >= 99.9 and not self.on_battery:
|
# `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"
|
charger = "full"
|
||||||
|
else:
|
||||||
|
flags = ["OL", "CHRG"]
|
||||||
|
charger = "chg"
|
||||||
|
status = " ".join(flags)
|
||||||
volts = (self.battery_nominal * (0.86 + 0.14 * self.charge / 100.0)
|
volts = (self.battery_nominal * (0.86 + 0.14 * self.charge / 100.0)
|
||||||
if self.on_battery else self.battery_nominal * 1.045)
|
if self.on_battery else self.battery_nominal * 1.045)
|
||||||
line = 121.4 + 1.1 * math.sin(now / 90.0)
|
line = 121.4 + 1.1 * math.sin(now / 90.0)
|
||||||
@@ -109,6 +132,7 @@ class SimUps:
|
|||||||
"ups.serial": self.serial,
|
"ups.serial": self.serial,
|
||||||
"ups.firmware": self.firmware,
|
"ups.firmware": self.firmware,
|
||||||
"ups.status": status,
|
"ups.status": status,
|
||||||
|
"ups.beeper.status": self._beeper,
|
||||||
"battery.charger.status": charger,
|
"battery.charger.status": charger,
|
||||||
"ups.load": f"{load_pct:.1f}",
|
"ups.load": f"{load_pct:.1f}",
|
||||||
"ups.realpower": f"{self.load_w:.1f}",
|
"ups.realpower": f"{self.load_w:.1f}",
|
||||||
@@ -152,23 +176,29 @@ class SimUps:
|
|||||||
if command == "test.battery.start.quick":
|
if command == "test.battery.start.quick":
|
||||||
self._test_until = now + 12.0
|
self._test_until = now + 12.0
|
||||||
self.on_battery = True
|
self.on_battery = True
|
||||||
|
self._result_pending = True
|
||||||
self.test_date = time.strftime("%Y-%m-%d", time.gmtime(now))
|
self.test_date = time.strftime("%Y-%m-%d", time.gmtime(now))
|
||||||
return "OK Start Quick Battery Test"
|
return "OK Start Quick Battery Test"
|
||||||
if command == "test.battery.start.deep":
|
if command == "test.battery.start.deep":
|
||||||
self._deep_until = now + 90.0
|
self._deep_until = now + 90.0
|
||||||
self.on_battery = True
|
self.on_battery = True
|
||||||
|
self._result_pending = True
|
||||||
return "OK Start Deep Battery Test"
|
return "OK Start Deep Battery Test"
|
||||||
if command in ("test.battery.stop", "calibrate.stop"):
|
if command in ("test.battery.stop", "calibrate.stop"):
|
||||||
self._test_until = self._deep_until = 0.0
|
self._test_until = self._deep_until = 0.0
|
||||||
self.on_battery = False
|
self.on_battery = False
|
||||||
|
self._result_pending = False # an aborted test reports nothing, not a pass
|
||||||
return "OK Stop Test"
|
return "OK Stop Test"
|
||||||
if command == "test.panel.start":
|
if command == "test.panel.start":
|
||||||
return "OK Start Panel Test"
|
return "OK Start Panel Test"
|
||||||
if command == "beeper.enable":
|
if command == "beeper.enable":
|
||||||
self._beeper = True
|
self._beeper = "enabled"
|
||||||
return "OK Beeper enabled"
|
return "OK Beeper enabled"
|
||||||
if command in ("beeper.disable", "beeper.mute"):
|
if command == "beeper.disable":
|
||||||
self._beeper = False
|
self._beeper = "disabled"
|
||||||
|
return "OK Beeper disabled"
|
||||||
|
if command == "beeper.mute":
|
||||||
|
self._beeper = "muted"
|
||||||
return "OK Beeper muted"
|
return "OK Beeper muted"
|
||||||
if command == "calibrate.start":
|
if command == "calibrate.start":
|
||||||
self._deep_until = now + 180.0
|
self._deep_until = now + 180.0
|
||||||
@@ -221,6 +251,10 @@ class NutSimulator:
|
|||||||
raise KeyError(f"unknown simulated UPS {ups_name!r}")
|
raise KeyError(f"unknown simulated UPS {ups_name!r}")
|
||||||
|
|
||||||
|
|
||||||
def from_env(value: str | None) -> Any:
|
def from_env(value: str | None) -> NutSimulator | None:
|
||||||
"""`NUT_SIMULATE=1` -> a simulator; anything else -> None (talk to real upsd)."""
|
"""`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
|
return NutSimulator() if (value or "").strip().lower() in ("1", "true", "yes", "on") else None
|
||||||
|
|||||||
@@ -36,52 +36,29 @@
|
|||||||
|
|
||||||
<div id="banners" class="stack" style="margin-bottom:.85rem"></div>
|
<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="body">
|
||||||
<div class="metrics">
|
<div class="metrics">
|
||||||
<div class="metric"><div class="label">Engines answering</div>
|
<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="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="metric"><div class="label">Active sessions</div>
|
||||||
<div class="value" id="f-active">n/a</div></div>
|
<div class="value" id="f-active">n/a</div></div>
|
||||||
<div class="metric"><div class="label">Queued</div>
|
<div class="metric"><div class="label">Queued</div>
|
||||||
<div class="value" id="f-waiting">n/a</div></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>
|
||||||
<div class="faint" id="seam-note" style="margin-top:.5rem;font-size:11.5px"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="grid cols-2" style="margin-top:.85rem" aria-label="Throughput charts">
|
<!-- The page leads with the engine panels: two throughput heroes per engine, a compact
|
||||||
<article class="card">
|
sparkline cell list, and the gufo slot table below. The four trend charts follow,
|
||||||
<header><h3>Decode throughput</h3><span class="spacer"></span>
|
half-height — supporting evidence, not the main event. -->
|
||||||
<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 & 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 style="margin-top:.85rem">
|
<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>
|
<div class="empty-state" id="engines-empty" hidden>
|
||||||
<h3>No engines configured</h3>
|
<h3>No engines configured</h3>
|
||||||
<p>Set <code>GUFO_BASE_URL</code> (llama.cpp / gufo) and/or <code>VLLM_BASE_URL</code>.
|
<p>Set <code>GUFO_BASE_URL</code> (llama.cpp / gufo) and/or <code>VLLM_BASE_URL</code>.
|
||||||
@@ -90,6 +67,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 & 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">
|
<section class="grid cols-2" style="margin-top:.85rem">
|
||||||
<article class="card" id="slots-card">
|
<article class="card" id="slots-card">
|
||||||
<header><h3>gufo slots</h3><span class="spacer"></span>
|
<header><h3>gufo slots</h3><span class="spacer"></span>
|
||||||
@@ -107,8 +105,8 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="pagefoot">
|
<footer class="pagefoot">
|
||||||
<span>each cell carries its provenance: scraped gauge, <span class="mono">Δ10s</span> measured
|
<span>each throughput number carries its provenance: scraped gauge, <span class="mono">Δ10s</span>
|
||||||
counter, or lifetime estimate</span>
|
measured counter, or lifetime estimate</span>
|
||||||
<span class="spacer" style="margin-inline-start:auto"></span>
|
<span class="spacer" style="margin-inline-start:auto"></span>
|
||||||
<span class="mono" id="foot-note"></span>
|
<span class="mono" id="foot-note"></span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -70,6 +70,28 @@ const Dash = (() => {
|
|||||||
|
|
||||||
const naText = 'n/a';
|
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 --------------------------------------------------------
|
// ---- DOM helpers --------------------------------------------------------
|
||||||
/** Write text only when it changed: avoids killing transitions and focus. */
|
/** Write text only when it changed: avoids killing transitions and focus. */
|
||||||
function setTxt(el, value) {
|
function setTxt(el, value) {
|
||||||
@@ -110,9 +132,9 @@ const Dash = (() => {
|
|||||||
*
|
*
|
||||||
* Thresholds live in `src/dashboards/severity.py` and travel in the payload as
|
* 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
|
* `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
|
* only because `page-ups.js` still calls it with its own numbers (phase 05 converts it,
|
||||||
* (phases 04–05 convert them page by page, so each page's change is reviewable on its
|
* so each page's change is reviewable on its own — `/` was converted in phase 02,
|
||||||
* own — `/` was converted in phase 02, `/system` in phase 03). Do not add a new call
|
* `/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.
|
* site: a threshold written in JS is a threshold that can disagree with the other pages.
|
||||||
*/
|
*/
|
||||||
function severityFor(value, warnAt, failAt) {
|
function severityFor(value, warnAt, failAt) {
|
||||||
@@ -444,7 +466,8 @@ const Dash = (() => {
|
|||||||
|
|
||||||
return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt,
|
return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt,
|
||||||
setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindPause,
|
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());
|
document.addEventListener('DOMContentLoaded', () => Dash.initChrome());
|
||||||
|
|||||||
@@ -344,31 +344,245 @@ function renderLegend(container, series, format, showPeak) {
|
|||||||
}).join('');
|
}).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) {
|
function sparkline(container, points, opts) {
|
||||||
const o = Object.assign({ height: 34, color: cssVar('--series-1'), min: null, max: null }, opts || {});
|
const o = Object.assign({
|
||||||
const pts = (points || []).filter((p) => p && Number.isFinite(p.value));
|
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 w = Math.max(60, container.clientWidth || 160);
|
||||||
const h = o.height;
|
const h = o.height;
|
||||||
container.innerHTML = '';
|
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>';
|
container.innerHTML = '<div class="faint mono" style="font-size:11px">no history</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const xs = pts.map((p) => p.ts), ys = pts.map((p) => p.value);
|
const ys = pts.map((p) => p.value);
|
||||||
const x0 = Math.min(...xs), x1 = Math.max(...xs);
|
const dataMax = Math.max(...ys);
|
||||||
let lo = o.min !== null ? o.min : Math.min(...ys);
|
let lo, hi;
|
||||||
let hi = o.max !== null ? o.max : Math.max(...ys);
|
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; }
|
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;
|
// the axis never gets finer than one thing (see `minSpan` above)
|
||||||
const sy = (v) => h - 2 - ((v - lo) / (hi - lo)) * (h - 4);
|
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 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"
|
const baseY = (inner.t + plotH).toFixed(1);
|
||||||
role="img" aria-label="trend from ${lo.toFixed(2)} to ${hi.toFixed(2)}">
|
const area = `<path d="${d} L${sx(last.ts).toFixed(1)} ${baseY} L${sx(first.ts).toFixed(1)} ${baseY} Z" fill="${stroke}" opacity=".14"/>`;
|
||||||
<path d="${d} L${sx(x1).toFixed(1)} ${h} L${sx(x0).toFixed(1)} ${h} Z" fill="${o.color}" opacity=".14"/>
|
const baseline = `<line x1="${inner.l}" x2="${(inner.l + plotW).toFixed(1)}" y1="${baseY}" y2="${baseY}" stroke="${stroke}" opacity=".22"/>`;
|
||||||
<path d="${d}" fill="none" stroke="${o.color}" stroke-width="1.4"/></svg>`;
|
let marks = '';
|
||||||
container.innerHTML = svg;
|
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,
|
/**
|
||||||
|
* 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 };
|
CHART_COLORS, DEFAULT_FORMAT, escapeHtml };
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user