phase: 26_ups_event_split

**Phase 26 `ups_event_split` — final verification pass (all 3 tasks already complete; nothing missing from `complete/`).**

Verified
- `page-ups.js`: `POWER_EVENT_KINDS = {status,test,action}` split, unconditional `refreshEvents()`, no `state.events`/toggle-fetch, shared `eventRow`, `#ups-errors-card.hidden = errors.length === 0`; `ups.html` error card above the disclosure, `hidden`, outside every `<details>`. Kind set cross-checked against the real writers (`collectors.py:393/409`, `api.py:1043/1058`, `poller.py:127/157/164/190`) — classification is correct.
- Re-ran the phase's browser pass live: `node data/logs/26-task03-verify.cjs` → **31 checks, 0 skipped, ALL PASS** (`data/logs/26-final-verify.out`) — poll-failure rows readable with the disclosure shut, card hidden on a power-only feed and appearing on the first `unreachable`, lists paging independently, 360 px clean.

Tests / lint (all green)
- `uv run pytest` → **1545 passed, 15 skipped**, exit 0 · `bash .agents/validate.sh` → exit 0, coverage **98.94%** (floor 94%) · `uv run ruff check src tests agent` clean · `uv run pyright` 0 errors · `node --check` clean · `uv run pytest -m live --live` (with `.env.dev`) → **11 passed**, dev server reads all 9 sources `ok`.

Completion criteria: poll-failure visible + power collapsed ✅ (pinned + browser); hidden-when-empty card, one `/api/events?source=ups` fetch, shared pager ✅; stale-last-good via the empty `catch` pin ✅; pytest/coverage/ruff/pyright ✅; `validate.sh` ✅; no behaviour change to prior work — `dash.css`/`app.js` untouched, disclosure CSS / pager / row-shape pins pass unchanged ✅.

