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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user