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,61 @@
# Task 01 — Severity module
**Phase:** `01_severity_language` · **Source:** `TODO.md:5` — "proper green (good), yellow (warn), red (danger) color coding" and `TODO.md:7` — "charge, load, and runtime left to be big, obvious, and color coded"
**Story:** n/a
## Objective
Move the good/worrying/bad decision out of the browser and into one Python module, so the
four pages cannot drift into four different opinions about what red means.
Today the thresholds live in call sites: `page-system.js:129` passes `75, 90`,
`page-system.js:139` and `:144` pass `80, 92`, `page-index.js:89` passes `80, 92`,
`page-llm.js:195` and `:307` pass `75, 90`, and `Dash.severityFor` (`app.js:105`) defaults
to `75/90`. That is the drift this task ends.
## Work
1. `src/dashboards/severity.py` (new) —
- `THRESHOLDS: dict[str, tuple[float, float]]` keyed by metric name, with the ratified
defaults: `cpu.usage_pct` 75/90, `mem.used_pct` 80/92, `disk.used_pct` 80/90,
`cpu.temp_c` 75/90, `gpu.busy_pct` 85/95, `gpu.mem_used_pct` 85/95,
`llm.kv_cache_usage_pct` 75/90, `ups.charge_pct` 50/20, `ups.battery_runtime` 600/300,
`ups.load_pct` 70/90.
- `INVERTED: set[str]` — metrics where **lower is worse** (`ups.charge_pct`,
`ups.battery_runtime`), so callers cannot get the direction wrong.
- `classify(metric: str, value: float | None) -> str | None` returning `ok`, `warn`,
`fail`, or `None`. `None` in → `None` out, always.
- `attach(metric: str, value: float | None) -> dict` returning
`{"value": value, "severity": classify(...)}` for the payload shapes that are plain
numbers, and `with_severity(metric, cell)` for payloads that already are
`{"value":…, "source":…, "derived":…}` (LLM metrics) — additive, never a rename.
- Module docstring stating the two rules: unknown is **not** ok (it is `None`, the page
renders `n/a` with no colour), and a threshold exists nowhere else in the app. Ties to
the `AGENTS.md` "never invent a number" rule.
2. `tests/test_severity.py` (new) —
- boundaries inclusive on the warn edge (`75 → warn`, `74.9 → ok`), danger edge
(`90 → fail`), every metric in the table, both directions for the inverted ones,
unknown metric name → `None` (no exception), `None` and `float("nan")` → `None`.
- a table sanity test asserting each entry's bounds are ordered correctly for its
direction, so a future edit cannot silently invert a metric.
- ASSUMPTION: the numbers above were confirmed in chat on 2026-10-16; they match what
the call sites already pass, except the two UPS metrics which are new.
- ASSUMPTION: the vocabulary is **`ok|warn|fail`**, not `ok|warn|danger` — it is what
`dash.css` (`.bar.ok`, `.dot.warn`, `.chip.fail`, `ul.checks li.fail`) and
`sources/nut.py`'s `health.checks[].status` already use. The TODO's word "danger" maps
to `fail`; do not introduce a synonym for the same colour.
- ASSUMPTION: `sources/nut.py`'s existing `health.checks[].status` values are left
untouched (additive-only anchor). `severity` is a new field on *values*, computed by
this module; `nut.assess()` keeps its own judgement about *conditions*.
## Testing & Quality
- Unit only, no I/O, no fixtures needed.
- Coverage: **>90%** on `severity.py`.
## Completion Criteria
- [ ] `uv run pytest tests/test_severity.py -v` green
- [ ] `uv run python -c "from dashboards.severity import classify as c; print(c('cpu.usage_pct',95), c('cpu.usage_pct',60), c('cpu.usage_pct',None), c('ups.charge_pct',15))"` → `fail ok None fail`
- [ ] `uv run pytest` green overall; `ruff` + `pyright` clean
- [ ] Nothing else changed yet — this task adds a leaf module and its tests