Docs
- Fixed (pre-existing + this phase's drift): AGENTS.md "one helper with four call sites" → **five over four pages** and the `row(event)` sentence (phase 26 added a second `/ups` call site); invariant 5's `web/static/charts.js` → `src/dashboards/web/static/charts.js`; recorded the completion-pass transcript. README: "one helper renders every feed" now notes five lists. Full sweep otherwise consistent — all 38 env vars match `config.py` exactly, all served routes/scripts/contracts referenced exist, suite/coverage/live counts and marker splits verified.

Deviations: `fastapi 0.153.0 → 0.159.1` advisory left alone (validate.sh declares it advisory-not-a-gate; this phase touched no dependencies, matching prior phases' precedent). Next phase: **`27_mobile_tables_and_audit`**.
This commit is contained in:
2026-10-21 19:42:19 -04:00
parent 2bf236fd92
commit 9ff3c7889b
25 changed files with 734 additions and 121 deletions
+52 -13
View File
@@ -81,7 +81,7 @@ page still renders.
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;
charts are hand-rolled SVG (`web/static/charts.js`). The engines live on a network with
charts are hand-rolled SVG (`src/dashboards/web/static/charts.js`). The engines live on a network with
no outbound access — a CDN reference is a request that never comes back.
6. **Never leak a credential.** Target URLs may carry a userinfo token (`parse_targets`
moves it into an `Authorization` header, so `Target.url` ends up clean). Nothing that leaves
@@ -1229,7 +1229,7 @@ uv run python scripts/dev-certs-export.py --url http://127.0.0.1:8090 \
# GET /api/certs/<name>/bundle and writes
# data/certs/<name>.bundle at 0600 — mints nothing
uv run pytest # hermetic: 1543 passed + 15 skipped (11 live,
uv run pytest # hermetic: 1545 passed + 15 skipped (11 live,
# 4 store_perf), no network
uv run pytest --cov # coverage gate (98.94%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
@@ -1687,7 +1687,8 @@ This is presentation only — the stored event and `/api/events` keep every word
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 four call sites (`/system`, `/disks`, `/llm`, `/ups`) — a pager that
`app.js`, one helper with **five** call sites over four pages (`/system`, `/disks`, `/llm`, and
**both** halves of `/ups` since phase 26) — 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`
@@ -1695,7 +1696,8 @@ strip under the list: `1–50 of 200` between `‹ prev` and `next ›`, both `a
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
`row(event)` (so `/ups` keeps its longer timestamp and runtime footnote — one `eventRow` const
shared by both of its lists since phase 26 — 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
@@ -1711,15 +1713,52 @@ what keeps the reader's scroll position inside the container. The `/ups` feed is
no `open` attribute, so that card is one `Power events` summary line until the operator opens it
— a closed `<details>` renders no content, which is `/system`'s Charts rule applied to a feed,
and the `#events` id stays *inside* the disclosure so the pager and its bounded scroll keep their
target (pinned on the served bytes). The disclosure is also what makes it **lazy**: `refresh()`
fetches the feed only while `state.events.open` is true, so a card nobody is looking at costs the
poll loop nothing (a hidden-but-still-polled list is the half-measure this is). The first expand
fetches; a re-expand paints the rows already in the `<ul>` while they are younger than one poll
interval (`state.pollMs`, the page's own cadence) and re-fetches only past it — a `<details>`
keeps its children, so a fresh re-open costs no request and loses neither the pager's page nor
the reader's scroll. `painted`/`fetchedAt` are stamped on the success path alone, which is the
invariant-4 shape here: a failed fetch leaves the rows on screen, leaves the stamp where it was,
and the next open re-fetches. Its styling is `details.charts-box`'s shape with one structural
target (pinned on the served bytes). What the disclosure gates is **visibility only** (phase 26,
`TODO.md:6`): `refresh()` fetches the feed on every poll and `refreshEvents()` splits the one
response on the stored `kind` — `status` (an `ups.status` transition, power loss included,
`collectors.py`), `test` (a self-test verdict) and `action` (a command this page sent, `api.py`)
are the power events that belong behind the summary, and **every other kind** is the
connection/error half (the poller's `unreachable`/`reachable`/`recording`/`store-error`, i.e.
`NUT server NutError: cannot reach upsd … (timed out after 4s)`), which must never be the list
behind a summary. Phase 19's lazy gate went out with that decision — no `state.events`, no
`toggle`-handler fetch, one unconditional `refreshEvents()` call — because a visible list that
ages is worse than one more 200-event fetch per interval, and the stale-last-good rule is now the
empty `catch` in `refreshEvents()`: nothing is stamped and nothing is re-drawn, so a failed fetch
leaves each `<ul>` holding the rows it already had. `/api/events` grew no vocabulary for the
split (`limit` + `source` only, pinned), and the kind set is pinned on the served script —
**parsed out of the `POWER_EVENT_KINDS` literal and compared for equality**, not matched as one
line — by `tests/test_api.py::test_ups_events_are_fetched_on_every_poll`, so a silent rename in
`collectors.py`/`api.py`, or a kind quietly added to or dropped from the hidden list, fails a
test instead of moving errors back into the collapsed list. The error half is a card of its own
(phase 26 task 02): `section.card#ups-errors-card`
sits **above** the disclosure with a `ul.events#events-errors` inside its `.body`, ships
`hidden` (the resting state — a permanent empty "Connection & errors" card is the ink the
empty-state rules exist to avoid) and `refreshEvents()` keeps it that way while the split has no
error rows, so its first `unreachable` line is what makes it appear and the `loading…` seed never
flashes. Both lists go through the **same** `Dash.eventsPager` with the **same** `eventRow` const
(one table split on `kind` cannot grow two row shapes — the pin reads each call's options through
`_pager_call()`, so a list that loses its renderer fails by name rather than turning a whole-file
count off by one, and the row template itself may not read `event.kind`), and because the pager's
state is a `WeakMap` keyed by the `<ul>` the two page independently. Nothing about the second list
needed a stylesheet: `ul.events` and `.events-pager` are class rules and the card carries the
page's own `.85rem` section gap inline, which is pinned as the *absence* of a
`#ups-errors-card`/`#events-errors` selector (`test_the_ups_error_card_adds_no_stylesheet_selector`,
and the same guard inside the disclosure's own CSS pin) alongside the card's shape, position and
`hidden = errors.length === 0` line
(`test_the_ups_connection_errors_are_a_card_of_their_own_above_the_disclosure`); the card also
ships outside **every** `<details>` on the page, checked over the disclosure blocks rather than
one known disclosure (`test_ups_power_events_feed_sits_behind_a_collapsed_disclosure`) — the
operator's complaint was that a `NutError` line needed a click, so "not inside the disclosure I
happened to name" is not the claim. The layout, the gap in both states and the first-appear are
measured in `data/logs/26-task02-layout.cjs`; the phase's own pass is
`data/logs/26-task03-verify.cjs` (transcript `26-task03-verify.out`, re-run green on the
phase-completion pass as `26-final-verify.out`, 31 checks: the disclosure
shut with its list unpainted while the error card holds 50 of the 199 error kinds and the power
list the one power kind of a direct `/api/events?source=ups&limit=200` read, every row equal to
the page's own `Dash.eventText` of that event, both lists paging independently, the card hidden
on a power-only feed and appearing on the poll that carries the first `unreachable` row, and 360 px
with no sideways scroll; screenshots `data/logs/ups-event-split-{visible,expanded,hidden,appears,phone}.png`).
The **disclosure's** styling is `details.charts-box`'s shape with one structural
difference: that toggle sits *inside* a card body, while this `<summary>` **replaces** the card's
`header` — so it carries the header's type, padding, sunken ground and bottom rule (a closed card
reads as its heading, an open one puts the feed under the same rule every other card draws) and