chore(agent): phase roadmap from TODO.md, 5 phases

The five UI items in TODO.md become an executable pipeline. Planning files
only — no application code changed.

- 01_severity_language: one Python threshold table + `severity` on the
  values the pages colour, plus the .hero style. Today the thresholds are
  magic numbers at six JS call sites (75/90 here, 80/92 there), which is
  exactly how "same colour, four meanings" happens.
- 02_overview_fleet_and_alerts: /api/summary gains a derived `alerts`
  list; the page drops to fleet + warnings. Unconfigured sources must not
  alert — that is invariant 3, tested.
- 03_system_per_host_panels: per-host hero cards, no per-core, shared
  multi-host charts and the detail picker deleted. The six card metrics
  already exist in the payload, so the first task pins them with tests
  instead of inventing fields.
- 04_llm_chart_readability: charts.js already exports sparkline() and no
  page calls it — that is why the page reads flat. Port llm_monitor's
  drawSpark behaviour (fixed max, right growth, p95 foots) and keep the
  provenance tags.
- 05_ups_hero_battery_glyph: charge/load/runtime hero + an SVG battery
  whose fill is the charge, verified against both real UPSes and the
  low-charge/on-battery states only the simulator can produce.

PLAN.md records the ratified anchors and the deviation worth knowing:
severity vocabulary is ok|warn|fail, matching dash.css and nut.py, not the
"danger" wording in the proposal — one synonym for one colour.

TODO.md cleared to a bare heading; the items are now phases. .gitignore
stops ignoring all of .agents/ (the roadmap is project history) while
keeping phase-sessions/ and pipeline.log out. deploy/nut-sudoers rewritten:
its header still blamed a port mystery that is solved; the remaining need
is reading upsd.users so adding a scoped dashboards user does not delete
the accounts upsmon authenticates as.

