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 status # per-source ok/DOWN
./scripts/dev-server.sh logs ./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 --cov # coverage gate (97.9%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd uv run pytest -m live --live # opt-in: real fleet / engines / upsd
uv run ruff check src tests agent && uv run pyright 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 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`. 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 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 `/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 `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 This is presentation only — the stored event and `/api/events` keep every word, and the raw
error string still surfaces in `/api/health`. error string still surfaces in `/api/health`.
An event feed is a **window**, not a printout (`Dash.eventsPager(el, events, opts)` in
`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 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). `.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 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 one per stale metric; the list dedupes per (source, metric) and is capped at 12 with a
marker that says how many it hid. marker that says how many it hid.
* `web/` — four static pages, one stylesheet, hand-rolled SVG charts (`charts.js`). No * `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 **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 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 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 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` UPS is on battery. Health checks, the battery-test buttons (with their `DANGER`
confirmation) and the NUT variable dump are unchanged, just second. 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 ## Testing
+2
View File
@@ -1 +1,3 @@
# TODO # 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> <ul class="alerts" id="alerts" aria-live="polite"></ul>
<div class="empty-state" id="alerts-clear" hidden> <div class="empty-state" id="alerts-clear" hidden>
<h3>All clear</h3> <h3>All clear</h3>
<p>Nothing is failing and no threshold is breached right now. Sources the operator <p>Nothing is failing and no threshold is breached right now.</p>
never configured are not problems, so they are not here either.</p>
</div> </div>
</div> </div>
</section> </section>
@@ -69,8 +68,6 @@
<footer class="pagefoot"> <footer class="pagefoot">
<span id="foot-versions"></span> <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> </footer>
</main> </main>
+2 -7
View File
@@ -105,9 +105,7 @@
<div class="grid cols-2 items-start" id="engines"></div> <div class="grid cols-2 items-start" id="engines"></div>
<div class="empty-state" id="engines-empty" hidden> <div class="empty-state" id="engines-empty" hidden>
<h3>No engines configured</h3> <h3>No engines configured</h3>
<p>Set <code>GUFO_BASE_URL</code> (llama.cpp / gufo) and/or <code>VLLM_BASE_URL</code>. <p>Set <code>GUFO_BASE_URL</code> (llama.cpp / gufo) and/or <code>VLLM_BASE_URL</code>.</p>
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>
</div> </div>
</section> </section>
@@ -134,15 +132,12 @@
<section style="margin-top:.85rem"> <section style="margin-top:.85rem">
<article class="card"> <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> <div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</article> </article>
</section> </section>
<footer class="pagefoot"> <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> <span class="mono" id="foot-note"></span>
</footer> </footer>
</main> </main>
+2 -6
View File
@@ -41,9 +41,7 @@
chart is drawn inside one host panel below, and the question "which machine stands chart is drawn inside one host panel below, and the question "which machine stands
out?" belongs to the overview's alerts and fleet table. --> out?" belongs to the overview's alerts and fleet table. -->
<section> <section>
<div class="page-head"><h2 style="font-size:.95rem;margin:0">Per host</h2> <div class="page-head"><h2 style="font-size:.95rem;margin:0">Per host</h2></div>
<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>
<!-- `items-start`: an expanded chart stack must not stretch its collapsed siblings into <!-- `items-start`: an expanded chart stack must not stretch its collapsed siblings into
blank cards (the same fix `/llm` needed for uneven engine panels). --> blank cards (the same fix `/llm` needed for uneven engine panels). -->
<div class="grid auto items-start" id="hosts"></div> <div class="grid auto items-start" id="hosts"></div>
@@ -56,13 +54,11 @@
</section> </section>
<section class="card" style="margin-top:.85rem"> <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> <div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</section> </section>
<footer class="pagefoot"> <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> <span class="mono" id="foot-note"></span>
</footer> </footer>
</main> </main>
+7 -9
View File
@@ -39,11 +39,12 @@
<section class="grid auto" id="ups-cards"></section> <section class="grid auto" id="ups-cards"></section>
<div class="empty-state" id="ups-empty" hidden> <div class="empty-state" id="ups-empty" hidden>
<h3>No UPS reporting</h3> <h3>No UPS reporting</h3>
<p>The dashboard speaks the NUT protocol directly to <code>upsd</code>. On the NUT host:</p> <p>Set <code>NUT_HOST</code> / <code>NUT_PORT</code> to the <code>upsd</code> that serves
<p class="mono">/etc/ups/upsd.conf → <code>LISTEN 0.0.0.0</code> · <code>PORT 3493</code></p> this LAN — its real port, which is not always the <code>3493</code> default.</p>
<p class="mono">/etc/ups/upsd.users → <code>[dashboards]</code> with <p class="mono">/etc/ups/upsd.conf → <code>LISTEN 127.0.0.1</code></p>
<code>password = …</code> and <code>actions = -</code></p> <p class="mono">/etc/ups/upsd.users → <code>[dashboards]</code> with <code>password = …</code>
<p>then <code>systemctl restart nut-server</code> and open the port to this server.</p> and one <code>instcmds = …</code> line per command</p>
<p>then <code>systemctl restart nut-server</code>.</p>
</div> </div>
<section class="grid cols-2" style="margin-top:.85rem" aria-label="Battery charts"> <section class="grid cols-2" style="margin-top:.85rem" aria-label="Battery charts">
@@ -67,14 +68,11 @@
</section> </section>
<section class="card" style="margin-top:.85rem"> <section class="card" style="margin-top:.85rem">
<header><h3>Power events</h3><span class="spacer"></span> <header><h3>Power events</h3></header>
<span class="faint">state changes, self-test results, commands sent</span></header>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div> <div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</section> </section>
<footer class="pagefoot"> <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> <span class="mono" id="foot-note"></span>
</footer> </footer>
</main> </main>
+165 -1
View File
@@ -92,6 +92,170 @@ const Dash = (() => {
return stripped.trim() ? stripped : out; 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 -------------------------------------------------------- // ---- DOM helpers --------------------------------------------------------
/** Write text only when it changed: avoids killing transitions and focus. */ /** Write text only when it changed: avoids killing transitions and focus. */
function setTxt(el, value) { function setTxt(el, value) {
@@ -469,7 +633,7 @@ const Dash = (() => {
return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt, return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt,
setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindPause, setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindPause,
staleness, markStale, severityFor, severityFrom, hero, ageSeconds, naText, staleness, markStale, severityFor, severityFrom, hero, ageSeconds, naText,
eventText }; eventText, eventsPager, EVENT_PAGE_SIZE, EVENT_FEED_LIMIT };
})(); })();
document.addEventListener('DOMContentLoaded', () => Dash.initChrome()); 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: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); } 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 ------------------------------------------------------------- */ /* ---- layout ------------------------------------------------------------- */
main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; } 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); } .tooltip .row .val { margin-inline-start: auto; font-family: var(--mono); }
/* ---- lists -------------------------------------------------------------- */ /* ---- 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 { ul.events li {
display: grid; grid-template-columns: 62px 1fr; gap: .55rem; align-items: baseline; 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); 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.warn { color: var(--warn); }
ul.events .sev.error { color: var(--fail); } 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 /* 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. */ 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; } 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 // the simulator needs no credentials: asking for them there sends the operator fixing
// something that is not broken // something that is not broken
banners.push(['info', 'UPS commands need credentials: set <code>NUT_USER</code>/' + 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) { if (config.ups.simulated) {
banners.push(['warn', '<strong>SIMULATED UPS DATA.</strong> <code>NUT_SIMULATE=1</code> is set: ' + 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'], { key: 'spec_accept', label: 'spec accept', digits: 0, unit: '%', only: ['gufo'],
tip: 'Speculative-decoding draft tokens the model accepted.' }, tip: 'Speculative-decoding draft tokens the model accepted.' },
{ key: 'queue_mean', label: 'queue time', digits: 0, unit: 'ms', p95: true, only: ['vllm'], { 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, { 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, { 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 · ` + $('#sub').textContent = `${fleet.engines_live || 0}/${fleet.engines_total || 0} engines · ` +
`${fleet.requests_active == null ? 'n/a' : num(fleet.requests_active, 0)} active · ` + `${fleet.requests_active == null ? 'n/a' : num(fleet.requests_active, 0)} active · ` +
`window ${duration(state.range)}`; `window ${duration(state.range)}`;
$('#foot-note').textContent = `poll ${Math.round(state.pollMs / 1000)}s · rates over the ` + $('#foot-note').textContent = `poll ${Math.round(state.pollMs / 1000)}s`;
`Δ window in each tag`;
await refreshHistory(names); await refreshHistory(names);
} }
async function refreshEvents() { async function refreshEvents() {
try { try {
const data = await fetchJSON('/api/events?limit=20&source=llm'); // The same bounded, paginated window as the other two feeds (`Dash.eventsPager`): an
const list = $('#events'); // engine that has been restarting all week must not push the panels off the page.
if (!(data.events || []).length) { const data = await fetchJSON(`/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=llm`);
list.innerHTML = '<li class="muted" style="grid-template-columns:1fr">No engine transitions yet.</li>'; Dash.eventsPager($('#events'), data.events, {
return; label: 'Engine events',
} empty: '<li class="muted" style="grid-template-columns:1fr">No engine transitions yet.</li>',
list.innerHTML = data.events.map((event) => `<li><time>${ row: (event) => `<time>${
new Date(event.ts * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }) 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> ` + }</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` +
`${DashCharts.escapeHtml(eventText(event))}</span></li>`).join(''); `${DashCharts.escapeHtml(eventText(event))}</span>`,
});
} catch (err) { /* next poll */ } } 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 // `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). // (`poller.py` writes `Event(ts, self.dashboard, …)`; samples use `host`, events do not).
// Querying `source=host` matched nothing and left this feed forever empty. // Querying `source=host` matched nothing and left this feed forever empty.
const data = await fetchJSON('/api/events?limit=25&source=system'); // 50 rows a page (`Dash.eventsPager`), and the list itself scrolls: a host that has been
const list = $('#events'); // flapping for a month has a long history and this page has other panels to show.
if (!(data.events || []).length) { const data = await fetchJSON(`/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=system`);
list.innerHTML = '<li class="muted" style="grid-template-columns:1fr">No host transitions ' + Dash.eventsPager($('#events'), data.events, {
'recorded yet — this log fills when a host goes away, comes back, or reboots.</li>'; label: 'Host events',
return; 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>',
list.innerHTML = data.events.map((event) => `<li><time>${ row: (event) => `<time>${
new Date(event.ts * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }) 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> ` + }</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` +
`${DashCharts.escapeHtml(Dash.eventText(event))}</span></li>`).join(''); `${DashCharts.escapeHtml(Dash.eventText(event))}</span>`,
});
} catch (err) { /* the next poll will try again */ } } catch (err) { /* the next poll will try again */ }
} }
+17 -15
View File
@@ -404,7 +404,7 @@ async function refresh() {
} }
if (!snapshot.actions_enabled) { if (!snapshot.actions_enabled) {
banners.push(['info', 'Command buttons are disabled. Set <code>NUT_ALLOW_ACTIONS=1</code> plus ' + 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>.']); '<code>upsd.users</code>.']);
} }
if (stateRow && !stateRow.ok && stateRow.error) { if (stateRow && !stateRow.ok && stateRow.error) {
@@ -429,20 +429,22 @@ async function refresh() {
async function refreshEvents() { async function refreshEvents() {
try { try {
const data = await fetchJSON('/api/events?limit=40&source=ups'); // Battery history is the longest of the three feeds (every charge / discharge transition
const list = $('#events'); // lands here), so this is the page that needs the bounded window most: 50 rows a page
if (!(data.events || []).length) { // inside a scrolling list, with the count and the arrows under it (`Dash.eventsPager`).
list.innerHTML = '<li class="muted" style="grid-template-columns:1fr">No power events yet — ' + const data = await fetchJSON(`/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=ups`);
'state changes, self-test results, and commands you send all land here.</li>'; Dash.eventsPager($('#events'), data.events, {
return; label: 'Power events',
} empty: '<li class="muted" style="grid-template-columns:1fr">No power events yet — '
list.innerHTML = data.events.map((event) => `<li><time>${ + 'state changes, self-test results, and commands you send all land here.</li>',
new Date(event.ts * 1000).toLocaleString(undefined, row: (event) => `<time>${
{ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }) new Date(event.ts * 1000).toLocaleString(undefined,
}</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` + { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
`${DashCharts.escapeHtml(eventText(event))}` + }</time><span><span class="sev ${event.severity}">${DashCharts.escapeHtml(event.host)}</span> ` +
`${event.data && event.data.runtime_seconds ? ` <span class="faint mono">(${ `${DashCharts.escapeHtml(eventText(event))}` +
duration(event.data.runtime_seconds)} runtime)</span>` : ''}</span></li>`).join(''); `${event.data && event.data.runtime_seconds ? ` <span class="faint mono">(${
duration(event.data.runtime_seconds)} runtime)</span>` : ''}</span>`,
});
} catch (err) { /* next poll */ } } 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" 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: 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. """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). the served bytes because there is no JS test runner (a locked anchor).
""" """
js = client.get("/static/page-system.js").text js = client.get("/static/page-system.js").text
feed = js[js.index("async function refreshEvents("):] feed = _event_feed_path(js)
url = re.search(r"fetchJSON\('(/api/events[^']*)'\)", feed) url = re.search(r"fetchJSON\([`'](/api/events[^`']*)[`']\)", feed)
assert url, "the /system page no longer fetches the event feed" assert url, "the /system page no longer fetches the event feed"
assert "source=system" in url.group(1), \ assert "source=system" in url.group(1), \
"the /system event feed must ask for the source the poller stores events under" "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" 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: def test_api_responses_are_not_cacheable(client) -> None:
for path in ("/api/config", "/api/health", "/api/summary"): for path in ("/api/config", "/api/health", "/api/summary"):
assert "no-store" in client.get(path).headers.get("cache-control", "") assert "no-store" in client.get(path).headers.get("cache-control", "")