diff --git a/.agents/phases/todo/26_ups_event_split/00_phase.md b/.agents/phases/complete/26_ups_event_split/00_phase.md similarity index 100% rename from .agents/phases/todo/26_ups_event_split/00_phase.md rename to .agents/phases/complete/26_ups_event_split/00_phase.md diff --git a/.agents/phases/todo/26_ups_event_split/01_page_side_event_split.md b/.agents/phases/complete/26_ups_event_split/01_page_side_event_split.md similarity index 100% rename from .agents/phases/todo/26_ups_event_split/01_page_side_event_split.md rename to .agents/phases/complete/26_ups_event_split/01_page_side_event_split.md diff --git a/.agents/phases/todo/26_ups_event_split/02_render_both_lists_paged.md b/.agents/phases/complete/26_ups_event_split/02_render_both_lists_paged.md similarity index 100% rename from .agents/phases/todo/26_ups_event_split/02_render_both_lists_paged.md rename to .agents/phases/complete/26_ups_event_split/02_render_both_lists_paged.md diff --git a/.agents/phases/todo/26_ups_event_split/03_repin_and_browser_verify.md b/.agents/phases/complete/26_ups_event_split/03_repin_and_browser_verify.md similarity index 100% rename from .agents/phases/todo/26_ups_event_split/03_repin_and_browser_verify.md rename to .agents/phases/complete/26_ups_event_split/03_repin_and_browser_verify.md diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__00_phase.a1.err b/.agents/reports/26_ups_event_split/26_ups_event_split__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__00_phase.a1.md b/.agents/reports/26_ups_event_split/26_ups_event_split__00_phase.a1.md new file mode 100644 index 0000000..dbc0011 --- /dev/null +++ b/.agents/reports/26_ups_event_split/26_ups_event_split__00_phase.a1.md @@ -0,0 +1,15 @@ +**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 `
`. 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`**. diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__00_phase.a1.md.status b/.agents/reports/26_ups_event_split/26_ups_event_split__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/26_ups_event_split/26_ups_event_split__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__00_phase.a1.validate b/.agents/reports/26_ups_event_split/26_ups_event_split__00_phase.a1.validate new file mode 100644 index 0000000..89ed018 --- /dev/null +++ b/.agents/reports/26_ups_event_split/26_ups_event_split__00_phase.a1.validate @@ -0,0 +1,66 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 23%] +............................................................s........... [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 46%] +........................................................................ [ 50%] +........................................................................ [ 55%] +........................................................................ [ 60%] +........................................................................ [ 64%] +........................................................................ [ 69%] +...sssssssssss.......................................................... [ 73%] +........................................................................ [ 78%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +..............................................................sss....... [ 96%] +................................................ [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 440 0 100% +src/dashboards/app.py 186 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3495 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1545 passed, 15 skipped, 1 warning in 163.65s (0:02:43) +== uv run ruff check src tests agent == +All checks passed! +== uv run pyright == +0 errors, 0 warnings, 0 informations +== node --check src/dashboards/web/static/*.js == +advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose): + fastapi 0.153.0 0.159.1 wheel diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__01_page_side_event_split.a1.err b/.agents/reports/26_ups_event_split/26_ups_event_split__01_page_side_event_split.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__01_page_side_event_split.a1.md b/.agents/reports/26_ups_event_split/26_ups_event_split__01_page_side_event_split.a1.md new file mode 100644 index 0000000..f69d7b9 --- /dev/null +++ b/.agents/reports/26_ups_event_split/26_ups_event_split__01_page_side_event_split.a1.md @@ -0,0 +1,17 @@ +All green. Summary: + +**Implemented** (`src/dashboards/web/static/page-ups.js`) +- Added `POWER_EVENT_KINDS = new Set(['status', 'test', 'action'])` with a doc comment naming the writers (`collectors.py` `status`/`test`, `api.py` `action`) and stating that any other kind defaults to the visible error list. +- `refreshEvents()` now splits the one unchanged `/api/events?limit=…&source=ups` fetch into `power` (into `#events`, row renderer/eventText/`runtime_seconds` footnote byte-identical) and `errors` (the always-visible half, rendered by task 02). +- Removed the phase-19 lazy gate: `refresh()` calls `await refreshEvents();` unconditionally; `state.events` and the `toggle` handler's fetch logic are deleted — the native `
` now gates visibility only; stale-last-good is `refreshEvents()`' empty `catch`. + +**Tests / lint / coverage** +- `tests/test_api.py`: replaced `test_ups_events_are_fetched_only_while_the_disclosure_is_open` with its inverted successor `test_ups_events_are_fetched_on_every_poll_and_split_by_kind` (kind set incl. a `UPS_POWER_EVENT_KINDS` table, no `state.events`, unconditional poll fetch, no toggle fetch, `/api/events` URL byte-identical with no `kind=`/`page=`/`severity=` vocabulary, empty catch = stale-last-good); updated the row-shape pin to `Dash.eventsPager($('#events'), power, {`. Also fixed a latent vacuous regex in the old pin (`refreshEvents();` unescaped → never matched). +- `uv run pytest` → **1543 passed, 15 skipped**; `uv run pytest --cov --cov-fail-under=94` → **98.94%** (unchanged); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` all static JS clean; `bash .agents/validate.sh` **exit 0**. +- Browser proof (invariant 10): `data/logs/26-task01-smoke.cjs` + `data/logs/26-task01-smoke.png` against the restarted dev server (9 sources ok) — page runs with no JS errors, disclosure shut by default yet its list filled by the poll path, rows exactly the power-kind half of a direct `/api/events` read, no error row leaked into the collapsed list. Live store has 199 error-kind vs 1 power-kind row in the last 200 `ups` events — the TODO's complaint, quantified. + +**Docs**: AGENTS.md event-feed paragraph rewritten (lazy gate → always-fetched + `kind` split + the new pin name); README.md `/ups` "expandable" paragraph rewritten to the same fact. + +**Decisions/deviations**: had to re-pin the two failing phase-19 pins now (task 03 extends them, e.g. the error list's row shape) to keep the suite green; `errors` is computed but not yet drawn — task 02 adds `#events-errors`. + +**Next pending task**: `.agents/phases/todo/26_ups_event_split/02_render_both_lists_paged.md`. diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__01_page_side_event_split.a1.md.status b/.agents/reports/26_ups_event_split/26_ups_event_split__01_page_side_event_split.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/26_ups_event_split/26_ups_event_split__01_page_side_event_split.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__01_page_side_event_split.a1.validate b/.agents/reports/26_ups_event_split/26_ups_event_split__01_page_side_event_split.a1.validate new file mode 100644 index 0000000..a74c234 --- /dev/null +++ b/.agents/reports/26_ups_event_split/26_ups_event_split__01_page_side_event_split.a1.validate @@ -0,0 +1,66 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 23%] +..........................................................s............. [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 46%] +........................................................................ [ 50%] +........................................................................ [ 55%] +........................................................................ [ 60%] +........................................................................ [ 64%] +........................................................................ [ 69%] +.sssssssssss............................................................ [ 73%] +........................................................................ [ 78%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +............................................................sss......... [ 97%] +.............................................. [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 440 0 100% +src/dashboards/app.py 186 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3495 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1543 passed, 15 skipped, 1 warning in 165.86s (0:02:45) +== uv run ruff check src tests agent == +All checks passed! +== uv run pyright == +0 errors, 0 warnings, 0 informations +== node --check src/dashboards/web/static/*.js == +advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose): + fastapi 0.153.0 0.159.1 wheel diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__02_render_both_lists_paged.a1.err b/.agents/reports/26_ups_event_split/26_ups_event_split__02_render_both_lists_paged.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__02_render_both_lists_paged.a1.md b/.agents/reports/26_ups_event_split/26_ups_event_split__02_render_both_lists_paged.a1.md new file mode 100644 index 0000000..60a8dd2 --- /dev/null +++ b/.agents/reports/26_ups_event_split/26_ups_event_split__02_render_both_lists_paged.a1.md @@ -0,0 +1,19 @@ +All green. Final report: + +**Implemented (task 02 — the always-visible error card)** +- Verified the shipped half of the task (task 01's run had already written the `#ups-errors-card` markup, the second `Dash.eventsPager($('#events-errors'), …)` call, the single shared `eventRow` const, and `$('#ups-errors-card').hidden = errors.length === 0`); the prior task-02 run died on a provider 502 with pyright interrupted, so its gates were never closed. +- Wrote the missing real-browser verification: `data/logs/26-task02-layout.cjs` (+ `.out`, `.log`, 7 screenshots) — 33 checks, all pass: live feed visible **above** the still-shut Power events disclosure and not inside a `
`; rows exactly the non-power kinds of a direct `/api/events?source=ups&limit=200` split, rendered through the page's own `Dash.eventText`; the quoted `NutError: cannot reach upsd … (timed out after 4s)` readable without opening anything; `.85rem` gap in **both** states (13.59 px measured, no doubled gap when hidden); independent paging on a synthetic 60+60 feed (`51–60 of 60` moves only the list you clicked); stale-last-good on a 500 feed (both `
    `s hold their rows, card holds its visibility); hidden-while-empty arm with no `loading…` flash and the hidden→visible transition on the first error row; 360 px with no horizontal overflow. +- `dash.css` untouched (no new selector); power list's empty sentence and the disclosure CSS shape unchanged. +- Docs: AGENTS.md event-feed paragraph now states the error card, its shared row/pager, the inline `.85rem` gap and both pin names; README's `/ups` paragraph names the always-visible **Connection & errors** card. + +**Tests / lint / coverage** +- `uv run pytest` → **1545 passed, 15 skipped** · `uv run pytest --cov --cov-fail-under=94` → **TOTAL 99%** (3495 stmts, 37 miss) +- `uv run ruff check src tests agent` → All checks passed · `uv run pyright` → 0 errors, 0 warnings · `node --check web/static/*.js` clean +- `bash .agents/validate.sh` → **exit 0** · `uv run pytest -m live --live` → 11 passed, exit 0 (read-only) +- browser: `node data/logs/26-task02-layout.cjs` → **ALL CHECKS PASS** (33/33) + +**Decisions / deviations** +- Task 02's served-byte pins (already present in `tests/test_api.py` from task 01's run) were left in place rather than rewritten; I verified each claims what the task file asks and added the measured half it was missing. No new Python changed, so coverage was never at risk. +- Gap check confirmed the hidden card does not double the stack's gap, so the error card keeps its inline `margin-top:.85rem` (the task file's fallback was not needed). + +**Next pending task:** `.agents/phases/todo/26_ups_event_split/03_repin_and_browser_verify.md` diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__02_render_both_lists_paged.a1.md.status b/.agents/reports/26_ups_event_split/26_ups_event_split__02_render_both_lists_paged.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/26_ups_event_split/26_ups_event_split__02_render_both_lists_paged.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__02_render_both_lists_paged.a1.validate b/.agents/reports/26_ups_event_split/26_ups_event_split__02_render_both_lists_paged.a1.validate new file mode 100644 index 0000000..df0347d --- /dev/null +++ b/.agents/reports/26_ups_event_split/26_ups_event_split__02_render_both_lists_paged.a1.validate @@ -0,0 +1,66 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 23%] +............................................................s........... [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 46%] +........................................................................ [ 50%] +........................................................................ [ 55%] +........................................................................ [ 60%] +........................................................................ [ 64%] +........................................................................ [ 69%] +...sssssssssss.......................................................... [ 73%] +........................................................................ [ 78%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +..............................................................sss....... [ 96%] +................................................ [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 440 0 100% +src/dashboards/app.py 186 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3495 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1545 passed, 15 skipped, 1 warning in 165.97s (0:02:45) +== uv run ruff check src tests agent == +All checks passed! +== uv run pyright == +0 errors, 0 warnings, 0 informations +== node --check src/dashboards/web/static/*.js == +advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose): + fastapi 0.153.0 0.159.1 wheel diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__03_repin_and_browser_verify.a1.err b/.agents/reports/26_ups_event_split/26_ups_event_split__03_repin_and_browser_verify.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__03_repin_and_browser_verify.a1.md b/.agents/reports/26_ups_event_split/26_ups_event_split__03_repin_and_browser_verify.a1.md new file mode 100644 index 0000000..349d8d4 --- /dev/null +++ b/.agents/reports/26_ups_event_split/26_ups_event_split__03_repin_and_browser_verify.a1.md @@ -0,0 +1,20 @@ +Everything is green. Final verification summary: + +**Implemented (task 03 — `26_ups_event_split`)** +- `tests/test_api.py`: new helpers `_details_blocks()` and `_pager_call()`; `_power_event_kinds()` now *parses* the `POWER_EVENT_KINDS` literal and the pin asserts set **equality** with `UPS_POWER_EVENT_KINDS`. +- `test_ups_power_events_feed_sits_behind_a_collapsed_disclosure`: all phase-19 assertions kept, extended — `#events-errors` present, outside **every** `
    `, and its card ships `hidden`; docstring rewritten off the old lazy-fetch rationale. +- Renamed the inverted lazy-gate pin to `test_ups_events_are_fetched_on_every_poll` (unconditional `await refreshEvents()`, no `state.events`, no `toggle` fetch, URL byte-identical, empty `catch` = stale-last-good). +- Row-shape pin now reads **each** pager call (`#events` + `#events-errors`: shared `row: eventRow`, label, empty sentence), forbids `event.kind`/one-template rule, and pins `hidden = errors.length === 0` set *after* both lists paint. +- Disclosure CSS pin extended with the no-`#ups-errors-card`/`#events-errors`-selector guard (original assertions untouched). +- `data/logs/26-task03-verify.cjs` + `26-task03-verify.out/.log` + screenshots `ups-event-split-{visible,expanded,hidden,appears,phone}.png`; mutation-checked the new pins (dropping a `row: eventRow`, wrapping the card in a `
    `, adding a CSS id rule each fail). +- `AGENTS.md`: pin names/sentences re-pointed, kind-set parse described, phase browser pass recorded, suite count 1543→1545. README needed no change (already accurate). + +**Tests / lint (exact)** +- `bash .agents/validate.sh` → exit 0: `pytest --cov --cov-fail-under=94` → **1545 passed, 15 skipped**, coverage **98.94%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check web/static/*.js` clean. +- `uv run pytest -m live --live` → 11 passed. Browser pass: **31 checks, 0 skipped, ALL CHECKS PASS** (feed: 200 events = 1 power + 199 error; error card visible above the shut disclosure with 50-of-199 rows matching a direct `/api/events?source=ups&limit=200` split). + +**Deviations / notes** +- Kept `_pager_call`-per-list slicing instead of a whole-file `row:` count; the old long test name from task 01 (`…_and_split_by_kind`) was renamed to the task file's `test_ups_events_are_fetched_on_every_poll` and AGENTS.md re-pointed. +- validate.sh prints a pre-existing advisory (`fastapi 0.153.0 → 0.159.1`); dependencies were not touched by this task, so I left the lock alone. + +**Next pending task:** none in phase 26 (this was the last); next is `27_mobile_tables_and_audit`. diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__03_repin_and_browser_verify.a1.md.status b/.agents/reports/26_ups_event_split/26_ups_event_split__03_repin_and_browser_verify.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/26_ups_event_split/26_ups_event_split__03_repin_and_browser_verify.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/26_ups_event_split/26_ups_event_split__03_repin_and_browser_verify.a1.validate b/.agents/reports/26_ups_event_split/26_ups_event_split__03_repin_and_browser_verify.a1.validate new file mode 100644 index 0000000..0c6d97b --- /dev/null +++ b/.agents/reports/26_ups_event_split/26_ups_event_split__03_repin_and_browser_verify.a1.validate @@ -0,0 +1,66 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 13%] +........................................................................ [ 18%] +........................................................................ [ 23%] +............................................................s........... [ 27%] +........................................................................ [ 32%] +........................................................................ [ 36%] +........................................................................ [ 41%] +........................................................................ [ 46%] +........................................................................ [ 50%] +........................................................................ [ 55%] +........................................................................ [ 60%] +........................................................................ [ 64%] +........................................................................ [ 69%] +...sssssssssss.......................................................... [ 73%] +........................................................................ [ 78%] +........................................................................ [ 83%] +........................................................................ [ 87%] +........................................................................ [ 92%] +..............................................................sss....... [ 96%] +................................................ [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 440 0 100% +src/dashboards/app.py 186 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3495 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1545 passed, 15 skipped, 1 warning in 166.36s (0:02:46) +== uv run ruff check src tests agent == +All checks passed! +== uv run pyright == +0 errors, 0 warnings, 0 informations +== node --check src/dashboards/web/static/*.js == +advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose): + fastapi 0.153.0 0.159.1 wheel diff --git a/AGENTS.md b/AGENTS.md index 6efbd4e..8a5a772 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//bundle and writes # data/certs/.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 `
    ` 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 `
      ` while they are younger than one poll -interval (`state.pollMs`, the page's own cadence) and re-fetches only past it — a `
      ` -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 `
        ` 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 `
          ` 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** `
          ` 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 `` **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 diff --git a/README.md b/README.md index 455d603..e94d41f 100644 --- a/README.md +++ b/README.md @@ -892,14 +892,22 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n The four event feeds (`/system`, `/disks`, `/llm`, `/ups`) are a **paged window** over the `event` table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath. - One helper, `Dash.eventsPager()`, renders every feed and each keeps its own row markup; the fetch + One helper, `Dash.eventsPager()`, renders every feed — five lists today, because `/ups` splits + its one fetch in two — and each keeps its own row markup; the fetch asks `/api/events` for 200 so there is a page 2 to reach, and the endpoint itself is unchanged. Reading page 3 survives a poll; a refresh that brings new events goes back to page 1, because page 3 of a list that just changed is not the history you were reading. The `/ups` feed is also **expandable**: its card is a `Power events` summary line, closed until - you open it, and the list inside is the same bounded, paged window. Closed, it is not fetched - at all; the first expand fetches once, and re-opening inside one poll interval redraws what is - already there instead of asking again. + you open it, and the list inside is the same bounded, paged window. What sits behind that + summary is only the *power* half of the feed — `status` transitions (the power loss and its + return), self-test results, and the commands you sent — because the rest of it is connection + trouble (`NUT server NutError: cannot reach upsd …` and its recovery line), which is not + history to tuck away. That error half is its own **Connection & errors** card above the + summary — the same bounded, paged window, the same row shape — hidden while it has nothing to + say and appearing with its first unreachable/reachable line, so a poll failure is readable + without opening anything. That is also why the feed is fetched on every poll rather than only + while the summary is open: half of it is always on screen, and the two lists page + independently of each other. ## Testing diff --git a/src/dashboards/web/pages/ups.html b/src/dashboards/web/pages/ups.html index 39782f5..3adad41 100644 --- a/src/dashboards/web/pages/ups.html +++ b/src/dashboards/web/pages/ups.html @@ -71,6 +71,14 @@ + +
          Power events diff --git a/src/dashboards/web/static/page-ups.js b/src/dashboards/web/static/page-ups.js index 34dfe0d..66ab67c 100644 --- a/src/dashboards/web/static/page-ups.js +++ b/src/dashboards/web/static/page-ups.js @@ -10,24 +10,40 @@ * says `enabled`, and anything that can cut power requires typing DANGER here *and* the * server re-checks the same gate — a hidden button is not a safety control. * - * The power-events list is **collapsed by default** behind a `details.events-box` and lazy: - * a closed `
          ` renders nothing, so while it is shut the page fetches `/api/events` - * not at all (`/system`'s Charts rule — nothing is built for a feed nobody can see). The - * first expand fetches; a re-expand paints what is in hand while it is fresh. + * The event feed splits into two lists from the one `/api/events` fetch. The **power** + * events — state transitions, self-test verdicts, commands sent — stay **collapsed by + * default** behind `details.events-box`, because they are history worth keeping close and + * not glance-worthy. Everything else (the poller's connection errors) is the half that must + * never sit behind a summary: it is a card of its own (`#ups-errors-card`) above that + * disclosure, `hidden` only while it has no rows to show, which is also why the fetch happens + * on every poll — a list the operator reads without opening anything must not age. */ 'use strict'; const { $, fetchJSON, setTxt, setValue, setDot, num, duration, poll, eventText, naText } = Dash; const state = { range: 86400, cards: new Map(), charts: {}, poller: null, pollMs: 5000, - simulated: false, pending: null, - // The events disclosure: `open` gates the poll path, `painted`/`fetchedAt` - // decide whether an expand has to fetch or can draw the rows already in the - // `
            ` (a `
            ` keeps its children, so re-opening costs nothing). - events: { open: false, painted: false, fetchedAt: 0 } }; + simulated: false, pending: null }; const MARKS = { ok: '✓', warn: '!', fail: '✕', unknown: '?' }; +/** + * The three event kinds that belong in the collapsed *power events* list. + * + * Their writers, so a rename on the source side shows up at the site of the drift: + * - `status` — a `ups.status` transition (`OB` → `OL` and back), written by the NUT + * monitor's transition bookkeeping (`collectors.py:393`); + * - `test` — the verdict of a battery self-test (`collectors.py:409`); + * - `action` — a command this page sent through `POST /api/ups/command` (`api.py`). + * + * **Anything that is not one of these three is an error row**, and lands in the list that is + * always visible — the poller's `unreachable` / `reachable` / `recording` / `store-error` + * lines, including `NUT server NutError: cannot reach upsd … (timed out after 4s)`. That + * default is the point: an unknown kind in the hidden list is the bug this phase exists to + * fix, re-shipped. + */ +const POWER_EVENT_KINDS = new Set(['status', 'test', 'action']); + /** * Runtime left as "1h 32m", not "1.5h". The health check next to it spells the same number * out in h+m; two formats for one value invites a misread while the UPS is discharging. @@ -433,35 +449,66 @@ async function refresh() { $('#foot-note').textContent = `poll ${Math.round(state.pollMs / 1000)}s · ` + `${items.reduce((total, item) => total + (item.advertised_commands || []).length, 0)} commands advertised`; await refreshCharts(items.map((item) => item.name)); - // The feed is only refreshed while its disclosure is open. The list is the longest-lived - // payload on the page (200 events) and the one a glance at "is my load safe" never reads, - // so a shut `
            ` must cost the poll loop nothing. - if (state.events.open) await refreshEvents(); + // The feed is fetched on every poll, whether or not the power-events disclosure is open: + // half of it is the connection errors, which are the rows nobody should have to open a + // summary to read, and a visible list that ages is worse than one fetch per interval. + await refreshEvents(); } +/** + * One row of either `/ups` list — the **same** markup for both halves of the feed. + * + * One const rather than two copies, because the two lists are one table split on `kind`: a + * row that drifts — the power list keeping its timestamp while the error list loses it — is + * the operator reading two shapes for one event. The runtime footnote stays conditional + * rather than per-list: an `action` row carries `data.runtime_seconds`, a poller's + * `unreachable` row does not, and a row with no runtime simply says nothing about one. + */ +const eventRow = (event) => `${DashCharts.escapeHtml(event.host)} ` + + `${DashCharts.escapeHtml(eventText(event))}` + + `${event.data && event.data.runtime_seconds ? ` (${ + duration(event.data.runtime_seconds)} runtime)` : ''}`; + async function refreshEvents() { try { // Battery history is the longest of the three feeds (every charge / discharge transition // lands here), so this is the page that needs the bounded window most: 50 rows a page // inside a scrolling list, with the count and the arrows under it (`Dash.eventsPager`). const data = await fetchJSON(`/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=ups`); - Dash.eventsPager($('#events'), data.events, { + // One fetch, two lists, split on the stored `kind` (see POWER_EVENT_KINDS). Nothing here + // asks the API for a kind or a page — `/api/events` grew no vocabulary for this split. + const power = data.events.filter((e) => POWER_EVENT_KINDS.has(e.kind)); + // The error half is everything that is *not* a power event, and it belongs to the list + // that is never hidden: the card above this disclosure, which appears with its first row. + const errors = data.events.filter((e) => !POWER_EVENT_KINDS.has(e.kind)); + // The pager keeps its state in a `WeakMap` keyed by the `
              `, so these two lists page + // independently — turning page 2 of the error feed never moves the power feed. + Dash.eventsPager($('#events'), power, { label: 'Power events', empty: '
            • No power events yet — ' + 'state changes, self-test results, and commands you send all land here.
            • ', - row: (event) => `${DashCharts.escapeHtml(event.host)} ` + - `${DashCharts.escapeHtml(eventText(event))}` + - `${event.data && event.data.runtime_seconds ? ` (${ - duration(event.data.runtime_seconds)} runtime)` : ''}`, + row: eventRow, }); - // Stamped only on the success path: a fetch that failed leaves `painted`/`fetchedAt` where - // they were, so the next open re-fetches and a failed poll while open keeps showing the - // rows already in the list (the stale-last-good rule the rest of this page follows). - state.events.fetchedAt = Date.now(); - state.events.painted = true; + Dash.eventsPager($('#events-errors'), errors, { + label: 'Connection & errors', + empty: '
            • No connection errors — upsd ' + + 'is answering and this process is recording.
            • ', + row: eventRow, + }); + // The card is `hidden` in the markup and stays that way while the feed holds no error + // rows: a permanent empty "Connection & errors" card is the noise the page's empty-state + // rules exist to avoid, and the first `unreachable` line is what makes it appear. Setting + // it here (after the pager painted) means a first fetch with no error rows never flashes + // the `loading…` seed either. + $('#ups-errors-card').hidden = errors.length === 0; + // A failed fetch leaves **both** lists on the rows already in them, and the card on the + // visibility it had: the pager rewrites a `
                ` only when its markup changed, so the rows + // of a poll that never answered stay on screen and the next poll replaces them (the + // stale-last-good rule the rest of this page follows). Nothing is stamped, because nothing + // is cached any more — the poll loop is the only thing that decides when this runs. } catch (err) { /* next poll */ } } @@ -485,23 +532,8 @@ document.addEventListener('DOMContentLoaded', async () => { $('#dialog-run').disabled = event.target.value.trim() !== 'DANGER'; }); - // The disclosure is the feed's only fetch trigger besides the poll loop. The native - // `toggle` event fires on close too; closing just records the state so the loop stops. - const eventsBox = $('.events-box'); - if (eventsBox) { - state.events.open = eventsBox.open; // false — the markup ships no `open` attribute - eventsBox.addEventListener('toggle', () => { - state.events.open = eventsBox.open; - if (!eventsBox.open) return; - // First expand: `painted` is false — fetch. Re-expand: paint from the data in hand only - // while it is fresh; a cache older than one poll interval is stale and re-fetches. A - // fresh, already-painted list needs nothing: the `
                ` keeps its children, so the - // rows (and the pager's page, in its WeakMap) are already where the operator left them. - if (!state.events.painted || Date.now() - state.events.fetchedAt > state.pollMs) { - refreshEvents(); - } - }); - } + // The power-events disclosure needs no JS at all: the feed is fetched on every poll, so the + // native `
                ` only has to gate the *visibility* of the list it holds. state.poller = poll(refresh, state.pollMs); Dash.bindShortcuts(); diff --git a/tests/test_api.py b/tests/test_api.py index 819afd9..d3b0325 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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($(''), …)` 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 '
                  None: - """The `/ups` feed is *expandable*: one `details.events-box`, shut by default. +def _details_blocks(html: str) -> list[str]: + """Every `
                  …
                  ` 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 `
                  ` 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"]*>", html): + end = html.find("
                  ", found.end()) + assert end != -1, "a served
                  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 `
                  ` 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'
                  (.*?)
                  ', html, re.S) @@ -2092,77 +2128,132 @@ def test_ups_power_events_feed_sits_behind_a_collapsed_disclosure(client) -> Non assert '
                    Power events" 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 `
                    `; 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('
                    ", html.index('
                    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 `
                    ` 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 `
                      `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 `