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:
@@ -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).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+65
@@ -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
|
||||
+13
@@ -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`).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+65
@@ -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
|
||||
+19
@@ -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`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+65
@@ -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
|
||||
+16
@@ -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`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+65
@@ -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
|
||||
+19
@@ -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).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+65
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user