phase: 08_events_paging_and_copy

All green. Final report:

**Verified (phase 08 — both task files already in `complete/`; this was the final verification pass)**
- `Dash.eventsPager()` in `app.js` (50/page, `1–50 of N` strip, aria-labelled `‹ prev`/`next ›` disabled at the ends, `WeakMap` page state, reset only when the event *set* changes, `EVENT_FEED_LIMIT`=200) with three call sites; `ul.events` bounded to 320px + `overflow-y:auto`; `/api/events` untouched (`le=500`, no silent clamp).
- Live browser against the real fleet: `/system` `1–50 of 121`, `/llm` `1–50 of 200`, `/ups` `1–50 of 200`; container 320px vs scrollHeight 1543/2368; page 2 → `51–100 of 200` and survived two polls; no empty `.sub`/`.faint` container visible on any page; footers carry only measurements (`rates over Δ11.9s · poll 10s`). Fresh artifacts: `data/logs/08-final-{system-head,llm-page,index-page,ups-feed-page2}.png` (32 phase screenshots already present).
- Grep of live served bytes for all 13 deleted flavour fragments + `actions = -`: none survive in HTML or JS.
- `AGENTS.md` carries both new UI-convention rules (flavour-text rule with the `TODO.md:7` cited examples; the event-feed-window rule) and the updated test count.

