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
@@ -1,34 +0,0 @@
# Phase 26 — UPS events: power events collapsible, connection errors always visible
**Source:** `TODO.md` L6 — "The phase I ran earlier collapsed all the events in the UPS tab. That wasn't the point. I wanted the power loss events in a collapsible list, not events like \"NUT server NutError: cannot reach upsd at 10.1.0.126:3051 (timed out after 4s)\" in a collapsible list."
**Story:** n/a
**Context:** phase 19 (complete) put the **whole** `/ups` event feed behind one collapsed `details.events-box` (no `open` attribute, lazy: `refresh()` fetches the feed only while `state.events.open`, the first expand fetches, a fresh re-expand paints from the `<ul>`'s kept children, `painted`/`fetchedAt` stamped on the success path alone, the `Dash.eventsPager` window of 50 inside the bounded 320px scroll). The operator's complaint is that the classification is wrong, not that a collapse exists: a poll failure like `NUT server NutError: cannot reach upsd … (timed out after 4s)` is hidden behind a summary nobody opens, while the power events it was meant to tuck away are the only ones the operator actually wants collapsed. The feed's **kinds** are fixed and known: the poller (`collectors.py:490` — `Job("nut", "ups", "NUT server", …)`, events under the dashboard name `ups`) writes `unreachable` / `reachable` / `recording` / `store-error` (`poller.py:127/157/164/190` — the quoted `NutError` line is the `unreachable` message); the `NutMonitor` transition bookkeeping writes `status` (a state change lands at `collectors.py:393`) and `test` (the self-test verdict, `collectors.py:409`); `api.py` writes `action` for every command sent through `POST /api/ups/command` (the fixed-text `action` events, `api.py:999/1014`). The served-byte pins for the current shape are in `tests/test_api.py`: `test_ups_power_events_feed_sits_behind_a_collapsed_disclosure` (the `details.events-box`, no `open`, `#events` inside), `test_ups_events_are_fetched_only_while_the_disclosure_is_open` (the lazy gate — **this phase inverts it on purpose**), `test_the_ups_event_rows_keep_the_date_time_and_severity_shape` (the row shape), `test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component` (the `details.events-box` CSS shape — unchanged by this phase). The page is `src/dashboards/web/static/page-ups.js` (`refresh()` around line 439, `refreshEvents()` around line 445, the disclosure's toggle handler in the `DOMContentLoaded` block) over the card in `src/dashboards/web/pages/ups.html:75-78`.
## Objective
`/ups` renders **two** lists from the one unchanged feed fetch: the power events — state transitions (including the power-loss transitions), self-test verdicts and sent commands — stay behind the collapsed `details.events-box` disclosure, and the connection/error events (`unreachable`/`reachable`/`recording`/`store-error`) are a card that is **always visible** (hidden only while it has no rows to show). The split is page-side, keyed on the event's `kind`; `/api/events` gains no vocabulary.
## Dependencies
- — (none) — the phase builds on complete phase 19; it is scheduled before `27_mobile_tables_and_audit` on purpose, because that phase's mobile audit captures `/ups` in the shape this phase leaves it in (two cards, one of them hidden-when-empty).
## Tasks
1. `01_page_side_event_split.md` — the `kind` split in `page-ups.js` (power kinds `status`/`test`/`action`, everything else errors) and the removal of the phase-19 lazy gate: the feed is fetched on every poll, because a visible list that ages is worse than one more 200-event fetch per interval
2. `02_render_both_lists_paged.md` — the always-visible `#events-errors` card (above the disclosure, hidden while empty) and both lists paged through the shared `Dash.eventsPager`
3. `03_repin_and_browser_verify.md` — the pin re-pins (the disclosure stays, the lazy gate inverts, the row shape extends to the error list, the kind set is pinned so a silent upstream rename breaks a test) and the phase's real-browser verification
## Testing & Quality
- Unit/integration: the re-pinned and new pins in `tests/test_api.py` (task 03); the split itself is pinned on the served script (the kind set) and the served markup (the error card, `hidden`, not inside a `<details>`)
- Coverage: **>90%** on new/modified code
- Real-browser check (invariant 10): a `data/logs/26-task03-*.cjs` script in the `20-task01-rows.cjs` convention, run against the restarted dev server: the disclosure shut by default, the error card's visibility matching the feed's error-kind rows, both lists' rows matching a direct `/api/events?source=ups&limit=200` split, screenshots in `data/logs/`
## Documentation & Consistency
- The final pass updates the now-stale sentences in `AGENTS.md`: the event-feed paragraph's lazy-fetch block ("`refresh()` fetches the feed only while `state.events.open` is true, so a card nobody is looking at costs the poll loop nothing" and the first-expand/fresh-re-expand sentences) is re-written to the new fact (the one fetch serves two lists, always; the disclosure gates only the power list's visibility), and the pin description of `test_ups_events_are_fetched_only_while_the_disclosure_is_open` is re-pointed at its inverted successor. `README.md` is checked for the same sentence; none is expected.
## Completion Criteria
- [ ] a poll failure (e.g. `NUT server NutError: cannot reach upsd …`) is visible on `/ups` without opening anything, and the power events stay behind the collapsed disclosure (pinned + verified in a browser)
- [ ] the error card is hidden while it has no rows and appears with its first error row; both lists share the one `/api/events?source=ups` fetch and the shared pager (pinned)
- [ ] a failed fetch leaves both lists on their last good rows (the stale-last-good rule) — pinned where phase 19 pinned its
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: the `details.events-box` CSS shape, the pager's page/scroll semantics, and the row's time/host/severity shape are untouched
@@ -1,28 +0,0 @@
# Task 01 — The feed splits on `kind`, and the lazy gate comes out
**Phase:** `26_ups_event_split` · **Source:** `TODO.md:6` — "I wanted the power loss events in a collapsible list, not events like \"NUT server NutError: cannot reach upsd at 10.1.0.126:3051 (timed out after 4s)\" in a collapsible list."
**Story:** n/a
## Objective
`page-ups.js` splits the one unchanged feed fetch into two lists: the **power** events — `status` (the state transitions, including the power-loss transitions the operator names), `test` (the self-test verdicts) and `action` (the commands sent through the confirm dialog) — and **everything else** (the poller's `unreachable`/`reachable`/`recording`/`store-error`, the quoted `NutError` line included), which is the always-visible error list. The phase-19 lazy gate goes out with the split: a visible list that ages is worse than one more 200-event fetch per interval, so `refresh()` fetches on every poll.
- ASSUMPTION: "power events" is the **known** set of three kinds (`status`/`test`/`action`); any other kind is an error-row by default, because the operator's complaint is that errors must never be the hidden list, and a new kind landing in the hidden list would be the same bug re-shipped.
## Work
1. `src/dashboards/web/static/page-ups.js` — at the top of the file, `const POWER_EVENT_KINDS = new Set(["status", "test", "action"]);` with a comment naming the three writers (`collectors.py:393` `status`, `collectors.py:409` `test`, `api.py` `action` — so a rename on the source side is visible at the site of the drift). In `refreshEvents()`: after the one `fetchJSON(\`/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=ups\`)` (the call itself is **unchanged** — no `page`/`limit`/`kind` vocabulary, the phase-19 rule), `const power = data.events.filter((e) => POWER_EVENT_KINDS.has(e.kind));` and `const errors = data.events.filter((e) => !POWER_EVENT_KINDS.has(e.kind));`.
2. `src/dashboards/web/static/page-ups.js` — in `refresh()`, replace `if (state.events.open) await refreshEvents();` with `await refreshEvents();` and re-word the comment above it (the "a shut `<details>` must cost the poll loop nothing" rationale is what the operator overruled: the error list is visible, so the feed is needed whether or not the disclosure is open). In the `DOMContentLoaded` block, delete the `eventsBox` toggle handler's fetch logic — with an always-fresh list the `toggle` event no longer needs to do anything, and `state.events` (with its `open`/`painted`/`fetchedAt` fields) is deleted entirely: the disclosure now gates only the power list's **visibility** (the native `<details>` behaviour), and a failed fetch leaves both lists on their last good rows through `refreshEvents()`'s existing `try { … } catch (err) { /* next poll */ }` — the success-path stamps go away with the state they stamped, and the stale-last-good rule is the `catch` block, which stays.
3. `src/dashboards/web/static/page-ups.js` — the split is the **only** change to the fetch path: the `row` renderer, `eventText` handling and the `runtime_seconds` footnote stay byte-identical (task 02 points them at both lists), and the `state` object loses `events` with no other field touched.
## Testing & Quality
- Unit/integration: the split and the gate removal are pinned on the served script in task 03 (`tests/test_api.py`) — the kind set, the unconditional `refreshEvents()` call, the deleted `state.events`
- Coverage: **>90%** on this task's new/modified code
- The behavioural proof (a real `unreachable` row visible without opening the disclosure) is the phase's real-browser check (task 03)
## Completion Criteria
- [ ] `page-ups.js` defines `POWER_EVENT_KINDS = new Set(["status", "test", "action"])` and `refresh()` calls `refreshEvents()` unconditionally (pinned on the served script)
- [ ] `state.events` and the toggle handler's fetch logic are gone; a failed fetch leaves both lists on their last good rows (pinned where phase 19 pinned its)
- [ ] the `/api/events` call is byte-identical to the phase-19 one (no new query vocabulary — pinned)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] no behavior change in completed work: the poll cadence, the confirm dialog, the hero row and the charts are untouched
@@ -1,31 +0,0 @@
# Task 02 — The error card: always visible, hidden only when empty
**Phase:** `26_ups_event_split` · **Source:** `TODO.md:6` — "I wanted the power loss events in a collapsible list, not events like \"NUT server NutError: cannot reach upsd at 10.1.0.126:3051 (timed out after 4s)\" in a collapsible list."
**Story:** n/a
## Objective
The error list is a card of its own, **above** the power-events card, built from the existing vocabulary (`section.card`, `ul.events`, the pager's strip) with no new CSS component: visible from first paint while it has rows, `hidden` while it has none, and paged by the same `Dash.eventsPager` the power list uses (its `WeakMap` state is keyed by the `<ul>`, so the two lists page independently — the pager's existing contract).
- ASSUMPTION: the error section sits **above** the Power events disclosure — the visibility complaint is about *seeing* the errors, and the collapsible list keeps its current position in the card stack.
- ASSUMPTION: the error card is `hidden` while it has zero rows — a permanent empty "Connection & errors" card is the noise the page's empty-state rules exist to avoid, and its first `unreachable` row is what makes it appear.
## Work
1. `src/dashboards/web/pages/ups.html` — above the existing `<section class="card" style="margin-top:.85rem">` (the disclosure card, `ups.html:74-79`), add the error card: `<section class="card" id="ups-errors-card" hidden>` with `<header><h3>Connection &amp; errors</h3><span class="spacer"></span><span class="faint">upsd reachability and this process's own failures</span></header>` and `<div class="body"><ul class="events" id="events-errors"><li class="muted">loading…</li></ul></div>`. The `hidden` attribute is the resting state (before the first successful fetch there are no rows); `style="margin-top:.85rem"` moves to the **error** card's position only in the sense that the disclosure card keeps its existing attribute — a `hidden` card is `display: none`, so the disclosure card's own `margin-top` still gives the gap to whatever is above it when the error card is absent, and no rule is needed for the two-cards case beyond the disclosure card's existing attribute (verify in the browser that the gap is `.85rem` in both states; if the hidden card's own margin would double it, drop the error card's `margin-top` and let the stack's own gap rule do the work — the page's sections carry the shared `.85rem` gap).
2. `src/dashboards/web/static/page-ups.js` — in `refreshEvents()`, after the split (task 01): render the power list exactly as today — `Dash.eventsPager($('#events'), power, { label: 'Power events', … })` with its existing `empty` sentence and `row` renderer — and the error list — `Dash.eventsPager($('#events-errors'), errors, { label: 'Connection & errors', empty: '<li class="muted" style="grid-template-columns:1fr">No connection errors — upsd is answering and this process is recording.</li>', row: <the same `row` function as the power list, extracted to one const so the two lists cannot drift> })`; then `$('#ups-errors-card').hidden = errors.length === 0;`. The `loading…` seed row in the error card is overwritten on the first successful fetch and never shows, because the card is `hidden` until that fetch lands with rows (a first fetch with zero error rows keeps it hidden — no flash).
3. `src/dashboards/web/static/dash.css` — **no new rules**: the card, the `ul.events` bounded 320px scroll and the `.events-pager` strip are all existing vocabulary (the phase-19 rule a second opinion about the driver's word is the bug — the same applies to a second style for a second list). If step 1's gap verification needs a rule, the only allowed one is a margin on `#ups-errors-card` itself, pinned in task 03.
4. The `row` renderer's `runtime_seconds` footnote stays on both lists (an `action` row's runtime is the power list's; an error row simply has no `event.data.runtime_seconds` and prints none — no branch added).
## Testing & Quality
- Unit/integration: task 03's pins — the error card in the served markup (`hidden`, not inside a `<details>`), the kind set, the single `row` const, the `hidden = errors.length === 0` line
- Coverage: **>90%** on this task's new/modified code
- The two-cards layout, the gap in both states and the error card's first-appear are the phase's real-browser check (task 03)
## Completion Criteria
- [ ] `/ups` shows the `Connection & errors` card above the Power events disclosure, paged, sharing the row shape (pinned + measured in a browser)
- [ ] the card is `hidden` while the feed has no non-power kinds and appears with its first error row, the gap to the disclosure card being `.85rem` in both states (measured)
- [ ] both lists page independently (pager state per `<ul>`) and a failed fetch leaves both on their last good rows (pinned)
- [ ] no new CSS selector is introduced for the error card (pinned)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] no behavior change in completed work: the disclosure's CSS shape and the power list's empty sentence are untouched
@@ -1,28 +0,0 @@
# Task 03 — The pins re-pin, and the browser proves the split
**Phase:** `26_ups_event_split` · **Source:** `TODO.md:6`
**Story:** n/a
## Objective
The phase's pins: the disclosure shape stays pinned (the power list is still behind the collapsed `details.events-box`), the lazy-gate pin inverts to its replacement (the fetch is no longer gated on the disclosure), the row-shape pin extends to the error list, and the kind set is pinned on the served script so a silent rename on the source side breaks a test before an operator sees a misclassified row. Then the real-browser verification (invariant 10).
## Work
1. `tests/test_api.py` — `test_ups_power_events_feed_sits_behind_a_collapsed_disclosure`: keep every existing assertion (one `details.events-box`, no `open` attribute, `#events` inside it) and extend: `id="events-errors"` is present in the served page and is **not** inside any `<details>` (the error list must not be collapsible — the operator's whole complaint), and the error card carries the `hidden` attribute in the template.
2. `tests/test_api.py` — `test_ups_events_are_fetched_only_while_the_disclosure_is_open` becomes its inversion, renamed `test_ups_events_are_fetched_on_every_poll` (the phase-19 lazy gate is what the operator overruled; the old test failing is the point of this phase): the served script's `refresh()` region (the same `boot.index("…")` slice the old test uses) shows `refreshEvents()` called **unconditionally**, and the served script carries no `state.events` and no `toggle`-handler fetch. The stale-last-good pin (a failed fetch leaves the rows on screen) moves onto the `refreshEvents()` `catch` block, the way phase 19 pinned it.
3. `tests/test_api.py` — `test_the_ups_event_rows_keep_the_date_time_and_severity_shape`: the existing `#events` assertions stay, and the same shape (the `<time>` element, the host chip with its `sev` class, the `eventText`-cleaned message, the optional runtime footnote) is asserted for `#events-errors` through the shared `row` const. New pin: the served script defines `POWER_EVENT_KINDS` as exactly `status`, `test`, `action` (a set literal read out of the script text — a kind added to or removed from the set is a classification the operator did not make). New pin: `$('#ups-errors-card').hidden = errors.length === 0` (or its exact equivalent line) — the hidden-when-empty behaviour is the one state this page may claim about a list it cannot see.
4. `tests/test_api.py` — `test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component`: unchanged, and extended with one guard — no new CSS selector for the error card in the served stylesheet (the card is `section.card` + `ul.events` + `.events-pager` vocabulary only; a second style for a second list is the phase-19 anti-pattern).
5. `data/logs/26-task03-verify.cjs` — the browser proof, in the `20-task01-rows.cjs` convention (`DASH_BASE`/`DASH_PW`, `check()` accumulator, non-zero exit on failure), against the restarted dev server (`./scripts/dev-server.sh restart` first): sign in; fetch `/api/events?source=ups&limit=200` **directly** and split by the three power kinds; load `/ups` and assert — (a) the disclosure is shut by default and the power list renders only on expand; (b) the error card is visible if and only if the direct fetch has non-power-kind rows; (c) each list's DOM row count and first row's text match the direct-fetch split (the `eventText` cleaning applied); (d) a failed-fetch leg is not needed against live hardware — the stale-last-good rule is pinned in step 2's pin instead. Screenshots `data/logs/ups-event-split-<state>.png` (the error card visible with rows, and hidden when the feed has none — if the live feed has no error rows, the hidden state is the one measured, and the visible state is measured by the DOM from the direct-fetch split, not by staging rows).
## Testing & Quality
- Unit/integration: the pins of steps 1–4
- Coverage: **>90%** on this task's new/modified code
- Real-browser check: step 5's `.cjs` pass, screenshots in `data/logs/`
## Completion Criteria
- [ ] the four pin groups above are green, with `test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component` passing unchanged in its original assertions
- [ ] the `.cjs` pass is all-green against the live dev server (screenshots in `data/logs/`)
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] `AGENTS.md`'s now-stale lazy-fetch sentences are updated (the phase overview's Documentation & Consistency list) and `README.md` checked