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:
@@ -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/`
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+48
@@ -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 ==
|
||||||
+14
@@ -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`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+48
@@ -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 ==
|
||||||
+20
@@ -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`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+48
@@ -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 ==
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
|
- [ ] "Last self-test: Done and passed" still shows up as a warning in the overview and UPS section
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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: ' +
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>',
|
||||||
|
row: (event) => `<time>${
|
||||||
new Date(event.ts * 1000).toLocaleString(undefined,
|
new Date(event.ts * 1000).toLocaleString(undefined,
|
||||||
{ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
|
{ month: 'short', day: 'numeric', 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))}` +
|
`${DashCharts.escapeHtml(eventText(event))}` +
|
||||||
`${event.data && event.data.runtime_seconds ? ` <span class="faint mono">(${
|
`${event.data && event.data.runtime_seconds ? ` <span class="faint mono">(${
|
||||||
duration(event.data.runtime_seconds)} runtime)</span>` : ''}</span></li>`).join('');
|
duration(event.data.runtime_seconds)} runtime)</span>` : ''}</span>`,
|
||||||
|
});
|
||||||
} catch (err) { /* next poll */ }
|
} catch (err) { /* next poll */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+224
-2
@@ -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", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user