From 5e267737dc2930e33fb195a039756fc712f4540a Mon Sep 17 00:00:00 2001 From: ducoterra Date: Wed, 21 Oct 2026 01:44:52 -0400 Subject: [PATCH] phase: 21_page_cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Phase 21 final verification pass** (all 3 task files already in `complete/`, working tree carries the implementation) - **Verified** task 01: `#pause`/`Space`/`setPaused`/`_paused` gone — `grep -ri pause src/dashboards/web/` empty, 0 pause hits in all 8 served pages + 10 static assets; re-ran `data/logs/21-task01-nopause.cjs` → 30/30 PASS (hidden tab: 0 data fetches in 22 s, one poll 2 ms after return). - **Verified** task 02: re-ran `21-task02-gpu-values.cjs` on the live fleet → all PASS (deskwork/driveripper show Model/VRAM/Pool/Temp at 16.3 px; gateway = 5 heroes, slot `hidden`, no `n/a`; hero order unchanged; GPU history chart still builds per host). - **Verified** task 03: re-ran `21-task03-scroll.cjs` → 14/14 PASS (`class="data scroll"`, scrollWidth 1021 vs clientWidth 292 at 360 px, Rotate/Revoke/bundle reachable and hit-testable; unchanged at 1280 px). Screenshots refreshed in `data/logs/`. - **Gate**: `uv run pytest` → **1471 passed, 15 skipped**; `--cov --cov-fail-under=94` → **98.94%**; `ruff check src tests agent` clean; `pyright` **0 errors**; `bash .agents/validate.sh` → **EXIT 0** (incl. `node --check`); `uv run pytest -m live --live` → **11 passed**; `/api/health` all 9 sources `ok`. - **Docs fixed** (pre-existing + this phase): "all **eight** signed-in pages" was a miscount of `MARKUP_PAGES` (7 = 5 dashboards + `/settings` + `/certs`; `/login` is the 8th, unsigned) → corrected in `README.md`, `AGENTS.md` (×2) and the `tests/test_api.py` docstring/comment; `AGENTS.md` Commands hermetic tally 1470 → **1471**. - **Docs reviewed, consistent**: Quickstart/commands/flags (`install-agent.sh`, `capture-fixtures.sh`, `dev-server.sh`), the env table vs `config.py`, every documented route vs the served route table, masthead/`/system` GPU/`/certs` scroll descriptions vs code, and all `data/logs/*` + `tests/fixtures/*` references. - **Deviation**: `validate.sh`'s advisory `fastapi 0.153.0 → 0.156.4` left alone — no dependency was touched this phase, so AGENTS rule 8 ("check after touching dependencies") is not triggered; upgrading here would be an unrelated behaviour change. - No live-infra changes made (repo-side + browser verification only). - **Next pending phase**: `22_disks_hide_unconfigured`. --- .../21_page_cleanups/00_phase.md | 0 .../01_remove_pause_button.md | 0 .../21_page_cleanups/02_gpu_own_values.md | 0 .../21_page_cleanups/03_certs_table_scroll.md | 0 .../21_page_cleanups__00_phase.a1.err | 0 .../21_page_cleanups__00_phase.a1.md | 11 + .../21_page_cleanups__00_phase.a1.md.status | 1 + .../21_page_cleanups__00_phase.a1.validate | 65 ++++ ...ge_cleanups__01_remove_pause_button.a1.err | 0 ...age_cleanups__01_remove_pause_button.a1.md | 14 + ...anups__01_remove_pause_button.a1.md.status | 1 + ...eanups__01_remove_pause_button.a1.validate | 65 ++++ ...21_page_cleanups__02_gpu_own_values.a1.err | 0 .../21_page_cleanups__02_gpu_own_values.a1.md | 13 + ...e_cleanups__02_gpu_own_values.a1.md.status | 1 + ...ge_cleanups__02_gpu_own_values.a1.validate | 65 ++++ ...age_cleanups__03_certs_table_scroll.a1.err | 0 ...page_cleanups__03_certs_table_scroll.a1.md | 8 + ...eanups__03_certs_table_scroll.a1.md.status | 1 + ...leanups__03_certs_table_scroll.a1.validate | 65 ++++ AGENTS.md | 81 +++-- README.md | 35 +- src/dashboards/web/pages/certs.html | 2 +- src/dashboards/web/pages/disks.html | 1 - src/dashboards/web/pages/llm.html | 1 - src/dashboards/web/pages/system.html | 1 - src/dashboards/web/pages/ups.html | 1 - src/dashboards/web/static/app.js | 45 ++- src/dashboards/web/static/dash.css | 31 +- src/dashboards/web/static/page-disks.js | 2 +- src/dashboards/web/static/page-llm.js | 2 +- src/dashboards/web/static/page-system.js | 122 ++++--- src/dashboards/web/static/page-ups.js | 2 +- tests/card_contract.py | 11 +- tests/test_api.py | 309 +++++++++++++++++- tests/test_system_contract.py | 13 +- 36 files changed, 830 insertions(+), 139 deletions(-) rename .agents/phases/{todo => complete}/21_page_cleanups/00_phase.md (100%) rename .agents/phases/{todo => complete}/21_page_cleanups/01_remove_pause_button.md (100%) rename .agents/phases/{todo => complete}/21_page_cleanups/02_gpu_own_values.md (100%) rename .agents/phases/{todo => complete}/21_page_cleanups/03_certs_table_scroll.md (100%) create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.err create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.md create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.md.status create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.validate create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.err create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.md create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.md.status create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.validate create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.err create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.md create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.md.status create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.validate create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.err create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.md create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.md.status create mode 100644 .agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.validate diff --git a/.agents/phases/todo/21_page_cleanups/00_phase.md b/.agents/phases/complete/21_page_cleanups/00_phase.md similarity index 100% rename from .agents/phases/todo/21_page_cleanups/00_phase.md rename to .agents/phases/complete/21_page_cleanups/00_phase.md diff --git a/.agents/phases/todo/21_page_cleanups/01_remove_pause_button.md b/.agents/phases/complete/21_page_cleanups/01_remove_pause_button.md similarity index 100% rename from .agents/phases/todo/21_page_cleanups/01_remove_pause_button.md rename to .agents/phases/complete/21_page_cleanups/01_remove_pause_button.md diff --git a/.agents/phases/todo/21_page_cleanups/02_gpu_own_values.md b/.agents/phases/complete/21_page_cleanups/02_gpu_own_values.md similarity index 100% rename from .agents/phases/todo/21_page_cleanups/02_gpu_own_values.md rename to .agents/phases/complete/21_page_cleanups/02_gpu_own_values.md diff --git a/.agents/phases/todo/21_page_cleanups/03_certs_table_scroll.md b/.agents/phases/complete/21_page_cleanups/03_certs_table_scroll.md similarity index 100% rename from .agents/phases/todo/21_page_cleanups/03_certs_table_scroll.md rename to .agents/phases/complete/21_page_cleanups/03_certs_table_scroll.md diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.err b/.agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.md b/.agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.md new file mode 100644 index 0000000..524d746 --- /dev/null +++ b/.agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.md @@ -0,0 +1,11 @@ +**Phase 21 final verification pass** (all 3 task files already in `complete/`, working tree carries the implementation) + +- **Verified** task 01: `#pause`/`Space`/`setPaused`/`_paused` gone — `grep -ri pause src/dashboards/web/` empty, 0 pause hits in all 8 served pages + 10 static assets; re-ran `data/logs/21-task01-nopause.cjs` → 30/30 PASS (hidden tab: 0 data fetches in 22 s, one poll 2 ms after return). +- **Verified** task 02: re-ran `21-task02-gpu-values.cjs` on the live fleet → all PASS (deskwork/driveripper show Model/VRAM/Pool/Temp at 16.3 px; gateway = 5 heroes, slot `hidden`, no `n/a`; hero order unchanged; GPU history chart still builds per host). +- **Verified** task 03: re-ran `21-task03-scroll.cjs` → 14/14 PASS (`class="data scroll"`, scrollWidth 1021 vs clientWidth 292 at 360 px, Rotate/Revoke/bundle reachable and hit-testable; unchanged at 1280 px). Screenshots refreshed in `data/logs/`. +- **Gate**: `uv run pytest` → **1471 passed, 15 skipped**; `--cov --cov-fail-under=94` → **98.94%**; `ruff check src tests agent` clean; `pyright` **0 errors**; `bash .agents/validate.sh` → **EXIT 0** (incl. `node --check`); `uv run pytest -m live --live` → **11 passed**; `/api/health` all 9 sources `ok`. +- **Docs fixed** (pre-existing + this phase): "all **eight** signed-in pages" was a miscount of `MARKUP_PAGES` (7 = 5 dashboards + `/settings` + `/certs`; `/login` is the 8th, unsigned) → corrected in `README.md`, `AGENTS.md` (×2) and the `tests/test_api.py` docstring/comment; `AGENTS.md` Commands hermetic tally 1470 → **1471**. +- **Docs reviewed, consistent**: Quickstart/commands/flags (`install-agent.sh`, `capture-fixtures.sh`, `dev-server.sh`), the env table vs `config.py`, every documented route vs the served route table, masthead/`/system` GPU/`/certs` scroll descriptions vs code, and all `data/logs/*` + `tests/fixtures/*` references. +- **Deviation**: `validate.sh`'s advisory `fastapi 0.153.0 → 0.156.4` left alone — no dependency was touched this phase, so AGENTS rule 8 ("check after touching dependencies") is not triggered; upgrading here would be an unrelated behaviour change. +- No live-infra changes made (repo-side + browser verification only). +- **Next pending phase**: `22_disks_hide_unconfigured`. diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.md.status b/.agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.validate b/.agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.validate new file mode 100644 index 0000000..d84505e --- /dev/null +++ b/.agents/reports/21_page_cleanups/21_page_cleanups__00_phase.a1.validate @@ -0,0 +1,65 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 14%] +........................................................................ [ 19%] +........................................................................ [ 24%] +...............................................s........................ [ 29%] +........................................................................ [ 33%] +........................................................................ [ 38%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 53%] +........................................................................ [ 58%] +........................................................................ [ 62%] +........................................................................ [ 67%] +............sssssssssss................................................. [ 72%] +........................................................................ [ 77%] +........................................................................ [ 82%] +........................................................................ [ 87%] +........................................................................ [ 92%] +............................................................sss......... [ 96%] +.............................................. [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 436 0 100% +src/dashboards/app.py 185 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3490 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1471 passed, 15 skipped, 1 warning in 156.37s (0:02:36) +== uv run ruff check src tests agent == +All checks passed! +== uv run pyright == +0 errors, 0 warnings, 0 informations +== node --check src/dashboards/web/static/*.js == +advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose): + fastapi 0.153.0 0.156.4 wheel diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.err b/.agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.md b/.agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.md new file mode 100644 index 0000000..e2cefaa --- /dev/null +++ b/.agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.md @@ -0,0 +1,14 @@ +**Implemented** +- `src/dashboards/web/pages/{system,disks,llm,ups}.html` — deleted the `#pause` button line (only served occurrences; `grep -ri pause src/dashboards/web/` now exits 1). +- `static/app.js` — dropped `_paused`/`paused`/`setPaused` from `Dash.poll()` and the `#pause` binding + `Space` handler; `document.hidden` skip and the `visibilitychange` refire kept. The `T` theme shortcut that shared that listener survives as `Dash.bindShortcuts()` (same four pages call it; deliberately not moved into `initChrome()`, so its reach is unchanged). +- `static/dash.css` — the three comments naming the pause button as a row-3/wrap tenant cleaned. +- `tests/test_api.py` — new section: `PAUSE_ARTIFACTS` absent from all 8 served pages (`/login` via its own anonymous client) + 11 assets; `document.hidden`/`visibilitychange` guards still present and no `event.code`/`'Space'` in the shared script; `T` + the four `Dash.bindShortcuts()` call sites pinned. +- Browser proof `data/logs/21-task01-nopause.cjs` (+ `.out`, `21-task01-masthead-{,system-}{1280,360}.png`, `21-task01-system-live.png`): 30/30 PASS — no control on 7 pages × 2 widths + `/login`; `Space` presses nothing and polls keep running (0→5 fetches/14 s); hidden tab: **0 data fetches in 22 s** (`document.hidden=true`); return fires one poll in 1 ms. + +**Gate** — `bash .agents/validate.sh` → EXIT=0: `1466 passed, 15 skipped`, coverage 98.94 % (floor 94, unchanged); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean. `uv run pytest -m live --live` → 11 passed (live fleet untouched). + +**Docs** — `README.md`: masthead row-3 list no longer names `⏸ Pause`, plus the "no control stops the polling" sentence and a hidden-tab note on the `POLL_MS_*` row. `AGENTS.md`: same masthead sentence rewritten with the phase-21 rationale. + +**Notes/deviations** — the masthead's `/api/health` status-dot interval in `initChrome()` was never gated on hidden/paused, so the browser check filters it out of "data fetches" (documented in the script); pre-existing behaviour, left alone. `validate.sh` prints a pre-existing advisory (`fastapi 0.153.0` vs `0.156.4`); no dependency was touched by this task, so no upgrade. Concurrent working-tree changes not mine: emptied `NOTES.md`, new `TODO.md` items and `todo/25_…`, `todo/26_…`. + +**Next pending task:** `21_page_cleanups/02_gpu_own_values.md`. diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.md.status b/.agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.validate b/.agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.validate new file mode 100644 index 0000000..2d7a5c8 --- /dev/null +++ b/.agents/reports/21_page_cleanups/21_page_cleanups__01_remove_pause_button.a1.validate @@ -0,0 +1,65 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 14%] +........................................................................ [ 19%] +........................................................................ [ 24%] +..........................................s............................. [ 29%] +........................................................................ [ 34%] +........................................................................ [ 38%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 53%] +........................................................................ [ 58%] +........................................................................ [ 63%] +........................................................................ [ 68%] +.......sssssssssss...................................................... [ 72%] +........................................................................ [ 77%] +........................................................................ [ 82%] +........................................................................ [ 87%] +........................................................................ [ 92%] +.......................................................sss.............. [ 97%] +......................................... [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 436 0 100% +src/dashboards/app.py 185 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3490 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1466 passed, 15 skipped, 1 warning in 158.10s (0:02:38) +== uv run ruff check src tests agent == +All checks passed! +== uv run pyright == +0 errors, 0 warnings, 0 informations +== node --check src/dashboards/web/static/*.js == +advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose): + fastapi 0.153.0 0.156.4 wheel diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.err b/.agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.md b/.agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.md new file mode 100644 index 0000000..42f99a4 --- /dev/null +++ b/.agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.md @@ -0,0 +1,13 @@ +**Implemented** +- `page-system.js`: `gpuSub()` → `GPU_VALUE_FIELDS` + `gpuValues()` — Model / VRAM / Pool / Temp as four labelled `.metric` cells under a busy-only hero (no `severity`, no bar; model escaped; missing field = `n/a`, never `0 °C`). `renderGpuSlot()` now renders a `.gpu-card` per card and **omits** the slot for a host with no GPU (`data-k="gpus"` ships `hidden`; `[hidden]{display:none!important}` reflows the row to five). +- `dash.css`: `.gpu-card` + `.gpu-values` (`auto-fit minmax(min(150px,100%),1fr)`, `overflow-wrap: anywhere`, no colour tokens); `.hero-slot` comment updated. +- `tests/test_api.py`: new `GPU_VALUE_CELLS` table read out of the served script and resolved against all three captures **and** over HTTP; pins for the four cells, the vanished `gpuSub`/`join(' · ')`, the no-verdict/no-bar grid + CSS wrap, and the omission (hidden slot, no `n/a`, `[hidden]` rule); six-slot order pin kept + `hidden` pin. `card_contract.py`/`test_system_contract.py`: stale "hero footnote" prose only, assertions untouched. +- Browser proof `data/logs/21-task02-gpu-values.cjs` (+ `.out.txt`, `21-task02-gpu-values-{1280,360}.png`, `21-task02-gpu-charts.png`): all checks pass — four values at 16.3px (old sub was 11px), gateway = 5 heroes / no slot / no `n/a`, hero order unchanged, GPU chart still builds per GPU host, no sideways scroll. + +**Gate** — `bash .agents/validate.sh` EXIT=0: `1470 passed, 15 skipped`, coverage 98.94 % (floor 94, unchanged); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean. `uv run pytest -m live --live` → **11 passed**. + +**Docs** — `AGENTS.md`: the `/system` paragraph split (missing *number* → `n/a` uncoloured; missing *device* → slot omitted), four-labelled-values layout, `GPU_VALUE_FIELDS` device, GPU **chart** slot disambiguation, hermetic tally 1464→1470. `README.md`: same rules in the `/system` page description. + +**Decisions/deviations** — Temp's label is the word `Temp` with `°C` in the value (pinned `count("unit: ' °C'") == 1`). `.gpu-values` is `auto-fit` rather than a fixed 2×2: measured hero track is ~180px on the live 3-host fleet at 1280px, so 2 columns re-wrap the TODO's own strings onto 3 lines; wide slots still get 2×2. A missing field on a *present* card now prints `n/a` (the task's rule), replacing phase 06's "print no temperature at all" — the never-`0 °C` half is kept and pinned. Pre-existing `validate.sh` advisory (`fastapi 0.153.0` vs `0.156.4`) left alone: no dependency touched. + +**Next pending task:** `21_page_cleanups/03_certs_table_scroll.md`. diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.md.status b/.agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.validate b/.agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.validate new file mode 100644 index 0000000..462cf4f --- /dev/null +++ b/.agents/reports/21_page_cleanups/21_page_cleanups__02_gpu_own_values.a1.validate @@ -0,0 +1,65 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 14%] +........................................................................ [ 19%] +........................................................................ [ 24%] +..............................................s......................... [ 29%] +........................................................................ [ 33%] +........................................................................ [ 38%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 53%] +........................................................................ [ 58%] +........................................................................ [ 63%] +........................................................................ [ 67%] +...........sssssssssss.................................................. [ 72%] +........................................................................ [ 77%] +........................................................................ [ 82%] +........................................................................ [ 87%] +........................................................................ [ 92%] +...........................................................sss.......... [ 96%] +............................................. [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 436 0 100% +src/dashboards/app.py 185 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3490 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1470 passed, 15 skipped, 1 warning in 153.88s (0:02:33) +== uv run ruff check src tests agent == +All checks passed! +== uv run pyright == +0 errors, 0 warnings, 0 informations +== node --check src/dashboards/web/static/*.js == +advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose): + fastapi 0.153.0 0.156.4 wheel diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.err b/.agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.md b/.agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.md new file mode 100644 index 0000000..1b67f6d --- /dev/null +++ b/.agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.md @@ -0,0 +1,8 @@ +**Task 03 complete — `/certs` agents table scrolls horizontally.** + +- **Implemented:** `#agents-table` in `src/dashboards/web/pages/certs.html` gains the repo's own `scroll` modifier (`class="data scroll"`) — the existing `table.data.scroll { display:block; overflow:auto; max-height:320px }` rule in `dash.css` is the mechanism, so no CSS added, no wrapper invented (verified the selector form first). `page-certs.js` needed no change (id/`tbody` selectors intact). New served-bytes pin `tests/test_api.py::test_the_certs_agents_table_scrolls_to_its_last_column` pins the class on the table **and** the `table.data.scroll` rule together (either side renaming silently un-scrolls the table). +- **Browser verification (invariant 10):** `data/logs/21-task03-scroll.cjs` against the live dev server — 14/14 checks PASS: at 360 px the container overflows (scrollWidth 1021 vs clientWidth 292), scrolling exposes the full Actions column (Rotate/Revoke/bundle all inside, `elementFromPoint` hit-tests to the control — no write sent); at 1280 px nothing changed (no h- or v-scrollbar, all columns inside). Screenshots: `data/logs/certs-scroll-360-{start,mid,end}.png`, `certs-scroll-1280.png` (mid-scroll shows the table scrolled to the serial/expiry columns). +- **Tests/lint:** `uv run pytest` → **1471 passed, 15 skipped** (was 1470; +1 new pin); coverage **98.94%** (gate 94%); `ruff` + `pyright` clean; `bash .agents/validate.sh` → **exit 0** (incl. `node --check`); `uv run pytest -m live --live` → **11 passed**. Pre-existing fastapi 0.153→0.156 advisory is non-blocking and outside this task (no dependencies touched — invariant 8 fires only when deps move). +- **Docs:** README.md *Managing the certificates* — one sentence that the agents table scrolls at phone width; AGENTS.md `/certs` paragraph — the `table.data.scroll` contract, the pinned class+rule pair, and the measuring script. Gap 12's forward reference to this change remains accurate. +- **Decisions:** used the class form (the only one the stylesheet styles); kept the `max-height: 320px` cap as shipped (3 rows ≈ 150 px, verified no vertical scrollbar); no CSS/JS behavior change — row contents, chips, controls, and the `COLUMNS` contract test untouched. +- **Next pending task:** none in `todo/21_page_cleanups` — this was the last task; phase 21 is ready for the harness's completion/validation pass. diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.md.status b/.agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.validate b/.agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.validate new file mode 100644 index 0000000..ea32f42 --- /dev/null +++ b/.agents/reports/21_page_cleanups/21_page_cleanups__03_certs_table_scroll.a1.validate @@ -0,0 +1,65 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 14%] +........................................................................ [ 19%] +........................................................................ [ 24%] +...............................................s........................ [ 29%] +........................................................................ [ 33%] +........................................................................ [ 38%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 53%] +........................................................................ [ 58%] +........................................................................ [ 62%] +........................................................................ [ 67%] +............sssssssssss................................................. [ 72%] +........................................................................ [ 77%] +........................................................................ [ 82%] +........................................................................ [ 87%] +........................................................................ [ 92%] +............................................................sss......... [ 96%] +.............................................. [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.14.4-final-0 ________________ + +Name Stmts Miss Cover Missing +------------------------------------------------------------------ +src/dashboards/__init__.py 3 0 100% +src/dashboards/__main__.py 14 1 93% 32 +src/dashboards/alerts.py 176 0 100% +src/dashboards/api.py 436 0 100% +src/dashboards/app.py 185 0 100% +src/dashboards/auth.py 265 0 100% +src/dashboards/certs.py 292 0 100% +src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307 +src/dashboards/config.py 120 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183 +src/dashboards/rates.py 73 0 100% +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/disks.py 134 0 100% +src/dashboards/sources/host.py 209 0 100% +src/dashboards/sources/llm.py 178 1 99% 285 +src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 313 2 99% 553-554 +------------------------------------------------------------------ +TOTAL 3490 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1471 passed, 15 skipped, 1 warning in 155.49s (0:02:35) +== uv run ruff check src tests agent == +All checks passed! +== uv run pyright == +0 errors, 0 warnings, 0 informations +== node --check src/dashboards/web/static/*.js == +advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose): + fastapi 0.153.0 0.156.4 wheel diff --git a/AGENTS.md b/AGENTS.md index b0e0c11..8e13172 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -710,10 +710,11 @@ Responsibilities, one line each: 15's "no self-link" precedent: chrome that changes shape depending on where you already are reads as a broken button, while a link to the page you are on is a no-op that answers with the page you were looking at (`data/logs/20-task04-admin-links.cjs` measures exactly that — one document - response, no redirect, the same cluster afterwards). The two links therefore sit on all eight - signed-in pages in one order (clock → `⚷ Certs` → `⚙ Account` → theme toggle), which is what lets - the pinned audit be one loop over all eight instead of two over different tuples; `/login` stays - the one page with neither. Everything on it is + response, no redirect, the same cluster afterwards). The two links therefore sit on all seven + signed-in pages (the five dashboards plus `/settings` and `/certs`) in one order + (clock → `⚷ Certs` → `⚙ Account` → theme toggle), which is what lets + the pinned audit be one loop over `MARKUP_PAGES` instead of two over different tuples; `/login` + is the eighth page and the one with neither. Everything on it is `GET /api/certs`: the Root CA block, the display-only Dashboard client line (that certificate is minted with the root and has no route of its own), and one row per host with its status chip, serial, expiry (local timestamp *and* `Dash.duration` left), issued-vs-deployed `bundle_rev`, @@ -731,7 +732,12 @@ Responsibilities, one line each: names every payload key it indexes, and `tests/test_api.py` resolves that table against a live `GET /api/certs` row in both directions (the `SLOT_FIELDS` device), so a rename on either side fails a test instead of printing `n/a` beside a healthy fleet. The two CNs are markup, and pinned - to `certs.ROOT_CN`/`certs.CLIENT_CN` the way `/settings` pins its password floor. + to `certs.ROOT_CN`/`certs.CLIENT_CN` the way `/settings` pins its password floor. The agents + table is `table.data.scroll` — the repo's one scroll mechanism (`TODO.md:9`: at phone width the + serial / bundle / `san` / controls tail was clipped by `.card { overflow: hidden }` with no way + to reach it), and the class on the served table and the served `table.data.scroll` rule are + pinned **together** in `tests/test_api.py`, because the mechanism exists only while both name + it (measured at 360 px and 1280 px by `data/logs/21-task03-scroll.cjs`). `app.js`'s masthead status dots are one per dashboard job — including `disks`, because the same agent can answer the 10-second utilisation poll and have a wedged SMART pass, and one dot for both would be right about neither. `app.js` owns the @@ -748,7 +754,11 @@ Responsibilities, one line each: gets a row in `severity.py`, never a bound in JS. **The masthead is three left-justified rows at every width** (phase 20 task 01, `TODO.md:5-6`): row 1 the brand, `#nav-toggle` and the five tabs; row 2 the `#nav-status` per-source dots; row 3 - the `.masthead-right` controls (clock, `⏸ Pause`, `⚷ Certs`, `⚙ Account`, theme). That is CSS and + the `.masthead-right` controls (clock, `⚷ Certs`, `⚙ Account`, theme — phase 21 task 01 deleted + the `⏸ Pause` that used to sit between the clock and the two links, button, `Space` shortcut and + `setPaused`/`_paused` plumbing alike: it froze the numbers on screen without growing a `stale` + label, and a backgrounded tab already idles its polls, which is the only stop this app needs). + That is CSS and no markup change, which decides the mechanism: `#nav-status` is a **child** of `.masthead-right`, so there is no wrapper to promote, and the rows are line breaks — `.masthead` is `flex-wrap: wrap`, and both `#nav-status` and `.masthead-right` carry `flex-basis: 100%` with @@ -1064,7 +1074,7 @@ set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no ./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health ./scripts/dev-server.sh logs -uv run pytest # hermetic: 1464 passed + 15 skipped (11 live, +uv run pytest # hermetic: 1471 passed + 15 skipped (11 live, # 4 store_perf), no network uv run pytest --cov # coverage gate (98.94%; floor 94%) uv run pytest -m live --live # opt-in: real fleet / engines / upsd @@ -1480,24 +1490,41 @@ body moved inside a disclosure stops being padded until something says so — wh and `.events-pager` are class-based and needed no new rule to keep the 320px scroll and the `1–50 of N` strip working inside it. -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 hero slots — CPU, MEM, TEMP, DISK, GPU, NET (the `.heroes` grid; `tests/test_api.py` pins the slots and their order on the served script). -Colour comes from the payload's `severity` dicts and nowhere else; a missing number or -verdict — gateway's CPU temp, any host's absent GPU — prints `n/a` **uncoloured**. NET is -one slot with two lines (`.hero-lines`, `↓` in / `↑` out) and is deliberately colourless: -throughput has no good/bad, so `network` carries no severity. The GPU slot holds one hero -per card whose `.sub` carries model, VRAM used/total, the honest APU `pool` view and the -card's temperature (`gpus[].temp_c` — the agent's max over that card's hwmon labels, the -same value stored as `gpu.temp_c@N`, so the hero and the history cannot disagree), and it is -colourless for the same reason (phase 06): a 95 %-busy card on an LLM server is the job, so -the hero carries no `severity` and no utilisation bar — a bar under a verdictless number -would be a verdict by shape. A card whose driver exposes no temperature input prints **no** -temperature in that footnote rather than `n/a` or `0 °C`: "where you can" is the operator's -wording, and `n/a` is reserved for numbers the card is supposed to have. Everything not -glance-worthy — load averages, clock, disk read/write/busy, uptime, GPU power and the -per-label temp breakdown, filesystems, processes — folds into a collapsed "More detail" -`
`: a simpler surface, not a less complete page (the table keeps its Temp column; -this was a promotion, not a de-duplication). +Colour comes from the payload's `severity` dicts and nowhere else, and a missing **number or +verdict** prints `n/a` **uncoloured** — gateway's CPU temp, a driver with no temperature +input, a rate with no second sample yet. What a machine does not **have** is a different +fact and prints nothing at all: the GPU slot ships `hidden` and `renderGpuSlot()` un-hides +it only for a host whose payload carries a card, so gateway's card is **five** heroes with +no GPU slot in the row (`TODO.md:12` — "Boxes without a GPU should not report N/A but +instead should omit these values"), and the sheet's `[hidden] { display: none !important }` +is what takes the wrapper out of the grid so the row reflows instead of leaving a wide gap. +The two rules are one sentence each in the page and one test each; collapsing them into "any +missing thing is `n/a`" is what made a router look broken over the GPU it never had +(invariant 3). NET is one slot with two lines (`.hero-lines`, `↓` in / `↑` out) and is +deliberately colourless: throughput has no good/bad, so `network` carries no severity. + +The GPU slot is one busy hero per card plus **four labelled values** under it — **Model** +(mono, `gpus[].model`), **VRAM** (`used / total`), **Pool** (`mem_used_pct`, the honest APU +view a Strix Halo needs because its model lives in GTT) and **Temp** (`gpus[].temp_c` — the +agent's max over that card's hwmon labels, the same value stored as `gpu.temp_c@N`, so the +cell and the history cannot disagree; the per-label breakdown stays in "More detail"). They +replaced one ` · `-joined 11px `.sub` — `TODO.md:12`: "`Radeon 8060S Graphics · VRAM 180.2 +MiB / 512.0 MiB · pool 79% · 69 °C` is too much info for such small text and should be its +own values" — and they are `.metric` cells in a `.gpu-values` grid, so a 20-character model +string wraps instead of truncating. The four cells come from one `GPU_VALUE_FIELDS` table, +which `tests/test_api.py` resolves against every capture **and** against the served +`/api/system/snapshot` (`SLOT_FIELDS`' device): a rename on either side fails a test instead +of printing `n/a` beside a healthy fleet. A field the agent did not publish prints `n/a` in +its own cell (the card is real, so it is a number the card is supposed to have) and never +`0 °C`. Nothing in this slot is coloured and nothing draws a bar — no `severity`, no +`ok|warn|fail` token in the markup or the grid's rules (phase 06: a 95 %-busy card on an LLM +server is the job, and a bar under a verdictless number would be a verdict by shape). +Everything not glance-worthy — load averages, clock, disk read/write/busy, uptime, GPU power +and the per-label temp breakdown, filesystems, processes — folds into a collapsed "More +detail" `
`: a simpler surface, not a less complete page (the table keeps its Temp +column; this was a promotion, not a de-duplication). A `Δ` label is a measurement, never a config value: `sources/host.py` sends the window the estimator actually used as the payload's `window_seconds`, and `/system`'s footnote prints @@ -1601,8 +1628,8 @@ transition events — and nothing else: the fleet-wide chart stack, the nine-fig panel and the host/metric detail picker are deleted, along with the `#chart-*` / `#detail-*` ids and the `select` / `label.field` styling only those pickers used. Each panel stacks its own `.host-charts` — utilisation (CPU, memory, worst disk), network (in/out), then GPU busy — where -the GPU slot is created and destroyed with `host.gpus`, so gateway shows neither a phantom card -nor a hole. **That stack is collapsed behind a per-host `Charts` `
`** (phase 06: "only +the GPU **chart** slot is created and destroyed with `host.gpus` (its own thing from the hero +slot above it, which is hidden instead), so gateway shows neither a phantom card nor a hole. **That stack is collapsed behind a per-host `Charts` `
`** (phase 06: "only visible if expanded… there will be lots of hosts"): the default `/system` view is the hero row, the summary carries the window label the stack will draw (the chips themselves stay in the page head, one control per page), and `drawHostCharts()` is the single place that decides whether to @@ -2025,7 +2052,7 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl shape applied to one table and does not reach this one. Of the two operator TODO items that sat beside it, `20_masthead_nav_polish` has landed in full (now complete): the bar is three left-justified rows at every width, it is no longer sticky, the phone menu slides, and both - `⚙ Account` and `⚷ Certs` render on all eight signed-in pages including the two they name + `⚙ Account` and `⚷ Certs` render on all seven signed-in pages including the two they name (task 04, reversing phase 15's "no self-link" rule at the operator's request). The other, `24_dev_mtls_rollout` (never accepting a non-mTLS agent — fleet gap 11's rollout, half of it), is still in `todo/`. diff --git a/README.md b/README.md index 535c407..7ca5f79 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,9 @@ CA** block (CN, serial, expiry as a local timestamp *and* as time left, and **Ro the **Dashboard client** line (the one certificate every agent verifies the dial against — display-only, because it is minted with the root and has no route of its own), and one row per host with its status chip, serial, expiry, issued-vs-deployed bundle revision, the addresses its -certificate is valid for, and **Rotate** / **Revoke** / **bundle**. +certificate is valid for, and **Rotate** / **Revoke** / **bundle**. The agents table carries the +stylesheet's own `scroll` modifier, so at phone width its right-hand columns — the serial, the +bundle revisions, the addresses and the controls — are reachable by scrolling instead of clipped. Nothing there is coloured: `severity.py` rates no certificate, so an expiry is a date and a bundle revision is a count. The one verdict on the page is the API's `status` word (`active` green, @@ -491,7 +493,7 @@ documentation and is never loaded, and `scripts/dev-server.sh` exports `.env.dev | `DASH_SESSION_HOURS` | session lifetime (default `720` = 30 days) | | `DASH_COOKIE_SECURE` | `1` marks the session cookie `Secure`; off because dev is `http://127.0.0.1`, and a request that arrived over `X-Forwarded-Proto: https` is treated as secure either way | | `DASH_PBKDF2_ITERATIONS` | verifier cost (default `600000`). The knob exists for the test suite; leave it alone in a deployment. Raising it on a real box does take effect: the next successful sign-in re-hashes a cheaper stored verifier and keeps the signing key | -| `POLL_MS_SYSTEM` / `POLL_MS_LLM` / `POLL_MS_UPS` | how often the *browser* refetches each page (10 s / 2 s / 5 s) — shown in the page footer. `/disks` has no `POLL_MS_*` of its own and polls at a fixed 30 s: its payload only moves when the `disks` job re-reads the agents | +| `POLL_MS_SYSTEM` / `POLL_MS_LLM` / `POLL_MS_UPS` | how often the *browser* refetches each page (10 s / 2 s / 5 s) — shown in the page footer. `/disks` has no `POLL_MS_*` of its own and polls at a fixed 30 s: its payload only moves when the `disks` job re-reads the agents. A backgrounded tab idles its polls and fires one on return; nothing an operator presses stops them (phase 21) | | `DASH_RELOAD` | `1` makes `uv run dashboards` watch the source and restart on a change — development only, and every poll loop restarts with it | | `DEBUGPY` / `DEBUGPY_PORT` | `DEBUGPY=1` opens a debugpy listener (default 5678) without blocking startup | | `RETAIN_RAW_HOURS` / `RETAIN_MINUTE_DAYS` / `RETAIN_HOUR_DAYS` | history rollup and pruning | @@ -644,12 +646,16 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n SVG charts (`charts.js`), and a masthead status dot per dashboard job (`disks` has its own: the same agent can answer the 10-second utilisation poll and have a wedged SMART pass). The masthead is **three left-justified rows at every width**: the brand and the five dashboard - tabs, then the per-source indicators on their own row, then the clock / `⏸ Pause` (on the four - pages that poll) / `⚷ Certs` / `⚙ Account` / `☾ Dark` controls on a row under those (a page with no + tabs, then the per-source indicators on their own row, then the clock / `⚷ Certs` / `⚙ Account` + / `☾ Dark` controls on a row under those (a page with no indicators — `/login`, `/settings`, `/certs` — renders two, and a control is never hidden to make a - narrower width work). Both admin links are on all eight - signed-in pages, the two pages they name included: the cluster is the same wherever you are, and a - link to the page you are already on is a harmless no-op rather than chrome that vanishes. The bar is **not sticky**: it scrolls away + narrower width work). There is no control that stops the polling (phase 21): the bar used to carry + a `⏸ Pause` button with a `Space` shortcut, and it left the numbers on screen looking current with + no `stale` label on them. Both admin links are on all seven + signed-in pages — the five dashboards plus `/settings` and `/certs`, the two they name included — + and not on `/login`, the eighth page and the only anonymous one: the cluster is the same wherever + you are, and a link to the page you are already on is a harmless no-op rather than chrome that + vanishes. The bar is **not sticky**: it scrolls away with the page, so every pixel of height goes to the data (it stays a positioned element, which is what keeps the mobile dropdown anchored under it). At ≤640px the five tab links leave the bar and come back as a dropdown under the whole of it, opened by the `☰` button (`/login` renders no @@ -674,11 +680,16 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n charging, dashed outline on battery, a dash through the body when there is no reading) and a `` / `aria-label` that names it ("apc: Charge 100 percent, on line") unless the number beside it already does. Each `/system` host is one panel led - by exactly six of them — CPU %, MEM %, TEMP, DISK % (mount underneath), GPU busy (VRAM, the - APU pool and the card's temperature in the footnote — no temperature is printed when the - driver exposes no sensor, never `0 °C`; and likewise never coloured and with no bar: a busy - GPU is the job on an LLM server), and NET on two lines (↓ in / ↑ out, never coloured: a byte - rate has no good/bad). Under the numbers, **each panel charts only its own host** — behind a + by six hero slots — CPU %, MEM %, TEMP, DISK % (mount underneath), GPU busy, and NET on two + lines (↓ in / ↑ out, never coloured: a byte rate has no good/bad). A host with **no GPU** + renders **five**: the GPU slot is omitted rather than filled with `n/a`, because a router is + not broken for the card it never had. A GPU is one busy hero plus **four labelled values** + under it — **Model**, **VRAM** (`used / total`), **Pool** (the honest APU view) and **Temp** + — instead of the dense `model · VRAM · pool · °C` footnote that was 11px mono; a field the + agent did not publish prints `n/a` in its own cell, never `0 °C`, and nothing in the slot is + coloured or barred (a busy GPU is the job on an LLM server). Missing *numbers* still print + `n/a` uncoloured — gateway's CPU temp — which is a different fact from a missing *device*. + Under the numbers, **each panel charts only its own host** — behind a collapsed **Charts** toggle, because the default view is current stats and a fleet of ten hosts each drawing three plots is noise: the summary reads `▸ Charts` with the window in it (`1h`, `24h` …), and opening it is what builds and paints the charts — instantly, from the diff --git a/src/dashboards/web/pages/certs.html b/src/dashboards/web/pages/certs.html index 2dfcb2d..c1ac06b 100644 --- a/src/dashboards/web/pages/certs.html +++ b/src/dashboards/web/pages/certs.html @@ -82,7 +82,7 @@ <section class="card" style="margin-top:.85rem" aria-labelledby="agents-title"> <header><h2 id="agents-title">Agents</h2></header> <div class="body tight"> - <table class="data" id="agents-table"> + <table class="data scroll" id="agents-table"> <thead> <tr> <th>Host</th><th>Status</th><th>Serial</th><th>Expires</th> diff --git a/src/dashboards/web/pages/disks.html b/src/dashboards/web/pages/disks.html index 6646392..3ea8eb9 100644 --- a/src/dashboards/web/pages/disks.html +++ b/src/dashboards/web/pages/disks.html @@ -22,7 +22,6 @@ <div class="masthead-right"> <div id="nav-status" class="gap" aria-live="polite"></div> <span class="clock" id="clock">--:--:--</span> - <button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button> <a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a> <a class="ghost account" href="/settings" title="Session and password">⚙ Account</a> <button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button> diff --git a/src/dashboards/web/pages/llm.html b/src/dashboards/web/pages/llm.html index a493085..f13464a 100644 --- a/src/dashboards/web/pages/llm.html +++ b/src/dashboards/web/pages/llm.html @@ -22,7 +22,6 @@ <div class="masthead-right"> <div id="nav-status" class="gap" aria-live="polite"></div> <span class="clock" id="clock">--:--:--</span> - <button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button> <a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a> <a class="ghost account" href="/settings" title="Session and password">⚙ Account</a> <button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button> diff --git a/src/dashboards/web/pages/system.html b/src/dashboards/web/pages/system.html index 7606c61..ed47d7d 100644 --- a/src/dashboards/web/pages/system.html +++ b/src/dashboards/web/pages/system.html @@ -22,7 +22,6 @@ <div class="masthead-right"> <div id="nav-status" class="gap" aria-live="polite"></div> <span class="clock" id="clock">--:--:--</span> - <button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button> <a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a> <a class="ghost account" href="/settings" title="Session and password">⚙ Account</a> <button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button> diff --git a/src/dashboards/web/pages/ups.html b/src/dashboards/web/pages/ups.html index 12c1e71..8aab7b7 100644 --- a/src/dashboards/web/pages/ups.html +++ b/src/dashboards/web/pages/ups.html @@ -22,7 +22,6 @@ <div class="masthead-right"> <div id="nav-status" class="gap" aria-live="polite"></div> <span class="clock" id="clock">--:--:--</span> - <button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button> <a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a> <a class="ghost account" href="/settings" title="Session and password">⚙ Account</a> <button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button> diff --git a/src/dashboards/web/static/app.js b/src/dashboards/web/static/app.js index fc15172..7cf6ff3 100644 --- a/src/dashboards/web/static/app.js +++ b/src/dashboards/web/static/app.js @@ -583,36 +583,35 @@ const Dash = (() => { } /** - * Poll `fn` every `ms`, skipping overlapping runs and pausing when the tab is hidden - * or the user hit pause. Errors are reported, never swallowed silently. + * Poll `fn` every `ms`, skipping overlapping runs and idling while the tab is hidden. + * Errors are reported, never swallowed silently. + * + * The hidden-tab skip is idle hygiene rather than something the operator holds: a backgrounded + * tab asks for nothing, and `visibilitychange` fires one poll on return. There is no + * operator-facing way to stop the loop any more (phase 21 task 01, `TODO.md:7`): the control + * that did left the numbers on screen looking current with no `stale` label on them, and + * `Space` tripped it from anywhere on the page. */ function poll(fn, ms) { let stopped = false; let timer = null; let inFlight = false; const state = { - get paused() { return state._paused; }, - _paused: false, interval: ms, setInterval(next) { state.interval = next; if (timer) { clearTimeout(timer); tick(); } }, - setPaused(next) { - state._paused = next; - if (next) { if (timer) clearTimeout(timer); timer = null; } - else if (!timer) tick(); - }, stop() { stopped = true; if (timer) clearTimeout(timer); }, runNow() { tick(); }, }; async function tick() { - if (stopped || inFlight || state._paused || document.hidden) { schedule(); return; } + if (stopped || inFlight || document.hidden) { schedule(); return; } inFlight = true; try { await fn(); } finally { inFlight = false; schedule(); } } function schedule() { - if (stopped || state._paused) return; + if (stopped) return; timer = setTimeout(tick, state.interval); } - document.addEventListener('visibilitychange', () => { if (!document.hidden && !state._paused) tick(); }); + document.addEventListener('visibilitychange', () => { if (!document.hidden) tick(); }); tick(); return state; } @@ -769,25 +768,23 @@ const Dash = (() => { toastTimer = setTimeout(() => { host.remove(); host = null; }, 10000); } - /** Pause button + Space, wired to a poll handle. */ - function bindPause(poller) { - const button = $('#pause'); - if (!button || !poller) return; - const paint = () => { - button.setAttribute('aria-pressed', poller.paused ? 'true' : 'false'); - button.textContent = poller.paused ? '▶ Paused' : '⏸ Pause'; - }; - button.addEventListener('click', () => { poller.setPaused(!poller.paused); paint(); }); + /** + * The one keyboard shortcut the dashboard pages offer: `T` toggles the theme. + * + * It stayed a per-page call rather than moving into `initChrome()`, which every page runs: its + * reach is what it was before phase 21 task 01 deleted the `Space` binding that shared this + * listener (`TODO.md:7`), and widening `T` to `/login` is not that task. The input guard is + * what keeps a keystroke typed into a field from being a shortcut. + */ + function bindShortcuts() { document.addEventListener('keydown', (event) => { const tag = (event.target && event.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || event.metaKey || event.ctrlKey) return; - if (event.code === 'Space') { event.preventDefault(); poller.setPaused(!poller.paused); paint(); } if (event.key === 't' || event.key === 'T') { const theme = $('#theme-toggle'); if (theme) theme.click(); } }); - paint(); } /** The "last poll" line every card header shows. */ @@ -818,7 +815,7 @@ const Dash = (() => { } 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, bindShortcuts, staleness, markStale, severityFor, severityFrom, hero, ageSeconds, naText, eventText, eventsPager, EVENT_PAGE_SIZE, EVENT_FEED_LIMIT, // phase 11: the session this browser holds, the one call that fills it in, and the diff --git a/src/dashboards/web/static/dash.css b/src/dashboards/web/static/dash.css index 3d3b5a8..acefec9 100644 --- a/src/dashboards/web/static/dash.css +++ b/src/dashboards/web/static/dash.css @@ -100,7 +100,7 @@ a:hover { text-decoration: underline; } * — "the hosts disks engines and UPS indicators … left justified in their own row", "the * ⚷ Certs ⚙ Account ☾ Dark buttons … in their own row under" them): row 1 the brand, * `#nav-toggle` and the five tabs; row 2 the `#nav-status` reachability dots; row 3 the - * `.masthead-right` controls (clock, `⏸ Pause`, `⚷ Certs`, `⚙ Account`, theme). + * `.masthead-right` controls (clock, `⚷ Certs`, `⚙ Account`, theme). * * No markup changed to get them, and that decides the mechanism. `#nav-status` is the first * child of `.masthead-right`, so there is no wrapper to promote: in a `flex-wrap: wrap` @@ -173,7 +173,7 @@ nav.tabs a[aria-current="page"] { background: var(--accent-soft); color: var(--a * what pinned this cluster to the right edge of the old single-row bar; in a row of its own it * pushes against nothing, and `justify-content: flex-start` is the TODO's word for where the * buttons go. `flex-basis: 100%` is the row break (see `.masthead` above) and `flex-wrap` is - * what a phone gets when clock + pause + two links + theme will not share a line — phase 18's + * what a phone gets when clock + two links + theme will not share a line — phase 18's * rule, and no control is ever hidden to make the arithmetic work. */ .masthead-right { flex-basis: 100%; @@ -281,8 +281,8 @@ button.ghost:disabled { opacity: .45; cursor: not-allowed; } * is `flex-wrap: wrap`) and `⚷ Certs` broke over two lines, because every item shrank to a * fraction of its content. So the cluster may wrap, its items may neither shrink nor break a * label, and the dots stay the one line that makes them a readout instead of a stack. Row 3 - * still wraps *within itself*: `⏸ Pause`, `⚷ Certs`, `⚙ Account` and the theme button do not - * fit in 320px once over on the narrowest phones, and nothing here hides a control to make + * still wraps *within itself*: `⚷ Certs`, `⚙ Account` and the theme button do not fit in + * 320px once over on the narrowest phones, and nothing here hides a control to make * the arithmetic work — a phone gets the same words every other width does. The row * alignment is the base rule's `flex-start`, restated nowhere: phase 20 replaced the * right-justified cluster, so this block only tightens the gap. */ @@ -737,8 +737,29 @@ main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; } display: grid; gap: .9rem 1rem; grid-template-columns: repeat(auto-fit, minmax(min(118px, 100%), 1fr)); } -/* a slot that holds more than one hero (one per GPU card) without breaking the row */ +/* a slot that holds more than one thing (one GPU card per physical card) without breaking + the row; `hidden` — which `renderGpuSlot()` sets for a host with no card — beats the + `display` here through the `[hidden]` rule at the end of this sheet */ .hero-slot { display: grid; gap: .7rem; min-width: 0; align-content: start; } +/* One GPU: the busy hero, then its four labelled values (phase 21 task 02 — the dense + `model · VRAM · pool · °C` footnote in 11px mono is what TODO.md:12 asked for values of). + `[hidden]` at the end of this sheet is what removes a GPU-less host's slot from the + `.heroes` row entirely, so the row reflows to five rather than showing a wide `n/a`. */ +.gpu-card { display: grid; gap: .3rem; min-width: 0; } +/* The four values: `.metric` shapes (label above value) at the same `.sm` size every other + secondary number on this page uses, in two columns on a wide slot and one on a narrow + one. `auto-fit`, not a fixed pair of tracks, because this grid lives inside one hero + track and the track's width is the fleet's business, not the stylesheet's. No colour + vocabulary at all (phase 06 has no `gpu.*` threshold row); `overflow-wrap` is the half + of TODO.md:12 that a 20-character model string wraps instead of truncating. The 150px floor + is measured, not taste: a hero track is ~180px wide on a three-host fleet at 1280px, and a + 20-character model string in mono at `.sm` needs ~170px, so a narrower floor would put the + TODO's own string back on three lines. Wide slots get their 2×2; narrow ones stack 4×1. */ +.gpu-values { + display: grid; gap: .3rem .6rem; min-width: 0; + grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); +} +.gpu-values .value { overflow-wrap: anywhere; } /* Network is one hero with two lines: in and out are one wire seen in two directions, and two big numbers side by side read as a competition. It is deliberately colourless — a byte rate has no good/bad, so no verdict ever paints here (severity.py has no net.* diff --git a/src/dashboards/web/static/page-disks.js b/src/dashboards/web/static/page-disks.js index 5f4c9af..810748b 100644 --- a/src/dashboards/web/static/page-disks.js +++ b/src/dashboards/web/static/page-disks.js @@ -557,5 +557,5 @@ document.addEventListener('DOMContentLoaded', async () => { ['90d', 7776000]]); state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs); - Dash.bindPause(state.poller); + Dash.bindShortcuts(); }); diff --git a/src/dashboards/web/static/page-llm.js b/src/dashboards/web/static/page-llm.js index 0d18907..6a6e875 100644 --- a/src/dashboards/web/static/page-llm.js +++ b/src/dashboards/web/static/page-llm.js @@ -1033,5 +1033,5 @@ document.addEventListener('DOMContentLoaded', async () => { }); state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs); - Dash.bindPause(state.poller); + Dash.bindShortcuts(); }); diff --git a/src/dashboards/web/static/page-system.js b/src/dashboards/web/static/page-system.js index 60883ed..0de8dc4 100644 --- a/src/dashboards/web/static/page-system.js +++ b/src/dashboards/web/static/page-system.js @@ -32,18 +32,23 @@ const state = { // ---- host cards ----------------------------------------------------------- // -// Six heroes per host, in one fixed order: CPU, MEM, TEMP, DISK, GPU, NET. Every colour +// Six hero slots per host, in one fixed order: CPU, MEM, TEMP, DISK, GPU, NET. Every colour // comes from the payload's `severity` dicts — `severity.py` owns the thresholds, this file // compares no number to any bound — and `Dash.hero()` prints `n/a` with **no** colour // class when the number or its verdict is missing, so gateway's absent CPU sensor cannot // borrow last poll's green. // +// The GPU slot is the one that can leave the row: a host with no card renders **five** +// heroes, not a sixth of `n/a`s (`renderGpuSlot`, TODO.md:12). What it does render is the +// busy hero plus four labelled values per card — Model / VRAM / Pool / Temp — because the +// dense ` · `-joined footnote they replaced was "too much info for such small text". +// // `data-sev` names the verdict each slot paints; tests/test_api.py pins the six slots and // their order on the served bytes. Two slots carry none on purpose. NET: `network` has no // `severity` (a byte rate has no good/bad). GPU: `severity.THRESHOLDS` has no `gpu.*` row // either — these are LLM servers, a 95 %-busy card is the job, so the GPU hero prints its // numbers uncoloured at every level and carries no utilisation bar (phase 06; a bar under -// a verdictless number would be a verdict by shape). +// a verdictless number would be a verdict by shape), and neither does any of its four cells. // // Per-core usage is not plotted here or anywhere on this page — the series is still // stored as `cpu.usage_pct@coreN`, the page just stopped drawing a pattern nobody reads. @@ -69,7 +74,7 @@ function hostCard(name) { <div class="hero hero-bar" data-k="mem" data-sev="memory.severity.used_pct"></div> <div class="hero" data-k="temp" data-sev="cpu.severity.temp_c"></div> <div class="hero hero-bar" data-k="disk" data-sev="disks.severity.worst_used_pct"></div> - <div class="hero-slot" data-k="gpus"></div> + <div class="hero-slot" data-k="gpus" hidden></div> <div class="hero" data-k="net"> <div class="label">Net</div> <div class="hero-lines"> @@ -134,59 +139,102 @@ function memorySub(mem) { return `${bytes(mem.used_bytes, 1)} of ${bytes(mem.total_bytes, 0)}`; } -/** Model, VRAM used / total, the honest APU pool view, and the card's temperature: - * `Radeon AI PRO R9700 · VRAM 30.5 GB / 31.9 GB · 43 °C`. +/** The four labelled values under one GPU's busy hero: **Model**, **VRAM**, **Pool**, + * **Temp** — `TODO.md:12`: "`Radeon 8060S Graphics · VRAM 180.2 MiB / 512.0 MiB · pool 79% + * · 69 °C` is too much info for such small text and should be its own values." * - * A Strix Halo's model lives in GTT, so "VRAM 3%" and "pool 78%" are both true and the card - * shows both, not whichever flatters the machine. + * So each fact gets a label and a value of its own instead of one ` · `-joined sentence at + * 11px, and the model string wraps rather than truncating (the phase's readability complaint + * *is* the model string). Nothing was invented for this card: the four cells read + * `gpus[].model`, `vram_used_bytes`/`vram_total_bytes`, `mem_used_pct` and `temp_c` — the + * same paths `tests/card_contract.py` pins, the same `gpus[].temp_c` (the agent's own max + * over the card's hwmon labels, phase 06) that the `gpu.temp_c@N` history series stores, and + * the per-label breakdown still lives in "More detail". * - * The temperature rides here because it is the one number worth glancing at *while* a card - * is busy (phase 06: "Pull GPU temp for the system resources tab where you can"). It is - * `gpus[].temp_c`, the agent's own max over the card's hwmon labels — no new field, no new - * metric name, and the per-label table in "More detail" still carries the breakdown. - * A card whose agent cannot read a sensor (an APU build with no hwmon input) prints no - * temperature at all: `n/a` is for numbers the card is supposed to have, and `0 °C` would - * be a cool GPU that does not exist. */ -function gpuSub(gpu) { - const bits = [gpu.model || 'GPU']; - bits.push(gpu.vram_used_bytes == null || gpu.vram_total_bytes == null - ? 'VRAM n/a' - : `VRAM ${bytes(gpu.vram_used_bytes, 1)} / ${bytes(gpu.vram_total_bytes, 1)}`); - if (gpu.mem_used_pct != null) bits.push(`pool ${num(gpu.mem_used_pct, 0)}%`); - if (gpu.temp_c != null) bits.push(`${num(gpu.temp_c, 0)} °C`); - return bits.join(' · '); + * A Strix Halo's model lives in GTT, so "VRAM 32 %" and "pool 79 %" are both true and the + * card shows both, not whichever flatters the machine. + * + * A field the agent did not publish prints `n/a` in its own cell — the card is real, so the + * number is one it is supposed to have (rule 2), and `0 °C` would be a cool GPU that does + * not exist. An absent *card* is the other fact and is handled by `renderGpuSlot()`, which + * omits the whole slot. No colour: `severity.THRESHOLDS` has no `gpu.*` row, so these cells + * print in plain ink at 0 % and at 100 % alike (phase 06). */ +const GPU_VALUE_FIELDS = [ + { label: 'Model', field: 'model', mono: true }, + // `31.3 GiB / 31.9 GiB`: used without total is the number that already has its own + // percentage on the card, so this cell is the only one that reads two keys. + { label: 'VRAM', field: 'vram_used_bytes', total: 'vram_total_bytes' }, + // the honest APU view — a Strix Halo keeps the model in GTT, so this and VRAM differ + { label: 'Pool', field: 'mem_used_pct', unit: '%', digits: 0 }, + // the agent's max over this card's hwmon labels, i.e. `gpu.temp_c@N` re-spoken + { label: 'Temp', field: 'temp_c', unit: ' °C', digits: 0 }, +]; + +/** Render the table above for one card: four `.metric` cells, `n/a` faint where the agent + * published nothing. No severity class on any of them (phase 06). */ +function gpuValues(gpu) { + return GPU_VALUE_FIELDS.map((cell) => { + let text; + if (cell.total) { + text = gpu[cell.field] == null || gpu[cell.total] == null + ? null + : `${bytes(gpu[cell.field], 1)} / ${bytes(gpu[cell.total], 1)}`; + } else if (gpu[cell.field] == null) { + text = null; + } else if (cell.unit) { + text = `${num(gpu[cell.field], cell.digits)}${cell.unit}`; + } else { + // the one free-text cell on the card, and it comes from a PCI device name + text = DashCharts.escapeHtml(String(gpu[cell.field])); + } + const missing = text == null; + return `<div class="metric"><div class="label">${cell.label}</div>` + + `<div class="value sm${cell.mono ? ' mono' : ''}${missing ? ' is-null na' : ''}">${ + missing ? 'n/a' : text}</div></div>`; + }).join(''); } -/** The GPU slot of the hero row: one hero per card, or the honest no-GPU state. A box - * with no card is not a box at 0% busy, and gateway must not look broken for it. - * No `severity` and no `bar` on purpose (phase 06): utilisation and VRAM carry no verdict - * on an LLM server, so the number renders uncoloured at 0 % and at 100 % alike. */ +/** The GPU slot of the hero row: one busy hero plus its four labelled values per card. + * + * A host with **no** GPU renders nothing at all (`TODO.md:12` — "Boxes without a GPU should + * not report N/A but instead should omit these values"): the wrapper is emptied and + * `hidden`, which `[hidden] { display: none !important }` in `dash.css` takes out of the + * `.heroes` grid so the row reflows to five. A big `n/a` in the sixth slot made gateway + * look broken over hardware it never had, which is invariant 3's "unconfigured is not + * broken" on the one page that had not learned it yet. The wrapper element itself stays — + * the hero row's markup order remains one static list, a host whose agent starts reporting + * a card needs the slot to grow into, and an empty hidden element is zero footprint. + * + * No `severity` and no `bar` anywhere in here on purpose (phase 06): utilisation and VRAM + * carry no verdict on an LLM server, so every number in this slot renders uncoloured. */ function renderGpuSlot(cell, gpus) { if (cell.dataset.count !== String(gpus.length)) { cell.innerHTML = ''; - for (let i = 0; i < Math.max(gpus.length, 1); i += 1) { + for (let i = 0; i < gpus.length; i += 1) { const row = document.createElement('div'); - row.className = 'hero'; + row.className = 'gpu-card'; + row.innerHTML = '<div class="hero"></div><div class="gpu-values"></div>'; cell.appendChild(row); } cell.dataset.count = String(gpus.length); } + cell.hidden = gpus.length === 0; const rows = Array.from(cell.children); - if (!gpus.length) { - Dash.hero(rows[0], { label: 'GPU', value: null, sub: 'No GPU reported by this agent' }); - return; - } gpus.forEach((gpu, i) => { - Dash.hero(rows[i], { + Dash.hero(rows[i].querySelector('.hero'), { label: gpus.length > 1 ? `GPU ${i}` : 'GPU', value: gpu.busy_pct, unit: '%', digits: 0, - sub: gpuSub(gpu), }); + // `gpuValues()` is four cells of text; rebuilding them is cheap and the hero above + // still patches in place, which is the part with the tabular-numeral layout to keep. + const values = rows[i].querySelector('.gpu-values'); + const markup = gpuValues(gpu); + if (values.innerHTML !== markup) values.innerHTML = markup; }); } -/** What the GPU hero left out, in "More detail": power and both memory pools, plus the - * per-label temperature breakdown behind the single number in the hero's footnote. */ +/** What the four labelled values left out, in "More detail": power and both memory pools, + * plus the per-label temperature breakdown behind the single number in the GPU's Temp cell. */ function renderGpuExtra(cell, gpus) { if (!gpus.length) { cell.innerHTML = ''; return; } cell.innerHTML = ` @@ -605,5 +653,5 @@ document.addEventListener('DOMContentLoaded', async () => { }); state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs); - Dash.bindPause(state.poller); + Dash.bindShortcuts(); }); diff --git a/src/dashboards/web/static/page-ups.js b/src/dashboards/web/static/page-ups.js index 999024a..34dfe0d 100644 --- a/src/dashboards/web/static/page-ups.js +++ b/src/dashboards/web/static/page-ups.js @@ -504,5 +504,5 @@ document.addEventListener('DOMContentLoaded', async () => { } state.poller = poll(refresh, state.pollMs); - Dash.bindPause(state.poller); + Dash.bindShortcuts(); }); diff --git a/tests/card_contract.py b/tests/card_contract.py index 0fafd52..44a5f46 100644 --- a/tests/card_contract.py +++ b/tests/card_contract.py @@ -35,8 +35,10 @@ utilisation — the *absence* of the dict is pinned below, the way ``network``'s ``gpus[].temp_c`` is on the card because the number worth reading *while* a card is busy is how hot it is (phase 06, TODO.md L9). It is the agent's **max over that card's hwmon labels** — the same max the agent already computes and the same value stored as -``gpu.temp_c@N`` — so the hero prints a measurement rather than a new field, and the value -may honestly be ``None`` on a box whose driver exposes no temperature input. +``gpu.temp_c@N`` — so the card prints a measurement rather than a new field, and the value +may honestly be ``None`` on a box whose driver exposes no temperature input (phase 21 task +02 made it one of the card's four labelled values, and the GPU-less host that has no card at +all prints no values at all — see ``GPU_VALUE_CELLS`` in ``tests/test_api.py``). """ from __future__ import annotations @@ -63,8 +65,9 @@ GPU_METRICS: tuple[str, ...] = ( "vram_used_pct", # the honest APU view: vram + GTT, because a Strix Halo keeps the model in GTT "mem_used_pct", - # the hero's footnote: the agent's max over this card's hwmon labels (None is legal — - # a driver with no temperature input must not be rendered as 0 °C) + # the Temp cell of the card's four labelled values (phase 21 task 02): the agent's max + # over this card's hwmon labels. None is legal — a driver with no temperature input is + # `n/a` in that cell and must never be rendered as 0 °C. "temp_c", ) diff --git a/tests/test_api.py b/tests/test_api.py index 9774743..a13171c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -26,14 +26,17 @@ from fastapi.testclient import TestClient import store_perf from auth_client import sign_in from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2 +from card_contract import HOST_SHAPES, assert_card_contract from dashboards import alerts, auth, certs from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, parse_range from dashboards.app import create_app from dashboards.collectors import UpstreamError from dashboards.config import Settings, Target +from dashboards.rates import RateEstimator from dashboards.severity import classify from dashboards.sources import nut from dashboards.sources.disks import SMART_SERIES, DisksCollector, parse_disks_snapshot +from dashboards.sources.host import HostCollector, parse_host_snapshot from dashboards.sources.llm import ( HISTORY_METRICS, parse_gufo_metrics, @@ -999,7 +1002,8 @@ def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> N def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None: """`⚙ Account` and `⚷ Certs` are the only way to the two admin pages, so they are everywhere. - One loop over all eight signed-in pages, because the controls are now the *same* on all of + One loop over all seven signed-in pages (`MARKUP_PAGES`), because the controls are now the *same* + on all of them: clock → `⚷ Certs` → `⚙ Account` → theme toggle, in `.masthead-right` and **outside** `.tabs` (the tab strip's structure — five dashboards, one of them current — is pinned by `test_the_disks_tab_is_in_the_masthead_of_every_page`, and a sixth entry with no @@ -1015,7 +1019,7 @@ def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None: certs_open = '<a class="ghost account" href="/certs"' # the opening tag **and** its label: the claim is the cluster does not change shape between # pages, and `/certs` shipped a theme toggle missing its `aria-pressed` for exactly the - # reason nothing ever compared the eight markups + # reason nothing ever compared the seven markups account = f'{account_open} title="Session and password">⚙ Account</a>' certs_link = f'{certs_open} title="Agent certificates">⚷ Certs</a>' theme = '<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">' @@ -1099,6 +1103,25 @@ def test_the_certs_page_carries_the_authority_and_the_agents_table(client) -> No assert "/api/certs/root/rotate" in js +def test_the_certs_agents_table_scrolls_to_its_last_column(client) -> None: + """`#agents-table` carries the `.scroll` modifier, and the stylesheet still styles it. + + `TODO.md:9`: at phone width the serial, expiry, `san` and controls columns were clipped by + `.card { overflow: hidden }` with no way to reach them. `dash.css` turns `table.data` into + a scroll container **only** through the `scroll` class, so the class and the rule are one + mechanism and are pinned together — the phase-18 idiom of comparing the two places a + contract lives, because a rename on either side silently un-scrolls the table. + """ + html = client.get(CERTS_PAGE).text + assert '<table class="data scroll" id="agents-table">' in html, \ + "the agents table lost its .scroll modifier — the controls column is unreachable at 360px" + + css = client.get("/static/dash.css").text + start = css.index("table.data.scroll {") + rule = css[start:css.index("}", start)] + assert "overflow: auto" in rule, "table.data.scroll no longer scrolls — the class names nothing" + + def test_the_certs_page_prints_the_names_the_authority_mints(client) -> None: """The CN of each certificate is markup, so the literal is pinned to `certs.py`. @@ -1427,42 +1450,212 @@ def test_system_page_colours_from_the_payload_not_from_js_thresholds(client) -> assert "hero-bar" not in gpu_slot, "the GPU hero rebuilt the bar variant of .hero" -def test_gpu_hero_footnote_prints_the_temperature_the_agent_read(client) -> None: - """TODO.md L9: the GPU temperature is glanceable on the hero row, not only in the table. +#: Every cell of the `/system` GPU card's value grid, in render order, with the `gpus[]` +#: key(s) it prints. This is the `SLOT_FIELDS` device applied to the four values phase 21 +#: task 02 created: the table is read out of the served `GPU_VALUE_FIELDS` and resolved +#: against every captured host shape *and* against `/api/system/snapshot`, so a rename on +#: either side fails a test instead of printing `n/a` beside a healthy fleet — and so the +#: dense ` · `-joined sub-line the TODO complained about cannot come back. +GPU_VALUE_CELLS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("Model", ("model",)), + ("VRAM", ("vram_used_bytes", "vram_total_bytes")), + ("Pool", ("mem_used_pct",)), + ("Temp", ("temp_c",)), +) - `gpus[].temp_c` is the agent's max over that card's hwmon labels (`sources/host.py`, - pinned by `tests/test_system_contract.py`), so the hero needed no new field and the - `gpu.temp_c@N` history series kept its name. The null guard *is* the behaviour, so that - is what the assertion pins (there is no JS test runner — a locked anchor): a card whose - driver exposes no temperature prints none, because `n/a` is for numbers the card is - supposed to have and a `0 °C` would be a cool GPU that does not exist. + +def _gpu_value_fields(js: str) -> list[tuple[str, tuple[str, ...]]]: + """The served `GPU_VALUE_FIELDS` table as `(label, payload keys)` rows.""" + table = re.search(r"const GPU_VALUE_FIELDS = \[(.*?)\n\];", js, re.S) + assert table, "page-system.js no longer has a GPU_VALUE_FIELDS table" + rows: list[tuple[str, tuple[str, ...]]] = [] + for entry in re.findall(r"\{[^{}]*\}", table.group(1)): + label = re.search(r"label: '(\w+)'", entry) + field = re.search(r"field: '(\w+)'", entry) + assert label and field, f"a GPU value cell names no label or field: {entry!r}" + total = re.search(r"total: '(\w+)'", entry) + rows.append((label.group(1), + (field.group(1), total.group(1)) if total else (field.group(1),))) + return rows + + +def test_gpu_values_are_four_labelled_cells_not_a_dense_sub_line(client) -> None: + """`TODO.md:12` — "… is too much info for such small text and should be its own values." + + The GPU slot used to print `Radeon 8060S Graphics · VRAM 180.2 MiB / 512.0 MiB · pool + 79% · 69 °C` as one `.sub` line in 11px mono. It is now four labelled cells, and the + pin is bidirectional: the table has the four rows in order, and the joined-sentence + builder that produced the TODO's quoted string is gone (`gpuSub` and its + `bits.join(' · ')` are the thing that made the card unreadable). + + Two invariants ride on the same slice. The model string is upstream text (a PCI device + name), so it leaves through `DashCharts.escapeHtml`; and the temperature keeps its + null guard — `0 °C` would be a cool GPU that does not exist (phase 06). """ js = client.get("/static/page-system.js").text - sub = js[js.index("function gpuSub("):js.index("function renderGpuSlot(")] - assert "gpu.temp_c" in sub, "the GPU hero stopped printing the temperature" - assert "if (gpu.temp_c != null)" in sub, "the temperature lost its null guard" - assert sub.count("°C") == 1, ( - "a second °C in gpuSub is a branch printing a temperature nobody measured" - ) + assert _gpu_value_fields(js) == list(GPU_VALUE_CELLS), \ + "the GPU value grid is no longer Model / VRAM / Pool / Temp over those payload keys" + # the °C rides in the value (the label is the word, as on the /disks card and the GPU + # detail table), and it appears exactly once in the table: one cell, one unit. + assert js.count("unit: ' °C'") == 1, "the Temp cell's unit is not the one °C on the card" + + body = js[js.index("function gpuValues("):js.index("function renderGpuSlot(")] + assert "GPU_VALUE_FIELDS.map(" in body, "gpuValues() stopped rendering the table" + assert "escapeHtml" in body, "the Model cell prints an upstream string unescaped" + assert "== null" in body, "a cell lost its null guard (missing must print n/a, not 0)" + + # the dense sub-line the TODO quoted is gone, container and all + assert "function gpuSub(" not in js, "gpuSub() came back" + assert "join(' · ')" not in js, "the ` · `-joined GPU footnote came back" + # "More detail" keeps the per-label breakdown: a surface change, not a de-duplication extra = js[js.index("function renderGpuExtra("):js.index("function renderHostCard(")] assert "Temp" in extra and "gpu.temp_c" in extra, "GPU detail lost its temperature column" +def test_gpu_value_cells_resolve_against_every_captured_host( + agent_snapshots: dict[str, dict[str, Any]]) -> None: + """Every key the four cells print exists on every captured card — discrete GPU and APU. + + `tests/card_contract.py` owns the payload table; this resolves the *page's* table + against the same three captures, which is where a rename would otherwise be caught by + an operator looking at `n/a` next to a healthy machine. `temp_c` may legitimately be + `None` (a driver with no hwmon input) — present is the contract, and the cell prints + `n/a` for it, never `0 °C`. + """ + for name in HOST_SHAPES: + payload = parse_host_snapshot(name, agent_snapshots[name], HostCollector( + RateEstimator(span=60.0)), now=1_000.0).payload + assert_card_contract(payload) + for gpu in payload["gpus"]: + for label, keys in GPU_VALUE_CELLS: + for key in keys: + assert key in gpu, f"{name}: the {label!r} cell has no {key!r} key" + if not payload["gpus"]: + # gateway: the omission's input. An empty list is a *fact* about the box, and + # `renderGpuSlot()` turns that fact into an absent slot rather than an `n/a`. + assert name == "gateway", "a capture lost its GPU row" + + +def test_the_gpu_value_cells_are_served_for_the_hosts_that_have_a_card( + tmp_path: Path, agent_snapshots: dict[str, dict[str, Any]]) -> None: + """The same four keys over HTTP, on the body `/system` actually renders. + + Parsing in-process proves the source; this proves the endpoint. One app with all three + hosts injected (dead ports, so no poll can overwrite what the test put there) answers + with the discrete-GPU card, the APU card, and gateway's empty list — the exact union + `renderGpuSlot()` branches on: cards present means four labelled values per card, `[]` + means no slot at all. + """ + db = str(tmp_path / "gpu-cells.sqlite3") + store = SampleStore(db) + app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD, + pbkdf2_iterations=FAST_PBKDF2, + host_targets=tuple( + Target(name, DEAD_URL) for name in HOST_SHAPES)), store) + test_client = TestClient(app) + test_client.__enter__() + try: + sign_in(test_client) + hub = app.state.ctx.hub + for name in HOST_SHAPES: + hub.jobs[f"host:{name}"].state.payload = parse_host_snapshot( + name, agent_snapshots[name], HostCollector(RateEstimator(span=60.0)), + now=1_000.0).payload + hosts = {row["name"]: row for row in test_client.get("/api/system/snapshot").json()["hosts"]} + assert set(hosts) == set(HOST_SHAPES) + for name, row in hosts.items(): + for gpu in row["gpus"]: + for label, keys in GPU_VALUE_CELLS: + for key in keys: + assert key in gpu, f"{name}: /api/system/snapshot has no {key!r} " \ + f"for the {label!r} cell" + assert hosts["gateway"]["gpus"] == [], \ + "gateway must answer [] — that is what makes the page omit the slot" + assert hosts["driveripper"]["gpus"] and hosts["deskwork"]["gpus"] + finally: + test_client.__exit__(None, None, None) + store.close() + + +def test_a_host_with_no_gpu_omits_the_gpu_slot_instead_of_printing_n_a(client) -> None: + """`TODO.md:12` — "Boxes without a GPU should not report N/A but instead should omit + these values." + + gateway has no card, and the sixth hero slot used to say so with a big faint `n/a` plus + the sentence "No GPU reported by this agent" — invariant 3's complaint in the one place + the page had not learned it. `renderGpuSlot()` now empties the slot and sets `hidden`, + and `[hidden] { display: none !important }` at the end of `dash.css` is what takes the + wrapper out of the `.heroes` grid so the row reflows to five. Pinned on the served + bytes (no JS runner — a locked anchor) and measured in a browser by + `data/logs/21-task02-gpu-values.cjs`. + + The wrapper element itself stays in `hostCard()`: the hero row remains one static list + of six `data-k` slots (pinned by the test below), a host whose agent starts reporting a + card has something to grow into, and an empty hidden element is zero footprint. + """ + js = client.get("/static/page-system.js").text + slot = js[js.index("function renderGpuSlot("):js.index("function renderGpuExtra(")] + assert "cell.hidden = gpus.length === 0" in slot, \ + "the GPU slot no longer hides itself when the host has no card" + assert "No GPU reported" not in js, "the no-GPU n/a sentence came back" + assert "n/a" not in slot, "the GPU slot grew its own n/a fallback again" + # and the row itself keeps the six static slots, so a GPU host still has somewhere to go + assert "display: none !important" in client.get("/static/dash.css").text, \ + "[hidden] stopped beating .hero-slot's display, so a hidden slot would still " \ + "occupy a hero track" + + +def test_gpu_value_grid_ships_no_verdict_and_wraps(client) -> None: + """The four new cells colour nothing and let a long model string wrap. + + Phase 06's rule extends to them: `severity.THRESHOLDS` has no `gpu.*` row, the payload + carries no `gpus[].severity`, so neither the markup nor the grid may reach for + `--ok`/`--warn`/`--fail` or a `severity` option (a busy GPU on an LLM server is the job). + `overflow-wrap` is the other half of `TODO.md:12` — the readability complaint *is* the + 20-character model string, and truncating it would keep the complaint. + """ + js = client.get("/static/page-system.js").text + # word-boundary on purpose: the docstring in this slice is allowed to *say* "severity", + # the code is not allowed to pass one (or a bar, or a verdict class) to anything + markup = js[js.index("const GPU_VALUE_FIELDS"):js.index("function renderGpuSlot(")] + verdicts = re.findall(r"\b(ok|warn|fail|severity:|bar:|hero-bar)\b", markup) + assert not verdicts, f"the GPU value grid grew a verdict: {verdicts}" + + css = client.get("/static/dash.css").text + rule = re.search(r"\.gpu-values \{([^}]*)\}", css) + assert rule, "/system lost its .gpu-values grid" + assert "auto-fit" in rule.group(1), ".gpu-values stopped adapting to the hero track" + wrap = re.search(r"\.gpu-values \.value \{([^}]*)\}", css) + assert wrap and "overflow-wrap" in wrap.group(1), \ + "a long GPU model string truncates instead of wrapping" + for token in ("--ok", "--warn", "--fail"): + assert token not in rule.group(1) + (wrap.group(1) if wrap else ""), \ + f"the GPU value grid coloured itself with {token} (severity.py rates no GPU)" + + def test_system_host_card_is_six_heroes_without_per_core(client) -> None: - """Each host card leads with exactly six heroes: CPU, MEM, TEMP, DISK, GPU, NET. + """Each host card ships six hero slots in order: CPU, MEM, TEMP, DISK, GPU, NET. Asserted on the served script because there is no JS test runner (a locked anchor). The two dense `.metrics` rows and the per-core heat bars are exactly what made the card unreadable, and "no per-core anywhere on /system" is a promise a stray `renderCores` call would silently break — the series stays stored, the page just stopped drawing it (PLAN.md *Dropped visuals*). + + Six **slots**, not six visible heroes: the GPU slot ships `hidden` and + `renderGpuSlot()` un-hides it only for a host whose payload has a card, so gateway's + card renders five (pinned by the test above and measured in the browser by + `data/logs/21-task02-gpu-values.cjs`). The static order below is what makes that a + one-line rule instead of a re-flowing template. """ js = client.get("/static/page-system.js").text card = js[js.index("function hostCard("):js.index("function renderHostCard(")] slots = re.findall(r'<div class="(?:hero|hero-slot)[^"]*" data-k="([^"]+)"', card) assert slots == ["cpu", "mem", "temp", "disk", "gpus", "net"], \ "the hero row is no longer the six numbers in the agreed order" + assert re.search(r'data-k="gpus"[^>]*hidden', card), \ + "the GPU slot is visible before the payload says this host has a card" # per-core visualisation is gone from the card code entirely assert "cores" not in card, "a per-core visual came back to the card" assert "renderCores" not in js and "core_usage" not in js @@ -4459,6 +4652,88 @@ def test_the_menu_snaps_in_both_directions_under_reduced_motion(client) -> None: "the named open rule stopped stilling the transition" +# ---- the masthead with no polling control (phase 21 task 01) -------------- # +# +# `TODO.md:7` — "Completely remove the pause button". The control was four things: a masthead +# button on the four dashboards, a `Space` binding, the `setPaused`/`_paused` plumbing inside +# `Dash.poll()`, and a stylesheet comment naming it as a row-3 tenant. A browser is the only +# thing that can prove the operator no longer sees a button, but the served bytes are what ship +# it, so those are what fail a test when someone puts it back. +# +# The **hidden-tab** skip is deliberately *not* part of the removal and is pinned on the same +# page for that reason: a backgrounded tab asking for nothing is idle hygiene rather than a +# decision the operator made, and a poll loop "simplified" back to an unconditional `setTimeout` +# would be invisible until a laptop battery complained. + +#: Every spelling the deleted control ever had, in markup, script or stylesheet. +PAUSE_ARTIFACTS = ('id="pause"', 'name="pause"', "⏸", "▶ Paused", + "bindPause", "setPaused", "_paused") + + +def test_no_page_serves_a_button_that_stops_the_polls(client, tmp_path: Path) -> None: + """Eight pages, eleven assets, zero pause control. + + `/login` needs its own anonymous `TestClient` for the reason every other login-page audit + gives: this `client` is signed in, and a signed-in `GET /login` is a `303` to the dashboard, + which would audit `/`'s bytes and call them `/login`. The other seven are read as themselves. + """ + bodies = {path: client.get(path).text for path in MARKUP_PAGES} + + db = str(tmp_path / "no-pause-login.sqlite3") + store = SampleStore(db) + app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD, + pbkdf2_iterations=FAST_PBKDF2), store) + try: + with TestClient(app) as anon: + response = anon.get("/login") + assert response.status_code == 200, \ + "a signed-out /login must be the form, not a bounce" + bodies["/login"] = response.text + finally: + store.close() + + assert len(bodies) == 8, "the audit lost a page; every served page owes this check" + for path, html in bodies.items(): + for artifact in PAUSE_ARTIFACTS: + assert artifact not in html, f"{path} serves the removed control again: {artifact!r}" + + for asset in ASSETS: + body = client.get(asset).text + for artifact in PAUSE_ARTIFACTS: + assert artifact not in body, \ + f"{asset} still carries the removed control: {artifact!r}" + + +def test_the_poll_loop_still_idles_on_a_hidden_tab_and_binds_no_key_for_the_old_control( + client, +) -> None: + """What survives the deletion, pinned beside what must not come back. + + The first block is the hidden-tab behaviour the phase keeps (`document.hidden` skips the run + and reschedules; `visibilitychange` fires one poll on return). The second is the `Space` + binding: no `event.code` check may live in the shared script, because a key that stops the + numbers with no control on screen to say so is the failure mode this task exists to retire. + The third is the half of the old listener that was never about pausing — `T` still toggles + the theme, on the same four pages it always did. + """ + app_js = client.get("/static/app.js").text + loop = app_js[app_js.index("function poll(fn, ms) {"):app_js.index(" // ---- chrome")] + assert "if (stopped || inFlight || document.hidden) { schedule(); return; }" in loop, \ + "a hidden tab no longer skips its poll — the idle-hygiene guard is not the button" + assert "document.addEventListener('visibilitychange', () => { if (!document.hidden) tick(); })" in loop, \ + "returning to a backgrounded tab no longer fires a poll — the page would sit on old numbers" + + assert "event.code" not in app_js, \ + "a key-code binding is back in the shared script — the only one it carried was the deleted control's" + assert "'Space'" not in app_js, "the deleted control's keyboard shortcut came back" + + assert "event.key === 't' || event.key === 'T'" in app_js, \ + "`T` stopped toggling the theme: the shortcut that shared the deleted binding survives" + for name in ("page-system.js", "page-disks.js", "page-llm.js", "page-ups.js"): + assert "Dash.bindShortcuts();" in client.get(f"/static/{name}").text, \ + f"{name} no longer opts in to the theme shortcut it always had" + + def test_the_header_status_dot_picks_up_the_disks_job(client) -> None: """`disks` gets its own dot instead of hiding inside `hosts`. diff --git a/tests/test_system_contract.py b/tests/test_system_contract.py index 9aaaae4..f0fecad 100644 --- a/tests/test_system_contract.py +++ b/tests/test_system_contract.py @@ -150,8 +150,9 @@ def test_the_discrete_gpu_capture_supplies_real_numbers( assert gpu["busy_pct"] == pytest.approx(snapshot["gpus"][0]["busy_pct"]) assert gpu["vram_used_pct"] == pytest.approx( gpu["vram_used_bytes"] / gpu["vram_total_bytes"] * 100.0) - # the hero's footnote is the agent's own max over this card's hwmon labels (phase 06), - # so the card and the `gpu.temp_c@N` history series can never disagree + # the Temp cell is the agent's own max over this card's hwmon labels (phase 06, printed + # as one of the card's four labelled values since phase 21 task 02), so the card and the + # `gpu.temp_c@N` history series can never disagree assert gpu["temp_c"] == max(snapshot["gpus"][0]["temps"].values()) # one scrape is not a rate: usage and throughput are honestly n/a until the second @@ -243,10 +244,12 @@ def test_the_apu_reports_vram_and_the_whole_addressable_pool( def test_a_gpu_with_no_temperature_input_reports_none_not_zero(agent_snapshot: dict[str, Any], ) -> None: - """Phase 06 put `gpus[].temp_c` on the hero footnote, so its absence must stay honest. + """Phase 06 put `gpus[].temp_c` on the card and phase 21 task 02 gave it its own Temp + cell, so its absence must stay honest. - A driver exposing no hwmon temperature has to come back as `None` (the page prints no - temperature at all) rather than `0 °C`, the same rule + A driver exposing no hwmon temperature has to come back as `None` (the cell prints `n/a`, + the same faint `n/a` every other unmeasured value gets — the *host* with no card at all + is the other fact, and that card is omitted) rather than `0 °C`, the same rule `tests/test_host_source.py::test_missing_sensors_stay_missing` holds for the flat sensor list — and no `gpu.temp_c@N` sample may be written for a number nobody measured. """