diff --git a/.agents/phases/todo/08_events_paging_and_copy/00_phase.md b/.agents/phases/complete/08_events_paging_and_copy/00_phase.md
similarity index 100%
rename from .agents/phases/todo/08_events_paging_and_copy/00_phase.md
rename to .agents/phases/complete/08_events_paging_and_copy/00_phase.md
diff --git a/.agents/phases/todo/08_events_paging_and_copy/01_paginate_event_feeds.md b/.agents/phases/complete/08_events_paging_and_copy/01_paginate_event_feeds.md
similarity index 100%
rename from .agents/phases/todo/08_events_paging_and_copy/01_paginate_event_feeds.md
rename to .agents/phases/complete/08_events_paging_and_copy/01_paginate_event_feeds.md
diff --git a/.agents/phases/todo/08_events_paging_and_copy/02_strip_flavor_text.md b/.agents/phases/complete/08_events_paging_and_copy/02_strip_flavor_text.md
similarity index 100%
rename from .agents/phases/todo/08_events_paging_and_copy/02_strip_flavor_text.md
rename to .agents/phases/complete/08_events_paging_and_copy/02_strip_flavor_text.md
diff --git a/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__00_phase.a1.err b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__00_phase.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__00_phase.a1.md b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__00_phase.a1.md
new file mode 100644
index 0000000..6b4489c
--- /dev/null
+++ b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__00_phase.a1.md
@@ -0,0 +1,19 @@
+All green. Final report:
+
+**Verified (phase 08 — both task files already in `complete/`; this was the final verification pass)**
+- `Dash.eventsPager()` in `app.js` (50/page, `1–50 of N` strip, aria-labelled `‹ prev`/`next ›` disabled at the ends, `WeakMap` page state, reset only when the event *set* changes, `EVENT_FEED_LIMIT`=200) with three call sites; `ul.events` bounded to 320px + `overflow-y:auto`; `/api/events` untouched (`le=500`, no silent clamp).
+- Live browser against the real fleet: `/system` `1–50 of 121`, `/llm` `1–50 of 200`, `/ups` `1–50 of 200`; container 320px vs scrollHeight 1543/2368; page 2 → `51–100 of 200` and survived two polls; no empty `.sub`/`.faint` container visible on any page; footers carry only measurements (`rates over Δ11.9s · poll 10s`). Fresh artifacts: `data/logs/08-final-{system-head,llm-page,index-page,ups-feed-page2}.png` (32 phase screenshots already present).
+- Grep of live served bytes for all 13 deleted flavour fragments + `actions = -`: none survive in HTML or JS.
+- `AGENTS.md` carries both new UI-convention rules (flavour-text rule with the `TODO.md:7` cited examples; the event-feed-window rule) and the updated test count.
+
+**Tests / lint (full project 100% green)**
+- `bash .agents/validate.sh` → **EXIT=0**: `uv run pytest --cov --cov-fail-under=94` → **624 passed, 9 skipped, coverage 97.91%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` ×5 clean; repo hygiene clean.
+- `uv run pytest -m live --live` (`.env.dev` sourced) → **9 passed**; `uv pip list --outdated` → empty. Re-ran pytest/ruff/pyright/node after the doc edits → all exit 0.
+
+**Completion criteria**: bounded scroll + 50/page + count footer ✅ (all three feeds, live) · no removed flavour string ✅ · AGENTS.md rule ✅ · `validate.sh` green + screenshots in `data/logs/` ✅.
+
+**Documentation**: full README/AGENTS review against code (env table vs `config.py`/`.env*.example`, script flags, Containerfile uid 10001/8090, chart heights 120/110/160, test count, coverage) — one inaccuracy fixed: README said "Every page's event feed" (the overview has none) → now "The three event feeds (`/system`, `/llm`, `/ups`)"; also restored the missing trailing newline in `TODO.md`. Everything else verified consistent.
+
+**Notes**: paging stays client-side (no API change); the task-02 defect fix in `/ups` setup copy (`NUT_HOST`/`NUT_PORT`, `LISTEN 127.0.0.1`, one `instcmds` per command, never `actions = -`) re-verified against README and live bytes. Pre-existing items intentionally left to their own phases: `ups.test.result: "Done and passed"` rated `warn` (operator's `TODO.md` entry) and the dev-DB `database is locked` retention issue.
+
+**Next pending phase**: `.agents/phases/todo/09_disks_smartctl/`
diff --git a/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__00_phase.a1.md.status b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__00_phase.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__00_phase.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__00_phase.a1.validate b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__00_phase.a1.validate
new file mode 100644
index 0000000..36b642f
--- /dev/null
+++ b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__00_phase.a1.validate
@@ -0,0 +1,48 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 11%]
+........................................................................ [ 22%]
+........................................................................ [ 34%]
+........................................................................ [ 45%]
+...........................sssssssss.................................... [ 56%]
+........................................................................ [ 68%]
+........................................................................ [ 79%]
+........................................................................ [ 90%]
+......................................................... [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% 28
+src/dashboards/alerts.py 166 0 100%
+src/dashboards/api.py 193 0 100%
+src/dashboards/app.py 107 6 94% 50-51, 56, 58, 60, 127
+src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
+src/dashboards/config.py 113 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
+src/dashboards/rates.py 37 1 97% 51
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/host.py 199 1 99% 147
+src/dashboards/sources/llm.py 150 1 99% 228
+src/dashboards/sources/nut.py 389 20 95% 128, 142, 148, 160, 166, 178, 186, 199, 270-271, 273, 489, 501, 536, 569, 573, 630-631, 636, 648
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 178 2 99% 243-244
+------------------------------------------------------------------
+TOTAL 2158 45 98%
+Required test coverage of 94% reached. Total coverage: 97.91%
+624 passed, 9 skipped, 1 warning in 53.90s
+== 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 ==
diff --git a/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__01_paginate_event_feeds.a1.err b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__01_paginate_event_feeds.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__01_paginate_event_feeds.a1.md b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__01_paginate_event_feeds.a1.md
new file mode 100644
index 0000000..4e2b74c
--- /dev/null
+++ b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__01_paginate_event_feeds.a1.md
@@ -0,0 +1,14 @@
+**Implemented (task 01 — event feeds: scrollable and paginated)**
+- `Dash.eventsPager(el, events, opts)` in `app.js`: one shared pager — 50 rows/page, `1–50 of N` footer with `‹ prev` / `next ›` (aria-labelled for direction, `disabled` at the ends), strip hidden when the feed fits, page state in a `WeakMap` keyed by the `
`, rows re-written only when their markup changed (reader's scroll survives), reset to page 1 **only when the event set changes**. No default row renderer (a mis-wired feed says so rather than escaping nothing).
+- Three call sites (`page-system.js`, `page-llm.js`, `page-ups.js`) keep their own row markup (`Dash.eventText()`, `/ups`'s runtime footnote) and their own empty sentence; `loading…`/empty behaviour unchanged. `/api/events` untouched — no `page`/`limit` vocabulary grew.
+- `Dash.EVENT_FEED_LIMIT` = 200 (was 20/25/40 — *below* one page, so page 2 could never exist); `dash.css`: `ul.events` bounded to 320px + `overflow-y: auto` + `overscroll-behavior: contain`, `.events-pager` strip, `button.ghost:disabled`.
+- `tests/test_api.py`: 5 new served-byte pins (shared pager + no per-page pager, 50/page and fetch>page, footer count/aria/disabled, scrollable container + the three `id="events"`, page-1-reset rule); existing `source=system` pin widened for the templated URL.
+
+**Green** (`bash .agents/validate.sh` → EXIT=0): `uv run pytest --cov --cov-fail-under=94` → **609 passed, 9 skipped, coverage 97.91%** · `ruff check src tests agent` → All checks passed · `pyright` → 0 errors · `node --check` all 5 scripts · `.env.dev` + `uv run pytest -m live --live` → **9 passed** · dev server restarted, `/api/health` ok (6/6 sources).
+
+**Docs**: `AGENTS.md` → UI conventions gained the feed-window rule (shared pager, 320px scroll box, 50/page, fetch-limit-must-outgrow-a-page invariant, reset semantics, API unchanged); `README.md` → one sentence on the paged feeds in the page description.
+
+**Decisions/deviations**: the "fixture-events render check" ran as a throwaway node + DOM-stub harness in `/tmp` (29 assertions: 120 events → 50 rows, `1–50 of 120`, prev disabled on page 1, `51–100`, tail `101–120`, new events → page 1, empty/no-row-renderer states) and was **not** committed, because PLAN.md locks "JS tests: None added"; the same behaviour was then proven live. Browser DoD screenshots (`data/logs/events-pager-{system,llm,ups}-{page1,page2,full}.png`, `…-ups-page2-after-poll.png`): `/system` `1–50 of 101`, `/ups` `51–100 of 200` after clicking next, container 320px vs scrollHeight 2307, page 2 + scroll offset survive two 5s polls.
+**Pre-existing defect noticed, not fixed (out of scope)**: the dev DB (~480 MB, 60 MB WAL) had logged `OperationalError: database is locked (the poll succeeded; the store refused it)` 79× in the last 500 events; none since the restart — deserves its own retention/checkpoint phase.
+
+**Next pending task**: `.agents/phases/todo/08_events_paging_and_copy/02_strip_flavor_text.md`.
diff --git a/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__01_paginate_event_feeds.a1.md.status b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__01_paginate_event_feeds.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__01_paginate_event_feeds.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__01_paginate_event_feeds.a1.validate b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__01_paginate_event_feeds.a1.validate
new file mode 100644
index 0000000..0ffca70
--- /dev/null
+++ b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__01_paginate_event_feeds.a1.validate
@@ -0,0 +1,48 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 11%]
+........................................................................ [ 23%]
+........................................................................ [ 34%]
+........................................................................ [ 46%]
+............sssssssss................................................... [ 58%]
+........................................................................ [ 69%]
+........................................................................ [ 81%]
+........................................................................ [ 93%]
+.......................................... [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% 28
+src/dashboards/alerts.py 166 0 100%
+src/dashboards/api.py 193 0 100%
+src/dashboards/app.py 107 6 94% 50-51, 56, 58, 60, 127
+src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
+src/dashboards/config.py 113 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
+src/dashboards/rates.py 37 1 97% 51
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/host.py 199 1 99% 147
+src/dashboards/sources/llm.py 150 1 99% 228
+src/dashboards/sources/nut.py 389 20 95% 128, 142, 148, 160, 166, 178, 186, 199, 270-271, 273, 489, 501, 536, 569, 573, 630-631, 636, 648
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 178 2 99% 243-244
+------------------------------------------------------------------
+TOTAL 2158 45 98%
+Required test coverage of 94% reached. Total coverage: 97.91%
+609 passed, 9 skipped, 1 warning in 51.50s
+== 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 ==
diff --git a/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__02_strip_flavor_text.a1.err b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__02_strip_flavor_text.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__02_strip_flavor_text.a1.md b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__02_strip_flavor_text.a1.md
new file mode 100644
index 0000000..9a1bfd0
--- /dev/null
+++ b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__02_strip_flavor_text.a1.md
@@ -0,0 +1,20 @@
+Done. Everything is green.
+
+**Implemented (task 02 — strip the flavour text)**
+- `index.html`: deleted the footer sentence ("every number is scraped from the box it belongs to · `null` renders as `n/a`") + its spacer; trimmed the design-narrating second sentence out of the all-clear state.
+- `system.html`: deleted the *Per host* sub-line ("agent-reported values; rates are measured over the Δ window in the footer…") and the "numbers are measured, not inferred" footer narration; `#foot-note` (the measured `Δ…· poll …`) stays.
+- `llm.html` + `page-llm.js`: deleted the provenance-narrating footer line and the `· rates over the Δ window in each tag` half-sentence (the per-rate `provenance()` tags are untouched); dropped the "a server you do not run should not have a red panel" clause; trimmed `— an incident, not a stat` / `— mean, with the p95 under it` tooltip tails.
+- `ups.html` + `page-ups.js`/`page-index.js`: deleted "commands are the only write path in this app…" narration; **defect fix** — the empty state told operators to write `PORT 3493` and `actions = -` (AGENTS.md/README say never `actions = -`, and this upsd is on 3051), so it now names `NUT_HOST`/`NUT_PORT`, `LISTEN 127.0.0.1` and one `instcmds` line per command; the command banners now name `instcmds` too.
+- Removed the three event feeds' content captions (the kept empty-state sentences already say it); no empty `.sub`/`.faint` containers left, no dead CSS (`.pagefoot`/`.spacer`/`.sub` all still used).
+- Kept by the act-differently test and now pinned present: units, `Δ` provenance, `fleet.seams`, *sums only engines answering now*, queued-request note, SIMULATED banners, empty-state setup instructions, `Last good values` tooltip, metric-definition tooltips.
+
+**Tests / lint (full suite 100% green)**
+- `uv run pytest` → **624 passed, 9 skipped**; `uv run pytest --cov --cov-fail-under=94` → **97.91%**; `uv run ruff check src tests agent` → All checks passed; `uv run pyright` → 0 errors; `node --check` on all 5 scripts → clean; `uv run pytest -m live --live` (`.env.dev` sourced) → **9 passed**; `bash .agents/validate.sh` → **EXIT=0**.
+- New pins in `tests/test_api.py`: `test_no_page_narrates_its_own_pipeline` (13 deleted fragments absent from all 4 pages + all 5 assets), `test_pages_leave_no_empty_narration_container`, `test_the_copy_the_operator_acts_on_survives_the_audit` (functional copy present, `instcmds` named, `actions = -` gone).
+- Browser DoD against live data (`./scripts/dev-server.sh restart`, 6/6 sources ok): `data/logs/flavour-strip-{index,system,llm,ups}-{head,full,footer}.png`, `…-ups-feed.png`, `…-{index,ups}-empty.png` — page heads clean, feed still `1–50 of 200`, no empty containers, no console errors.
+
+**Docs**: `AGENTS.md` → UI conventions gained the **Flavour text is not helpful information** rule (act-differently test, the two TODO-quoted lines as cited examples, what survived and why, the test pins) and the test-count line; `README.md` → the `web/` bullet says pages carry no narration.
+
+**Decisions**: deleted each feed's header caption as duplicate narration (info preserved in the kept empty-state sentence); kept hover metric definitions (they define what a summed/lifetime number counts — the rule-2 family); fixed the `/ups` setup guidance defect inside this audit rather than leaving actively wrong copy. Not fixed (out of scope, already logged in `TODO.md` by the operator): `ups.test.result: "Done and passed"` classifies as `warn` in `sources/nut.py` — needs its own phase.
+
+**Next pending task**: none in phase 08 (this completes it); next is `.agents/phases/todo/09_disks_smartctl/01_agent_smartctl_collection.md`.
diff --git a/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__02_strip_flavor_text.a1.md.status b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__02_strip_flavor_text.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__02_strip_flavor_text.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__02_strip_flavor_text.a1.validate b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__02_strip_flavor_text.a1.validate
new file mode 100644
index 0000000..c15d487
--- /dev/null
+++ b/.agents/reports/08_events_paging_and_copy/08_events_paging_and_copy__02_strip_flavor_text.a1.validate
@@ -0,0 +1,48 @@
+== uv run pytest --cov (hermetic: no network, live tests skip) ==
+........................................................................ [ 11%]
+........................................................................ [ 22%]
+........................................................................ [ 34%]
+........................................................................ [ 45%]
+...........................sssssssss.................................... [ 56%]
+........................................................................ [ 68%]
+........................................................................ [ 79%]
+........................................................................ [ 90%]
+......................................................... [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% 28
+src/dashboards/alerts.py 166 0 100%
+src/dashboards/api.py 193 0 100%
+src/dashboards/app.py 107 6 94% 50-51, 56, 58, 60, 127
+src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120
+src/dashboards/config.py 113 0 100%
+src/dashboards/debug.py 14 0 100%
+src/dashboards/poller.py 165 3 98% 192-194, 279
+src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
+src/dashboards/rates.py 37 1 97% 51
+src/dashboards/severity.py 49 0 100%
+src/dashboards/sources/__init__.py 0 0 100%
+src/dashboards/sources/host.py 199 1 99% 147
+src/dashboards/sources/llm.py 150 1 99% 228
+src/dashboards/sources/nut.py 389 20 95% 128, 142, 148, 160, 166, 178, 186, 199, 270-271, 273, 489, 501, 536, 569, 573, 630-631, 636, 648
+src/dashboards/sources/nut_sim.py 134 0 100%
+src/dashboards/store.py 178 2 99% 243-244
+------------------------------------------------------------------
+TOTAL 2158 45 98%
+Required test coverage of 94% reached. Total coverage: 97.91%
+624 passed, 9 skipped, 1 warning in 59.18s
+== 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 ==
diff --git a/AGENTS.md b/AGENTS.md
index db3d8b6..85c5391 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -256,7 +256,7 @@ cp .env.dev.example .env.dev # dev-only: ssh tunnels + live targets
./scripts/dev-server.sh status # per-source ok/DOWN
./scripts/dev-server.sh logs
-uv run pytest # hermetic: 604 passed + 9 live-skipped, no network
+uv run pytest # hermetic: 624 passed + 9 live-skipped, no network
uv run pytest --cov # coverage gate (97.9%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
uv run ruff check src tests agent && uv run pyright
@@ -343,6 +343,25 @@ Window chips (5m…30d on `/system` and `/llm`, 1h…90d on `/ups`; the API acce
panel that cannot get data says why in the panel — the operator should never have to open
devtools to learn that a source is unconfigured. Escape anything upstream-provided through `DashCharts.escapeHtml`.
+**Flavour text is not helpful information.** A page does not narrate its own data pipeline or
+its own design; copy exists only where the operator needs it to act — a why-this-panel-is-empty
+sentence, a provenance tag, a measured `Δ` label, a chart's unit, a `SIMULATED` badge,
+an error that names what failed. The audit is one question per string: *would the operator act
+differently without it?* No → delete it, container and all. Deleted in phase 08 (the cited
+examples, from `TODO.md:7`): "every number is scraped from the box it belongs to · `null`
+renders as `n/a`" (`/`'s footer), "agent-reported values; rates are measured over the Δ window
+in the footer, and each panel's charts cover only that host" (`/system`'s *Per host* sub-line),
+and the same genre — `/system`'s "numbers are measured, not inferred", `/llm`'s "each throughput
+number carries its provenance", `/ups`'s "commands are the only write path in this app", each
+event feed's content caption (the empty-state sentence already says it), and the design
+clauses inside empty-state prose. What survived the audit did so because it is a measurement or
+a why: every `Δ` footnote, `provenance()` on each rate, `fleet.seams`, *sums only
+engines answering now*, the queued-request note under the slot grid, the SIMULATED banners, and
+the setup instructions in every empty state. `tests/test_api.py` pins the deleted sentences
+**absent** from every served page and script (a sentence that migrates to another page is the
+same slop), the kept functional strings **present**, and that no removed sub-line left an empty
+`.sub`/`.faint` container behind.
+
The overview is exactly two panels — **Warnings & errors** (`ul.alerts`, one row per
`/api/summary` alert: verdict class, source linked to its dashboard via the alert's own
`href`, message, age) and **Fleet**. A non-empty list floats the alerts panel above the
@@ -432,6 +451,27 @@ the UPS rows used to `replace()` it anywhere and mangled "Quick battery test sen
This is presentation only — the stored event and `/api/events` keep every word, and the raw
error string still surfaces in `/api/health`.
+An event feed is a **window**, not a printout (`Dash.eventsPager(el, events, opts)` in
+`app.js`, one helper with three call sites — a pager that works on one page is the bug this
+shape prevents). `ul.events` is a bounded 320px scroll container (`overscroll-behavior:
+contain`) and the pager puts `EVENT_PAGE_SIZE` = **50** events in it, with a `.events-pager`
+strip under the list: `1–50 of 200` between `‹ prev` and `next ›`, both `aria-label`led for
+direction (the feed is newest-first, so `prev` walks towards now) and `disabled` at the ends.
+The strip is `hidden` when the feed fits on one page — every row on screen *is* the whole
+story, and a disabled arrow says "more history" when there is none. Each page keeps its own
+`row(event)` (so `/ups` keeps its longer timestamp and runtime footnote, and escaping stays
+the page's job — the pager has no default renderer and says so in a row when a caller forgets
+one) and its own `empty` sentence, so the `loading…` and empty states read exactly as they
+did before paging existed. Two numbers are load-bearing and pinned together: the fetch
+(`EVENT_FEED_LIMIT` = 200, `/api/events?limit=…`) must exceed one page, because a limit at or
+below `EVENT_PAGE_SIZE` is a pager that silently never appears; `/api/events` itself is
+**unchanged** — no `page`/`limit` vocabulary grew for one page's layout. Paging state lives in
+a `WeakMap` keyed by the `
`: it survives a poll (so page 3 is still there two seconds
+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.
+
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).
Colour comes from the payload's `severity` dicts and nowhere else; a missing number or
diff --git a/README.md b/README.md
index 689b475..9d164b2 100644
--- a/README.md
+++ b/README.md
@@ -158,7 +158,9 @@ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ──
one per stale metric; the list dedupes per (source, metric) and is capped at 12 with a
marker that says how many it hid.
* `web/` — four static pages, one stylesheet, hand-rolled SVG charts (`charts.js`). No
- framework, no CDN, no bundler. The overview (`/`) is exactly two panels — a
+ framework, no CDN, no bundler, and no narration: a page never explains its own pipeline, so
+ the words on screen are the ones you act on — a unit, a `Δ10.0s` provenance tag, a
+ `SIMULATED` badge, or the sentence beside an empty panel. The overview (`/`) is exactly two panels — a
**Warnings & errors** list built from `/api/summary`'s `alerts` (each row links to the
dashboard that explains it) and the **Fleet** table — and nothing else: an empty alert
list reads as an explicit "all clear", and a live problem moves the panel above the
@@ -238,6 +240,13 @@ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ──
battery °C) kept below them in smaller muted type because they are what you read *while* the
UPS is on battery. Health checks, the battery-test buttons (with their `DANGER`
confirmation) and the NUT variable dump are unchanged, just second.
+ The three event feeds (`/system`, `/llm`, `/ups`) are a **paged window** over the `event`
+ table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a
+ page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath.
+ One helper, `Dash.eventsPager()`, renders every feed and each keeps its own row markup; the fetch
+ 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.
## Testing
diff --git a/TODO.md b/TODO.md
index 4640904..b47c328 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1 +1,3 @@
# TODO
+
+- [ ] "Last self-test: Done and passed" still shows up as a warning in the overview and UPS section
diff --git a/src/dashboards/web/pages/index.html b/src/dashboards/web/pages/index.html
index 6f464e1..e87ee38 100644
--- a/src/dashboards/web/pages/index.html
+++ b/src/dashboards/web/pages/index.html
@@ -45,8 +45,7 @@
All clear
-
Nothing is failing and no threshold is breached right now. Sources the operator
- never configured are not problems, so they are not here either.
+
Nothing is failing and no threshold is breached right now.
Set GUFO_BASE_URL (llama.cpp / gufo) and/or VLLM_BASE_URL.
- Either half appears only when its variable is set — a server you do not run
- should not have a red panel on your dashboard.
+
Set GUFO_BASE_URL (llama.cpp / gufo) and/or VLLM_BASE_URL.
@@ -134,15 +132,12 @@
-
Events
engine up / down
+
Events
loading…
diff --git a/src/dashboards/web/pages/system.html b/src/dashboards/web/pages/system.html
index e7b1ca0..9ca2bf3 100644
--- a/src/dashboards/web/pages/system.html
+++ b/src/dashboards/web/pages/system.html
@@ -41,9 +41,7 @@
chart is drawn inside one host panel below, and the question "which machine stands
out?" belongs to the overview's alerts and fleet table. -->
-
Per host
- agent-reported values; rates are measured over the Δ window in the
- footer, and each panel's charts cover only that host
The dashboard speaks the NUT protocol directly to upsd. On the NUT host:
-
/etc/ups/upsd.conf → LISTEN 0.0.0.0 · PORT 3493
-
/etc/ups/upsd.users → [dashboards] with
- password = … and actions = -
-
then systemctl restart nut-server and open the port to this server.
+
Set NUT_HOST / NUT_PORT to the upsd that serves
+ this LAN — its real port, which is not always the 3493 default.
+
/etc/ups/upsd.conf → LISTEN 127.0.0.1
+
/etc/ups/upsd.users → [dashboards] with password = …
+ and one instcmds = … line per command
+
then systemctl restart nut-server.
@@ -67,14 +68,11 @@
-
Power events
- state changes, self-test results, commands sent
+
Power events
loading…
diff --git a/src/dashboards/web/static/app.js b/src/dashboards/web/static/app.js
index b939098..e36ae9a 100644
--- a/src/dashboards/web/static/app.js
+++ b/src/dashboards/web/static/app.js
@@ -92,6 +92,170 @@ const Dash = (() => {
return stripped.trim() ? stripped : out;
}
+ // ---- event feeds --------------------------------------------------------
+ /**
+ * Rows per page in every event feed. The `event` table keeps transitions for as long as
+ * the fleet runs, and a feed that prints all of them is what made `/system` take a
+ * scroll to read: a dead host alone writes an event on every outage and recovery.
+ */
+ const EVENT_PAGE_SIZE = 50;
+
+ /**
+ * How many events a feed asks `/api/events` for.
+ *
+ * Paging is page-local — `/api/events` keeps its contract and grows no `page`/`limit`
+ * vocabulary for one page's layout — so the fetch has to ask for **more than one page**.
+ * A `limit` at or below `EVENT_PAGE_SIZE` is a pager that silently never appears: there
+ * is never a second page to reach. `tests/test_api.py` pins the relation.
+ */
+ const EVENT_FEED_LIMIT = 200;
+
+ /** Per-`
` pager state: the page being read, what was last drawn, the footer it owns. */
+ const feedPagers = new WeakMap();
+
+ /**
+ * The row a feed draws when its caller passed no `opts.row`.
+ *
+ * The pager has no default row renderer on purpose: escaping and choosing which fields of
+ * an event to print is the page's job. A feed wired up without one must not draw 50 empty
+ * rows, and must not draw a message nobody escaped.
+ */
+ function unroutedRow() {
+ return 'this feed cannot render its rows';
+ }
+
+ /** The footer's count: `1–50 of 137`. */
+ function pagerCount(first, last, total) {
+ return `${first}–${last} of ${total}`;
+ }
+
+ /**
+ * The strip under a feed — prev, `1–50 of N`, next — built once and patched after.
+ *
+ * It is a sibling of the `
` inside the card body, so the bounded list scrolls while the
+ * count and the controls stay put. Clicks are handled on the strip itself (not per button)
+ * so nothing is lost when the buttons are re-patched.
+ */
+ function ensurePagerFoot(list, pager) {
+ if (pager.foot && pager.foot.parentNode) return pager.foot;
+ const parent = list.parentNode;
+ if (!parent) return null;
+ let foot = list.nextElementSibling;
+ if (!foot || !foot.classList.contains('events-pager')) {
+ foot = document.createElement('div');
+ foot.className = 'events-pager';
+ foot.innerHTML = ''
+ + ''
+ + '';
+ parent.insertBefore(foot, list.nextSibling);
+ }
+ pager.foot = foot;
+ if (!pager.bound) {
+ pager.bound = true;
+ foot.addEventListener('click', (event) => {
+ const which = event.target && event.target.getAttribute('data-pager');
+ if (which !== 'prev' && which !== 'next') return;
+ const next = pager.page + (which === 'prev' ? -1 : 1);
+ if (next < 0 || next > pager.pages - 1) return;
+ pager.page = next;
+ paintEventFeed(list, pager);
+ });
+ }
+ return foot;
+ }
+
+ /** Draw the page `pager` is on, then patch its footer. */
+ function paintEventFeed(list, pager) {
+ const rows = pager.rows;
+ pager.pages = Math.max(1, Math.ceil(rows.length / pager.size));
+ pager.page = Math.min(Math.max(pager.page, 0), pager.pages - 1);
+ const start = pager.page * pager.size;
+ const visible = rows.slice(start, start + pager.size);
+ // Rows are rebuilt from the payload, as these feeds always did — but only when the
+ // markup actually changed. Rewriting 50 rows every poll resets the container's scroll
+ // position under the reader and re-announces the whole list to assistive tech for a
+ // difference of one row somewhere further down.
+ const markup = visible.map((event) => `
${pager.row(event)}
`).join('')
+ || pager.empty;
+ if (pager.markup !== markup) {
+ pager.markup = markup;
+ list.innerHTML = markup;
+ }
+ const foot = ensurePagerFoot(list, pager);
+ if (!foot) return;
+ // No controls for a feed that fits: every row on screen *is* the whole story, and a
+ // disabled pair of arrows next to "1–12 of 12" is ink that says nothing.
+ const paginating = rows.length > pager.size;
+ foot.hidden = !paginating;
+ if (!paginating) return;
+ setTxt($('[data-pager="count"]', foot), pagerCount(start + 1, start + visible.length, rows.length));
+ const prev = foot.querySelector('[data-pager="prev"]');
+ const next = foot.querySelector('[data-pager="next"]');
+ setTxt(prev, '‹ prev');
+ setTxt(next, 'next ›');
+ // The feed is newest-first, so "prev" walks towards *now*: say so in the name,
+ // because two arrows alone make nobody sure which way history goes.
+ prev.setAttribute('aria-label', 'Previous page (newer events)');
+ next.setAttribute('aria-label', 'Next page (older events)');
+ prev.disabled = pager.page === 0;
+ next.disabled = pager.page >= pager.pages - 1;
+ }
+
+ /**
+ * Render — or update in place — one page of an event feed.
+ *
+ * `eventsPager(el, events, opts)`
+ * - `el`: the feed's `
` (element or selector). `dash.css` bounds its
+ * height, so the **list** scrolls and the page stays short whatever the table holds;
+ * - `events`: the list from `/api/events`, newest first; nothing is sliced server-side;
+ * - `opts.row(event)` returns one row's *inner* HTML (the pager supplies the `
`) —
+ * each page keeps its own row markup and its own `eventText()` call, so the
+ * subject-said-once rule stays in one place and the `/ups` rows keep their runtime
+ * footnote and longer timestamp;
+ * - `opts.empty`: the sentence for a feed with no events (kept per page: "no host
+ * transitions" and "no power events" are different sentences). It hides the footer;
+ * - `opts.pageSize` overrides `EVENT_PAGE_SIZE`; `opts.label` names the scrollable
+ * container for assistive tech (a bounded container is a region you can get into).
+ *
+ * **A refresh carrying a different set of events resets to page 1** — an operator
+ * reading page 3 of yesterday's history must not land, silently, on page 3 of a list that
+ * just changed underneath them. When the set is unchanged the page is kept: resetting on
+ * every poll would snap `next` back within one interval and make page 2 unreadable.
+ */
+ function eventsPager(el, events, opts) {
+ const list = typeof el === 'string' ? $(el) : el;
+ if (!list) return null;
+ const options = opts || {};
+ let pager = feedPagers.get(list);
+ if (!pager) {
+ pager = { page: 0, pages: 1, rows: [], size: EVENT_PAGE_SIZE, markup: null, foot: null,
+ bound: false, signature: null, row: unroutedRow, empty: '' };
+ feedPagers.set(list, pager);
+ }
+ const rows = Array.isArray(events) ? events : [];
+ if (typeof options.row === 'function') pager.row = options.row;
+ if (options.empty != null) pager.empty = String(options.empty);
+ if (Number.isFinite(options.pageSize) && options.pageSize > 0) {
+ pager.size = Math.floor(options.pageSize);
+ }
+ if (typeof options.label === 'string' && options.label) {
+ list.setAttribute('aria-label', options.label);
+ list.setAttribute('tabindex', '0');
+ }
+ // New rows (or rows going away) change the signature; a poll that re-serves the same
+ // history does not, and that is the difference between "new events arrived" and "the
+ // timer fired".
+ const signature = `${rows.length}:${rows.length ? rows[0].ts : ''}`
+ + `:${rows.length ? rows[rows.length - 1].ts : ''}`;
+ if (signature !== pager.signature) {
+ pager.page = 0;
+ pager.signature = signature;
+ }
+ pager.rows = rows;
+ paintEventFeed(list, pager);
+ return list;
+ }
+
// ---- DOM helpers --------------------------------------------------------
/** Write text only when it changed: avoids killing transitions and focus. */
function setTxt(el, value) {
@@ -469,7 +633,7 @@ const Dash = (() => {
return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt,
setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindPause,
staleness, markStale, severityFor, severityFrom, hero, ageSeconds, naText,
- eventText };
+ eventText, eventsPager, EVENT_PAGE_SIZE, EVENT_FEED_LIMIT };
})();
document.addEventListener('DOMContentLoaded', () => Dash.initChrome());
diff --git a/src/dashboards/web/static/dash.css b/src/dashboards/web/static/dash.css
index a3afc90..ce036fe 100644
--- a/src/dashboards/web/static/dash.css
+++ b/src/dashboards/web/static/dash.css
@@ -160,6 +160,9 @@ button.ghost {
}
button.ghost:hover { border-color: var(--border-strong); color: var(--text); background: var(--bg-sunken); }
button.ghost[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
+/* The pager's ends: still legible, still unclickable (an arrow that does nothing at page 1
+ or the last page is a control that lies about there being more history). */
+button.ghost:disabled { opacity: .45; cursor: not-allowed; }
/* ---- layout ------------------------------------------------------------- */
main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; }
@@ -681,7 +684,14 @@ details.charts-box > summary .chip { text-transform: none; letter-spacing: .02em
.tooltip .row .val { margin-inline-start: auto; font-family: var(--mono); }
/* ---- lists -------------------------------------------------------------- */
-ul.events { list-style: none; margin: 0; padding: 0; display: grid; gap: .1rem; }
+/* An event feed is a **window** over the `event` table, not a printout of it: the list is
+ bounded and scrolls (same 320px as the scrollable data table), and `Dash.eventsPager()`
+ puts 50 events in it per page. A host that has been flapping for a month writes hundreds
+ of rows, and that history used to push the rest of `/system` off the screen. */
+ul.events {
+ list-style: none; margin: 0; padding: 0; display: grid; gap: .1rem;
+ max-height: 320px; overflow-y: auto; overscroll-behavior: contain;
+}
ul.events li {
display: grid; grid-template-columns: 62px 1fr; gap: .55rem; align-items: baseline;
padding: .3rem .1rem; border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
@@ -694,6 +704,16 @@ ul.events .sev.info { color: var(--text-muted); }
ul.events .sev.warn { color: var(--warn); }
ul.events .sev.error { color: var(--fail); }
+/* The pager strip under a feed: quiet ink, no box, the window chips' own `button.ghost`.
+ Hidden when the feed fits on one page — the `[hidden]` rule at the end of this sheet is
+ what beats the `display: flex` here, as it does for `.banner`. */
+.events-pager {
+ display: flex; align-items: center; justify-content: center; gap: .6rem;
+ margin-block-start: .45rem; padding-block-start: .4rem;
+ border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
+}
+.events-pager .faint { font-size: 11.5px; }
+
/* overview alerts: source (link to its dashboard) · message · age. The class on the row
is the server's verdict — `alerts.py` decided, the page only paints it. */
ul.alerts { list-style: none; margin: 0; padding: 0; display: grid; gap: .1rem; }
diff --git a/src/dashboards/web/static/page-index.js b/src/dashboards/web/static/page-index.js
index 010046e..fa675f0 100644
--- a/src/dashboards/web/static/page-index.js
+++ b/src/dashboards/web/static/page-index.js
@@ -34,7 +34,8 @@ async function loadConfig() {
// the simulator needs no credentials: asking for them there sends the operator fixing
// something that is not broken
banners.push(['info', 'UPS commands need credentials: set NUT_USER/' +
- 'NUT_PASSWORD with actions in upsd.users.']);
+ 'NUT_PASSWORD for a user with instcmds in '
+ + 'upsd.users.']);
}
if (config.ups.simulated) {
banners.push(['warn', 'SIMULATED UPS DATA.NUT_SIMULATE=1 is set: ' +
diff --git a/src/dashboards/web/static/page-llm.js b/src/dashboards/web/static/page-llm.js
index 646b7b1..0d18907 100644
--- a/src/dashboards/web/static/page-llm.js
+++ b/src/dashboards/web/static/page-llm.js
@@ -103,11 +103,11 @@ const STRIP_ROWS = [
{ key: 'spec_accept', label: 'spec accept', digits: 0, unit: '%', only: ['gufo'],
tip: 'Speculative-decoding draft tokens the model accepted.' },
{ key: 'queue_mean', label: 'queue time', digits: 0, unit: 'ms', p95: true, only: ['vllm'],
- tip: 'Time a request spends waiting for KV space — mean, with the p95 under it.' },
+ tip: 'Time a request spends waiting for KV space.' },
{ key: 'preemptions', label: 'Preemptions', alarm: true,
- tip: 'Running requests kicked back to the queue — an incident, not a stat.' },
+ tip: 'Running requests kicked back to the queue.' },
{ key: 'device_lost', label: 'GPU context lost', alarm: true,
- tip: 'The engine lost its GPU context — an incident, not a stat.' },
+ tip: 'The engine lost its GPU context.' },
];
/**
@@ -993,23 +993,23 @@ async function refresh() {
$('#sub').textContent = `${fleet.engines_live || 0}/${fleet.engines_total || 0} engines · ` +
`${fleet.requests_active == null ? 'n/a' : num(fleet.requests_active, 0)} active · ` +
`window ${duration(state.range)}`;
- $('#foot-note').textContent = `poll ${Math.round(state.pollMs / 1000)}s · rates over the ` +
- `Δ window in each tag`;
+ $('#foot-note').textContent = `poll ${Math.round(state.pollMs / 1000)}s`;
await refreshHistory(names);
}
async function refreshEvents() {
try {
- const data = await fetchJSON('/api/events?limit=20&source=llm');
- const list = $('#events');
- if (!(data.events || []).length) {
- list.innerHTML = '
`).join('');
+ // The same bounded, paginated window as the other two feeds (`Dash.eventsPager`): an
+ // engine that has been restarting all week must not push the panels off the page.
+ const data = await fetchJSON(`/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=llm`);
+ Dash.eventsPager($('#events'), data.events, {
+ label: 'Engine events',
+ empty: '
No engine transitions yet.
',
+ row: (event) => `${DashCharts.escapeHtml(event.host)} ` +
+ `${DashCharts.escapeHtml(eventText(event))}`,
+ });
} catch (err) { /* next poll */ }
}
diff --git a/src/dashboards/web/static/page-system.js b/src/dashboards/web/static/page-system.js
index 239089f..60883ed 100644
--- a/src/dashboards/web/static/page-system.js
+++ b/src/dashboards/web/static/page-system.js
@@ -569,17 +569,18 @@ async function refreshEvents() {
// `system`, not `host`: reachability/reboot events are logged under the *dashboard* name
// (`poller.py` writes `Event(ts, self.dashboard, …)`; samples use `host`, events do not).
// Querying `source=host` matched nothing and left this feed forever empty.
- const data = await fetchJSON('/api/events?limit=25&source=system');
- const list = $('#events');
- if (!(data.events || []).length) {
- list.innerHTML = '
No host transitions ' +
- 'recorded yet — this log fills when a host goes away, comes back, or reboots.
`).join('');
+ // 50 rows a page (`Dash.eventsPager`), and the list itself scrolls: a host that has been
+ // flapping for a month has a long history and this page has other panels to show.
+ const data = await fetchJSON(`/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=system`);
+ Dash.eventsPager($('#events'), data.events, {
+ label: 'Host events',
+ empty: '
No host transitions '
+ + 'recorded yet — this log fills when a host goes away, comes back, or reboots.
',
+ row: (event) => `${DashCharts.escapeHtml(event.host)} ` +
+ `${DashCharts.escapeHtml(Dash.eventText(event))}`,
+ });
} catch (err) { /* the next poll will try again */ }
}
diff --git a/src/dashboards/web/static/page-ups.js b/src/dashboards/web/static/page-ups.js
index 45ad1aa..b0c1c92 100644
--- a/src/dashboards/web/static/page-ups.js
+++ b/src/dashboards/web/static/page-ups.js
@@ -404,7 +404,7 @@ async function refresh() {
}
if (!snapshot.actions_enabled) {
banners.push(['info', 'Command buttons are disabled. Set NUT_ALLOW_ACTIONS=1 plus ' +
- 'NUT_USER/NUT_PASSWORD for a user with actions in ' +
+ 'NUT_USER/NUT_PASSWORD for a user with instcmds in ' +
'upsd.users.']);
}
if (stateRow && !stateRow.ok && stateRow.error) {
@@ -429,20 +429,22 @@ async function refresh() {
async function refreshEvents() {
try {
- const data = await fetchJSON('/api/events?limit=40&source=ups');
- const list = $('#events');
- if (!(data.events || []).length) {
- list.innerHTML = '
No power events yet — ' +
- 'state changes, self-test results, and commands you send all land here.
`).join('');
+ // Battery history is the longest of the three feeds (every charge / discharge transition
+ // lands here), so this is the page that needs the bounded window most: 50 rows a page
+ // inside a scrolling list, with the count and the arrows under it (`Dash.eventsPager`).
+ const data = await fetchJSON(`/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=ups`);
+ Dash.eventsPager($('#events'), data.events, {
+ label: 'Power events',
+ empty: '
No power events yet — '
+ + 'state changes, self-test results, and commands you send all land here.
',
+ row: (event) => `${DashCharts.escapeHtml(event.host)} ` +
+ `${DashCharts.escapeHtml(eventText(event))}` +
+ `${event.data && event.data.runtime_seconds ? ` (${
+ duration(event.data.runtime_seconds)} runtime)` : ''}`,
+ });
} catch (err) { /* next poll */ }
}
diff --git a/tests/test_api.py b/tests/test_api.py
index 9cdae5a..ce550a8 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -922,6 +922,132 @@ def test_every_system_chart_names_itself_for_assistive_tech(client) -> None:
f"the {slot} chart has no host-scoped accessible name"
+# ---- event feeds: a bounded, paginated window ----------------------------- #
+#
+# The three `ul.events` feeds are one component (`Dash.eventsPager`) because a pager that
+# works on only one page is the bug this shape prevents: the `event` table grows for as long
+# as the fleet runs, and an unbounded feed turns `/system` into a scroll to nowhere
+# (`TODO.md` L6 — "All the events tables need to be scrollable and paginated"). There is no
+# JS test runner (a locked anchor), so what is pinned here is the shape every feed must keep:
+# one shared renderer, a fetch bigger than one page, a footer that counts and labels itself,
+# and a container that scrolls instead of growing the page.
+
+# page -> (its script, the `source=` the poller stores that page's events under)
+EVENT_FEEDS = {
+ "/system": ("page-system.js", "system"),
+ "/llm": ("page-llm.js", "llm"),
+ "/ups": ("page-ups.js", "ups"),
+}
+
+
+def _event_feed_path(js: str) -> str:
+ """Just the `refreshEvents()` half of a page script — the feed's whole code path, comments out."""
+ js = _code(js)
+ return js[js.index("async function refreshEvents("):]
+
+
+def _feed_fetch_url(js: str) -> str:
+ """The `/api/events` URL a feed asks for, quoted or templated."""
+ found = re.search(r"fetchJSON\([`'](/api/events[^`']*)[`']\)", _event_feed_path(js))
+ assert found, "this page no longer fetches the event feed"
+ return found.group(1)
+
+
+def test_the_event_feeds_are_one_shared_pager_not_three(client) -> None:
+ """`Dash.eventsPager()` renders all three feeds, and no page grows its own pager.
+
+ Three copies of a pager is three chances for one page to keep the old behaviour — and
+ `/system` is the one whose feed was already too tall when this was written down.
+ """
+ app_js = _code(client.get("/static/app.js").text)
+ assert app_js.count("function eventsPager(") == 1
+ exports = client.get("/static/app.js").text.rsplit("return {", 1)[1].split("}", 1)[0]
+ assert "eventsPager" in exports, "Dash.eventsPager is not exported"
+
+ for path, (script, _) in EVENT_FEEDS.items():
+ js = _code(client.get(f"/static/{script}").text)
+ assert "Dash.eventsPager($('#events')" in js, f"{path} does not render its feed through the pager"
+ assert "function eventsPager(" not in js, f"{path} invented its own pager"
+ assert "events.slice(" not in js, f"{path} windows its own events instead of asking the pager"
+ assert "row:" in _event_feed_path(js), f"{path} passes no row renderer"
+
+
+def test_the_pager_pages_50_events_and_fetches_more_than_one_page(client) -> None:
+ """The two numbers that make a client-side pager work, and the relation between them.
+
+ `EVENT_PAGE_SIZE` is the 50 the operator asked for; `EVENT_FEED_LIMIT` is what the feed
+ asks `/api/events` for. A limit at or below the page size is a pager that silently never
+ shows — there is never a second page to reach — and the feeds used to fetch 20/25/40.
+ """
+ app_js = client.get("/static/app.js").text
+ page_size = re.search(r"const EVENT_PAGE_SIZE = (\d+);", app_js)
+ feed_limit = re.search(r"const EVENT_FEED_LIMIT = (\d+);", app_js)
+ assert page_size and int(page_size.group(1)) == 50, "a feed no longer pages 50 events"
+ assert feed_limit, "the feeds' event limit is no longer a named constant"
+ assert int(feed_limit.group(1)) > int(page_size.group(1)), \
+ "a fetch at or below one page means page 2 can never exist"
+
+ for path, (script, source) in EVENT_FEEDS.items():
+ url = _feed_fetch_url(client.get(f"/static/{script}").text)
+ assert "EVENT_FEED_LIMIT" in url, f"{path} hardcodes its own event limit"
+ assert f"source={source}" in url, f"{path} asks for the wrong event source"
+
+
+def test_the_pager_footer_counts_and_labels_its_controls(client) -> None:
+ """The footer counts (`1\u201350 of 137`), and prev/next are labelled and disabled at the ends.
+
+ The count is what tells the operator the window is a window; a control that stays live at
+ the last page claims there is more history than there is.
+ """
+ app_js = _code(client.get("/static/app.js").text)
+ assert "${first}\u2013${last} of ${total}" in app_js, "the footer no longer counts"
+ assert 'data-pager="prev"' in app_js and 'data-pager="next"' in app_js
+ assert "prev.setAttribute('aria-label'" in app_js
+ assert "next.setAttribute('aria-label'" in app_js
+ assert "prev.disabled = pager.page === 0" in app_js
+ assert "next.disabled = pager.page >= pager.pages - 1" in app_js
+ # pagination only applies when there is more than one page of data
+ assert "foot.hidden = !paginating" in app_js
+
+ css = client.get("/static/dash.css").text
+ assert ".events-pager {" in css, "the pager footer has no style"
+ assert "button.ghost:disabled" in css, "a disabled pager control looks live"
+
+
+def test_the_event_feeds_scroll_instead_of_growing_the_page(client) -> None:
+ """The `
` is the scrollable element, bounded in `dash.css`; the page stays short.
+
+ Pinned on the served bytes because there is no JS test runner (a locked anchor): a
+ stylesheet edit that drops `max-height` is invisible until the feed is long, and by then
+ the page is massive again.
+ """
+ css = client.get("/static/dash.css").text
+ block = css[css.index("ul.events {"):css.index("}", css.index("ul.events {"))]
+ assert "max-height" in block and "overflow-y: auto" in block, \
+ "the event feed is unbounded again — the page grows with history"
+
+ for path in EVENT_FEEDS:
+ html = client.get(path).text
+ assert '
None:
+ """New events reset the window; the same events do not.
+
+ Resetting on every poll would snap `next` back within one interval, so page 2 could never
+ be read; never resetting lands an operator on page 3 of a list that changed underneath
+ them. The page key is the set of events, not the clock.
+ """
+ app_js = _code(client.get("/static/app.js").text)
+ body = app_js[app_js.index("function eventsPager("):]
+ assert "signature !== pager.signature" in body, "the pager no longer notices new events"
+ reset = body[body.index("signature !== pager.signature"):]
+ assert reset.index("pager.page = 0") < reset.index("paintEventFeed"), \
+ "new events do not reset the page before the feed is drawn"
+ assert "if (pager.markup !== markup)" in app_js, \
+ "every poll rewrites 50 rows and throws away the reader's scroll position"
+
+
def test_system_event_feed_queries_the_source_transitions_are_stored_under(client) -> None:
"""Host transitions are stored under the *dashboard* name, not the sample source.
@@ -932,8 +1058,8 @@ def test_system_event_feed_queries_the_source_transitions_are_stored_under(clien
the served bytes because there is no JS test runner (a locked anchor).
"""
js = client.get("/static/page-system.js").text
- feed = js[js.index("async function refreshEvents("):]
- url = re.search(r"fetchJSON\('(/api/events[^']*)'\)", feed)
+ feed = _event_feed_path(js)
+ url = re.search(r"fetchJSON\([`'](/api/events[^`']*)[`']\)", feed)
assert url, "the /system page no longer fetches the event feed"
assert "source=system" in url.group(1), \
"the /system event feed must ask for the source the poller stores events under"
@@ -2198,6 +2324,102 @@ def test_no_page_carries_an_id_nothing_reads(client) -> None:
assert quoted, f"{path} has id=\"{element_id}\" that nothing reads"
+# ---- the flavour-text audit (phase 08 task 02) ----------------------------
+#
+# `TODO.md:7`: "Get rid of most of the flavor text and add a note to AGENTS.md to not add it
+# unless necessary. It just comes off as LLM generated slop, not helpful information." The
+# test is one question per string — *would the operator act differently without it?* — and
+# the answer decided the deletion. Copy that explains how the app builds its numbers is not
+# helpful information; a provenance tag, a measured Δ window, and the sentence beside an
+# empty panel are. `AGENTS.md` → *UI conventions* now carries the rule; the pins below are
+# what stop the deleted sentences crawling back, because a screenshot only shows them once
+# somebody already knows to look.
+
+#: Fragments deleted from the served pages. The first two are the lines the operator quoted.
+DELETED_FLAVOUR = (
+ # the overview's footer
+ "every number is scraped from the box",
+ "Sources the operator never configured are not problems",
+ # /system's "Per host" sub-line and its footer
+ "agent-reported values",
+ "each panel's charts cover only that host",
+ "numbers are measured, not inferred",
+ # /llm's footer, the one half-sentence of it that lived in the script too
+ "each throughput number carries its provenance",
+ "rates over the Δ window in each tag",
+ "a server you do not run",
+ # /ups's footer and its empty state
+ "commands are the only write path in this app",
+ "The dashboard speaks the NUT protocol",
+ # the three event feeds' header captions, all duplicated by an empty-state sentence
+ "unreachable / reachable / reboots",
+ "engine up / down",
+ "state changes, self-test results, commands sent",
+)
+
+
+@pytest.mark.parametrize("fragment", DELETED_FLAVOUR)
+def test_no_page_narrates_its_own_pipeline(client, fragment: str) -> None:
+ """The deleted copy is gone from every served byte, markup and script alike.
+
+ Asserted across all four pages and all five scripts rather than the file it came from:
+ the same sentence migrating into another page's card is the same slop, and a pin that
+ only guards its original file would let that through.
+ """
+ for path in ("/", "/system", "/llm", "/ups", *ASSETS):
+ body = client.get(path).text
+ assert fragment not in body, f"{path} narrates the app again: {fragment!r}"
+
+
+def test_pages_leave_no_empty_narration_container(client) -> None:
+ """A deleted sub-line removes its container, not just its words.
+
+ An empty `` is markup that reserves a line for nothing — the page head
+ renders a gap where the sentence used to be. Elements the scripts fill (`#sub`,
+ `#alerts-count`) carry an `id` and are not in this category.
+ """
+ for path in ("/", "/system", "/llm", "/ups"):
+ html = _code(client.get(path).text)
+ for cls in ("sub", "faint", "mono"):
+ assert f'' not in html, \
+ f"{path} keeps an empty .{cls} container"
+
+
+def test_the_copy_the_operator_acts_on_survives_the_audit(client) -> None:
+ """The other half of the deletion: functional copy stays.
+
+ Stripping flavour must not become stripping information. Every string here answers a
+ question the operator is asking while looking at the page — why is this panel empty,
+ is this number a measurement, is this battery real, is this host's data last-good —
+ and each one is the reason the audit deletes sentences and not labels.
+ """
+ pages = {path: client.get(path).text for path in ("/", "/system", "/llm", "/ups")}
+ scripts = {name: client.get(f"/static/{name}").text for name in PAGE_SCRIPTS.values()}
+
+ # an empty panel says why, in terms the operator can act on
+ assert "No hosts configured" in pages["/"]
+ assert "HOST_TARGETS" in pages["/system"]
+ assert "No engines configured" in pages["/llm"]
+ assert "No UPS reporting" in pages["/ups"]
+ assert "instcmds" in pages["/ups"], "the UPS setup note stopped naming the grant that matters"
+ assert "3493" in pages["/ups"], "the page stopped warning that upsd's port is not always 3493"
+ # …and the setup copy names the grant NUT actually checks. `README.md` is explicit: a
+ # dashboard user gets `instcmds`, never `actions = -`, so a page that told the operator
+ # to write that was sending them to fix the wrong line in `upsd.users`.
+ assert "actions = -" not in pages["/ups"] + scripts["page-ups.js"] + scripts["page-index.js"]
+ assert "instcmds" in scripts["page-ups.js"] and "instcmds" in scripts["page-index.js"]
+
+ # a Δ label is a measurement, and the rate's provenance travels with the rate
+ assert "windowLabel(" in scripts["page-system.js"]
+ assert "Δ" in scripts["page-llm.js"] and "function provenance(" in scripts["page-llm.js"]
+ assert "at the current load" in scripts["page-ups.js"]
+
+ # modelled data is labelled, and so is a feed that cannot contain what you are hunting
+ assert "SIMULATED" in scripts["page-index.js"] and "SIMULATED" in scripts["page-ups.js"]
+ assert "queued (deferred) requests are not in /slots" in scripts["page-llm.js"]
+ assert "Last good values" in scripts["page-index.js"]
+
+
def test_api_responses_are_not_cacheable(client) -> None:
for path in ("/api/config", "/api/health", "/api/summary"):
assert "no-store" in client.get(path).headers.get("cache-control", "")