**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`**.
6.0 KiB
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_auditon purpose, because that phase's mobile audit captures/upsin the shape this phase leaves it in (two cards, one of them hidden-when-empty).
Tasks
01_page_side_event_split.md— thekindsplit inpage-ups.js(power kindsstatus/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 interval02_render_both_lists_paged.md— the always-visible#events-errorscard (above the disclosure, hidden while empty) and both lists paged through the sharedDash.eventsPager03_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-*.cjsscript in the20-task01-rows.cjsconvention, 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=200split, screenshots indata/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 whilestate.events.openis 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 oftest_ups_events_are_fetched_only_while_the_disclosure_is_openis re-pointed at its inverted successor.README.mdis checked for the same sentence; none is expected.
Completion Criteria
- a poll failure (e.g.
NUT server NutError: cannot reach upsd …) is visible on/upswithout 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=upsfetch 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 pytestgreen, coverage not down,ruff+pyrightcleanbash .agents/validate.shgreen- no behavior change in completed work: the
details.events-boxCSS shape, the pager's page/scroll semantics, and the row's time/host/severity shape are untouched