Files
dashboards/.agents/phases/todo/26_ups_event_split/00_phase.md
T
ducoterra 973b7299fe chore(agent): phase roadmap from TODO.md, 3 phases
Append phases 25-27 (masthead control placement, UPS event split, mobile
tables + full mobile audit) derived from TODO.md L3-L7; the TODO file is
cleared, its items now live in .agents/phases/todo/. Protocol B: PLAN.md
and AGENTS.md untouched.

LLM-Generated: true
2026-10-20 23:24:27 -04:00

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_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