phase: 26_ups_event_split
**Phase 26 `ups_event_split` — final verification pass (all 3 tasks already complete; nothing missing from `complete/`).**
Verified
- `page-ups.js`: `POWER_EVENT_KINDS = {status,test,action}` split, unconditional `refreshEvents()`, no `state.events`/toggle-fetch, shared `eventRow`, `#ups-errors-card.hidden = errors.length === 0`; `ups.html` error card above the disclosure, `hidden`, outside every `<details>`. Kind set cross-checked against the real writers (`collectors.py:393/409`, `api.py:1043/1058`, `poller.py:127/157/164/190`) — classification is correct.
- Re-ran the phase's browser pass live: `node data/logs/26-task03-verify.cjs` → **31 checks, 0 skipped, ALL PASS** (`data/logs/26-final-verify.out`) — poll-failure rows readable with the disclosure shut, card hidden on a power-only feed and appearing on the first `unreachable`, lists paging independently, 360 px clean.
Tests / lint (all green)
- `uv run pytest` → **1545 passed, 15 skipped**, exit 0 · `bash .agents/validate.sh` → exit 0, coverage **98.94%** (floor 94%) · `uv run ruff check src tests agent` clean · `uv run pyright` 0 errors · `node --check` clean · `uv run pytest -m live --live` (with `.env.dev`) → **11 passed**, dev server reads all 9 sources `ok`.
Completion criteria: poll-failure visible + power collapsed ✅ (pinned + browser); hidden-when-empty card, one `/api/events?source=ups` fetch, shared pager ✅; stale-last-good via the empty `catch` pin ✅; pytest/coverage/ruff/pyright ✅; `validate.sh` ✅; no behaviour change to prior work — `dash.css`/`app.js` untouched, disclosure CSS / pager / row-shape pins pass unchanged ✅.
Docs
- Fixed (pre-existing + this phase's drift): AGENTS.md "one helper with four call sites" → **five over four pages** and the `row(event)` sentence (phase 26 added a second `/ups` call site); invariant 5's `web/static/charts.js` → `src/dashboards/web/static/charts.js`; recorded the completion-pass transcript. README: "one helper renders every feed" now notes five lists. Full sweep otherwise consistent — all 38 env vars match `config.py` exactly, all served routes/scripts/contracts referenced exist, suite/coverage/live counts and marker splits verified.
Deviations: `fastapi 0.153.0 → 0.159.1` advisory left alone (validate.sh declares it advisory-not-a-gate; this phase touched no dependencies, matching prior phases' precedent). Next phase: **`27_mobile_tables_and_audit`**.
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
**Phase 26 `ups_event_split` — final verification pass (all 3 tasks already complete; nothing missing from `complete/`).**
|
||||||
|
|
||||||
|
Verified
|
||||||
|
- `page-ups.js`: `POWER_EVENT_KINDS = {status,test,action}` split, unconditional `refreshEvents()`, no `state.events`/toggle-fetch, shared `eventRow`, `#ups-errors-card.hidden = errors.length === 0`; `ups.html` error card above the disclosure, `hidden`, outside every `<details>`. Kind set cross-checked against the real writers (`collectors.py:393/409`, `api.py:1043/1058`, `poller.py:127/157/164/190`) — classification is correct.
|
||||||
|
- Re-ran the phase's browser pass live: `node data/logs/26-task03-verify.cjs` → **31 checks, 0 skipped, ALL PASS** (`data/logs/26-final-verify.out`) — poll-failure rows readable with the disclosure shut, card hidden on a power-only feed and appearing on the first `unreachable`, lists paging independently, 360 px clean.
|
||||||
|
|
||||||
|
Tests / lint (all green)
|
||||||
|
- `uv run pytest` → **1545 passed, 15 skipped**, exit 0 · `bash .agents/validate.sh` → exit 0, coverage **98.94%** (floor 94%) · `uv run ruff check src tests agent` clean · `uv run pyright` 0 errors · `node --check` clean · `uv run pytest -m live --live` (with `.env.dev`) → **11 passed**, dev server reads all 9 sources `ok`.
|
||||||
|
|
||||||
|
Completion criteria: poll-failure visible + power collapsed ✅ (pinned + browser); hidden-when-empty card, one `/api/events?source=ups` fetch, shared pager ✅; stale-last-good via the empty `catch` pin ✅; pytest/coverage/ruff/pyright ✅; `validate.sh` ✅; no behaviour change to prior work — `dash.css`/`app.js` untouched, disclosure CSS / pager / row-shape pins pass unchanged ✅.
|
||||||
|
|
||||||
|
Docs
|
||||||
|
- Fixed (pre-existing + this phase's drift): AGENTS.md "one helper with four call sites" → **five over four pages** and the `row(event)` sentence (phase 26 added a second `/ups` call site); invariant 5's `web/static/charts.js` → `src/dashboards/web/static/charts.js`; recorded the completion-pass transcript. README: "one helper renders every feed" now notes five lists. Full sweep otherwise consistent — all 38 env vars match `config.py` exactly, all served routes/scripts/contracts referenced exist, suite/coverage/live counts and marker splits verified.
|
||||||
|
|
||||||
|
Deviations: `fastapi 0.153.0 → 0.159.1` advisory left alone (validate.sh declares it advisory-not-a-gate; this phase touched no dependencies, matching prior phases' precedent). Next phase: **`27_mobile_tables_and_audit`**.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 23%]
|
||||||
|
............................................................s........... [ 27%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 36%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
...sssssssssss.......................................................... [ 73%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
..............................................................sss....... [ 96%]
|
||||||
|
................................................ [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover Missing
|
||||||
|
------------------------------------------------------------------
|
||||||
|
src/dashboards/__init__.py 3 0 100%
|
||||||
|
src/dashboards/__main__.py 14 1 93% 32
|
||||||
|
src/dashboards/alerts.py 176 0 100%
|
||||||
|
src/dashboards/api.py 440 0 100%
|
||||||
|
src/dashboards/app.py 186 0 100%
|
||||||
|
src/dashboards/auth.py 265 0 100%
|
||||||
|
src/dashboards/certs.py 292 0 100%
|
||||||
|
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
|
||||||
|
src/dashboards/config.py 120 0 100%
|
||||||
|
src/dashboards/debug.py 14 0 100%
|
||||||
|
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||||
|
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
|
||||||
|
src/dashboards/rates.py 73 0 100%
|
||||||
|
src/dashboards/severity.py 49 0 100%
|
||||||
|
src/dashboards/sources/__init__.py 0 0 100%
|
||||||
|
src/dashboards/sources/disks.py 134 0 100%
|
||||||
|
src/dashboards/sources/host.py 209 0 100%
|
||||||
|
src/dashboards/sources/llm.py 178 1 99% 285
|
||||||
|
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
|
||||||
|
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||||
|
src/dashboards/store.py 313 2 99% 553-554
|
||||||
|
------------------------------------------------------------------
|
||||||
|
TOTAL 3495 37 99%
|
||||||
|
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||||
|
1545 passed, 15 skipped, 1 warning in 163.65s (0:02:43)
|
||||||
|
== uv run ruff check src tests agent ==
|
||||||
|
All checks passed!
|
||||||
|
== uv run pyright ==
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
== node --check src/dashboards/web/static/*.js ==
|
||||||
|
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
|
||||||
|
fastapi 0.153.0 0.159.1 wheel
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
All green. Summary:
|
||||||
|
|
||||||
|
**Implemented** (`src/dashboards/web/static/page-ups.js`)
|
||||||
|
- Added `POWER_EVENT_KINDS = new Set(['status', 'test', 'action'])` with a doc comment naming the writers (`collectors.py` `status`/`test`, `api.py` `action`) and stating that any other kind defaults to the visible error list.
|
||||||
|
- `refreshEvents()` now splits the one unchanged `/api/events?limit=…&source=ups` fetch into `power` (into `#events`, row renderer/eventText/`runtime_seconds` footnote byte-identical) and `errors` (the always-visible half, rendered by task 02).
|
||||||
|
- Removed the phase-19 lazy gate: `refresh()` calls `await refreshEvents();` unconditionally; `state.events` and the `toggle` handler's fetch logic are deleted — the native `<details>` now gates visibility only; stale-last-good is `refreshEvents()`' empty `catch`.
|
||||||
|
|
||||||
|
**Tests / lint / coverage**
|
||||||
|
- `tests/test_api.py`: replaced `test_ups_events_are_fetched_only_while_the_disclosure_is_open` with its inverted successor `test_ups_events_are_fetched_on_every_poll_and_split_by_kind` (kind set incl. a `UPS_POWER_EVENT_KINDS` table, no `state.events`, unconditional poll fetch, no toggle fetch, `/api/events` URL byte-identical with no `kind=`/`page=`/`severity=` vocabulary, empty catch = stale-last-good); updated the row-shape pin to `Dash.eventsPager($('#events'), power, {`. Also fixed a latent vacuous regex in the old pin (`refreshEvents();` unescaped → never matched).
|
||||||
|
- `uv run pytest` → **1543 passed, 15 skipped**; `uv run pytest --cov --cov-fail-under=94` → **98.94%** (unchanged); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` all static JS clean; `bash .agents/validate.sh` **exit 0**.
|
||||||
|
- Browser proof (invariant 10): `data/logs/26-task01-smoke.cjs` + `data/logs/26-task01-smoke.png` against the restarted dev server (9 sources ok) — page runs with no JS errors, disclosure shut by default yet its list filled by the poll path, rows exactly the power-kind half of a direct `/api/events` read, no error row leaked into the collapsed list. Live store has 199 error-kind vs 1 power-kind row in the last 200 `ups` events — the TODO's complaint, quantified.
|
||||||
|
|
||||||
|
**Docs**: AGENTS.md event-feed paragraph rewritten (lazy gate → always-fetched + `kind` split + the new pin name); README.md `/ups` "expandable" paragraph rewritten to the same fact.
|
||||||
|
|
||||||
|
**Decisions/deviations**: had to re-pin the two failing phase-19 pins now (task 03 extends them, e.g. the error list's row shape) to keep the suite green; `errors` is computed but not yet drawn — task 02 adds `#events-errors`.
|
||||||
|
|
||||||
|
**Next pending task**: `.agents/phases/todo/26_ups_event_split/02_render_both_lists_paged.md`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 23%]
|
||||||
|
..........................................................s............. [ 27%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 36%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
.sssssssssss............................................................ [ 73%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
............................................................sss......... [ 97%]
|
||||||
|
.............................................. [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover Missing
|
||||||
|
------------------------------------------------------------------
|
||||||
|
src/dashboards/__init__.py 3 0 100%
|
||||||
|
src/dashboards/__main__.py 14 1 93% 32
|
||||||
|
src/dashboards/alerts.py 176 0 100%
|
||||||
|
src/dashboards/api.py 440 0 100%
|
||||||
|
src/dashboards/app.py 186 0 100%
|
||||||
|
src/dashboards/auth.py 265 0 100%
|
||||||
|
src/dashboards/certs.py 292 0 100%
|
||||||
|
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
|
||||||
|
src/dashboards/config.py 120 0 100%
|
||||||
|
src/dashboards/debug.py 14 0 100%
|
||||||
|
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||||
|
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
|
||||||
|
src/dashboards/rates.py 73 0 100%
|
||||||
|
src/dashboards/severity.py 49 0 100%
|
||||||
|
src/dashboards/sources/__init__.py 0 0 100%
|
||||||
|
src/dashboards/sources/disks.py 134 0 100%
|
||||||
|
src/dashboards/sources/host.py 209 0 100%
|
||||||
|
src/dashboards/sources/llm.py 178 1 99% 285
|
||||||
|
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
|
||||||
|
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||||
|
src/dashboards/store.py 313 2 99% 553-554
|
||||||
|
------------------------------------------------------------------
|
||||||
|
TOTAL 3495 37 99%
|
||||||
|
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||||
|
1543 passed, 15 skipped, 1 warning in 165.86s (0:02:45)
|
||||||
|
== uv run ruff check src tests agent ==
|
||||||
|
All checks passed!
|
||||||
|
== uv run pyright ==
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
== node --check src/dashboards/web/static/*.js ==
|
||||||
|
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
|
||||||
|
fastapi 0.153.0 0.159.1 wheel
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
All green. Final report:
|
||||||
|
|
||||||
|
**Implemented (task 02 — the always-visible error card)**
|
||||||
|
- Verified the shipped half of the task (task 01's run had already written the `#ups-errors-card` markup, the second `Dash.eventsPager($('#events-errors'), …)` call, the single shared `eventRow` const, and `$('#ups-errors-card').hidden = errors.length === 0`); the prior task-02 run died on a provider 502 with pyright interrupted, so its gates were never closed.
|
||||||
|
- Wrote the missing real-browser verification: `data/logs/26-task02-layout.cjs` (+ `.out`, `.log`, 7 screenshots) — 33 checks, all pass: live feed visible **above** the still-shut Power events disclosure and not inside a `<details>`; rows exactly the non-power kinds of a direct `/api/events?source=ups&limit=200` split, rendered through the page's own `Dash.eventText`; the quoted `NutError: cannot reach upsd … (timed out after 4s)` readable without opening anything; `.85rem` gap in **both** states (13.59 px measured, no doubled gap when hidden); independent paging on a synthetic 60+60 feed (`51–60 of 60` moves only the list you clicked); stale-last-good on a 500 feed (both `<ul>`s hold their rows, card holds its visibility); hidden-while-empty arm with no `loading…` flash and the hidden→visible transition on the first error row; 360 px with no horizontal overflow.
|
||||||
|
- `dash.css` untouched (no new selector); power list's empty sentence and the disclosure CSS shape unchanged.
|
||||||
|
- Docs: AGENTS.md event-feed paragraph now states the error card, its shared row/pager, the inline `.85rem` gap and both pin names; README's `/ups` paragraph names the always-visible **Connection & errors** card.
|
||||||
|
|
||||||
|
**Tests / lint / coverage**
|
||||||
|
- `uv run pytest` → **1545 passed, 15 skipped** · `uv run pytest --cov --cov-fail-under=94` → **TOTAL 99%** (3495 stmts, 37 miss)
|
||||||
|
- `uv run ruff check src tests agent` → All checks passed · `uv run pyright` → 0 errors, 0 warnings · `node --check web/static/*.js` clean
|
||||||
|
- `bash .agents/validate.sh` → **exit 0** · `uv run pytest -m live --live` → 11 passed, exit 0 (read-only)
|
||||||
|
- browser: `node data/logs/26-task02-layout.cjs` → **ALL CHECKS PASS** (33/33)
|
||||||
|
|
||||||
|
**Decisions / deviations**
|
||||||
|
- Task 02's served-byte pins (already present in `tests/test_api.py` from task 01's run) were left in place rather than rewritten; I verified each claims what the task file asks and added the measured half it was missing. No new Python changed, so coverage was never at risk.
|
||||||
|
- Gap check confirmed the hidden card does not double the stack's gap, so the error card keeps its inline `margin-top:.85rem` (the task file's fallback was not needed).
|
||||||
|
|
||||||
|
**Next pending task:** `.agents/phases/todo/26_ups_event_split/03_repin_and_browser_verify.md`
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 23%]
|
||||||
|
............................................................s........... [ 27%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 36%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
...sssssssssss.......................................................... [ 73%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
..............................................................sss....... [ 96%]
|
||||||
|
................................................ [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover Missing
|
||||||
|
------------------------------------------------------------------
|
||||||
|
src/dashboards/__init__.py 3 0 100%
|
||||||
|
src/dashboards/__main__.py 14 1 93% 32
|
||||||
|
src/dashboards/alerts.py 176 0 100%
|
||||||
|
src/dashboards/api.py 440 0 100%
|
||||||
|
src/dashboards/app.py 186 0 100%
|
||||||
|
src/dashboards/auth.py 265 0 100%
|
||||||
|
src/dashboards/certs.py 292 0 100%
|
||||||
|
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
|
||||||
|
src/dashboards/config.py 120 0 100%
|
||||||
|
src/dashboards/debug.py 14 0 100%
|
||||||
|
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||||
|
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
|
||||||
|
src/dashboards/rates.py 73 0 100%
|
||||||
|
src/dashboards/severity.py 49 0 100%
|
||||||
|
src/dashboards/sources/__init__.py 0 0 100%
|
||||||
|
src/dashboards/sources/disks.py 134 0 100%
|
||||||
|
src/dashboards/sources/host.py 209 0 100%
|
||||||
|
src/dashboards/sources/llm.py 178 1 99% 285
|
||||||
|
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
|
||||||
|
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||||
|
src/dashboards/store.py 313 2 99% 553-554
|
||||||
|
------------------------------------------------------------------
|
||||||
|
TOTAL 3495 37 99%
|
||||||
|
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||||
|
1545 passed, 15 skipped, 1 warning in 165.97s (0:02:45)
|
||||||
|
== uv run ruff check src tests agent ==
|
||||||
|
All checks passed!
|
||||||
|
== uv run pyright ==
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
== node --check src/dashboards/web/static/*.js ==
|
||||||
|
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
|
||||||
|
fastapi 0.153.0 0.159.1 wheel
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
Everything is green. Final verification summary:
|
||||||
|
|
||||||
|
**Implemented (task 03 — `26_ups_event_split`)**
|
||||||
|
- `tests/test_api.py`: new helpers `_details_blocks()` and `_pager_call()`; `_power_event_kinds()` now *parses* the `POWER_EVENT_KINDS` literal and the pin asserts set **equality** with `UPS_POWER_EVENT_KINDS`.
|
||||||
|
- `test_ups_power_events_feed_sits_behind_a_collapsed_disclosure`: all phase-19 assertions kept, extended — `#events-errors` present, outside **every** `<details>`, and its card ships `hidden`; docstring rewritten off the old lazy-fetch rationale.
|
||||||
|
- Renamed the inverted lazy-gate pin to `test_ups_events_are_fetched_on_every_poll` (unconditional `await refreshEvents()`, no `state.events`, no `toggle` fetch, URL byte-identical, empty `catch` = stale-last-good).
|
||||||
|
- Row-shape pin now reads **each** pager call (`#events` + `#events-errors`: shared `row: eventRow`, label, empty sentence), forbids `event.kind`/one-template rule, and pins `hidden = errors.length === 0` set *after* both lists paint.
|
||||||
|
- Disclosure CSS pin extended with the no-`#ups-errors-card`/`#events-errors`-selector guard (original assertions untouched).
|
||||||
|
- `data/logs/26-task03-verify.cjs` + `26-task03-verify.out/.log` + screenshots `ups-event-split-{visible,expanded,hidden,appears,phone}.png`; mutation-checked the new pins (dropping a `row: eventRow`, wrapping the card in a `<details>`, adding a CSS id rule each fail).
|
||||||
|
- `AGENTS.md`: pin names/sentences re-pointed, kind-set parse described, phase browser pass recorded, suite count 1543→1545. README needed no change (already accurate).
|
||||||
|
|
||||||
|
**Tests / lint (exact)**
|
||||||
|
- `bash .agents/validate.sh` → exit 0: `pytest --cov --cov-fail-under=94` → **1545 passed, 15 skipped**, coverage **98.94%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check web/static/*.js` clean.
|
||||||
|
- `uv run pytest -m live --live` → 11 passed. Browser pass: **31 checks, 0 skipped, ALL CHECKS PASS** (feed: 200 events = 1 power + 199 error; error card visible above the shut disclosure with 50-of-199 rows matching a direct `/api/events?source=ups&limit=200` split).
|
||||||
|
|
||||||
|
**Deviations / notes**
|
||||||
|
- Kept `_pager_call`-per-list slicing instead of a whole-file `row:` count; the old long test name from task 01 (`…_and_split_by_kind`) was renamed to the task file's `test_ups_events_are_fetched_on_every_poll` and AGENTS.md re-pointed.
|
||||||
|
- validate.sh prints a pre-existing advisory (`fastapi 0.153.0 → 0.159.1`); dependencies were not touched by this task, so I left the lock alone.
|
||||||
|
|
||||||
|
**Next pending task:** none in phase 26 (this was the last); next is `27_mobile_tables_and_audit`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 13%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 23%]
|
||||||
|
............................................................s........... [ 27%]
|
||||||
|
........................................................................ [ 32%]
|
||||||
|
........................................................................ [ 36%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 55%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 69%]
|
||||||
|
...sssssssssss.......................................................... [ 73%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
..............................................................sss....... [ 96%]
|
||||||
|
................................................ [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover Missing
|
||||||
|
------------------------------------------------------------------
|
||||||
|
src/dashboards/__init__.py 3 0 100%
|
||||||
|
src/dashboards/__main__.py 14 1 93% 32
|
||||||
|
src/dashboards/alerts.py 176 0 100%
|
||||||
|
src/dashboards/api.py 440 0 100%
|
||||||
|
src/dashboards/app.py 186 0 100%
|
||||||
|
src/dashboards/auth.py 265 0 100%
|
||||||
|
src/dashboards/certs.py 292 0 100%
|
||||||
|
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
|
||||||
|
src/dashboards/config.py 120 0 100%
|
||||||
|
src/dashboards/debug.py 14 0 100%
|
||||||
|
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||||
|
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
|
||||||
|
src/dashboards/rates.py 73 0 100%
|
||||||
|
src/dashboards/severity.py 49 0 100%
|
||||||
|
src/dashboards/sources/__init__.py 0 0 100%
|
||||||
|
src/dashboards/sources/disks.py 134 0 100%
|
||||||
|
src/dashboards/sources/host.py 209 0 100%
|
||||||
|
src/dashboards/sources/llm.py 178 1 99% 285
|
||||||
|
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
|
||||||
|
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||||
|
src/dashboards/store.py 313 2 99% 553-554
|
||||||
|
------------------------------------------------------------------
|
||||||
|
TOTAL 3495 37 99%
|
||||||
|
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||||
|
1545 passed, 15 skipped, 1 warning in 166.36s (0:02:46)
|
||||||
|
== uv run ruff check src tests agent ==
|
||||||
|
All checks passed!
|
||||||
|
== uv run pyright ==
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
== node --check src/dashboards/web/static/*.js ==
|
||||||
|
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
|
||||||
|
fastapi 0.153.0 0.159.1 wheel
|
||||||
@@ -81,7 +81,7 @@ page still renders.
|
|||||||
while `/api/health` went on calling them `ok` — a frozen source that looks healthy is the
|
while `/api/health` went on calling them `ok` — a frozen source that looks healthy is the
|
||||||
one failure mode worse than an outage.
|
one failure mode worse than an outage.
|
||||||
5. **No CDN, no build step, no internet at runtime.** Every asset is served by this app;
|
5. **No CDN, no build step, no internet at runtime.** Every asset is served by this app;
|
||||||
charts are hand-rolled SVG (`web/static/charts.js`). The engines live on a network with
|
charts are hand-rolled SVG (`src/dashboards/web/static/charts.js`). The engines live on a network with
|
||||||
no outbound access — a CDN reference is a request that never comes back.
|
no outbound access — a CDN reference is a request that never comes back.
|
||||||
6. **Never leak a credential.** Target URLs may carry a userinfo token (`parse_targets`
|
6. **Never leak a credential.** Target URLs may carry a userinfo token (`parse_targets`
|
||||||
moves it into an `Authorization` header, so `Target.url` ends up clean). Nothing that leaves
|
moves it into an `Authorization` header, so `Target.url` ends up clean). Nothing that leaves
|
||||||
@@ -1229,7 +1229,7 @@ uv run python scripts/dev-certs-export.py --url http://127.0.0.1:8090 \
|
|||||||
# GET /api/certs/<name>/bundle and writes
|
# GET /api/certs/<name>/bundle and writes
|
||||||
# data/certs/<name>.bundle at 0600 — mints nothing
|
# data/certs/<name>.bundle at 0600 — mints nothing
|
||||||
|
|
||||||
uv run pytest # hermetic: 1543 passed + 15 skipped (11 live,
|
uv run pytest # hermetic: 1545 passed + 15 skipped (11 live,
|
||||||
# 4 store_perf), no network
|
# 4 store_perf), no network
|
||||||
uv run pytest --cov # coverage gate (98.94%; floor 94%)
|
uv run pytest --cov # coverage gate (98.94%; floor 94%)
|
||||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||||
@@ -1687,7 +1687,8 @@ This is presentation only — the stored event and `/api/events` keep every word
|
|||||||
error string still surfaces in `/api/health`.
|
error string still surfaces in `/api/health`.
|
||||||
|
|
||||||
An event feed is a **window**, not a printout (`Dash.eventsPager(el, events, opts)` in
|
An event feed is a **window**, not a printout (`Dash.eventsPager(el, events, opts)` in
|
||||||
`app.js`, one helper with four call sites (`/system`, `/disks`, `/llm`, `/ups`) — a pager that
|
`app.js`, one helper with **five** call sites over four pages (`/system`, `/disks`, `/llm`, and
|
||||||
|
**both** halves of `/ups` since phase 26) — a pager that
|
||||||
works on one page is the bug this
|
works on one page is the bug this
|
||||||
shape prevents). `ul.events` is a bounded 320px scroll container (`overscroll-behavior:
|
shape prevents). `ul.events` is a bounded 320px scroll container (`overscroll-behavior:
|
||||||
contain`) and the pager puts `EVENT_PAGE_SIZE` = **50** events in it, with a `.events-pager`
|
contain`) and the pager puts `EVENT_PAGE_SIZE` = **50** events in it, with a `.events-pager`
|
||||||
@@ -1695,7 +1696,8 @@ strip under the list: `1–50 of 200` between `‹ prev` and `next ›`, both `a
|
|||||||
direction (the feed is newest-first, so `prev` walks towards now) and `disabled` at the ends.
|
direction (the feed is newest-first, so `prev` walks towards now) and `disabled` at the ends.
|
||||||
The strip is `hidden` when the feed fits on one page — every row on screen *is* the whole
|
The strip is `hidden` when the feed fits on one page — every row on screen *is* the whole
|
||||||
story, and a disabled arrow says "more history" when there is none. Each page keeps its own
|
story, and a disabled arrow says "more history" when there is none. Each page keeps its own
|
||||||
`row(event)` (so `/ups` keeps its longer timestamp and runtime footnote, and escaping stays
|
`row(event)` (so `/ups` keeps its longer timestamp and runtime footnote — one `eventRow` const
|
||||||
|
shared by both of its lists since phase 26 — and escaping stays
|
||||||
the page's job — the pager has no default renderer and says so in a row when a caller forgets
|
the page's job — the pager has no default renderer and says so in a row when a caller forgets
|
||||||
one) and its own `empty` sentence, so the `loading…` and empty states read exactly as they
|
one) and its own `empty` sentence, so the `loading…` and empty states read exactly as they
|
||||||
did before paging existed. Two numbers are load-bearing and pinned together: the fetch
|
did before paging existed. Two numbers are load-bearing and pinned together: the fetch
|
||||||
@@ -1711,15 +1713,52 @@ what keeps the reader's scroll position inside the container. The `/ups` feed is
|
|||||||
no `open` attribute, so that card is one `Power events` summary line until the operator opens it
|
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,
|
— 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
|
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()`
|
target (pinned on the served bytes). What the disclosure gates is **visibility only** (phase 26,
|
||||||
fetches the feed only while `state.events.open` is true, so a card nobody is looking at costs the
|
`TODO.md:6`): `refresh()` fetches the feed on every poll and `refreshEvents()` splits the one
|
||||||
poll loop nothing (a hidden-but-still-polled list is the half-measure this is). The first expand
|
response on the stored `kind` — `status` (an `ups.status` transition, power loss included,
|
||||||
fetches; a re-expand paints the rows already in the `<ul>` while they are younger than one poll
|
`collectors.py`), `test` (a self-test verdict) and `action` (a command this page sent, `api.py`)
|
||||||
interval (`state.pollMs`, the page's own cadence) and re-fetches only past it — a `<details>`
|
are the power events that belong behind the summary, and **every other kind** is the
|
||||||
keeps its children, so a fresh re-open costs no request and loses neither the pager's page nor
|
connection/error half (the poller's `unreachable`/`reachable`/`recording`/`store-error`, i.e.
|
||||||
the reader's scroll. `painted`/`fetchedAt` are stamped on the success path alone, which is the
|
`NUT server NutError: cannot reach upsd … (timed out after 4s)`), which must never be the list
|
||||||
invariant-4 shape here: a failed fetch leaves the rows on screen, leaves the stamp where it was,
|
behind a summary. Phase 19's lazy gate went out with that decision — no `state.events`, no
|
||||||
and the next open re-fetches. Its styling is `details.charts-box`'s shape with one structural
|
`toggle`-handler fetch, one unconditional `refreshEvents()` call — because a visible list that
|
||||||
|
ages is worse than one more 200-event fetch per interval, and the stale-last-good rule is now the
|
||||||
|
empty `catch` in `refreshEvents()`: nothing is stamped and nothing is re-drawn, so a failed fetch
|
||||||
|
leaves each `<ul>` holding the rows it already had. `/api/events` grew no vocabulary for the
|
||||||
|
split (`limit` + `source` only, pinned), and the kind set is pinned on the served script —
|
||||||
|
**parsed out of the `POWER_EVENT_KINDS` literal and compared for equality**, not matched as one
|
||||||
|
line — by `tests/test_api.py::test_ups_events_are_fetched_on_every_poll`, so a silent rename in
|
||||||
|
`collectors.py`/`api.py`, or a kind quietly added to or dropped from the hidden list, fails a
|
||||||
|
test instead of moving errors back into the collapsed list. The error half is a card of its own
|
||||||
|
(phase 26 task 02): `section.card#ups-errors-card`
|
||||||
|
sits **above** the disclosure with a `ul.events#events-errors` inside its `.body`, ships
|
||||||
|
`hidden` (the resting state — a permanent empty "Connection & errors" card is the ink the
|
||||||
|
empty-state rules exist to avoid) and `refreshEvents()` keeps it that way while the split has no
|
||||||
|
error rows, so its first `unreachable` line is what makes it appear and the `loading…` seed never
|
||||||
|
flashes. Both lists go through the **same** `Dash.eventsPager` with the **same** `eventRow` const
|
||||||
|
(one table split on `kind` cannot grow two row shapes — the pin reads each call's options through
|
||||||
|
`_pager_call()`, so a list that loses its renderer fails by name rather than turning a whole-file
|
||||||
|
count off by one, and the row template itself may not read `event.kind`), and because the pager's
|
||||||
|
state is a `WeakMap` keyed by the `<ul>` the two page independently. Nothing about the second list
|
||||||
|
needed a stylesheet: `ul.events` and `.events-pager` are class rules and the card carries the
|
||||||
|
page's own `.85rem` section gap inline, which is pinned as the *absence* of a
|
||||||
|
`#ups-errors-card`/`#events-errors` selector (`test_the_ups_error_card_adds_no_stylesheet_selector`,
|
||||||
|
and the same guard inside the disclosure's own CSS pin) alongside the card's shape, position and
|
||||||
|
`hidden = errors.length === 0` line
|
||||||
|
(`test_the_ups_connection_errors_are_a_card_of_their_own_above_the_disclosure`); the card also
|
||||||
|
ships outside **every** `<details>` on the page, checked over the disclosure blocks rather than
|
||||||
|
one known disclosure (`test_ups_power_events_feed_sits_behind_a_collapsed_disclosure`) — the
|
||||||
|
operator's complaint was that a `NutError` line needed a click, so "not inside the disclosure I
|
||||||
|
happened to name" is not the claim. The layout, the gap in both states and the first-appear are
|
||||||
|
measured in `data/logs/26-task02-layout.cjs`; the phase's own pass is
|
||||||
|
`data/logs/26-task03-verify.cjs` (transcript `26-task03-verify.out`, re-run green on the
|
||||||
|
phase-completion pass as `26-final-verify.out`, 31 checks: the disclosure
|
||||||
|
shut with its list unpainted while the error card holds 50 of the 199 error kinds and the power
|
||||||
|
list the one power kind of a direct `/api/events?source=ups&limit=200` read, every row equal to
|
||||||
|
the page's own `Dash.eventText` of that event, both lists paging independently, the card hidden
|
||||||
|
on a power-only feed and appearing on the poll that carries the first `unreachable` row, and 360 px
|
||||||
|
with no sideways scroll; screenshots `data/logs/ups-event-split-{visible,expanded,hidden,appears,phone}.png`).
|
||||||
|
The **disclosure's** styling is `details.charts-box`'s shape with one structural
|
||||||
difference: that toggle sits *inside* a card body, while this `<summary>` **replaces** the card's
|
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
|
`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
|
reads as its heading, an open one puts the feed under the same rule every other card draws) and
|
||||||
|
|||||||
@@ -892,14 +892,22 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
|||||||
The four event feeds (`/system`, `/disks`, `/llm`, `/ups`) are a **paged window** over the `event`
|
The four event feeds (`/system`, `/disks`, `/llm`, `/ups`) are a **paged window** over the `event`
|
||||||
table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a
|
table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a
|
||||||
page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath.
|
page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath.
|
||||||
One helper, `Dash.eventsPager()`, renders every feed and each keeps its own row markup; the fetch
|
One helper, `Dash.eventsPager()`, renders every feed — five lists today, because `/ups` splits
|
||||||
|
its one fetch in two — and each keeps its own row markup; the fetch
|
||||||
asks `/api/events` for 200 so there is a page 2 to reach, and the endpoint itself is
|
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
|
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.
|
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
|
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
|
you open it, and the list inside is the same bounded, paged window. What sits behind that
|
||||||
at all; the first expand fetches once, and re-opening inside one poll interval redraws what is
|
summary is only the *power* half of the feed — `status` transitions (the power loss and its
|
||||||
already there instead of asking again.
|
return), self-test results, and the commands you sent — because the rest of it is connection
|
||||||
|
trouble (`NUT server NutError: cannot reach upsd …` and its recovery line), which is not
|
||||||
|
history to tuck away. That error half is its own **Connection & errors** card above the
|
||||||
|
summary — the same bounded, paged window, the same row shape — hidden while it has nothing to
|
||||||
|
say and appearing with its first unreachable/reachable line, so a poll failure is readable
|
||||||
|
without opening anything. That is also why the feed is fetched on every poll rather than only
|
||||||
|
while the summary is open: half of it is always on screen, and the two lists page
|
||||||
|
independently of each other.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,14 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="ups-errors-card" style="margin-top:.85rem" hidden>
|
||||||
|
<header>
|
||||||
|
<h3>Connection & errors</h3><span class="spacer"></span>
|
||||||
|
<span class="faint">upsd reachability and this process's own failures</span>
|
||||||
|
</header>
|
||||||
|
<div class="body"><ul class="events" id="events-errors"><li class="muted">loading…</li></ul></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card" style="margin-top:.85rem">
|
<section class="card" style="margin-top:.85rem">
|
||||||
<details class="events-box">
|
<details class="events-box">
|
||||||
<summary>Power events</summary>
|
<summary>Power events</summary>
|
||||||
|
|||||||
@@ -10,24 +10,40 @@
|
|||||||
* says `enabled`, and anything that can cut power requires typing DANGER here *and* the
|
* 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.
|
* 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:
|
* The event feed splits into two lists from the one `/api/events` fetch. The **power**
|
||||||
* a closed `<details>` renders nothing, so while it is shut the page fetches `/api/events`
|
* events — state transitions, self-test verdicts, commands sent — stay **collapsed by
|
||||||
* not at all (`/system`'s Charts rule — nothing is built for a feed nobody can see). The
|
* default** behind `details.events-box`, because they are history worth keeping close and
|
||||||
* first expand fetches; a re-expand paints what is in hand while it is fresh.
|
* not glance-worthy. Everything else (the poller's connection errors) is the half that must
|
||||||
|
* never sit behind a summary: it is a card of its own (`#ups-errors-card`) above that
|
||||||
|
* disclosure, `hidden` only while it has no rows to show, which is also why the fetch happens
|
||||||
|
* on every poll — a list the operator reads without opening anything must not age.
|
||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { $, fetchJSON, setTxt, setValue, setDot, num, duration, poll, eventText, naText } = Dash;
|
const { $, fetchJSON, setTxt, setValue, setDot, num, duration, poll, eventText, naText } = Dash;
|
||||||
|
|
||||||
const state = { range: 86400, cards: new Map(), charts: {}, poller: null, pollMs: 5000,
|
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: '?' };
|
const MARKS = { ok: '✓', warn: '!', fail: '✕', unknown: '?' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three event kinds that belong in the collapsed *power events* list.
|
||||||
|
*
|
||||||
|
* Their writers, so a rename on the source side shows up at the site of the drift:
|
||||||
|
* - `status` — a `ups.status` transition (`OB` → `OL` and back), written by the NUT
|
||||||
|
* monitor's transition bookkeeping (`collectors.py:393`);
|
||||||
|
* - `test` — the verdict of a battery self-test (`collectors.py:409`);
|
||||||
|
* - `action` — a command this page sent through `POST /api/ups/command` (`api.py`).
|
||||||
|
*
|
||||||
|
* **Anything that is not one of these three is an error row**, and lands in the list that is
|
||||||
|
* always visible — the poller's `unreachable` / `reachable` / `recording` / `store-error`
|
||||||
|
* lines, including `NUT server NutError: cannot reach upsd … (timed out after 4s)`. That
|
||||||
|
* default is the point: an unknown kind in the hidden list is the bug this phase exists to
|
||||||
|
* fix, re-shipped.
|
||||||
|
*/
|
||||||
|
const POWER_EVENT_KINDS = new Set(['status', 'test', 'action']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime left as "1h 32m", not "1.5h". The health check next to it spells the same number
|
* Runtime left as "1h 32m", not "1.5h". The health check next to it spells the same number
|
||||||
* out in h+m; two formats for one value invites a misread while the UPS is discharging.
|
* out in h+m; two formats for one value invites a misread while the UPS is discharging.
|
||||||
@@ -433,35 +449,66 @@ async function refresh() {
|
|||||||
$('#foot-note').textContent = `poll ${Math.round(state.pollMs / 1000)}s · ` +
|
$('#foot-note').textContent = `poll ${Math.round(state.pollMs / 1000)}s · ` +
|
||||||
`${items.reduce((total, item) => total + (item.advertised_commands || []).length, 0)} commands advertised`;
|
`${items.reduce((total, item) => total + (item.advertised_commands || []).length, 0)} commands advertised`;
|
||||||
await refreshCharts(items.map((item) => item.name));
|
await refreshCharts(items.map((item) => item.name));
|
||||||
// The feed is only refreshed while its disclosure is open. The list is the longest-lived
|
// The feed is fetched on every poll, whether or not the power-events disclosure is open:
|
||||||
// payload on the page (200 events) and the one a glance at "is my load safe" never reads,
|
// half of it is the connection errors, which are the rows nobody should have to open a
|
||||||
// so a shut `<details>` must cost the poll loop nothing.
|
// summary to read, and a visible list that ages is worse than one fetch per interval.
|
||||||
if (state.events.open) await refreshEvents();
|
await refreshEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of either `/ups` list — the **same** markup for both halves of the feed.
|
||||||
|
*
|
||||||
|
* One const rather than two copies, because the two lists are one table split on `kind`: a
|
||||||
|
* row that drifts — the power list keeping its timestamp while the error list loses it — is
|
||||||
|
* the operator reading two shapes for one event. The runtime footnote stays conditional
|
||||||
|
* rather than per-list: an `action` row carries `data.runtime_seconds`, a poller's
|
||||||
|
* `unreachable` row does not, and a row with no runtime simply says nothing about one.
|
||||||
|
*/
|
||||||
|
const eventRow = (event) => `<time>${
|
||||||
|
new Date(event.ts * 1000).toLocaleString(undefined,
|
||||||
|
{ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
}</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` +
|
||||||
|
`${DashCharts.escapeHtml(eventText(event))}` +
|
||||||
|
`${event.data && event.data.runtime_seconds ? ` <span class="faint mono">(${
|
||||||
|
duration(event.data.runtime_seconds)} runtime)</span>` : ''}</span>`;
|
||||||
|
|
||||||
async function refreshEvents() {
|
async function refreshEvents() {
|
||||||
try {
|
try {
|
||||||
// Battery history is the longest of the three feeds (every charge / discharge transition
|
// Battery history is the longest of the three feeds (every charge / discharge transition
|
||||||
// lands here), so this is the page that needs the bounded window most: 50 rows a page
|
// lands here), so this is the page that needs the bounded window most: 50 rows a page
|
||||||
// inside a scrolling list, with the count and the arrows under it (`Dash.eventsPager`).
|
// inside a scrolling list, with the count and the arrows under it (`Dash.eventsPager`).
|
||||||
const data = await fetchJSON(`/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=ups`);
|
const data = await fetchJSON(`/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=ups`);
|
||||||
Dash.eventsPager($('#events'), data.events, {
|
// One fetch, two lists, split on the stored `kind` (see POWER_EVENT_KINDS). Nothing here
|
||||||
|
// asks the API for a kind or a page — `/api/events` grew no vocabulary for this split.
|
||||||
|
const power = data.events.filter((e) => POWER_EVENT_KINDS.has(e.kind));
|
||||||
|
// The error half is everything that is *not* a power event, and it belongs to the list
|
||||||
|
// that is never hidden: the card above this disclosure, which appears with its first row.
|
||||||
|
const errors = data.events.filter((e) => !POWER_EVENT_KINDS.has(e.kind));
|
||||||
|
// The pager keeps its state in a `WeakMap` keyed by the `<ul>`, so these two lists page
|
||||||
|
// independently — turning page 2 of the error feed never moves the power feed.
|
||||||
|
Dash.eventsPager($('#events'), power, {
|
||||||
label: 'Power events',
|
label: 'Power events',
|
||||||
empty: '<li class="muted" style="grid-template-columns:1fr">No power events yet — '
|
empty: '<li class="muted" style="grid-template-columns:1fr">No power events yet — '
|
||||||
+ 'state changes, self-test results, and commands you send all land here.</li>',
|
+ 'state changes, self-test results, and commands you send all land here.</li>',
|
||||||
row: (event) => `<time>${
|
row: eventRow,
|
||||||
new Date(event.ts * 1000).toLocaleString(undefined,
|
|
||||||
{ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
|
|
||||||
}</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` +
|
|
||||||
`${DashCharts.escapeHtml(eventText(event))}` +
|
|
||||||
`${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
|
Dash.eventsPager($('#events-errors'), errors, {
|
||||||
// they were, so the next open re-fetches and a failed poll while open keeps showing the
|
label: 'Connection & errors',
|
||||||
// rows already in the list (the stale-last-good rule the rest of this page follows).
|
empty: '<li class="muted" style="grid-template-columns:1fr">No connection errors — upsd '
|
||||||
state.events.fetchedAt = Date.now();
|
+ 'is answering and this process is recording.</li>',
|
||||||
state.events.painted = true;
|
row: eventRow,
|
||||||
|
});
|
||||||
|
// The card is `hidden` in the markup and stays that way while the feed holds no error
|
||||||
|
// rows: a permanent empty "Connection & errors" card is the noise the page's empty-state
|
||||||
|
// rules exist to avoid, and the first `unreachable` line is what makes it appear. Setting
|
||||||
|
// it here (after the pager painted) means a first fetch with no error rows never flashes
|
||||||
|
// the `loading…` seed either.
|
||||||
|
$('#ups-errors-card').hidden = errors.length === 0;
|
||||||
|
// A failed fetch leaves **both** lists on the rows already in them, and the card on the
|
||||||
|
// visibility it had: the pager rewrites a `<ul>` only when its markup changed, so the rows
|
||||||
|
// of a poll that never answered stay on screen and the next poll replaces them (the
|
||||||
|
// stale-last-good rule the rest of this page follows). Nothing is stamped, because nothing
|
||||||
|
// is cached any more — the poll loop is the only thing that decides when this runs.
|
||||||
} catch (err) { /* next poll */ }
|
} catch (err) { /* next poll */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,23 +532,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
$('#dialog-run').disabled = event.target.value.trim() !== 'DANGER';
|
$('#dialog-run').disabled = event.target.value.trim() !== 'DANGER';
|
||||||
});
|
});
|
||||||
|
|
||||||
// The disclosure is the feed's only fetch trigger besides the poll loop. The native
|
// The power-events disclosure needs no JS at all: the feed is fetched on every poll, so the
|
||||||
// `toggle` event fires on close too; closing just records the state so the loop stops.
|
// native `<details>` only has to gate the *visibility* of the list it holds.
|
||||||
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);
|
state.poller = poll(refresh, state.pollMs);
|
||||||
Dash.bindShortcuts();
|
Dash.bindShortcuts();
|
||||||
|
|||||||
+248
-61
@@ -1986,6 +1986,21 @@ def _event_feed_path(js: str) -> str:
|
|||||||
return js[js.index("async function refreshEvents("):]
|
return js[js.index("async function refreshEvents("):]
|
||||||
|
|
||||||
|
|
||||||
|
def _pager_call(feed: str, selector: str) -> str:
|
||||||
|
"""The options object of one `Dash.eventsPager($('<selector>'), …)` call inside the feed path.
|
||||||
|
|
||||||
|
Sliced per list so an assertion can name *which* feed lost its renderer, its accessible name
|
||||||
|
or its empty sentence. `/ups` is the page with two of them, so a whole-file assertion there
|
||||||
|
cannot tell one list from the other.
|
||||||
|
"""
|
||||||
|
head = f"Dash.eventsPager($('{selector}')"
|
||||||
|
assert head in feed, f"this feed is no longer rendered through Dash.eventsPager ({selector})"
|
||||||
|
start = feed.index(head)
|
||||||
|
end = feed.find("});", start)
|
||||||
|
assert end != -1, f"the {selector} pager call is never closed"
|
||||||
|
return feed[start:end]
|
||||||
|
|
||||||
|
|
||||||
def _feed_fetch_url(js: str) -> str:
|
def _feed_fetch_url(js: str) -> str:
|
||||||
"""The `/api/events` URL a feed asks for, quoted or templated."""
|
"""The `/api/events` URL a feed asks for, quoted or templated."""
|
||||||
found = re.search(r"fetchJSON\([`'](/api/events[^`']*)[`']\)", _event_feed_path(js))
|
found = re.search(r"fetchJSON\([`'](/api/events[^`']*)[`']\)", _event_feed_path(js))
|
||||||
@@ -2072,15 +2087,36 @@ def test_the_event_feeds_scroll_instead_of_growing_the_page(client) -> None:
|
|||||||
assert '<ul class="events" id="events"' in html, f"{path} lost its feed's id"
|
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:
|
def _details_blocks(html: str) -> list[str]:
|
||||||
"""The `/ups` feed is *expandable*: one `details.events-box`, shut by default.
|
"""Every `<details>…</details>` block on a served page, in document order.
|
||||||
|
|
||||||
`TODO.md:4` asks for an expandable list of incidents, and the shut disclosure is the half
|
Nesting is not a thing on these pages (one disclosure per card), so a flat scan of pairs is
|
||||||
that costs something — a collapsed `<details>` renders no content, which is what lets the
|
enough — and it is what lets a pin say *no disclosure contains this*, rather than only
|
||||||
page fetch nothing while nobody is looking at the feed (`/system`'s Charts rule). Pinned on
|
"not the one disclosure I happen to know about". Phase 26 needs exactly that: the operator's
|
||||||
the served bytes because there is no JS test runner (a locked anchor): an `open` attribute
|
complaint was that a connection error was reachable only by opening something.
|
||||||
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.
|
blocks = []
|
||||||
|
for found in re.finditer(r"<details\b[^>]*>", html):
|
||||||
|
end = html.find("</details>", found.end())
|
||||||
|
assert end != -1, "a served <details> is never closed"
|
||||||
|
blocks.append(html[found.start():end])
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def test_ups_power_events_feed_sits_behind_a_collapsed_disclosure(client) -> None:
|
||||||
|
"""The `/ups` power feed is *expandable*: one `details.events-box`, shut by default.
|
||||||
|
|
||||||
|
`TODO.md:4` asked for an expandable list of incidents and phase 26 kept that shape — what it
|
||||||
|
overruled was *what* was behind it. A collapsed `<details>` renders no content, so the shut
|
||||||
|
card is still the resting state; since phase 26 it gates **visibility only** (the feed is
|
||||||
|
fetched on every poll, which the next test pins), and it holds only the power kinds.
|
||||||
|
|
||||||
|
Pinned on the served bytes because there is no JS test runner (a locked anchor): an `open`
|
||||||
|
attribute would undo the phase silently, the `#events` id has to stay *inside* the disclosure
|
||||||
|
or the pager and the bounded 320px scroll lose their target, and the **error** half
|
||||||
|
(`#events-errors`) has to be outside *every* disclosure and ship `hidden` rather than open —
|
||||||
|
a `NUT server NutError: cannot reach upsd …` line an operator has to click to read is the
|
||||||
|
bug this phase exists to fix (`TODO.md:6`).
|
||||||
"""
|
"""
|
||||||
html = _code(client.get("/ups").text)
|
html = _code(client.get("/ups").text)
|
||||||
box = re.search(r'<details class="events-box">(.*?)</details>', html, re.S)
|
box = re.search(r'<details class="events-box">(.*?)</details>', html, re.S)
|
||||||
@@ -2092,77 +2128,132 @@ def test_ups_power_events_feed_sits_behind_a_collapsed_disclosure(client) -> Non
|
|||||||
assert '<ul class="events" id="events"' in inner, "the feed moved outside the disclosure"
|
assert '<ul class="events" id="events"' in inner, "the feed moved outside the disclosure"
|
||||||
assert "loading…" in inner, "the first-expand placeholder is gone"
|
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"
|
assert "<h3>Power events</h3>" not in html, "the card heads its own body twice"
|
||||||
|
|
||||||
|
# The error half is on the page **and not collapsible**: no disclosure anywhere holds it.
|
||||||
|
# The card-local pin in `test_the_ups_connection_errors_are_a_card_of_their_own_above_the_disclosure`
|
||||||
|
# checks the card does not *contain* a `<details>`; this is the stronger direction — moving
|
||||||
|
# that card inside a new disclosure somewhere else would pass there and fail here.
|
||||||
|
assert 'id="events-errors"' in html, "the connection-error list left the served page"
|
||||||
|
assert not any('id="events-errors"' in block for block in _details_blocks(html)), \
|
||||||
|
"the error list is inside a disclosure — reading it needs a click again"
|
||||||
|
card_open = html[html.index('<section class="card" id="ups-errors-card"'):
|
||||||
|
html.index(">", html.index('<section class="card" id="ups-errors-card"'))]
|
||||||
|
assert "hidden" in card_open, \
|
||||||
|
"the error card's resting state is visible — an empty card on every quiet day"
|
||||||
|
|
||||||
# the rest of the page is untouched by the disclosure
|
# the rest of the page is untouched by the disclosure
|
||||||
for still_there in ('id="ups-cards"', 'id="chart-charge"', 'id="chart-load"',
|
for still_there in ('id="ups-cards"', 'id="chart-charge"', 'id="chart-load"',
|
||||||
'id="confirm-dialog"'):
|
'id="confirm-dialog"'):
|
||||||
assert still_there in client.get("/ups").text, f"/ups lost {still_there}"
|
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:
|
# The event kinds `/ups` keeps behind the collapsed disclosure, and the writers that store
|
||||||
"""A closed Power events card costs the poll loop *nothing* — no fetch, no rows.
|
# them. `collectors.py` writes `status` (an `ups.status` transition, power loss included)
|
||||||
|
# and `test` (a self-test verdict); `api.py` writes `action` (a command this page sent).
|
||||||
|
# Everything else in the feed is a connection/error event (`unreachable` / `reachable` /
|
||||||
|
# `recording` / `store-error`) and belongs in the list that is never hidden.
|
||||||
|
UPS_POWER_EVENT_KINDS = ("status", "test", "action")
|
||||||
|
|
||||||
Hiding a list that is still fetched every 5 s is the half-measure this pins out: the
|
|
||||||
disclosure is only worth having if `refresh()` stops asking `/api/events` while it is shut
|
def _power_event_kinds(js: str) -> frozenset[str]:
|
||||||
(the `/system` Charts rule — "nothing is built or drawn while the stack is shut"), and the
|
"""The kinds `page-ups.js` keeps behind the disclosure, read out of its own set literal.
|
||||||
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
|
Parsing beats matching one exact line. The claim is *these three and only these* — a kind
|
||||||
because there is no JS test runner (a locked anchor).
|
added to the hidden list moves an operator-visible row out of sight, and a kind dropped from
|
||||||
|
it hides a power event — so an exact-string assert that happens to survive a reformat is not
|
||||||
|
the pin. An empty set is refused rather than returned, because it would make every event an
|
||||||
|
error row and look like a passing classification.
|
||||||
|
"""
|
||||||
|
found = re.search(r"const POWER_EVENT_KINDS = new Set\(\[(.*?)]\)", js, re.S)
|
||||||
|
assert found, "page-ups.js no longer defines POWER_EVENT_KINDS as a set literal of kind words"
|
||||||
|
kinds = re.findall(r"['\"]([^'\"]+)['\"]", found.group(1))
|
||||||
|
assert kinds, "POWER_EVENT_KINDS is empty — every event would land in the error list"
|
||||||
|
return frozenset(kinds)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ups_events_are_fetched_on_every_poll(client) -> None:
|
||||||
|
"""Phase 19's lazy gate is out, and the feed splits into power events and errors.
|
||||||
|
|
||||||
|
`TODO.md:6` is not a complaint about the disclosure existing, it is a complaint about what
|
||||||
|
was behind it: a poll failure (`NUT server NutError: cannot reach upsd … (timed out after
|
||||||
|
4s)`) sat in the same collapsed list as the power events, so the one thing an operator
|
||||||
|
needs without opening anything was the one thing they had to open something for. The fix
|
||||||
|
is page-side and keyed on the stored `kind`: `status` / `test` / `action` stay behind the
|
||||||
|
disclosure, **everything else** is the always-visible error list — and because that list
|
||||||
|
is visible, `refresh()` fetches on every poll (a visible list that ages is worse than one
|
||||||
|
more 200-event fetch per interval). `/api/events` grew no vocabulary for any of this.
|
||||||
|
|
||||||
|
Pinned on the served script because there is no JS test runner (a locked anchor), and
|
||||||
|
pinned in both directions: the gate's absence *and* the kind set, so a silent rename on
|
||||||
|
the writer's side fails here instead of moving errors back into the hidden list.
|
||||||
"""
|
"""
|
||||||
js = _code(client.get("/static/page-ups.js").text)
|
js = _code(client.get("/static/page-ups.js").text)
|
||||||
|
|
||||||
# The state the two paths share: closed, nothing painted yet, nothing fetched yet.
|
# The kind set, read out of the literal instead of matched as one line: exactly these three.
|
||||||
|
assert _power_event_kinds(js) == frozenset(UPS_POWER_EVENT_KINDS), \
|
||||||
|
(f"the hidden list's kind set is {sorted(_power_event_kinds(js))}, not "
|
||||||
|
f"{sorted(UPS_POWER_EVENT_KINDS)} — that classification is the operator's, not the page's")
|
||||||
|
|
||||||
|
# The lazy cache is gone: with an always-visible list there is nothing left to stamp.
|
||||||
decl = js[js.index("const state = {"):js.index("const MARKS")]
|
decl = js[js.index("const state = {"):js.index("const MARKS")]
|
||||||
assert "events: { open: false, painted: false, fetchedAt: 0 }" in decl, \
|
assert "events" not in decl, "the disclosure's lazy-fetch state came back"
|
||||||
"the disclosure lost the state its lazy rule reads"
|
assert "state.events" not in js, "something still reads the deleted state.events object"
|
||||||
|
|
||||||
# The poll path asks only while the card is open.
|
# The poll path asks every interval, gated on nothing.
|
||||||
refresh = js[js.index("async function refresh("):js.index("async function refreshEvents(")]
|
refresh = js[js.index("async function refresh("):js.index("async function refreshEvents(")]
|
||||||
assert "if (state.events.open) await refreshEvents();" in refresh, \
|
assert re.search(r"^\s*await refreshEvents\(\);", refresh, flags=re.M), \
|
||||||
"the poll loop fetches the event feed while the disclosure is shut"
|
"the poll loop no longer fetches the feed on every poll"
|
||||||
assert not re.search(r"^\s*await refreshEvents();", refresh, flags=re.M), \
|
assert "state.events.open" not in refresh and "eventsBox" not in refresh, \
|
||||||
"an ungated refreshEvents() call survived in the poll path"
|
"the fetch is gated on the disclosure again"
|
||||||
|
|
||||||
# …and the fetch stamps its own freshness, on the success path only: a failed fetch must
|
# The toggle handler's fetch logic is gone with it: the native `<details>` now gates only
|
||||||
# leave `painted`/`fetchedAt` alone so the next open re-fetches (invariant 4's shape).
|
# visibility, so no `toggle` listener may exist to decide whether to fetch.
|
||||||
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'"):]
|
boot = js[js.index("document.addEventListener('DOMContentLoaded'"):]
|
||||||
handler = boot[boot.index("const eventsBox = $('.events-box');"):boot.index("state.poller = poll(")]
|
assert "addEventListener('toggle'" not in boot, \
|
||||||
assert boot.index("const eventsBox =") < boot.index("state.poller = poll("), \
|
"a toggle handler is deciding whether to fetch again"
|
||||||
"the disclosure is wired after the first poll"
|
assert "eventsBox" not in boot, "the disclosure's fetch-on-expand handler came back"
|
||||||
assert "state.events.open = eventsBox.open;" in handler, \
|
|
||||||
"the toggle never learns whether the card is open"
|
# One fetch, two lists, split on `kind` — and the URL is the phase-19 one verbatim.
|
||||||
assert "if (!eventsBox.open) return;" in handler, \
|
feed = _event_feed_path(js)
|
||||||
"closing the card takes the fetch gate off"
|
assert _feed_fetch_url(js) == "/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=ups", \
|
||||||
# first expand fetches; a re-expand paints the cache while it is one poll interval old
|
"the feed's fetch is no longer the unchanged /api/events call"
|
||||||
assert "if (!state.events.painted || Date.now() - state.events.fetchedAt > state.pollMs) {" in handler, \
|
for vocabulary in ("kind=", "page=", "severity="):
|
||||||
"the expand lost its stale rule (one poll interval, the page's own cadence)"
|
assert vocabulary not in _feed_fetch_url(js), \
|
||||||
assert "refreshEvents();" in handler[handler.index("if (!state.events.painted"):], \
|
f"/api/events grew {vocabulary[:-1]} vocabulary for a page-side split"
|
||||||
"an open disclosure no longer fetches on a stale or empty cache"
|
assert "const power = data.events.filter((e) => POWER_EVENT_KINDS.has(e.kind));" in feed, \
|
||||||
|
"the power list is no longer the filter's positive arm"
|
||||||
|
assert "const errors = data.events.filter((e) => !POWER_EVENT_KINDS.has(e.kind));" in feed, \
|
||||||
|
"the error list is no longer the filter's default arm (an unknown kind must be visible)"
|
||||||
|
|
||||||
|
# Stale-last-good: the catch clause does *nothing*, so a poll that never answered leaves
|
||||||
|
# both `<ul>`s holding the rows they already had (the pager rewrites a list only when its
|
||||||
|
# markup changed — invariant 4's shape on the page side).
|
||||||
|
opened = "} catch (err) {"
|
||||||
|
at = feed.index(opened)
|
||||||
|
catch_body = feed[at + len(opened):feed.index("}", at + len(opened))].strip()
|
||||||
|
assert catch_body == "", \
|
||||||
|
f"the failure path does something ({catch_body!r}) instead of leaving the last good rows"
|
||||||
|
|
||||||
|
|
||||||
def test_the_ups_event_rows_keep_the_date_time_and_severity_shape(client) -> None:
|
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.
|
"""`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
|
Phase 19's disclosure and phase 26's split are the new parts; this is the half that had to
|
||||||
untouched. Every row still opens with a `<time>` carrying a month/day plus a 24-hour clock,
|
survive both untouched. Every row still opens with a `<time>` carrying a month/day plus a
|
||||||
then the UPS name inside the `sev <severity>` chip the stylesheet colours from the payload's
|
24-hour clock, then the UPS name inside the `sev <severity>` chip the stylesheet colours from
|
||||||
own verdict, then the message through `eventText()` (subject named once) with the runtime
|
the payload's own verdict, then the message through `eventText()` (subject named once) with
|
||||||
footnote only when the event actually carries one. Pinned on the served script because there
|
the runtime footnote only when the event actually carries one. Pinned on the served script
|
||||||
is no JS test runner (a locked anchor): a "tidy-up" of the row template that drops the
|
because there is no JS test runner (a locked anchor): a "tidy-up" of the row template that
|
||||||
timestamp, prints the raw message, or escapes nothing would ship silently otherwise.
|
drops the timestamp, prints the raw message, or escapes nothing would ship silently.
|
||||||
|
|
||||||
|
Since phase 26 task 02 the renderer is **one const read by both lists**, so the two halves
|
||||||
|
of one feed cannot grow two shapes — which is what this reads, rather than a `row:` option
|
||||||
|
that could differ per list.
|
||||||
"""
|
"""
|
||||||
js = _code(client.get("/static/page-ups.js").text)
|
js = _code(client.get("/static/page-ups.js").text)
|
||||||
feed = _event_feed_path(js)
|
feed = _event_feed_path(js)
|
||||||
at = feed.index("row: (event) =>")
|
at = js.index("const eventRow = (event) =>")
|
||||||
row = feed[at:feed.index("});", at)]
|
row = js[at:js.index("async function refreshEvents(", at)]
|
||||||
|
|
||||||
# the date/time: `<time>` first, month + day + hour + minute, 24-hour (the operator reads
|
# 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)
|
# an incident log in the order it happened, and "14:05" on its own is not that)
|
||||||
@@ -2190,11 +2281,97 @@ def test_the_ups_event_rows_keep_the_date_time_and_severity_shape(client) -> Non
|
|||||||
assert "duration(event.data.runtime_seconds)} runtime)" in row, \
|
assert "duration(event.data.runtime_seconds)} runtime)" in row, \
|
||||||
"the runtime footnote stopped printing the stored seconds as a duration"
|
"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
|
# and the renderer is still handed to the shared pager over the bounded list, labelled.
|
||||||
assert "Dash.eventsPager($('#events'), data.events, {" in feed, \
|
# Per **call**, not per file: `#events-errors` is a list of its own now, and the claim is
|
||||||
"the feed left the shared pager (no window, no page, no accessible name)"
|
# that it is drawn, named and emptied exactly like the power list — a count of `row:` in the
|
||||||
assert "label: 'Power events'" in feed, "the scroll container lost its accessible name"
|
# file would still pass if one call lost its renderer to a default.
|
||||||
assert "No power events yet" in feed, "the empty feed no longer says which feed is empty"
|
for selector, label, empty in (("#events", "Power events", "No power events yet"),
|
||||||
|
("#events-errors", "Connection & errors",
|
||||||
|
"No connection errors")):
|
||||||
|
call = _pager_call(feed, selector)
|
||||||
|
assert "row: eventRow," in call, f"the {selector} list is no longer drawn by the shared row"
|
||||||
|
assert f"label: '{label}'" in call, f"the {selector} list lost its accessible name"
|
||||||
|
assert empty in call, f"the {selector} list no longer says which feed is empty"
|
||||||
|
|
||||||
|
# one renderer, two lists: a template the two lists do not share would be two shapes for one
|
||||||
|
# event, and the row may not branch on which list it landed in — the only split is the kind set
|
||||||
|
assert js.count("const eventRow = (event) =>") == 1, \
|
||||||
|
"the /ups lists no longer share one row template"
|
||||||
|
assert "event.kind" not in row and "POWER_EVENT_KINDS" not in row, \
|
||||||
|
"the row template reads the kind — the two lists are growing two shapes"
|
||||||
|
|
||||||
|
# The card's visibility is the one state this page claims about a list it cannot see, it is
|
||||||
|
# decided by the **error** half alone, and only after both lists are painted (earlier would
|
||||||
|
# flash the `loading…` seed on a first fetch with no error rows). The markup's own `hidden`
|
||||||
|
# default is pinned in test_ups_power_events_feed_sits_behind_a_collapsed_disclosure.
|
||||||
|
hidden_at = feed.index("$('#ups-errors-card').hidden")
|
||||||
|
assert feed[:hidden_at].count("Dash.eventsPager(") == 2, \
|
||||||
|
"the card's visibility is decided before both lists are drawn"
|
||||||
|
assert feed[hidden_at:feed.index(";", hidden_at)].endswith("= errors.length === 0"), \
|
||||||
|
"the error card's visibility no longer follows the error half of the feed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_ups_connection_errors_are_a_card_of_their_own_above_the_disclosure(client) -> None:
|
||||||
|
"""Phase 26 task 02: the error half of the feed is a card, not a second list in a `<details>`.
|
||||||
|
|
||||||
|
`TODO.md:6` is that a `NUT server NutError: cannot reach upsd …` line is not something to
|
||||||
|
open a summary for. So the error rows get their own `section.card` **above** the Power
|
||||||
|
events disclosure, built from the vocabulary every other card on this page uses
|
||||||
|
(`.card > header`, `.card > .body`, `ul.events`, the pager's strip) — **no new stylesheet
|
||||||
|
selector**, because a second style for a second list is a second thing to keep in sync with
|
||||||
|
the bounded 320px scroll and the `1\u201350 of N` footer.
|
||||||
|
|
||||||
|
`hidden` is the resting state in the markup and the script keeps it true while the feed has
|
||||||
|
no non-power kinds: a permanent empty "Connection & errors" card is exactly the ink the
|
||||||
|
page's empty-state rules exist to avoid, and the first `unreachable` row is what shows it.
|
||||||
|
"""
|
||||||
|
html = _code(client.get("/ups").text)
|
||||||
|
|
||||||
|
card_at = html.index('<section class="card" id="ups-errors-card"')
|
||||||
|
card_end = html.index("</section>", card_at)
|
||||||
|
card = html[card_at:card_end]
|
||||||
|
opening = html[card_at:html.index(">", card_at)]
|
||||||
|
assert "hidden" in opening, "the error card ships visible with nothing in it"
|
||||||
|
assert '<ul class="events" id="events-errors"' in card, "the error card has no bounded feed"
|
||||||
|
assert "<h3>Connection & errors</h3>" in card, "the card lost its title"
|
||||||
|
# the whole point: this list is *not* behind a summary
|
||||||
|
assert "<details" not in card, "the error list is inside a disclosure again"
|
||||||
|
assert card_end < html.index('<details class="events-box">'), \
|
||||||
|
"the error card sits below the power-events disclosure it is meant to lead"
|
||||||
|
assert 'id="events"' in html[html.index('<details class="events-box">'):], \
|
||||||
|
"the power list left its disclosure"
|
||||||
|
|
||||||
|
js = _code(client.get("/static/page-ups.js").text)
|
||||||
|
feed = _event_feed_path(js)
|
||||||
|
assert "Dash.eventsPager($('#events-errors'), errors, {" in feed, \
|
||||||
|
"the error list is not rendered through the shared pager"
|
||||||
|
assert "label: 'Connection & errors'" in feed, "the error list has no accessible name"
|
||||||
|
assert "$('#ups-errors-card').hidden = errors.length === 0;" in feed, \
|
||||||
|
"the error card no longer appears and disappears with its rows"
|
||||||
|
# and the pager state is per `<ul>` (`app.js`: a `WeakMap` keyed by the list), so the two
|
||||||
|
# lists page independently: this is the contract that makes a second call site free
|
||||||
|
app_js = _code(client.get("/static/app.js").text)
|
||||||
|
assert "const feedPagers = new WeakMap();" in app_js
|
||||||
|
assert "feedPagers.get(list)" in app_js and "feedPagers.set(list, pager)" in app_js
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_ups_error_card_adds_no_stylesheet_selector(client) -> None:
|
||||||
|
"""The second feed is markup + the existing vocabulary, and `dash.css` must stay that way.
|
||||||
|
|
||||||
|
Pinned as the *absence* of a rule, because the temptation this writes back into is a
|
||||||
|
`#ups-errors-card` block restating the feed's scroll or the pager's strip: `ul.events {` and
|
||||||
|
`.events-pager {` are each declared exactly once in the sheet (the disclosure pin already
|
||||||
|
holds that), and the error card carries its own gap as an inline `margin-top` like every
|
||||||
|
other section on this page rather than through an id rule.
|
||||||
|
"""
|
||||||
|
css = _css(client)
|
||||||
|
for selector in ("#ups-errors-card", "#events-errors"):
|
||||||
|
assert selector not in css, f"dash.css grew a rule for {selector}"
|
||||||
|
assert css.count("ul.events {") == 1 and css.count(".events-pager {") == 1
|
||||||
|
|
||||||
|
html = client.get("/ups").text
|
||||||
|
assert 'id="ups-errors-card" style="margin-top:.85rem"' in html, \
|
||||||
|
"the error card no longer carries the page's own section gap inline"
|
||||||
|
|
||||||
|
|
||||||
def test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component(client) -> None:
|
def test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component(client) -> None:
|
||||||
@@ -2250,6 +2427,16 @@ def test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component(cli
|
|||||||
assert 'class="events-box"' in client.get("/ups").text, \
|
assert 'class="events-box"' in client.get("/ups").text, \
|
||||||
"the stylesheet styles a disclosure no page renders"
|
"the stylesheet styles a disclosure no page renders"
|
||||||
|
|
||||||
|
# Phase 26's guard, here because it is the disclosure's own claim: the *second* feed this
|
||||||
|
# page renders borrowed `section.card` + `ul.events` + `.events-pager` and nothing else, so
|
||||||
|
# this block may not have grown a rule for it either. A second style for a second list is a
|
||||||
|
# second copy of the bounded scroll and the pager strip to keep in sync — the phase-19
|
||||||
|
# anti-pattern, one task later. (`test_the_ups_error_card_adds_no_stylesheet_selector` holds
|
||||||
|
# the same guard for the card's own section, plus the inline-gap pin.)
|
||||||
|
for selector in ("#ups-errors-card", "#events-errors"):
|
||||||
|
assert selector not in block and selector not in css, \
|
||||||
|
f"dash.css styles the second feed through {selector} instead of the shared classes"
|
||||||
|
|
||||||
|
|
||||||
def test_the_pager_returns_to_page_1_when_the_events_change(client) -> None:
|
def test_the_pager_returns_to_page_1_when_the_events_change(client) -> None:
|
||||||
"""New events reset the window; the same events do not.
|
"""New events reset the window; the same events do not.
|
||||||
|
|||||||
Reference in New Issue
Block a user