phase: 08_events_paging_and_copy

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/`
This commit is contained in:
2026-10-17 19:32:36 -04:00
parent 3d5ccd7a39
commit 19cfedfddf
29 changed files with 721 additions and 74 deletions
@@ -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/`
@@ -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 ==
@@ -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 `<ul>`, 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`.
@@ -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 ==
@@ -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`.
@@ -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 ==
+41 -1
View File
@@ -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 `Δ<window>` 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 `Δ<window>` 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 `<ul>`: 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
+10 -1
View File
@@ -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
+2
View File
@@ -1 +1,3 @@
# TODO
- [ ] "Last self-test: Done and passed" still shows up as a warning in the overview and UPS section
+1 -4
View File
@@ -45,8 +45,7 @@
<ul class="alerts" id="alerts" aria-live="polite"></ul>
<div class="empty-state" id="alerts-clear" hidden>
<h3>All clear</h3>
<p>Nothing is failing and no threshold is breached right now. Sources the operator
never configured are not problems, so they are not here either.</p>
<p>Nothing is failing and no threshold is breached right now.</p>
</div>
</div>
</section>
@@ -69,8 +68,6 @@
<footer class="pagefoot">
<span id="foot-versions"></span>
<span class="spacer" style="margin-inline-start:auto"></span>
<span>every number is scraped from the box it belongs to · <code>null</code> renders as <code>n/a</code></span>
</footer>
</main>
+2 -7
View File
@@ -105,9 +105,7 @@
<div class="grid cols-2 items-start" id="engines"></div>
<div class="empty-state" id="engines-empty" hidden>
<h3>No engines configured</h3>
<p>Set <code>GUFO_BASE_URL</code> (llama.cpp / gufo) and/or <code>VLLM_BASE_URL</code>.
Either half appears only when its variable is set — a server you do not run
should not have a red panel on your dashboard.</p>
<p>Set <code>GUFO_BASE_URL</code> (llama.cpp / gufo) and/or <code>VLLM_BASE_URL</code>.</p>
</div>
</section>
@@ -134,15 +132,12 @@
<section style="margin-top:.85rem">
<article class="card">
<header><h3>Events</h3><span class="spacer"></span><span class="faint">engine up / down</span></header>
<header><h3>Events</h3></header>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</article>
</section>
<footer class="pagefoot">
<span>each throughput number carries its provenance: scraped gauge, <span class="mono">Δ10s</span>
measured counter, or lifetime estimate</span>
<span class="spacer" style="margin-inline-start:auto"></span>
<span class="mono" id="foot-note"></span>
</footer>
</main>
+2 -6
View File
@@ -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. -->
<section>
<div class="page-head"><h2 style="font-size:.95rem;margin:0">Per host</h2>
<span class="sub">agent-reported values; rates are measured over the Δ window in the
footer, and each panel's charts cover only that host</span></div>
<div class="page-head"><h2 style="font-size:.95rem;margin:0">Per host</h2></div>
<!-- `items-start`: an expanded chart stack must not stretch its collapsed siblings into
blank cards (the same fix `/llm` needed for uneven engine panels). -->
<div class="grid auto items-start" id="hosts"></div>
@@ -56,13 +54,11 @@
</section>
<section class="card" style="margin-top:.85rem">
<header><h3>Events</h3><span class="spacer"></span><span class="faint">unreachable / reachable / reboots</span></header>
<header><h3>Events</h3></header>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</section>
<footer class="pagefoot">
<span>numbers are measured, not inferred: rates are Δcounter/Δt over the window below</span>
<span class="spacer" style="margin-inline-start:auto"></span>
<span class="mono" id="foot-note"></span>
</footer>
</main>
+7 -9
View File
@@ -39,11 +39,12 @@
<section class="grid auto" id="ups-cards"></section>
<div class="empty-state" id="ups-empty" hidden>
<h3>No UPS reporting</h3>
<p>The dashboard speaks the NUT protocol directly to <code>upsd</code>. On the NUT host:</p>
<p class="mono">/etc/ups/upsd.conf → <code>LISTEN 0.0.0.0</code> · <code>PORT 3493</code></p>
<p class="mono">/etc/ups/upsd.users → <code>[dashboards]</code> with
<code>password = …</code> and <code>actions = -</code></p>
<p>then <code>systemctl restart nut-server</code> and open the port to this server.</p>
<p>Set <code>NUT_HOST</code> / <code>NUT_PORT</code> to the <code>upsd</code> that serves
this LAN — its real port, which is not always the <code>3493</code> default.</p>
<p class="mono">/etc/ups/upsd.conf → <code>LISTEN 127.0.0.1</code></p>
<p class="mono">/etc/ups/upsd.users → <code>[dashboards]</code> with <code>password = …</code>
and one <code>instcmds = …</code> line per command</p>
<p>then <code>systemctl restart nut-server</code>.</p>
</div>
<section class="grid cols-2" style="margin-top:.85rem" aria-label="Battery charts">
@@ -67,14 +68,11 @@
</section>
<section class="card" style="margin-top:.85rem">
<header><h3>Power events</h3><span class="spacer"></span>
<span class="faint">state changes, self-test results, commands sent</span></header>
<header><h3>Power events</h3></header>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</section>
<footer class="pagefoot">
<span>commands are the only write path in this app, and every one of them is confirmed here first</span>
<span class="spacer" style="margin-inline-start:auto"></span>
<span class="mono" id="foot-note"></span>
</footer>
</main>
+165 -1
View File
@@ -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-`<ul>` 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 '<span class="muted">this feed cannot render its rows</span>';
}
/** 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 `<ul>` 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 = '<button class="ghost" type="button" data-pager="prev"></button>'
+ '<span class="faint mono" data-pager="count"></span>'
+ '<button class="ghost" type="button" data-pager="next"></button>';
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) => `<li>${pager.row(event)}</li>`).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 `<ul class="events">` (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 `<li>`) —
* 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());
+21 -1
View File
@@ -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; }
+2 -1
View File
@@ -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 <code>NUT_USER</code>/' +
'<code>NUT_PASSWORD</code> with <code>actions</code> in <code>upsd.users</code>.']);
'<code>NUT_PASSWORD</code> for a user with <code>instcmds</code> in '
+ '<code>upsd.users</code>.']);
}
if (config.ups.simulated) {
banners.push(['warn', '<strong>SIMULATED UPS DATA.</strong> <code>NUT_SIMULATE=1</code> is set: ' +
+15 -15
View File
@@ -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 = '<li class="muted" style="grid-template-columns:1fr">No engine transitions yet.</li>';
return;
}
list.innerHTML = data.events.map((event) => `<li><time>${
new Date(event.ts * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
}</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` +
`${DashCharts.escapeHtml(eventText(event))}</span></li>`).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: '<li class="muted" style="grid-template-columns:1fr">No engine transitions yet.</li>',
row: (event) => `<time>${
new Date(event.ts * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
}</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` +
`${DashCharts.escapeHtml(eventText(event))}</span>`,
});
} catch (err) { /* next poll */ }
}
+12 -11
View File
@@ -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 = '<li class="muted" style="grid-template-columns:1fr">No host transitions ' +
'recorded yet — this log fills when a host goes away, comes back, or reboots.</li>';
return;
}
list.innerHTML = data.events.map((event) => `<li><time>${
new Date(event.ts * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
}</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` +
`${DashCharts.escapeHtml(Dash.eventText(event))}</span></li>`).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: '<li class="muted" style="grid-template-columns:1fr">No host transitions '
+ 'recorded yet — this log fills when a host goes away, comes back, or reboots.</li>',
row: (event) => `<time>${
new Date(event.ts * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
}</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` +
`${DashCharts.escapeHtml(Dash.eventText(event))}</span>`,
});
} catch (err) { /* the next poll will try again */ }
}
+17 -15
View File
@@ -404,7 +404,7 @@ async function refresh() {
}
if (!snapshot.actions_enabled) {
banners.push(['info', 'Command buttons are disabled. Set <code>NUT_ALLOW_ACTIONS=1</code> plus ' +
'<code>NUT_USER</code>/<code>NUT_PASSWORD</code> for a user with <code>actions</code> in ' +
'<code>NUT_USER</code>/<code>NUT_PASSWORD</code> for a user with <code>instcmds</code> in ' +
'<code>upsd.users</code>.']);
}
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 = '<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>';
return;
}
list.innerHTML = data.events.map((event) => `<li><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></li>`).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: '<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>',
row: (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>`,
});
} catch (err) { /* next poll */ }
}
+224 -2
View File
@@ -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 `<ul>` 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 '<ul class="events" id="events"' in html, f"{path} lost its feed's id"
def test_the_pager_returns_to_page_1_when_the_events_change(client) -> 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 `<span class="sub">` 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'<span class="{cls}"></span>' 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", "")