**Tests / lint (full project 100% green)**
- `bash .agents/validate.sh` → **EXIT=0**: `uv run pytest --cov --cov-fail-under=94` → **624 passed, 9 skipped, coverage 97.91%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` ×5 clean; repo hygiene clean.
- `uv run pytest -m live --live` (`.env.dev` sourced) → **9 passed**; `uv pip list --outdated` → empty. Re-ran pytest/ruff/pyright/node after the doc edits → all exit 0.

**Completion criteria**: bounded scroll + 50/page + count footer ✅ (all three feeds, live) · no removed flavour string ✅ · AGENTS.md rule ✅ · `validate.sh` green + screenshots in `data/logs/` ✅.

**Documentation**: full README/AGENTS review against code (env table vs `config.py`/`.env*.example`, script flags, Containerfile uid 10001/8090, chart heights 120/110/160, test count, coverage) — one inaccuracy fixed: README said "Every page's event feed" (the overview has none) → now "The three event feeds (`/system`, `/llm`, `/ups`)"; also restored the missing trailing newline in `TODO.md`. Everything else verified consistent.

**Notes**: paging stays client-side (no API change); the task-02 defect fix in `/ups` setup copy (`NUT_HOST`/`NUT_PORT`, `LISTEN 127.0.0.1`, one `instcmds` per command, never `actions = -`) re-verified against README and live bytes. Pre-existing items intentionally left to their own phases: `ups.test.result: "Done and passed"` rated `warn` (operator's `TODO.md` entry) and the dev-DB `database is locked` retention issue.

**Next pending phase**: `.agents/phases/todo/09_disks_smartctl/`
This commit is contained in:
2026-10-17 19:32:36 -04:00
parent 3d5ccd7a39
commit 19cfedfddf
29 changed files with 721 additions and 74 deletions
+41 -1
View File
@@ -256,7 +256,7 @@ cp .env.dev.example .env.dev # dev-only: ssh tunnels + live targets
./scripts/dev-server.sh status # per-source ok/DOWN
./scripts/dev-server.sh logs
uv run pytest # hermetic: 604 passed + 9 live-skipped, no network
uv run pytest # hermetic: 624 passed + 9 live-skipped, no network
uv run pytest --cov # coverage gate (97.9%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
uv run ruff check src tests agent && uv run pyright
@@ -343,6 +343,25 @@ Window chips (5m…30d on `/system` and `/llm`, 1h…90d on `/ups`; the API acce
panel that cannot get data says why in the panel — the operator should never have to open
devtools to learn that a source is unconfigured. Escape anything upstream-provided through `DashCharts.escapeHtml`.
**Flavour text is not helpful information.** A page does not narrate its own data pipeline or
its own design; copy exists only where the operator needs it to act — a why-this-panel-is-empty
sentence, a provenance tag, a measured `Δ<window>` label, a chart's unit, a `SIMULATED` badge,
an error that names what failed. The audit is one question per string: *would the operator act
differently without it?* No → delete it, container and all. Deleted in phase 08 (the cited
examples, from `TODO.md:7`): "every number is scraped from the box it belongs to · `null`
renders as `n/a`" (`/`'s footer), "agent-reported values; rates are measured over the Δ window
in the footer, and each panel's charts cover only that host" (`/system`'s *Per host* sub-line),
and the same genre — `/system`'s "numbers are measured, not inferred", `/llm`'s "each throughput
number carries its provenance", `/ups`'s "commands are the only write path in this app", each
event feed's content caption (the empty-state sentence already says it), and the design
clauses inside empty-state prose. What survived the audit did so because it is a measurement or
a why: every `Δ<window>` footnote, `provenance()` on each rate, `fleet.seams`, *sums only
engines answering now*, the queued-request note under the slot grid, the SIMULATED banners, and
the setup instructions in every empty state. `tests/test_api.py` pins the deleted sentences
**absent** from every served page and script (a sentence that migrates to another page is the
same slop), the kept functional strings **present**, and that no removed sub-line left an empty
`.sub`/`.faint` container behind.
The overview is exactly two panels — **Warnings & errors** (`ul.alerts`, one row per
`/api/summary` alert: verdict class, source linked to its dashboard via the alert's own
`href`, message, age) and **Fleet**. A non-empty list floats the alerts panel above the
@@ -432,6 +451,27 @@ the UPS rows used to `replace()` it anywhere and mangled "Quick battery test sen
This is presentation only — the stored event and `/api/events` keep every word, and the raw
error string still surfaces in `/api/health`.
An event feed is a **window**, not a printout (`Dash.eventsPager(el, events, opts)` in
`app.js`, one helper with three call sites — a pager that works on one page is the bug this
shape prevents). `ul.events` is a bounded 320px scroll container (`overscroll-behavior:
contain`) and the pager puts `EVENT_PAGE_SIZE` = **50** events in it, with a `.events-pager`
strip under the list: `1–50 of 200` between `‹ prev` and `next ›`, both `aria-label`led for
direction (the feed is newest-first, so `prev` walks towards now) and `disabled` at the ends.
The strip is `hidden` when the feed fits on one page — every row on screen *is* the whole
story, and a disabled arrow says "more history" when there is none. Each page keeps its own
`row(event)` (so `/ups` keeps its longer timestamp and runtime footnote, and escaping stays
the page's job — the pager has no default renderer and says so in a row when a caller forgets
one) and its own `empty` sentence, so the `loading…` and empty states read exactly as they
did before paging existed. Two numbers are load-bearing and pinned together: the fetch
(`EVENT_FEED_LIMIT` = 200, `/api/events?limit=…`) must exceed one page, because a limit at or
below `EVENT_PAGE_SIZE` is a pager that silently never appears; `/api/events` itself is
**unchanged** — no `page`/`limit` vocabulary grew for one page's layout. Paging state lives in
a `WeakMap` keyed by the `<ul>`: it survives a poll (so page 3 is still there two seconds
later — resetting every refresh would make page 2 unreadable) and resets to page 1 when the
event *set* changes (an operator reading page 3 of yesterday's history must not land on page 3
of a list that just changed). Rows are re-written only when their markup changed, which is
what keeps the reader's scroll position inside the container.
The `/system` host card is one row of six heroes — CPU, MEM, TEMP, DISK, GPU, NET (the
`.heroes` grid; `tests/test_api.py` pins the slots and their order on the served script).
Colour comes from the payload's `severity` dicts and nowhere else; a missing number or