phase: 07_llm_monitor_style_pages

**Phase 07 final verification pass — all tasks already in `complete/`; every criterion re-verified live.**

- **Old slot table gone**: no `#slots-card`/`#slots-table`/`renderSlots` in HTML/JS/CSS (only the new grid's `.slots-empty` *class*); pinned by `test_the_broken_slot_table_is_gone_from_html_js_and_css`.
- **`tests/fixtures/slots_gufo.json`**: present; live `GET ${GUFO_BASE_URL}/slots` this pass matches it key-for-key (row + `next_token[0]`), so the capture is reproducible; drift proven to fail (mutation tests + live `test_gufo_slots_answer_the_pinned_shape`, 9 live tests pass).
- **Aggregate block**: live page shows `2 of 2 engines`, running/waiting/prompt/decode/total-tokens straight from `fleet_totals()`, dot `ok`, no verdict colour; on a throwaway single-engine instance (:8091, private temp DB) `#fleet` is `hidden`, one full-width strip card + one panel, zero banners → "one clean half".
- **Strip + slot grid on live data**: 2 `.card.stat` (KV bars `bar ok` 13 %/0 %), gufo grid rendered real cards incl. a busy slot (`#2189`, 67849 prompt tok, 99 % cache share, `processing`), vLLM note-only with no grid; no `NaN`/`undefined` anywhere. Screenshots: `data/logs/llm-phase07-final-live-{two-engines,single-engine}.png`, `ups-charts-aria-labels.png`.
- **Defect found in earlier work and fixed**: `/ups`' four `LineChart`s shipped no `ariaLabel`, violating AGENTS.md's "every chart names itself for assistive tech" (`/system`+`/llm` were pinned, `/ups` wasn't) → added labels to `page-ups.js` and `test_every_ups_chart_names_itself_for_assistive_tech`; browser-verified `role="img"` + label on all four.
- **Gate**: `bash .agents/validate.sh` → **exit 0**; `uv run pytest --cov --cov-fail-under=94` → **604 passed, 9 skipped, coverage 97.91 %**; `uv run ruff check src tests agent` → All checks passed; `uv run pyright` → **0 errors**; `node --check` every page script ok; `uv run pytest -m live --live` → **9 passed**; `uv pip list --outdated` empty.
- **Docs**: README + AGENTS.md reviewed end-to-end; fixed test count (582→604), `/llm` source column + both architecture diagrams now name gufo `/slots`, AGENTS.md agent line gained `GET /schema` + status page, ariaLabel convention now covers all three chart pages, README Testing block now lists `bash .agents/validate.sh`. Env-var table, agent/install flags, Containerfile facts, `/api/*` shapes, range chips, remote URL all verified accurate — no other changes needed.
- **Live-infra**: no fleet/host/config change; the temp :8091 instance, its temp DB and the fake processes are removed; dev server restarted and `/api/health` `ok` (all 6 sources ok). One thing I deliberately did **not** touch: the gitignored local `.env.dev` still carries a comment claiming the NUT `[dashboards]` account has only battery-test `instcmds`, whereas AGENTS.md/README record `instcmds = all` (verified on hardware 2026-10-17) — operator-owned file, flag for the operator to reword.
- **Next pending phase**: `08_events_paging_and_copy`.
This commit is contained in:
2026-10-17 17:50:19 -04:00
parent de325caa49
commit 3d5ccd7a39
43 changed files with 2544 additions and 210 deletions
@@ -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