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
+248 -61
View File
@@ -1986,6 +1986,21 @@ def _event_feed_path(js: str) -> str:
return js[js.index("async function refreshEvents("):]
def _pager_call(feed: str, selector: str) -> str:
"""The options object of one `Dash.eventsPager($('<selector>'), …)` call inside the feed path.
Sliced per list so an assertion can name *which* feed lost its renderer, its accessible name
or its empty sentence. `/ups` is the page with two of them, so a whole-file assertion there
cannot tell one list from the other.
"""
head = f"Dash.eventsPager($('{selector}')"
assert head in feed, f"this feed is no longer rendered through Dash.eventsPager ({selector})"
start = feed.index(head)
end = feed.find("});", start)
assert end != -1, f"the {selector} pager call is never closed"
return feed[start:end]
def _feed_fetch_url(js: str) -> str:
"""The `/api/events` URL a feed asks for, quoted or templated."""
found = re.search(r"fetchJSON\([`'](/api/events[^`']*)[`']\)", _event_feed_path(js))
@@ -2072,15 +2087,36 @@ def test_the_event_feeds_scroll_instead_of_growing_the_page(client) -> None:
assert '<ul class="events" id="events"' in html, f"{path} lost its feed's id"
def test_ups_power_events_feed_sits_behind_a_collapsed_disclosure(client) -> None:
"""The `/ups` feed is *expandable*: one `details.events-box`, shut by default.
def _details_blocks(html: str) -> list[str]:
"""Every `<details>…</details>` block on a served page, in document order.
`TODO.md:4` asks for an expandable list of incidents, and the shut disclosure is the half
that costs something — a collapsed `<details>` renders no content, which is what lets the
page fetch nothing while nobody is looking at the feed (`/system`'s Charts rule). Pinned on
the served bytes because there is no JS test runner (a locked anchor): an `open` attribute
would undo the phase silently, and the `#events` id has to stay *inside* the disclosure or
the pager and the bounded 320px scroll lose their target.
Nesting is not a thing on these pages (one disclosure per card), so a flat scan of pairs is
enough — and it is what lets a pin say *no disclosure contains this*, rather than only
"not the one disclosure I happen to know about". Phase 26 needs exactly that: the operator's
complaint was that a connection error was reachable only by opening something.
"""
blocks = []
for found in re.finditer(r"<details\b[^>]*>", html):
end = html.find("</details>", found.end())
assert end != -1, "a served <details> is never closed"
blocks.append(html[found.start():end])
return blocks
def test_ups_power_events_feed_sits_behind_a_collapsed_disclosure(client) -> None:
"""The `/ups` power feed is *expandable*: one `details.events-box`, shut by default.
`TODO.md:4` asked for an expandable list of incidents and phase 26 kept that shape — what it
overruled was *what* was behind it. A collapsed `<details>` renders no content, so the shut
card is still the resting state; since phase 26 it gates **visibility only** (the feed is
fetched on every poll, which the next test pins), and it holds only the power kinds.
Pinned on the served bytes because there is no JS test runner (a locked anchor): an `open`
attribute would undo the phase silently, the `#events` id has to stay *inside* the disclosure
or the pager and the bounded 320px scroll lose their target, and the **error** half
(`#events-errors`) has to be outside *every* disclosure and ship `hidden` rather than open —
a `NUT server NutError: cannot reach upsd …` line an operator has to click to read is the
bug this phase exists to fix (`TODO.md:6`).
"""
html = _code(client.get("/ups").text)
box = re.search(r'<details class="events-box">(.*?)</details>', html, re.S)
@@ -2092,77 +2128,132 @@ def test_ups_power_events_feed_sits_behind_a_collapsed_disclosure(client) -> Non
assert '<ul class="events" id="events"' in inner, "the feed moved outside the disclosure"
assert "loading…" in inner, "the first-expand placeholder is gone"
assert "<h3>Power events</h3>" not in html, "the card heads its own body twice"
# The error half is on the page **and not collapsible**: no disclosure anywhere holds it.
# The card-local pin in `test_the_ups_connection_errors_are_a_card_of_their_own_above_the_disclosure`
# checks the card does not *contain* a `<details>`; this is the stronger direction — moving
# that card inside a new disclosure somewhere else would pass there and fail here.
assert 'id="events-errors"' in html, "the connection-error list left the served page"
assert not any('id="events-errors"' in block for block in _details_blocks(html)), \
"the error list is inside a disclosure — reading it needs a click again"
card_open = html[html.index('<section class="card" id="ups-errors-card"'):
html.index(">", html.index('<section class="card" id="ups-errors-card"'))]
assert "hidden" in card_open, \
"the error card's resting state is visible — an empty card on every quiet day"
# the rest of the page is untouched by the disclosure
for still_there in ('id="ups-cards"', 'id="chart-charge"', 'id="chart-load"',
'id="confirm-dialog"'):
assert still_there in client.get("/ups").text, f"/ups lost {still_there}"
def test_ups_events_are_fetched_only_while_the_disclosure_is_open(client) -> None:
"""A closed Power events card costs the poll loop *nothing* — no fetch, no rows.
# The event kinds `/ups` keeps behind the collapsed disclosure, and the writers that store
# them. `collectors.py` writes `status` (an `ups.status` transition, power loss included)
# and `test` (a self-test verdict); `api.py` writes `action` (a command this page sent).
# Everything else in the feed is a connection/error event (`unreachable` / `reachable` /
# `recording` / `store-error`) and belongs in the list that is never hidden.
UPS_POWER_EVENT_KINDS = ("status", "test", "action")
Hiding a list that is still fetched every 5 s is the half-measure this pins out: the
disclosure is only worth having if `refresh()` stops asking `/api/events` while it is shut
(the `/system` Charts rule — "nothing is built or drawn while the stack is shut"), and the
expand still has to fill the list, so the toggle needs the two facts a re-open is judged
on: has it ever painted, and how old is what it painted. Pinned on the served script
because there is no JS test runner (a locked anchor).
def _power_event_kinds(js: str) -> frozenset[str]:
"""The kinds `page-ups.js` keeps behind the disclosure, read out of its own set literal.
Parsing beats matching one exact line. The claim is *these three and only these* — a kind
added to the hidden list moves an operator-visible row out of sight, and a kind dropped from
it hides a power event — so an exact-string assert that happens to survive a reformat is not
the pin. An empty set is refused rather than returned, because it would make every event an
error row and look like a passing classification.
"""
found = re.search(r"const POWER_EVENT_KINDS = new Set\(\[(.*?)]\)", js, re.S)
assert found, "page-ups.js no longer defines POWER_EVENT_KINDS as a set literal of kind words"
kinds = re.findall(r"['\"]([^'\"]+)['\"]", found.group(1))
assert kinds, "POWER_EVENT_KINDS is empty — every event would land in the error list"
return frozenset(kinds)
def test_ups_events_are_fetched_on_every_poll(client) -> None:
"""Phase 19's lazy gate is out, and the feed splits into power events and errors.
`TODO.md:6` is not a complaint about the disclosure existing, it is a complaint about what
was behind it: a poll failure (`NUT server NutError: cannot reach upsd … (timed out after
4s)`) sat in the same collapsed list as the power events, so the one thing an operator
needs without opening anything was the one thing they had to open something for. The fix
is page-side and keyed on the stored `kind`: `status` / `test` / `action` stay behind the
disclosure, **everything else** is the always-visible error list — and because that list
is visible, `refresh()` fetches on every poll (a visible list that ages is worse than one
more 200-event fetch per interval). `/api/events` grew no vocabulary for any of this.
Pinned on the served script because there is no JS test runner (a locked anchor), and
pinned in both directions: the gate's absence *and* the kind set, so a silent rename on
the writer's side fails here instead of moving errors back into the hidden list.
"""
js = _code(client.get("/static/page-ups.js").text)
# The state the two paths share: closed, nothing painted yet, nothing fetched yet.
# The kind set, read out of the literal instead of matched as one line: exactly these three.
assert _power_event_kinds(js) == frozenset(UPS_POWER_EVENT_KINDS), \
(f"the hidden list's kind set is {sorted(_power_event_kinds(js))}, not "
f"{sorted(UPS_POWER_EVENT_KINDS)} — that classification is the operator's, not the page's")
# The lazy cache is gone: with an always-visible list there is nothing left to stamp.
decl = js[js.index("const state = {"):js.index("const MARKS")]
assert "events: { open: false, painted: false, fetchedAt: 0 }" in decl, \
"the disclosure lost the state its lazy rule reads"
assert "events" not in decl, "the disclosure's lazy-fetch state came back"
assert "state.events" not in js, "something still reads the deleted state.events object"
# The poll path asks only while the card is open.
# The poll path asks every interval, gated on nothing.
refresh = js[js.index("async function refresh("):js.index("async function refreshEvents(")]
assert "if (state.events.open) await refreshEvents();" in refresh, \
"the poll loop fetches the event feed while the disclosure is shut"
assert not re.search(r"^\s*await refreshEvents();", refresh, flags=re.M), \
"an ungated refreshEvents() call survived in the poll path"
assert re.search(r"^\s*await refreshEvents\(\);", refresh, flags=re.M), \
"the poll loop no longer fetches the feed on every poll"
assert "state.events.open" not in refresh and "eventsBox" not in refresh, \
"the fetch is gated on the disclosure again"
# …and the fetch stamps its own freshness, on the success path only: a failed fetch must
# leave `painted`/`fetchedAt` alone so the next open re-fetches (invariant 4's shape).
feed = _event_feed_path(js)
assert "state.events.fetchedAt = Date.now();" in feed and "state.events.painted = true;" in feed, \
"a successful fetch does not stamp the cache the re-expand rule depends on"
assert feed.index("Dash.eventsPager(") < feed.index("state.events.fetchedAt = Date.now();"), \
"the cache is stamped before the rows are drawn"
assert feed.index("state.events.painted = true;") < feed.index("} catch (err)"), \
"a failed fetch would mark the list as painted"
# The toggle is bound before the loop starts, so the first expand of the session is wired.
# The toggle handler's fetch logic is gone with it: the native `<details>` now gates only
# visibility, so no `toggle` listener may exist to decide whether to fetch.
boot = js[js.index("document.addEventListener('DOMContentLoaded'"):]
handler = boot[boot.index("const eventsBox = $('.events-box');"):boot.index("state.poller = poll(")]
assert boot.index("const eventsBox =") < boot.index("state.poller = poll("), \
"the disclosure is wired after the first poll"
assert "state.events.open = eventsBox.open;" in handler, \
"the toggle never learns whether the card is open"
assert "if (!eventsBox.open) return;" in handler, \
"closing the card takes the fetch gate off"
# first expand fetches; a re-expand paints the cache while it is one poll interval old
assert "if (!state.events.painted || Date.now() - state.events.fetchedAt > state.pollMs) {" in handler, \
"the expand lost its stale rule (one poll interval, the page's own cadence)"
assert "refreshEvents();" in handler[handler.index("if (!state.events.painted"):], \
"an open disclosure no longer fetches on a stale or empty cache"
assert "addEventListener('toggle'" not in boot, \
"a toggle handler is deciding whether to fetch again"
assert "eventsBox" not in boot, "the disclosure's fetch-on-expand handler came back"
# One fetch, two lists, split on `kind` — and the URL is the phase-19 one verbatim.
feed = _event_feed_path(js)
assert _feed_fetch_url(js) == "/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=ups", \
"the feed's fetch is no longer the unchanged /api/events call"
for vocabulary in ("kind=", "page=", "severity="):
assert vocabulary not in _feed_fetch_url(js), \
f"/api/events grew {vocabulary[:-1]} vocabulary for a page-side split"
assert "const power = data.events.filter((e) => POWER_EVENT_KINDS.has(e.kind));" in feed, \
"the power list is no longer the filter's positive arm"
assert "const errors = data.events.filter((e) => !POWER_EVENT_KINDS.has(e.kind));" in feed, \
"the error list is no longer the filter's default arm (an unknown kind must be visible)"
# Stale-last-good: the catch clause does *nothing*, so a poll that never answered leaves
# both `<ul>`s holding the rows they already had (the pager rewrites a list only when its
# markup changed — invariant 4's shape on the page side).
opened = "} catch (err) {"
at = feed.index(opened)
catch_body = feed[at + len(opened):feed.index("}", at + len(opened))].strip()
assert catch_body == "", \
f"the failure path does something ({catch_body!r}) instead of leaving the last good rows"
def test_the_ups_event_rows_keep_the_date_time_and_severity_shape(client) -> None:
"""`TODO.md:4`'s other half: an expandable list is only *clear* if each row says when.
The disclosure is the new part of this phase; this is the half that had to survive it
untouched. Every row still opens with a `<time>` carrying a month/day plus a 24-hour clock,
then the UPS name inside the `sev <severity>` chip the stylesheet colours from the payload's
own verdict, then the message through `eventText()` (subject named once) with the runtime
footnote only when the event actually carries one. Pinned on the served script because there
is no JS test runner (a locked anchor): a "tidy-up" of the row template that drops the
timestamp, prints the raw message, or escapes nothing would ship silently otherwise.
Phase 19's disclosure and phase 26's split are the new parts; this is the half that had to
survive both untouched. Every row still opens with a `<time>` carrying a month/day plus a
24-hour clock, then the UPS name inside the `sev <severity>` chip the stylesheet colours from
the payload's own verdict, then the message through `eventText()` (subject named once) with
the runtime footnote only when the event actually carries one. Pinned on the served script
because there is no JS test runner (a locked anchor): a "tidy-up" of the row template that
drops the timestamp, prints the raw message, or escapes nothing would ship silently.
Since phase 26 task 02 the renderer is **one const read by both lists**, so the two halves
of one feed cannot grow two shapes — which is what this reads, rather than a `row:` option
that could differ per list.
"""
js = _code(client.get("/static/page-ups.js").text)
feed = _event_feed_path(js)
at = feed.index("row: (event) =>")
row = feed[at:feed.index("});", at)]
at = js.index("const eventRow = (event) =>")
row = js[at:js.index("async function refreshEvents(", at)]
# the date/time: `<time>` first, month + day + hour + minute, 24-hour (the operator reads
# an incident log in the order it happened, and "14:05" on its own is not that)
@@ -2190,11 +2281,97 @@ def test_the_ups_event_rows_keep_the_date_time_and_severity_shape(client) -> Non
assert "duration(event.data.runtime_seconds)} runtime)" in row, \
"the runtime footnote stopped printing the stored seconds as a duration"
# and the renderer is still handed to the shared pager over the bounded list, labelled
assert "Dash.eventsPager($('#events'), data.events, {" in feed, \
"the feed left the shared pager (no window, no page, no accessible name)"
assert "label: 'Power events'" in feed, "the scroll container lost its accessible name"
assert "No power events yet" in feed, "the empty feed no longer says which feed is empty"
# and the renderer is still handed to the shared pager over the bounded list, labelled.
# Per **call**, not per file: `#events-errors` is a list of its own now, and the claim is
# that it is drawn, named and emptied exactly like the power list — a count of `row:` in the
# file would still pass if one call lost its renderer to a default.
for selector, label, empty in (("#events", "Power events", "No power events yet"),
("#events-errors", "Connection & errors",
"No connection errors")):
call = _pager_call(feed, selector)
assert "row: eventRow," in call, f"the {selector} list is no longer drawn by the shared row"
assert f"label: '{label}'" in call, f"the {selector} list lost its accessible name"
assert empty in call, f"the {selector} list no longer says which feed is empty"
# one renderer, two lists: a template the two lists do not share would be two shapes for one
# event, and the row may not branch on which list it landed in — the only split is the kind set
assert js.count("const eventRow = (event) =>") == 1, \
"the /ups lists no longer share one row template"
assert "event.kind" not in row and "POWER_EVENT_KINDS" not in row, \
"the row template reads the kind — the two lists are growing two shapes"
# The card's visibility is the one state this page claims about a list it cannot see, it is
# decided by the **error** half alone, and only after both lists are painted (earlier would
# flash the `loading…` seed on a first fetch with no error rows). The markup's own `hidden`
# default is pinned in test_ups_power_events_feed_sits_behind_a_collapsed_disclosure.
hidden_at = feed.index("$('#ups-errors-card').hidden")
assert feed[:hidden_at].count("Dash.eventsPager(") == 2, \
"the card's visibility is decided before both lists are drawn"
assert feed[hidden_at:feed.index(";", hidden_at)].endswith("= errors.length === 0"), \
"the error card's visibility no longer follows the error half of the feed"
def test_the_ups_connection_errors_are_a_card_of_their_own_above_the_disclosure(client) -> None:
"""Phase 26 task 02: the error half of the feed is a card, not a second list in a `<details>`.
`TODO.md:6` is that a `NUT server NutError: cannot reach upsd …` line is not something to
open a summary for. So the error rows get their own `section.card` **above** the Power
events disclosure, built from the vocabulary every other card on this page uses
(`.card > header`, `.card > .body`, `ul.events`, the pager's strip) — **no new stylesheet
selector**, because a second style for a second list is a second thing to keep in sync with
the bounded 320px scroll and the `1\u201350 of N` footer.
`hidden` is the resting state in the markup and the script keeps it true while the feed has
no non-power kinds: a permanent empty "Connection & errors" card is exactly the ink the
page's empty-state rules exist to avoid, and the first `unreachable` row is what shows it.
"""
html = _code(client.get("/ups").text)
card_at = html.index('<section class="card" id="ups-errors-card"')
card_end = html.index("</section>", card_at)
card = html[card_at:card_end]
opening = html[card_at:html.index(">", card_at)]
assert "hidden" in opening, "the error card ships visible with nothing in it"
assert '<ul class="events" id="events-errors"' in card, "the error card has no bounded feed"
assert "<h3>Connection &amp; errors</h3>" in card, "the card lost its title"
# the whole point: this list is *not* behind a summary
assert "<details" not in card, "the error list is inside a disclosure again"
assert card_end < html.index('<details class="events-box">'), \
"the error card sits below the power-events disclosure it is meant to lead"
assert 'id="events"' in html[html.index('<details class="events-box">'):], \
"the power list left its disclosure"
js = _code(client.get("/static/page-ups.js").text)
feed = _event_feed_path(js)
assert "Dash.eventsPager($('#events-errors'), errors, {" in feed, \
"the error list is not rendered through the shared pager"
assert "label: 'Connection & errors'" in feed, "the error list has no accessible name"
assert "$('#ups-errors-card').hidden = errors.length === 0;" in feed, \
"the error card no longer appears and disappears with its rows"
# and the pager state is per `<ul>` (`app.js`: a `WeakMap` keyed by the list), so the two
# lists page independently: this is the contract that makes a second call site free
app_js = _code(client.get("/static/app.js").text)
assert "const feedPagers = new WeakMap();" in app_js
assert "feedPagers.get(list)" in app_js and "feedPagers.set(list, pager)" in app_js
def test_the_ups_error_card_adds_no_stylesheet_selector(client) -> None:
"""The second feed is markup + the existing vocabulary, and `dash.css` must stay that way.
Pinned as the *absence* of a rule, because the temptation this writes back into is a
`#ups-errors-card` block restating the feed's scroll or the pager's strip: `ul.events {` and
`.events-pager {` are each declared exactly once in the sheet (the disclosure pin already
holds that), and the error card carries its own gap as an inline `margin-top` like every
other section on this page rather than through an id rule.
"""
css = _css(client)
for selector in ("#ups-errors-card", "#events-errors"):
assert selector not in css, f"dash.css grew a rule for {selector}"
assert css.count("ul.events {") == 1 and css.count(".events-pager {") == 1
html = client.get("/ups").text
assert 'id="ups-errors-card" style="margin-top:.85rem"' in html, \
"the error card no longer carries the page's own section gap inline"
def test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component(client) -> None:
@@ -2250,6 +2427,16 @@ def test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component(cli
assert 'class="events-box"' in client.get("/ups").text, \
"the stylesheet styles a disclosure no page renders"
# Phase 26's guard, here because it is the disclosure's own claim: the *second* feed this
# page renders borrowed `section.card` + `ul.events` + `.events-pager` and nothing else, so
# this block may not have grown a rule for it either. A second style for a second list is a
# second copy of the bounded scroll and the pager strip to keep in sync — the phase-19
# anti-pattern, one task later. (`test_the_ups_error_card_adds_no_stylesheet_selector` holds
# the same guard for the card's own section, plus the inline-gap pin.)
for selector in ("#ups-errors-card", "#events-errors"):
assert selector not in block and selector not in css, \
f"dash.css styles the second feed through {selector} instead of the shared classes"
def test_the_pager_returns_to_page_1_when_the_events_change(client) -> None:
"""New events reset the window; the same events do not.