LLM-Generated: true
This commit is contained in:
2026-10-16 20:10:23 -04:00
parent 1e454a8f81
commit 4b4962d7fb
29 changed files with 1429 additions and 46 deletions
@@ -0,0 +1,47 @@
# Phase 02 — Overview is fleet and alerts
**Source:** `TODO.md` L3 — "The overview page should only show Fleet and warnings/errors. Should be very simple."
**Story:** n/a
**Context:** `.agents/PLAN.md` → *Data Model* (`/api/summary`), `01_severity_language` (alerts reuse the verdicts), `src/dashboards/api.py:153` (`/api/summary`), `src/dashboards/web/pages/index.html`, `src/dashboards/web/static/page-index.js`
## Objective
Turn the landing page into one question answered instantly: *is anything wrong, and which
page do I need?* Fleet table plus a warnings/errors panel — the three summary cards and the
raw event feed go away.
The event list is what makes the current overview busy. Its useful content is not "vLLM is
answering again" (that is history) but "something is wrong right now", which is a derived
state and belongs server-side.
## Dependencies
- `01_severity_language` (todo) — alerts are built from the `severity` verdicts and the UPS
`health.checks[]` results, not from new thresholds invented in the browser.
## Tasks
1. `01_alerts_builder.md` — `src/dashboards/alerts.py`: pure function from hub states + payloads to an alert list; `/api/summary` returns it.
2. `02_rewrite_overview_page.md` — `index.html` + `page-index.js` reduced to fleet table + alerts panel.
3. `03_retire_unused_overview_code.md` — delete the dead cards/events code and confirm the API contract other pages depend on is intact.
## Testing & Quality
- Unit: `tests/test_alerts.py` — a source down, a source stale-but-serving, a UPS `fail`
check, a threshold breach, an agent reporting `degraded`, an unconfigured source (must
**not** alert — unconfigured is not broken, `AGENTS.md` invariant 3), a simulated UPS
(warn, labelled), and the all-clear case producing `[]`.
- API: `/api/summary` gains `alerts` and keeps every existing key.
- Coverage: **>90%** on `alerts.py` and the changed part of `api.py`.
- Browser: screenshot of `/` with a real problem present (stop the driveripper agent tunnel
and re-shoot) — an overview that has never been seen in its alarm state has not been
tested.
## Completion Criteria
- [ ] `/` shows exactly two panels: Fleet and Warnings/Errors
- [ ] `curl -s localhost:8090/api/summary | python3 -c "import json,sys; print(json.load(sys.stdin)['alerts'])"` is `[]` when everything is healthy
- [ ] Killing one host tunnel produces exactly one alert naming that host, with a link to `/system`
- [ ] `uv run pytest` green (plus `-m live --live` unchanged), coverage ≥94%, `ruff` + `pyright` clean
- [ ] no behaviour change on `/system`, `/llm`, `/ups`
- [ ] no behavior change in completed phases
@@ -0,0 +1,56 @@
# Task 01 — Alerts builder
**Phase:** `02_overview_fleet_and_alerts` · **Source:** `TODO.md:3` — "The overview page should only show Fleet and warnings/errors."
**Story:** n/a
## Objective
Derive "what is wrong right now" on the server, so the overview is a list of verdicts and
every other consumer (future mobile view, notification later) gets the same answer.
## Work
1. `src/dashboards/alerts.py` (new) — `build(settings, hub_states, host_payloads, engine_payloads, ups_payload, recent_events) -> list[dict]`,
each alert `{"id", "severity": "ok|warn|fail", "source", "message", "href", "at"}`:
- **source down** — a configured job whose `Job.state.ok` is false → `fail`, message from
the recorded error, `href` to its dashboard. Stale-but-serving (`age_seconds` beyond
~3× its poll interval) is `warn`, not `fail`: the last good value is still on screen and
saying "dead" would be a lie.
- **host threshold breach** — from the `severity` sub-dicts added by phase 01: any
`fail` → an alert naming host + metric + value; `warn` → `warn`.
- **agent degraded** — `payload["degraded"]` non-empty (a `/proc` read failed on the box)
→ `warn`, naming the sections.
- **UPS** — any `health.checks[]` entry with `status: "fail"` → `fail`, `"warn"` → `warn`,
carrying the check name and its detail (`"Battery age: 5.3 years — past the 4-year
replacement window"` is exactly the kind of thing the landing page should say);
`simulated: true` → one `warn` alert saying the numbers are modelled, not batteries.
- **bad target config** — `settings.host_target_problems` → `warn` per problem.
- **never** for an unset/unconfigured source. `AGENTS.md` invariant 3: a source nobody
installed must not raise a red flag.
- **dedupe and bound** — one alert per (source, metric); cap the list (say 12) with the
count of suppressed ones so a 40-disk fleet cannot flood the page.
- sort fail → warn, then by source name.
2. `src/dashboards/api.py` — `/api/summary` calls it and adds `"alerts": [...]`. Keep
`system`, `llm`, `ups`, `recent_events` exactly as they are (additive-only anchor),
because `/api/summary` is also the cheapest whole-fleet probe anyone has.
3. `tests/test_alerts.py` (new) — every bullet above, using the existing fixtures
(`tests/fixtures/agent_*.json`) and the fakes in `tests/conftest.py`; the all-clear case
asserts `[]`, and the unconfigured-source case asserts no alert is produced even though
the state map has nothing for it.
- ASSUMPTION: `recent_events` is passed in only so a *currently failing* source can show
when it started failing; the event list itself is not re-rendered as alerts.
- ASSUMPTION: staleness threshold is 3× the source's own poll interval rather than a
single global number, because system polls at 10s and UPS at 5s.
## Testing & Quality
- Unit tests for all rules; no I/O in `alerts.py` (it is a pure projection, which is what
makes it worth its own module).
- Coverage: **>90%**.
## Completion Criteria
- [ ] `uv run pytest tests/test_alerts.py -v` green
- [ ] `curl -s localhost:8090/api/summary | python3 -m json.tool | head -40` shows `alerts` alongside the untouched `system`/`llm`/`ups`
- [ ] with a healthy fleet the list is `[]`; with `NUT_SIMULATE=1` it contains exactly the simulated-data warning
- [ ] `uv run pytest` green; `ruff` + `pyright` clean
@@ -0,0 +1,59 @@
# Task 02 — Rewrite the overview as fleet + alerts
**Phase:** `02_overview_fleet_and_alerts` · **Source:** `TODO.md:3` — "The overview page should only show Fleet and warnings/errors. Should be very simple."
**Story:** n/a
## Objective
The landing page becomes two things: the fleet table, and a list of what needs attention.
## Work
1. `src/dashboards/web/pages/index.html` —
- delete the three dashboard summary cards: the whole
`<section class="grid auto" aria-label="Dashboards">` block, i.e. `#sys-hosts-live`,
`#sys-cpu`, `#sys-mem`, `#sys-mem-unit`, `#sys-disk`, `#sys-gpus`, `#llm-live`,
`#llm-decode`, `#llm-prompt`, `#llm-active`, `#llm-waiting`, `#llm-kv`, `#ups-list`
and the `#dot-system` / `#dot-llm` / `#dot-ups` dots they own;
- delete the events panel (`#events` and its card) — alerts replace it;
- keep the masthead, `#nav-status` (the header source dots already live in `app.js`
chrome), `#clock`, `#theme-toggle`, `#config-chip`, `#banners`, `#fleet-table`,
`#fleet-empty`, `#foot-versions`;
- add one panel **Warnings & errors** (`#alerts`, `aria-live="polite"`) with an empty
state that says the fleet is clear — an empty list is a message, not a blank box.
- keep `#fleet-table` columns as they are (host, CPU, load 1, mem, worst disk, GPU busy,
temp, up). It is already the glanceable fleet view; simplifying it further loses the
comparison that makes it worth having.
2. `src/dashboards/web/static/page-index.js` —
- delete `renderEvents()` and the `#sys-*` / `#llm-*` / `#ups-list` population code in
`renderSummary()`;
- add `renderAlerts(alerts)`: one row per alert with the severity class from phase 01
(`.ok`/`.warn`/`.fail`), the source name as a link to its dashboard (`href` from the
API), the message, and the age via `Dash.age()`; empty → the "all clear" state;
- `refresh()` still calls `/api/summary` once and updates `#summary-line`
("3 hosts · 2 engines · 2 UPS · 1 warning") — that one line is the only summary left;
- keep `Dash.bindPause`, `Dash.rangeControl` (if the page used it) and the poll loop.
3. Layout: alerts panel sits above the fleet table when something is wrong and below it when
the list is empty, so a clean morning looks calm and a bad one puts the problem first.
- ASSUMPTION: the UPS/LLM summary content the cards showed is *available* on their own
pages, so removing it here loses nothing that is not one click away. The header status
dots stay, because "which of the three is down" is the overview's job.
- ASSUMPTION: `#banners` (config/setup hints, simulated-data notice) stays — it is a
warning surface, which is exactly what the TODO keeps.
## Testing & Quality
- Update `tests/test_api.py` only if it asserts page structure; the page is not unit-tested
(locked anchor: no JS tests).
- Browser check with live data, both states:
healthy (`alerts: []` → "all clear") and broken (stop one host agent:
`ssh <host> "pkill -f '[h]ost_agent.py'"` or stop its tunnel) → one red row naming it.
Screenshots to `data/logs/overview-clear.png` and `data/logs/overview-alert.png`.
## Completion Criteria
- [ ] `/` renders exactly two panels; `document.querySelectorAll('section').length` is 2 (check via the browser console or an `evaluate`)
- [ ] `#events`, `#sys-cpu`, `#llm-kv`, `#ups-list` no longer exist in the served HTML (`curl -s localhost:8090/ | grep -c 'id="sys-cpu"'` → 0)
- [ ] alert rows link to `/system`, `/llm`, `/ups` correctly
- [ ] both screenshots exist in `data/logs/` and show what they claim
- [ ] `uv run pytest` green; `ruff` + `pyright` clean
@@ -0,0 +1,43 @@
# Task 03 — Retire the dead overview code, keep the API honest
**Phase:** `02_overview_fleet_and_alerts` · **Source:** `TODO.md:3` — "Should be very simple." (simplicity includes not carrying code nothing renders)
**Story:** n/a
## Objective
Leave no orphaned code behind, and prove the `/api/summary` fields the *other* consumers
rely on were not quietly dropped along with the cards.
## Work
1. `src/dashboards/web/static/page-index.js` — remove helpers that only the deleted cards
used; run a grep for each symbol before deleting so nothing still referenced goes:
`grep -n "sys-cpu\|llm-kv\|ups-list\|renderEvents" src/dashboards/web/static/*`.
2. `src/dashboards/web/static/dash.css` — drop rules that only the deleted cards matched
(check with `grep -n "metric\b" dash.css` against the remaining pages before removing;
`.metric` is used by `/system` and `/llm` too, so most of it stays).
3. `src/dashboards/api.py` — **do not remove** the `system`/`llm`/`ups` sections from
`/api/summary` even though the page no longer renders them: it is the single cheapest
whole-fleet probe, the live tests and future alerting want it, and the API anchor is
additive-only. Add a short comment on `/api/summary` saying the page renders
`alerts` + `system.fleet`/`hosts` and that the rest is for programmatic use.
4. `tests/test_api.py` — add an explicit regression test: `/api/summary` contains the keys
`system.enabled`, `system.fleet`, `system.hosts`, `llm.engines`, `ups.ups`,
`recent_events`, `alerts`, so a later cleanup cannot silently shrink the contract.
5. `README.md` — the overview row in the top table still says "one request, all of it";
update the wording to "fleet + alerts in one request".
## Testing & Quality
- Full suite green, including the new contract test.
- Coverage: >90% on changed Python (`api.py` only).
- `grep` evidence that no dangling selector survives (paste the empty grep result in the
commit body or task notes).
## Completion Criteria
- [ ] `grep -rn "sys-cpu\|llm-kv\|ups-list\|renderEvents" src/dashboards/web/` returns nothing
- [ ] `curl -s localhost:8090/api/summary` still returns every key listed in step 4
- [ ] `uv run pytest` green (contract test included), coverage ≥94%, `ruff` + `pyright` clean
- [ ] `uv run pytest -m live --live` still 6 passed
- [ ] no behavior change in completed phases (01's severity fields still present)