phase: 19_ups_events_expandable

**Phase 19 final verification pass — all tasks already complete; everything green, no code changes needed.**

- **Verified (all 4 task files present in `complete/`)**: `details.events-box` disclosure on `/ups` with no `open` attribute and `#events` inside it; lazy fetch (`if (state.events.open) await refreshEvents()`), toggle stale rule on `state.pollMs`, success-path-only `painted`/`fetchedAt`; `charts-box`-shaped CSS with the `> .body` padding restatement; 4 new served-byte pins in `tests/test_api.py`.
- **Criterion 1 (collapsed on load, no first-poll fetch)**: PASS — re-ran `data/logs/19-task04-verify.cjs` against the live dev server (real upsd, 2 UPSes): **28/28 PASS**, `0` `/api/events` requests across 2 closed polls vs 4 snapshot polls; expand → exactly 1 request, 50 rows with `Oct 20, 07:11` timestamps + severity chips + `1–50 of 200` pager.
- **Criterion 2 (cache/stale/poll-while-open)**: PASS — fresh re-open 2→2 requests, stale re-open 2→3, loop refreshes while open, close asks for nothing, failed fetch keeps rows and doesn't stamp the cache.
- **Criterion 3**: `uv run pytest` → **1458 passed, 15 skipped**; `uv run pytest --cov` → **98.94%** (floor 94, unchanged); `uv run ruff check src tests agent` → clean; `uv run pyright` → **0 errors**.
- **Criterion 4**: `bash .agents/validate.sh` → **exit 0** (incl. `node --check` of all page scripts + repo-hygiene gates).
- **Criterion 5**: `data/logs/ups-events-closed.png` (collapsed `▸ POWER EVENTS`, 0 events requests noted in `ups-events-verify.out`) and `ups-events-open.png` (`▾`, bounded 320px list, pager strip) regenerated and inspected this pass.
- **Criterion 6 (no regressions)**: full suite green incl. UPS contract/pager pins; `uv run pytest -m live --live` (with `.env.dev`) → **11 passed**; phase 18's `nav-final-pass.cjs` sweep re-run → **45/45** (this phase's markup/CSS didn't touch the masthead).
- **Documentation**: full README.md (980 L) + AGENTS.md (1992 L) review against the code — verified env templates vs `config.py`, API route table vs `api.py`, installer flags vs `deploy/install-agent.sh`, agent routes vs `host_agent.py`, test/coverage/live tallies, `data/logs` artifacts, and live fleet facts (`HOST_TARGETS` all `http://`, all three agents `version 1.0.0`, nine sources `ok`). AGENTS.md UI conventions and README's events paragraph already carry the disclosure/lazy/stale sentences from tasks 01–04. **Verified consistent — no changes needed.**
- **Notable**: made zero repo edits (working tree holds only this phase's task work). `uv pip list --outdated` couldn't complete (PyPI 503 for the project name) — no dependency moved this pass, so rule 8 is not triggered; validate.sh's earlier advisory still lists `fastapi 0.153.0 → 0.156.3`, deliberately not upgraded in a UI/verification phase.
- **Next pending phase**: `.agents/phases/todo/20_masthead_nav_polish/` (tasks 01–04).
This commit is contained in:
2026-10-20 19:17:22 -04:00
parent f9b1565abb
commit 95df8b695b
31 changed files with 694 additions and 10 deletions
@@ -0,0 +1,12 @@
**Phase 19 final verification pass — all tasks already complete; everything green, no code changes needed.**
- **Verified (all 4 task files present in `complete/`)**: `details.events-box` disclosure on `/ups` with no `open` attribute and `#events` inside it; lazy fetch (`if (state.events.open) await refreshEvents()`), toggle stale rule on `state.pollMs`, success-path-only `painted`/`fetchedAt`; `charts-box`-shaped CSS with the `> .body` padding restatement; 4 new served-byte pins in `tests/test_api.py`.
- **Criterion 1 (collapsed on load, no first-poll fetch)**: PASS — re-ran `data/logs/19-task04-verify.cjs` against the live dev server (real upsd, 2 UPSes): **28/28 PASS**, `0` `/api/events` requests across 2 closed polls vs 4 snapshot polls; expand → exactly 1 request, 50 rows with `Oct 20, 07:11` timestamps + severity chips + `1–50 of 200` pager.
- **Criterion 2 (cache/stale/poll-while-open)**: PASS — fresh re-open 2→2 requests, stale re-open 2→3, loop refreshes while open, close asks for nothing, failed fetch keeps rows and doesn't stamp the cache.
- **Criterion 3**: `uv run pytest` → **1458 passed, 15 skipped**; `uv run pytest --cov` → **98.94%** (floor 94, unchanged); `uv run ruff check src tests agent` → clean; `uv run pyright` → **0 errors**.
- **Criterion 4**: `bash .agents/validate.sh` → **exit 0** (incl. `node --check` of all page scripts + repo-hygiene gates).
- **Criterion 5**: `data/logs/ups-events-closed.png` (collapsed `▸ POWER EVENTS`, 0 events requests noted in `ups-events-verify.out`) and `ups-events-open.png` (`▾`, bounded 320px list, pager strip) regenerated and inspected this pass.
- **Criterion 6 (no regressions)**: full suite green incl. UPS contract/pager pins; `uv run pytest -m live --live` (with `.env.dev`) → **11 passed**; phase 18's `nav-final-pass.cjs` sweep re-run → **45/45** (this phase's markup/CSS didn't touch the masthead).
- **Documentation**: full README.md (980 L) + AGENTS.md (1992 L) review against the code — verified env templates vs `config.py`, API route table vs `api.py`, installer flags vs `deploy/install-agent.sh`, agent routes vs `host_agent.py`, test/coverage/live tallies, `data/logs` artifacts, and live fleet facts (`HOST_TARGETS` all `http://`, all three agents `version 1.0.0`, nine sources `ok`). AGENTS.md UI conventions and README's events paragraph already carry the disclosure/lazy/stale sentences from tasks 01–04. **Verified consistent — no changes needed.**
- **Notable**: made zero repo edits (working tree holds only this phase's task work). `uv pip list --outdated` couldn't complete (PyPI 503 for the project name) — no dependency moved this pass, so rule 8 is not triggered; validate.sh's earlier advisory still lists `fastapi 0.153.0 → 0.156.3`, deliberately not upgraded in a UI/verification phase.
- **Next pending phase**: `.agents/phases/todo/20_masthead_nav_polish/` (tasks 01–04).
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
..................................s..................................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
.......................................................................s [ 68%]
ssssssssss.............................................................. [ 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 436 0 100%
src/dashboards/app.py 185 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 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1458 passed, 15 skipped, 1 warning in 153.66s (0:02:33)
== 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.156.4 wheel
@@ -0,0 +1,13 @@
Done — task 01 complete.
- **`src/dashboards/web/pages/ups.html`**: the `Power events` card body moved inside a `<details class="events-box">` with `<summary>Power events</summary>` — no `open` attribute, `#events`/`ul.events`/`loading…` placeholder unchanged, rest of the page byte-identical.
- **`tests/test_api.py::test_ups_power_events_feed_sits_behind_a_collapsed_disclosure`**: served-byte pin (`details.events-box` present, no `open` attr, summary label, `#events` ul + placeholder *inside*, no duplicate `<h3>`, page's other ids intact). Verified it bites: mutations (added `open`, ul moved out, summary renamed) each fail it.
- `bash .agents/validate.sh` → **exit 0**: `uv run pytest --cov --cov-fail-under=94` = **1455 passed, 15 skipped**, coverage **98.94 %** (floor 94, unchanged); `ruff check src tests agent` "All checks passed"; `pyright` "0 errors"; `node --check` of `web/static/*.js` clean.
- **Browser proof (live dev server, `data/logs/19-task01-disclosure.cjs`)**: 10/10 checks PASS — `data/logs/19-ups-events-closed.png` (card paints one 21 px `Power events` summary line; `::details-content` is `content-visibility: hidden`) and `-open.png` (expand draws the bounded 320 px list, 50 rows, date/time rows intact).
- **Docs**: `AGENTS.md` UI-conventions event-feed paragraph gains the `/ups` disclosure sentence (collapsed by default, `#events` stays inside the disclosure, pinned on served bytes); `README.md` `/ups` feed paragraph says the card is a closed `Power events` summary until opened.
- **Decisions / notes**:
- Markup only, per the task file — no JS/CSS touched, so `/ups` still fetches the feed on every poll while closed (that is task 02's lazy gate) and the open body currently has `.body` padding 0 because `.card > .body` no longer matches (task 03's `details.events-box` CSS closes it). Both intermediate states render correctly and every test is green.
- Pinned the summary as the plain word `Power events` (no count chip — an unfetched count would be rule 2's invented number) and no `data-k` attribute, as the task's ASSUMPTIONs state.
- `uv pip list --outdated` advisory (fastapi 0.153→0.156) left untouched: no dependency moved in this task, same handling as phases 15/17/18.
Next pending task: `.agents/phases/todo/19_ups_events_expandable/02_events_lazy_loading.md` (lazy fetch / poll-while-open in `page-ups.js`).
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
...............................s........................................ [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
....................................................................ssss [ 68%]
sssssss................................................................. [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
............................................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 436 0 100%
src/dashboards/app.py 185 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 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1455 passed, 15 skipped, 1 warning in 154.08s (0:02:34)
== 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.156.2 wheel
@@ -0,0 +1,19 @@
Task 02 complete.
**Implemented**
- `src/dashboards/web/static/page-ups.js`: `state.events {open,painted,fetchedAt}`; `refresh()` now does `if (state.events.open) await refreshEvents();` (poll loop feeds the list only while open); `refreshEvents()` stamps `fetchedAt`/`painted` **after** `Dash.eventsPager()` on the success path only (a failure keeps the rows and re-fetches on next open); `toggle` listener bound before `poll()` — first expand fetches, re-expand inside one `state.pollMs` paints the cached rows, older than that re-fetches; header comment documents the lazy rule.
- `tests/test_api.py`: `test_ups_events_are_fetched_only_while_the_disclosure_is_open` — served-script pins for the state shape, the `refresh()` gate (incl. no surviving ungated `await refreshEvents()`), the success-path-only stamp ordering, and the toggle's stale rule bound before the poll starts.
- `data/logs/19-task02-lazy.cjs` (+ `.out`): live browser proof, 21 checks — 0 `/api/events` requests across 2 closed polls, exactly 1 on first expand, loop refreshes while open, stops when closed, stale re-open re-fetches, fresh re-open makes no request (rows + pager footer + scroll identical), failed fetch keeps 50 rows and never moves the stamp. Screenshots: `19-ups-events-card-closed.png` (one summary line), `19-ups-events-card-open.png`, `19-ups-events-{closed,open,fetch-failed}.png`.
**Tests / lint / coverage** (all green)
- `uv run pytest` → 1456 passed, 15 skipped, exit 0
- `uv run pytest -q --cov --cov-fail-under=94` → Total coverage: 98.94% (unchanged)
- `uv run ruff check src tests agent` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
- `bash .agents/validate.sh` → exit 0 (incl. `node --check` of `page-ups.js`); only the pre-existing advisory `fastapi 0.153.0 → 0.156.2` (no dependency touched by this task)
- Dev server http://127.0.0.1:8090 still `status: ok` (all 9 sources)
**Docs**: AGENTS.md UI conventions (event-feed paragraph) and README's `/ups` expandable bullet now state the lazy rule — closed = not fetched, first expand fetches, re-open inside one poll interval redraws what is there.
**Decisions**: stale bound = one `state.pollMs` (task's ASSUMPTION); stamp placed after the pager call so a pager throw can't mark an unpainted list as cached. Live infrastructure untouched (repo-side + local browser only).
**Next pending task**: `19_ups_events_expandable/03_disclosure_css.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
................................s....................................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
.....................................................................sss [ 68%]
ssssssss................................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 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 436 0 100%
src/dashboards/app.py 185 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 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1456 passed, 15 skipped, 1 warning in 164.27s (0:02:44)
== 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.156.2 wheel
@@ -0,0 +1,16 @@
Everything is green. Report:
**Implemented**
- `src/dashboards/web/static/dash.css` — new `details.events-box` family beside `details.charts-box`: `min-width:0`, `> summary` (cursor, `list-style:none`, webkit marker hidden, `::before` `▸` / `[open] ::before` `▾` in `--text-faint`, hover, open-state de-emphasis), all tokens declared in `:root`.
- Shape deviation (deliberate, documented in the block's comment): this `<summary>` *replaces* the card's `header`, so it takes `.card > header`'s exact rhythm — `padding:.6rem .8rem`, the h3's `12.5px/650` uppercase type, `--text-muted`, `var(--border)` bottom rule, `color-mix` header ground — and the caret is a flex item (as in `charts-box`), not the snippet's `position:absolute` (which sits off the text's centre line). No `margin-bottom` when open: `.body` supplies the gap.
- **Defect fixed from task 01**: `.card > .body` is a *child* selector, so once the body moved inside `<details>` it lost its padding. Added `details.events-box > .body { padding: .8rem; }` (restatement, measured equal to another card's).
- No new rules for `ul.events` / `.events-pager` — verified they resolve inside the disclosure (320px bounded scroll, `overscroll-behavior:contain`, flex pager with its 1px top rule, `1–50 of 200`, both arrows).
- `AGENTS.md` UI conventions: the disclosure's styling sentence + the `.card > .body` child-selector gotcha.
**Tests / lint** (`bash .agents/validate.sh`, rc=0): `uv run pytest --cov --cov-fail-under=94` → **1456 passed, 15 skipped, coverage 98.94%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` all JS clean. `uv run pytest -m live --live` → **11 passed**. Browser proof: `data/logs/19-task03-css.cjs` → **24/24 PASS** (`19-task03-css.out`), screenshots `19-task03-{closed,open}.png` + `19-task03-dark-{closed,open}.png` against the live dev server (both palettes; other cards and battery charts unchanged).
**Docs**: AGENTS.md updated; README no change needed (task 02's paragraph already covers the user-visible behaviour).
**Notes**: the served-stylesheet pin is deferred to task 04 by the task file, so I did not pre-empt it. Advisory only: `fastapi 0.153.0 → 0.156.3` available; dependencies untouched by this task, so not pulled.
**Next pending task**: `.agents/phases/todo/19_ups_events_expandable/04_tests_and_verification.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
................................s....................................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
.....................................................................sss [ 68%]
ssssssss................................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 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 436 0 100%
src/dashboards/app.py 185 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 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1456 passed, 15 skipped, 1 warning in 158.48s (0:02:38)
== 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.156.3 wheel
@@ -0,0 +1,19 @@
Everything is green. Final state verified:
**Implemented (task 04 — pins + browser verification)**
- `tests/test_api.py::test_the_ups_event_rows_keep_the_date_time_and_severity_shape` — served-script pin of the row template: `<time>` leading with `month/day + 24h` `toLocaleString` options, `sev ${event.severity}` chip carrying the escaped host, message via `escapeHtml(eventText(event))`, conditional `runtime_seconds` footnote, pager + `label: 'Power events'` + empty sentence.
- `tests/test_api.py::test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component` — the served-stylesheet pin task 03 deferred: `▸`/`▾` carets, hidden webkit marker, the header ground/bottom rule (this `<summary>` replaces the card header), open-state de-emphasis with no `margin-bottom`, the `details.events-box > .body` padding restatement (`.card > .body` is a child selector), no verdict/accent token, and `ul.events`/`.events-pager` each styled exactly once.
- Both pins mutation-checked: dropping `<time>`, the severity class, `hour12: false`, raw-message printing, the caret, the `.body` padding, an accent token or a duplicated `ul.events {` each fails exactly one test; sources restored byte-identical.
- Browser proof against the running dev server with **real upsd** (2 UPSes, no simulator): `data/logs/19-task04-verify.cjs` → **28/28 PASS**; `data/logs/ups-events-closed.png` (collapsed, one 39px `Power events` summary, **0** `/api/events` requests across 2×5s polls while 4 snapshot polls ran) and `data/logs/ups-events-open.png` (bounded 320px list, 50 rows with `Oct 20, 07:11` timestamps + severity chips, `1–50 of 200` strip); counts recorded in `data/logs/ups-events-verify.out`. Also proven: 1 request on first expand, loop refreshes while open, close asks for nothing, fresh re-open makes no request (identical rows/pager), stale cache re-fetches, failed fetch keeps rows and doesn't stamp the cache.
- Docs: AGENTS.md UI conventions already carried the disclosure/lazy/stale sentence (tasks 01–03); corrected gap 12 (the two ex-NOTES items are now phases 20 and 24) and the `uv run pytest` tally 1454→1458. README's `/ups` "power events" row plus task 02's expandable paragraph stay accurate — no further README change.
**Tests / lint / coverage**
- `bash .agents/validate.sh` → **exit 0**: `pytest --cov --cov-fail-under=94` = **1458 passed, 15 skipped**, **98.94%** coverage (floor 94, unchanged); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` all JS clean; repo-hygiene gates clean. Advisory only: `fastapi 0.153.0 → 0.156.3` (no dependency moved this task).
- `uv run pytest -m live --live` → **11 passed** (read-only, real fleet/engines/upsd). Dev server still `status: ok`, nine sources ok.
**Decisions / deviations**
- Task-04's first two named pins already exist from tasks 01/02 (`test_ups_power_events_feed_sits_behind_a_collapsed_disclosure`, `test_ups_events_are_fetched_only_while_the_disclosure_is_open`) with the exact coverage the task describes; I left them untouched (criterion 2 asks for the existing feed pins unmodified) and added only the genuinely missing pins.
- "List renders nothing while shut" is measured with `checkVisibility({contentVisibilityAuto:true})`, not a rect: a closed `<details>` hides via `content-visibility`, which keeps the child's box measurable.
- No live-infrastructure changes; repo-side plus the local dev server only.
**Next pending task:** `.agents/phases/todo/20_masthead_nav_polish/01_two_row_masthead.md` (phase 19 has no remaining task files — this was its last).
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
..................................s..................................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
.......................................................................s [ 68%]
ssssssssss.............................................................. [ 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 436 0 100%
src/dashboards/app.py 185 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 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1458 passed, 15 skipped, 1 warning in 153.15s (0:02:33)
== 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.156.3 wheel
+28 -6
View File
@@ -997,7 +997,7 @@ set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
./scripts/dev-server.sh logs
uv run pytest # hermetic: 1454 passed + 15 skipped (11 live,
uv run pytest # hermetic: 1458 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
@@ -1390,7 +1390,28 @@ a `WeakMap` keyed by the `<ul>`: it survives a poll (so page 3 is still there tw
later — resetting every refresh would make page 2 unreadable) and resets to page 1 when the
event *set* changes (an operator reading page 3 of yesterday's history must not land on page 3
of a list that just changed). Rows are re-written only when their markup changed, which is
what keeps the reader's scroll position inside the container.
what keeps the reader's scroll position inside the container. The `/ups` feed is also
**expandable** (phase 19, `TODO.md:4`): its card body is a `details.events-box` disclosure with
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
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
no `margin-bottom` when open, because the `.body` below carries that spacing. That is also why
`details.events-box > .body` restates the `.8rem`: `.card > .body` is a **child** selector, so a
body moved inside a disclosure stops being padded until something says so — while `ul.events`
and `.events-pager` are class-based and needed no new rule to keep the 320px scroll and the
`1–50 of N` strip working inside it.
The `/system` host card is one row of six heroes — CPU, MEM, TEMP, DISK, GPU, NET (the
`.heroes` grid; `tests/test_api.py` pins the slots and their order on the served script).
@@ -1932,10 +1953,11 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
wraps, so nothing is lost and nothing gained: the masthead is complete at 360 px and the
table tail is still out of reach. The fix is a responsive-table pass (`.scroll` on the wide
tables, or a per-host card layout at ≤640px) and it touches pinned page shape on `/`, so it is
a phase rather than a cleanup. Two operator TODO items are unphased alongside it: never
accepting a non-mTLS agent (fleet gap 11's rollout, half of it), and keeping both
`⚙ Account` / `⚷ Certs` masthead links visible on the admin pages that today do not link to
themselves (phase 15's deliberate rule, now questioned).
a phase rather than a cleanup. The two operator TODO items that sat alongside it are phased
now, in `todo/`: `24_dev_mtls_rollout` (never accepting a non-mTLS agent — fleet gap 11's
rollout, half of it) and `20_masthead_nav_polish` (keeping both `⚙ Account` / `⚷ Certs`
masthead links visible on the admin pages that today do not link to themselves — phase 15's
deliberate rule, which that phase is the decision on).
## Locked decisions (and why)
+4
View File
@@ -757,6 +757,10 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
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.
## Testing
+4 -2
View File
@@ -73,8 +73,10 @@
</section>
<section class="card" style="margin-top:.85rem">
<header><h3>Power events</h3></header>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
<details class="events-box">
<summary>Power events</summary>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</details>
</section>
<footer class="pagefoot">
+33
View File
@@ -861,6 +861,39 @@ details.charts-box[open] > summary { color: var(--text-muted); margin-bottom: .5
/* the chip is a value, not a heading: it keeps its own casing and size inside the summary */
details.charts-box > summary .chip { text-transform: none; letter-spacing: .02em; }
/* The `/ups` Power events feed is the same kind of disclosure and shut by default, which is
what lets `page-ups.js` fetch nothing while nobody is looking at it. It is not `charts-box`
copied for one structural reason: that toggle sits *inside* a card body, while this
`<summary>` replaces the card's own `header` — so it takes the header's type, padding,
sunken ground and bottom rule (a closed card reads as its heading, and an open one puts the
feed under the same rule every other card draws), with the `▸`/`▾` caret as the summary's
first flex item so the marker rides the text's centre line and says open/closed in a shape
as well as a colour. No `margin-bottom` on the open state: the `.body` below carries the
spacing `charts-box`'s summary has to buy for itself. No animation either, same as
`charts-box` — a summary that snaps is the house style, and `prefers-reduced-motion` holds
by construction. */
details.events-box { min-width: 0; }
details.events-box > summary {
display: flex; align-items: center; gap: .55rem;
list-style: none; cursor: pointer; user-select: none;
font-size: 12.5px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase;
color: var(--text-muted);
padding: .6rem .8rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--bg-raised) 70%, var(--bg-sunken));
}
details.events-box > summary::-webkit-details-marker { display: none; }
details.events-box > summary::before { content: "\25B8"; font-size: 9px; color: var(--text-faint); }
details.events-box[open] > summary::before { content: "\25BE"; }
details.events-box > summary:hover { color: var(--text); }
/* open, the label steps back: the operator is reading the feed now, not the heading */
details.events-box[open] > summary { color: var(--text-faint); }
/* Inside the disclosure the body is a *grandchild* of the card, so the shared
`.card > .body` padding no longer reaches it. This restates that padding rather than
inventing a second one; `ul.events` and `.events-pager` stay untouched — they are
class-based and resolve here exactly as they do on the other three feeds. */
details.events-box > .body { padding: .8rem; }
.host-charts { display: grid; gap: .7rem; }
.host-chart { display: grid; gap: .15rem; min-width: 0; }
/* `sm` is the slot's floor before `LineChart` writes its measured height inline, so an
+37 -2
View File
@@ -9,13 +9,22 @@
* Commands are the only write path in the product. They are offered only when the backend
* 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 `<details>` 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.
*/
'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 };
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
// `<ul>` (a `<details>` keeps its children, so re-opening costs nothing).
events: { open: false, painted: false, fetchedAt: 0 } };
const MARKS = { ok: '✓', warn: '!', fail: '✕', unknown: '?' };
@@ -424,7 +433,10 @@ 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));
await refreshEvents();
// 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 `<details>` must cost the poll loop nothing.
if (state.events.open) await refreshEvents();
}
async function refreshEvents() {
@@ -445,6 +457,11 @@ async function refreshEvents() {
`${event.data && event.data.runtime_seconds ? ` <span class="faint mono">(${
duration(event.data.runtime_seconds)} runtime)</span>` : ''}</span>`,
});
// 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;
} catch (err) { /* next poll */ }
}
@@ -468,6 +485,24 @@ 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 `<details>` 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();
}
});
}
state.poller = poll(refresh, state.pollMs);
Dash.bindPause(state.poller);
});
+179
View File
@@ -1754,6 +1754,185 @@ 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.
`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.
"""
html = _code(client.get("/ups").text)
box = re.search(r'<details class="events-box">(.*?)</details>', html, re.S)
assert box, "the Power events card is no longer a details.events-box disclosure"
assert not re.search(r"<details[^>]*\bopen\b", html), \
"the disclosure ships open — the list is tall again, not expandable"
inner = box.group(1)
assert "<summary>Power events</summary>" in inner, "the summary lost its label"
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 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.
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).
"""
js = _code(client.get("/static/page-ups.js").text)
# The state the two paths share: closed, nothing painted yet, nothing fetched yet.
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"
# The poll path asks only while the card is open.
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"
# …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.
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"
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.
"""
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)]
# 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)
assert "<time>" in row and "</time>" in row, "the row lost its timestamp element"
assert "new Date(event.ts * 1000).toLocaleString(" in row, \
"the row formats something other than the event's own timestamp"
for option in ("month: 'short'", "day: 'numeric'", "hour: '2-digit'", "minute: '2-digit'",
"hour12: false"):
assert option in row, f"the timestamp no longer asks for {option}"
assert row.index("<time>") < row.index('class="sev'), "the row no longer leads with the date"
# who: the host inside the verdict chip, escaped, and the severity is the API's word
assert 'class="sev ${event.severity}"' in row, \
"the row stopped taking its colour word from the stored severity"
assert "${DashCharts.escapeHtml(event.host)}</span>" in row, \
"the row no longer names the UPS it belongs to, escaped"
# what: the message through the shared subject-said-once cleaner, and escaped
assert "${DashCharts.escapeHtml(eventText(event))}" in row, \
"the message is no longer cleaned and escaped (raw HTML from an upstream is a bug)"
# the runtime footnote is conditional: an event with no runtime says nothing about one
assert "event.data && event.data.runtime_seconds" in row, \
"the runtime footnote is no longer gated on the event carrying a runtime"
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"
def test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component(client) -> None:
"""The `/ups` summary borrows the card header's rhythm and no new palette (task 03).
Pinned on the served stylesheet because a CSS edit is invisible until someone looks: the
`\\25B8`/`\\25BE` caret pair says open/closed in a **shape** as well as a colour, the webkit
marker is hidden so the browser's own triangle does not double it, and this `<summary>`
carries the card header's ground and bottom rule because it *replaces* that header instead of
sitting inside the body like `charts-box`'s toggle does. The `> .body` padding restatement is
the quiet one: `.card > .body` is a **child** selector, so a body moved inside a disclosure
silently loses its padding and the feed renders flush to the card edge.
What the block must not do is grow a verdict colour (open/closed is not a grading) or
re-state the shared feed rules — `ul.events`' bounded scroll and `.events-pager`'s strip are
class-based and resolve inside the disclosure unchanged, so a second copy here would be a
second thing to keep in sync. The undeclared-token audit itself stays the existing
`test_no_stylesheet_rule_uses_an_undeclared_token`'s job, untouched.
"""
css = _css(client)
start = css.index("details.events-box { ")
block = css[start:css.index(".host-charts {", start)]
assert 'details.events-box > summary::before { content: "\\25B8"' in block, \
"the toggle has no caret shape"
assert 'details.events-box[open] > summary::before { content: "\\25BE"; }' in block, \
"open and closed differ only in colour"
assert "details.events-box > summary::-webkit-details-marker { display: none; }" in block, \
"the browser's own marker doubles the caret"
assert "cursor: pointer" in block and "list-style: none" in block, \
"the summary no longer reads as a control"
# it replaces the card header, so it carries the header's ground and bottom rule
assert "border-bottom: 1px solid var(--border)" in block, \
"a closed card no longer reads as its heading"
assert "color-mix(in srgb, var(--bg-raised) 70%, var(--bg-sunken))" in block, \
"the summary lost the header's sunken ground"
# open: the label steps back, and no margin — the `.body` below carries the spacing
assert "details.events-box[open] > summary { color: var(--text-faint); }" in block, \
"an open summary does not de-emphasise"
assert "margin-bottom" not in block, "the open summary stacks a gap on top of the body's"
# the child-selector gotcha that task 01's markup introduced
assert "details.events-box > .body { padding: .8rem; }" in block, \
"the feed body lost its padding inside the disclosure"
for token in ("var(--ok)", "var(--warn)", "var(--fail)", "var(--accent)"):
assert token not in block, f"the disclosure graded open/closed with {token}"
assert ".sev-" not in block, "the disclosure invented a verdict family"
# one copy of each shared feed rule, class-based, resolving inside the disclosure as elsewhere
assert css.count("ul.events {") == 1, "ul.events is styled twice — the bounded scroll drifted"
assert css.count(".events-pager {") == 1, "the pager strip is styled twice"
# the class this block styles is the class the page renders
assert 'class="events-box"' in client.get("/ups").text, \
"the stylesheet styles a disclosure no page renders"
def test_the_pager_returns_to_page_1_when_the_events_change(client) -> None:
"""New events reset the window; the same events do not.