phase: 21_page_cleanups
**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`.
This commit is contained in:
@@ -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`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
@@ -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
|
||||||
@@ -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
|
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
|
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
|
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
|
response, no redirect, the same cluster afterwards). The two links therefore sit on all seven
|
||||||
signed-in pages in one order (clock → `⚷ Certs` → `⚙ Account` → theme toggle), which is what lets
|
signed-in pages (the five dashboards plus `/settings` and `/certs`) in one order
|
||||||
the pinned audit be one loop over all eight instead of two over different tuples; `/login` stays
|
(clock → `⚷ Certs` → `⚙ Account` → theme toggle), which is what lets
|
||||||
the one page with neither. Everything on it is
|
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
|
`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,
|
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`,
|
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
|
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
|
`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
|
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 —
|
`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
|
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
|
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.
|
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`):
|
**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
|
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
|
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`
|
`.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
|
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 status # per-source ok/DOWN — unauthenticated: /api/health
|
||||||
./scripts/dev-server.sh logs
|
./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
|
# 4 store_perf), no network
|
||||||
uv run pytest --cov # coverage gate (98.94%; floor 94%)
|
uv run pytest --cov # coverage gate (98.94%; floor 94%)
|
||||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||||
@@ -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
|
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.
|
`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).
|
`.heroes` grid; `tests/test_api.py` pins the slots and their order on the served script).
|
||||||
Colour comes from the payload's `severity` dicts and nowhere else; a missing number or
|
Colour comes from the payload's `severity` dicts and nowhere else, and a missing **number or
|
||||||
verdict — gateway's CPU temp, any host's absent GPU — prints `n/a` **uncoloured**. NET is
|
verdict** prints `n/a` **uncoloured** — gateway's CPU temp, a driver with no temperature
|
||||||
one slot with two lines (`.hero-lines`, `↓` in / `↑` out) and is deliberately colourless:
|
input, a rate with no second sample yet. What a machine does not **have** is a different
|
||||||
throughput has no good/bad, so `network` carries no severity. The GPU slot holds one hero
|
fact and prints nothing at all: the GPU slot ships `hidden` and `renderGpuSlot()` un-hides
|
||||||
per card whose `.sub` carries model, VRAM used/total, the honest APU `pool` view and the
|
it only for a host whose payload carries a card, so gateway's card is **five** heroes with
|
||||||
card's temperature (`gpus[].temp_c` — the agent's max over that card's hwmon labels, the
|
no GPU slot in the row (`TODO.md:12` — "Boxes without a GPU should not report N/A but
|
||||||
same value stored as `gpu.temp_c@N`, so the hero and the history cannot disagree), and it is
|
instead should omit these values"), and the sheet's `[hidden] { display: none !important }`
|
||||||
colourless for the same reason (phase 06): a 95 %-busy card on an LLM server is the job, so
|
is what takes the wrapper out of the grid so the row reflows instead of leaving a wide gap.
|
||||||
the hero carries no `severity` and no utilisation bar — a bar under a verdictless number
|
The two rules are one sentence each in the page and one test each; collapsing them into "any
|
||||||
would be a verdict by shape. A card whose driver exposes no temperature input prints **no**
|
missing thing is `n/a`" is what made a router look broken over the GPU it never had
|
||||||
temperature in that footnote rather than `n/a` or `0 °C`: "where you can" is the operator's
|
(invariant 3). NET is one slot with two lines (`.hero-lines`, `↓` in / `↑` out) and is
|
||||||
wording, and `n/a` is reserved for numbers the card is supposed to have. Everything not
|
deliberately colourless: throughput has no good/bad, so `network` carries no severity.
|
||||||
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"
|
The GPU slot is one busy hero per card plus **four labelled values** under it — **Model**
|
||||||
`<details>`: a simpler surface, not a less complete page (the table keeps its Temp column;
|
(mono, `gpus[].model`), **VRAM** (`used / total`), **Pool** (`mem_used_pct`, the honest APU
|
||||||
this was a promotion, not a de-duplication).
|
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" `<details>`: a simpler surface, not a less complete page (the table keeps its Temp
|
||||||
|
column; this was a promotion, not a de-duplication).
|
||||||
|
|
||||||
A `Δ<window>` label is a measurement, never a config value: `sources/host.py` sends the window
|
A `Δ<window>` 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
|
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
|
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
|
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
|
`.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
|
the GPU **chart** slot is created and destroyed with `host.gpus` (its own thing from the hero
|
||||||
nor a hole. **That stack is collapsed behind a per-host `Charts` `<details>`** (phase 06: "only
|
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` `<details>`** (phase 06: "only
|
||||||
visible if expanded… there will be lots of hosts"): the default `/system` view is the hero row,
|
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
|
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
|
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
|
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
|
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
|
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,
|
(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),
|
`24_dev_mtls_rollout` (never accepting a non-mTLS agent — fleet gap 11's rollout, half of it),
|
||||||
is still in `todo/`.
|
is still in `todo/`.
|
||||||
|
|||||||
@@ -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 —
|
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
|
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
|
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
|
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,
|
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_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_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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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:
|
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 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
|
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
|
tabs, then the per-source indicators on their own row, then the clock / `⚷ Certs` / `⚙ Account`
|
||||||
pages that poll) / `⚷ Certs` / `⚙ Account` / `☾ Dark` controls on a row under those (a page with no
|
/ `☾ 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
|
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
|
narrower width work). There is no control that stops the polling (phase 21): the bar used to carry
|
||||||
signed-in pages, the two pages they name included: the cluster is the same wherever you are, and a
|
a `⏸ Pause` button with a `Space` shortcut, and it left the numbers on screen looking current with
|
||||||
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
|
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
|
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
|
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
|
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
|
charging, dashed outline on battery, a dash through the body when there is no reading) and a
|
||||||
`<title>` / `aria-label` that names it ("apc: Charge 100 percent, on line") unless the number
|
`<title>` / `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
|
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
|
by six hero slots — CPU %, MEM %, TEMP, DISK % (mount underneath), GPU busy, and NET on two
|
||||||
APU pool and the card's temperature in the footnote — no temperature is printed when the
|
lines (↓ in / ↑ out, never coloured: a byte rate has no good/bad). A host with **no GPU**
|
||||||
driver exposes no sensor, never `0 °C`; and likewise never coloured and with no bar: a busy
|
renders **five**: the GPU slot is omitted rather than filled with `n/a`, because a router is
|
||||||
GPU is the job on an LLM server), and NET on two lines (↓ in / ↑ out, never coloured: a byte
|
not broken for the card it never had. A GPU is one busy hero plus **four labelled values**
|
||||||
rate has no good/bad). Under the numbers, **each panel charts only its own host** — behind a
|
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
|
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
|
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
|
(`1h`, `24h` …), and opening it is what builds and paints the charts — instantly, from the
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
<section class="card" style="margin-top:.85rem" aria-labelledby="agents-title">
|
<section class="card" style="margin-top:.85rem" aria-labelledby="agents-title">
|
||||||
<header><h2 id="agents-title">Agents</h2></header>
|
<header><h2 id="agents-title">Agents</h2></header>
|
||||||
<div class="body tight">
|
<div class="body tight">
|
||||||
<table class="data" id="agents-table">
|
<table class="data scroll" id="agents-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Host</th><th>Status</th><th>Serial</th><th>Expires</th>
|
<th>Host</th><th>Status</th><th>Serial</th><th>Expires</th>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
<div class="masthead-right">
|
<div class="masthead-right">
|
||||||
<div id="nav-status" class="gap" aria-live="polite"></div>
|
<div id="nav-status" class="gap" aria-live="polite"></div>
|
||||||
<span class="clock" id="clock">--:--:--</span>
|
<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="/certs" title="Agent certificates">⚷ Certs</a>
|
||||||
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</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>
|
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
<div class="masthead-right">
|
<div class="masthead-right">
|
||||||
<div id="nav-status" class="gap" aria-live="polite"></div>
|
<div id="nav-status" class="gap" aria-live="polite"></div>
|
||||||
<span class="clock" id="clock">--:--:--</span>
|
<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="/certs" title="Agent certificates">⚷ Certs</a>
|
||||||
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</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>
|
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
<div class="masthead-right">
|
<div class="masthead-right">
|
||||||
<div id="nav-status" class="gap" aria-live="polite"></div>
|
<div id="nav-status" class="gap" aria-live="polite"></div>
|
||||||
<span class="clock" id="clock">--:--:--</span>
|
<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="/certs" title="Agent certificates">⚷ Certs</a>
|
||||||
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</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>
|
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
<div class="masthead-right">
|
<div class="masthead-right">
|
||||||
<div id="nav-status" class="gap" aria-live="polite"></div>
|
<div id="nav-status" class="gap" aria-live="polite"></div>
|
||||||
<span class="clock" id="clock">--:--:--</span>
|
<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="/certs" title="Agent certificates">⚷ Certs</a>
|
||||||
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</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>
|
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
|
||||||
|
|||||||
@@ -583,36 +583,35 @@ const Dash = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Poll `fn` every `ms`, skipping overlapping runs and pausing when the tab is hidden
|
* Poll `fn` every `ms`, skipping overlapping runs and idling while the tab is hidden.
|
||||||
* or the user hit pause. Errors are reported, never swallowed silently.
|
* 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) {
|
function poll(fn, ms) {
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
let inFlight = false;
|
let inFlight = false;
|
||||||
const state = {
|
const state = {
|
||||||
get paused() { return state._paused; },
|
|
||||||
_paused: false,
|
|
||||||
interval: ms,
|
interval: ms,
|
||||||
setInterval(next) { state.interval = next; if (timer) { clearTimeout(timer); tick(); } },
|
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); },
|
stop() { stopped = true; if (timer) clearTimeout(timer); },
|
||||||
runNow() { tick(); },
|
runNow() { tick(); },
|
||||||
};
|
};
|
||||||
async function tick() {
|
async function tick() {
|
||||||
if (stopped || inFlight || state._paused || document.hidden) { schedule(); return; }
|
if (stopped || inFlight || document.hidden) { schedule(); return; }
|
||||||
inFlight = true;
|
inFlight = true;
|
||||||
try { await fn(); } finally { inFlight = false; schedule(); }
|
try { await fn(); } finally { inFlight = false; schedule(); }
|
||||||
}
|
}
|
||||||
function schedule() {
|
function schedule() {
|
||||||
if (stopped || state._paused) return;
|
if (stopped) return;
|
||||||
timer = setTimeout(tick, state.interval);
|
timer = setTimeout(tick, state.interval);
|
||||||
}
|
}
|
||||||
document.addEventListener('visibilitychange', () => { if (!document.hidden && !state._paused) tick(); });
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) tick(); });
|
||||||
tick();
|
tick();
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -769,25 +768,23 @@ const Dash = (() => {
|
|||||||
toastTimer = setTimeout(() => { host.remove(); host = null; }, 10000);
|
toastTimer = setTimeout(() => { host.remove(); host = null; }, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pause button + Space, wired to a poll handle. */
|
/**
|
||||||
function bindPause(poller) {
|
* The one keyboard shortcut the dashboard pages offer: `T` toggles the theme.
|
||||||
const button = $('#pause');
|
*
|
||||||
if (!button || !poller) return;
|
* It stayed a per-page call rather than moving into `initChrome()`, which every page runs: its
|
||||||
const paint = () => {
|
* reach is what it was before phase 21 task 01 deleted the `Space` binding that shared this
|
||||||
button.setAttribute('aria-pressed', poller.paused ? 'true' : 'false');
|
* listener (`TODO.md:7`), and widening `T` to `/login` is not that task. The input guard is
|
||||||
button.textContent = poller.paused ? '▶ Paused' : '⏸ Pause';
|
* what keeps a keystroke typed into a field from being a shortcut.
|
||||||
};
|
*/
|
||||||
button.addEventListener('click', () => { poller.setPaused(!poller.paused); paint(); });
|
function bindShortcuts() {
|
||||||
document.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
const tag = (event.target && event.target.tagName || '').toLowerCase();
|
const tag = (event.target && event.target.tagName || '').toLowerCase();
|
||||||
if (tag === 'input' || tag === 'textarea' || event.metaKey || event.ctrlKey) return;
|
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') {
|
if (event.key === 't' || event.key === 'T') {
|
||||||
const theme = $('#theme-toggle');
|
const theme = $('#theme-toggle');
|
||||||
if (theme) theme.click();
|
if (theme) theme.click();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
paint();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The "last poll" line every card header shows. */
|
/** 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,
|
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,
|
staleness, markStale, severityFor, severityFrom, hero, ageSeconds, naText,
|
||||||
eventText, eventsPager, EVENT_PAGE_SIZE, EVENT_FEED_LIMIT,
|
eventText, eventsPager, EVENT_PAGE_SIZE, EVENT_FEED_LIMIT,
|
||||||
// phase 11: the session this browser holds, the one call that fills it in, and the
|
// phase 11: the session this browser holds, the one call that fills it in, and the
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ a:hover { text-decoration: underline; }
|
|||||||
* — "the hosts disks engines and UPS indicators … left justified in their own row", "the
|
* — "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,
|
* ⚷ 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
|
* `#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
|
* 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`
|
* 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
|
* 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
|
* 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
|
* 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. */
|
* rule, and no control is ever hidden to make the arithmetic work. */
|
||||||
.masthead-right {
|
.masthead-right {
|
||||||
flex-basis: 100%;
|
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
|
* 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
|
* 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
|
* 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
|
* still wraps *within itself*: `⚷ Certs`, `⚙ Account` and the theme button do not fit in
|
||||||
* fit in 320px once over on the narrowest phones, and nothing here hides a control to make
|
* 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
|
* 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
|
* alignment is the base rule's `flex-start`, restated nowhere: phase 20 replaced the
|
||||||
* right-justified cluster, so this block only tightens the gap. */
|
* 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;
|
display: grid; gap: .9rem 1rem;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(118px, 100%), 1fr));
|
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; }
|
.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
|
/* 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 —
|
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.*
|
a byte rate has no good/bad, so no verdict ever paints here (severity.py has no net.*
|
||||||
|
|||||||
@@ -557,5 +557,5 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
['90d', 7776000]]);
|
['90d', 7776000]]);
|
||||||
|
|
||||||
state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs);
|
state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs);
|
||||||
Dash.bindPause(state.poller);
|
Dash.bindShortcuts();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1033,5 +1033,5 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs);
|
state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs);
|
||||||
Dash.bindPause(state.poller);
|
Dash.bindShortcuts();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,18 +32,23 @@ const state = {
|
|||||||
|
|
||||||
// ---- host cards -----------------------------------------------------------
|
// ---- 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
|
// 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
|
// 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
|
// class when the number or its verdict is missing, so gateway's absent CPU sensor cannot
|
||||||
// borrow last poll's green.
|
// 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
|
// `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
|
// 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
|
// `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
|
// 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
|
// 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
|
// 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.
|
// 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 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" 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 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="hero" data-k="net">
|
||||||
<div class="label">Net</div>
|
<div class="label">Net</div>
|
||||||
<div class="hero-lines">
|
<div class="hero-lines">
|
||||||
@@ -134,59 +139,102 @@ function memorySub(mem) {
|
|||||||
return `${bytes(mem.used_bytes, 1)} of ${bytes(mem.total_bytes, 0)}`;
|
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:
|
/** The four labelled values under one GPU's busy hero: **Model**, **VRAM**, **Pool**,
|
||||||
* `Radeon AI PRO R9700 · VRAM 30.5 GB / 31.9 GB · 43 °C`.
|
* **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
|
* So each fact gets a label and a value of its own instead of one ` · `-joined sentence at
|
||||||
* shows both, not whichever flatters the machine.
|
* 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
|
* A Strix Halo's model lives in GTT, so "VRAM 32 %" and "pool 79 %" are both true and the
|
||||||
* is busy (phase 06: "Pull GPU temp for the system resources tab where you can"). It is
|
* card shows both, not whichever flatters the machine.
|
||||||
* `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 field the agent did not publish prints `n/a` in its own cell — the card is real, so the
|
||||||
* A card whose agent cannot read a sensor (an APU build with no hwmon input) prints no
|
* number is one it is supposed to have (rule 2), and `0 °C` would be a cool GPU that does
|
||||||
* temperature at all: `n/a` is for numbers the card is supposed to have, and `0 °C` would
|
* not exist. An absent *card* is the other fact and is handled by `renderGpuSlot()`, which
|
||||||
* be a cool GPU that does not exist. */
|
* omits the whole slot. No colour: `severity.THRESHOLDS` has no `gpu.*` row, so these cells
|
||||||
function gpuSub(gpu) {
|
* print in plain ink at 0 % and at 100 % alike (phase 06). */
|
||||||
const bits = [gpu.model || 'GPU'];
|
const GPU_VALUE_FIELDS = [
|
||||||
bits.push(gpu.vram_used_bytes == null || gpu.vram_total_bytes == null
|
{ label: 'Model', field: 'model', mono: true },
|
||||||
? 'VRAM n/a'
|
// `31.3 GiB / 31.9 GiB`: used without total is the number that already has its own
|
||||||
: `VRAM ${bytes(gpu.vram_used_bytes, 1)} / ${bytes(gpu.vram_total_bytes, 1)}`);
|
// percentage on the card, so this cell is the only one that reads two keys.
|
||||||
if (gpu.mem_used_pct != null) bits.push(`pool ${num(gpu.mem_used_pct, 0)}%`);
|
{ label: 'VRAM', field: 'vram_used_bytes', total: 'vram_total_bytes' },
|
||||||
if (gpu.temp_c != null) bits.push(`${num(gpu.temp_c, 0)} °C`);
|
// the honest APU view — a Strix Halo keeps the model in GTT, so this and VRAM differ
|
||||||
return bits.join(' · ');
|
{ 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
|
/** The GPU slot of the hero row: one busy hero plus its four labelled values per card.
|
||||||
* 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
|
* A host with **no** GPU renders nothing at all (`TODO.md:12` — "Boxes without a GPU should
|
||||||
* on an LLM server, so the number renders uncoloured at 0 % and at 100 % alike. */
|
* 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) {
|
function renderGpuSlot(cell, gpus) {
|
||||||
if (cell.dataset.count !== String(gpus.length)) {
|
if (cell.dataset.count !== String(gpus.length)) {
|
||||||
cell.innerHTML = '';
|
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');
|
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.appendChild(row);
|
||||||
}
|
}
|
||||||
cell.dataset.count = String(gpus.length);
|
cell.dataset.count = String(gpus.length);
|
||||||
}
|
}
|
||||||
|
cell.hidden = gpus.length === 0;
|
||||||
const rows = Array.from(cell.children);
|
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) => {
|
gpus.forEach((gpu, i) => {
|
||||||
Dash.hero(rows[i], {
|
Dash.hero(rows[i].querySelector('.hero'), {
|
||||||
label: gpus.length > 1 ? `GPU ${i}` : 'GPU',
|
label: gpus.length > 1 ? `GPU ${i}` : 'GPU',
|
||||||
value: gpu.busy_pct, unit: '%', digits: 0,
|
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
|
/** What the four labelled values left out, in "More detail": power and both memory pools,
|
||||||
* per-label temperature breakdown behind the single number in the hero's footnote. */
|
* plus the per-label temperature breakdown behind the single number in the GPU's Temp cell. */
|
||||||
function renderGpuExtra(cell, gpus) {
|
function renderGpuExtra(cell, gpus) {
|
||||||
if (!gpus.length) { cell.innerHTML = ''; return; }
|
if (!gpus.length) { cell.innerHTML = ''; return; }
|
||||||
cell.innerHTML = `
|
cell.innerHTML = `
|
||||||
@@ -605,5 +653,5 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs);
|
state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs);
|
||||||
Dash.bindPause(state.poller);
|
Dash.bindShortcuts();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -504,5 +504,5 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.poller = poll(refresh, state.pollMs);
|
state.poller = poll(refresh, state.pollMs);
|
||||||
Dash.bindPause(state.poller);
|
Dash.bindShortcuts();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
``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
|
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
|
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
|
``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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -63,8 +65,9 @@ GPU_METRICS: tuple[str, ...] = (
|
|||||||
"vram_used_pct",
|
"vram_used_pct",
|
||||||
# the honest APU view: vram + GTT, because a Strix Halo keeps the model in GTT
|
# the honest APU view: vram + GTT, because a Strix Halo keeps the model in GTT
|
||||||
"mem_used_pct",
|
"mem_used_pct",
|
||||||
# the hero's footnote: the agent's max over this card's hwmon labels (None is legal —
|
# the Temp cell of the card's four labelled values (phase 21 task 02): the agent's max
|
||||||
# a driver with no temperature input must not be rendered as 0 °C)
|
# 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",
|
"temp_c",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+292
-17
@@ -26,14 +26,17 @@ from fastapi.testclient import TestClient
|
|||||||
import store_perf
|
import store_perf
|
||||||
from auth_client import sign_in
|
from auth_client import sign_in
|
||||||
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
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 import alerts, auth, certs
|
||||||
from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, parse_range
|
from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, parse_range
|
||||||
from dashboards.app import create_app
|
from dashboards.app import create_app
|
||||||
from dashboards.collectors import UpstreamError
|
from dashboards.collectors import UpstreamError
|
||||||
from dashboards.config import Settings, Target
|
from dashboards.config import Settings, Target
|
||||||
|
from dashboards.rates import RateEstimator
|
||||||
from dashboards.severity import classify
|
from dashboards.severity import classify
|
||||||
from dashboards.sources import nut
|
from dashboards.sources import nut
|
||||||
from dashboards.sources.disks import SMART_SERIES, DisksCollector, parse_disks_snapshot
|
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 (
|
from dashboards.sources.llm import (
|
||||||
HISTORY_METRICS,
|
HISTORY_METRICS,
|
||||||
parse_gufo_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:
|
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.
|
"""`⚙ 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**
|
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
|
`.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
|
`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"'
|
certs_open = '<a class="ghost account" href="/certs"'
|
||||||
# the opening tag **and** its label: the claim is the cluster does not change shape between
|
# 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
|
# 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>'
|
account = f'{account_open} title="Session and password">⚙ Account</a>'
|
||||||
certs_link = f'{certs_open} title="Agent certificates">⚷ Certs</a>'
|
certs_link = f'{certs_open} title="Agent certificates">⚷ Certs</a>'
|
||||||
theme = '<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">'
|
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
|
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:
|
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`.
|
"""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"
|
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:
|
#: Every cell of the `/system` GPU card's value grid, in render order, with the `gpus[]`
|
||||||
"""TODO.md L9: the GPU temperature is glanceable on the hero row, not only in the table.
|
#: 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
|
def _gpu_value_fields(js: str) -> list[tuple[str, tuple[str, ...]]]:
|
||||||
`gpu.temp_c@N` history series kept its name. The null guard *is* the behaviour, so that
|
"""The served `GPU_VALUE_FIELDS` table as `(label, payload keys)` rows."""
|
||||||
is what the assertion pins (there is no JS test runner — a locked anchor): a card whose
|
table = re.search(r"const GPU_VALUE_FIELDS = \[(.*?)\n\];", js, re.S)
|
||||||
driver exposes no temperature prints none, because `n/a` is for numbers the card is
|
assert table, "page-system.js no longer has a GPU_VALUE_FIELDS table"
|
||||||
supposed to have and a `0 °C` would be a cool GPU that does not exist.
|
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
|
js = client.get("/static/page-system.js").text
|
||||||
sub = js[js.index("function gpuSub("):js.index("function renderGpuSlot(")]
|
assert _gpu_value_fields(js) == list(GPU_VALUE_CELLS), \
|
||||||
assert "gpu.temp_c" in sub, "the GPU hero stopped printing the temperature"
|
"the GPU value grid is no longer Model / VRAM / Pool / Temp over those payload keys"
|
||||||
assert "if (gpu.temp_c != null)" in sub, "the temperature lost its null guard"
|
# the °C rides in the value (the label is the word, as on the /disks card and the GPU
|
||||||
assert sub.count("°C") == 1, (
|
# detail table), and it appears exactly once in the table: one cell, one unit.
|
||||||
"a second °C in gpuSub is a branch printing a temperature nobody measured"
|
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
|
# "More detail" keeps the per-label breakdown: a surface change, not a de-duplication
|
||||||
extra = js[js.index("function renderGpuExtra("):js.index("function renderHostCard(")]
|
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"
|
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:
|
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).
|
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
|
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
|
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
|
`renderCores` call would silently break — the series stays stored, the page just
|
||||||
stopped drawing it (PLAN.md *Dropped visuals*).
|
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
|
js = client.get("/static/page-system.js").text
|
||||||
card = js[js.index("function hostCard("):js.index("function renderHostCard(")]
|
card = js[js.index("function hostCard("):js.index("function renderHostCard(")]
|
||||||
slots = re.findall(r'<div class="(?:hero|hero-slot)[^"]*" data-k="([^"]+)"', card)
|
slots = re.findall(r'<div class="(?:hero|hero-slot)[^"]*" data-k="([^"]+)"', card)
|
||||||
assert slots == ["cpu", "mem", "temp", "disk", "gpus", "net"], \
|
assert slots == ["cpu", "mem", "temp", "disk", "gpus", "net"], \
|
||||||
"the hero row is no longer the six numbers in the agreed order"
|
"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
|
# 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 "cores" not in card, "a per-core visual came back to the card"
|
||||||
assert "renderCores" not in js and "core_usage" not in js
|
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 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:
|
def test_the_header_status_dot_picks_up_the_disks_job(client) -> None:
|
||||||
"""`disks` gets its own dot instead of hiding inside `hosts`.
|
"""`disks` gets its own dot instead of hiding inside `hosts`.
|
||||||
|
|
||||||
|
|||||||
@@ -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["busy_pct"] == pytest.approx(snapshot["gpus"][0]["busy_pct"])
|
||||||
assert gpu["vram_used_pct"] == pytest.approx(
|
assert gpu["vram_used_pct"] == pytest.approx(
|
||||||
gpu["vram_used_bytes"] / gpu["vram_total_bytes"] * 100.0)
|
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),
|
# the Temp cell is the agent's own max over this card's hwmon labels (phase 06, printed
|
||||||
# so the card and the `gpu.temp_c@N` history series can never disagree
|
# 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())
|
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
|
# 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],
|
def test_a_gpu_with_no_temperature_input_reports_none_not_zero(agent_snapshot: dict[str, Any],
|
||||||
) -> None:
|
) -> 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
|
A driver exposing no hwmon temperature has to come back as `None` (the cell prints `n/a`,
|
||||||
temperature at all) rather than `0 °C`, the same rule
|
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
|
`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.
|
list — and no `gpu.temp_c@N` sample may be written for a number nobody measured.
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user