diff --git a/.agents/phases/todo/22_disks_hide_unconfigured/00_phase.md b/.agents/phases/complete/22_disks_hide_unconfigured/00_phase.md similarity index 100% rename from .agents/phases/todo/22_disks_hide_unconfigured/00_phase.md rename to .agents/phases/complete/22_disks_hide_unconfigured/00_phase.md diff --git a/.agents/phases/todo/22_disks_hide_unconfigured/01_hide_smartctl_false_hosts.md b/.agents/phases/complete/22_disks_hide_unconfigured/01_hide_smartctl_false_hosts.md similarity index 100% rename from .agents/phases/todo/22_disks_hide_unconfigured/01_hide_smartctl_false_hosts.md rename to .agents/phases/complete/22_disks_hide_unconfigured/01_hide_smartctl_false_hosts.md diff --git a/.agents/phases/todo/22_disks_hide_unconfigured/02_verification.md b/.agents/phases/complete/22_disks_hide_unconfigured/02_verification.md similarity index 100% rename from .agents/phases/todo/22_disks_hide_unconfigured/02_verification.md rename to .agents/phases/complete/22_disks_hide_unconfigured/02_verification.md diff --git a/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__00_phase.a1.err b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__00_phase.a1.md b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__00_phase.a1.md new file mode 100644 index 0000000..c00a428 --- /dev/null +++ b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__00_phase.a1.md @@ -0,0 +1,18 @@ +All green. Final report: + +**Verified (phase 22 final pass — both task files already in `complete/`)** +- `api._disk_visible` (`is not False`) is the single gate, applied to the assembled host list in `/api/disks/snapshot` **and** `/api/summary`'s disks block before the fleet sum and the rows; `sources/disks.py`, `page-disks.js`, `poller.py`, `store.py` and `alerts.py` are byte-unchanged (`git diff --stat`: only `api.py`, `test_api.py`, docs). +- Re-ran the live browser pass `data/logs/22-task02-disks-live.cjs` against the running dev server: **34/34 PASS** — no gateway card/why-line, `2/2 hosts answering`, fleet header reads the API's `2 of 2` / `17-17-0-1`, deskwork `nvme0n1` OK 47 °C/19 202 h/11 %, driveripper 16 live cards + `13 uncorrectable` chip, both aria-labelled plots, `/` still 3 fleet rows. Screenshots + `22-task02-api.json` refreshed. +- `./scripts/dev-server.sh status`: all **nine sources `ok`**, `disks:gateway` included; live `/api/events?source=disks` still carries gateway transitions (10 of 45), proving the README/AGENTS claim the gate hides a row and not the job's history. + +**Completion criteria** +- `false` host absent from both endpoints, `None` + `unreachable` stub present, fleet ≡ row count: **PASS** (6 pins in `tests/test_api.py`, incl. the single-hidden-host = 200-with-empty-list leg). +- Live `/disks` = deskwork + driveripper, header 2 hosts, cards unchanged, screenshot in `data/logs/`: **PASS** (re-run above). +- `uv run pytest` → **1477 passed, 15 skipped**; `--cov --cov-fail-under=94` → **98.94 %** (`api.py` 100 %); `ruff check src tests agent` → all passed; `pyright` → 0 errors; `bash .agents/validate.sh` → **EXIT 0**. `uv run pytest -m live --live` → **11 passed**. +- No behaviour change in completed work: **PASS** (unchanged files above; live numbers match the pre-phase fleet record). + +**Docs** — README: `/disks` page-table row + the agent SMART paragraph now distinguish "cannot read drives" (dimmed cards kept) from "no smartctl" (host absent from the page/API lists). AGENTS.md: hermetic tally 1471→**1477**, and the `collectors.py`/`api.py` passages now note the `false` arm never reaches the `/disks` why-line while an overview alert from a hidden host's `disks` job still names it. Everything else (routes vs served table, env table vs `config.py`, commands, scripts/fixtures/tests references, masthead/POLL_MS/threshold/retention claims) **verified consistent**. + +**Deviations** — `NOTES.md` left empty by the task runs (both notes are tracked: mTLS → phase 24 `todo/`, the admin-links bug → fixed in phase 20 task 04); `/disks`' `#hosts-empty` copy left as pinned markup (it already names smartmontools + the disk group, so an all-hidden fleet is still told the fix); validate's advisory `fastapi 0.153.0 → 0.156.4` untouched (no dependency changed this phase). No live fleet writes of any kind. + +**Next pending phase:** `23_llm_rate_responsiveness`. diff --git a/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__00_phase.a1.md.status b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__00_phase.a1.validate b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__00_phase.a1.validate new file mode 100644 index 0000000..a965690 --- /dev/null +++ b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__00_phase.a1.validate @@ -0,0 +1,65 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 14%] +........................................................................ [ 19%] +........................................................................ [ 24%] +.....................................................s.................. [ 28%] +........................................................................ [ 33%] +........................................................................ [ 38%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 53%] +........................................................................ [ 57%] +........................................................................ [ 62%] +........................................................................ [ 67%] +..................sssssssssss........................................... [ 72%] +........................................................................ [ 77%] +........................................................................ [ 82%] +........................................................................ [ 86%] +........................................................................ [ 91%] +..................................................................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 440 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 3494 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1477 passed, 15 skipped, 1 warning in 155.45s (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.5 wheel diff --git a/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__01_hide_smartctl_false_hosts.a1.err b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__01_hide_smartctl_false_hosts.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__01_hide_smartctl_false_hosts.a1.md b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__01_hide_smartctl_false_hosts.a1.md new file mode 100644 index 0000000..1637e37 --- /dev/null +++ b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__01_hide_smartctl_false_hosts.a1.md @@ -0,0 +1,16 @@ +All green. Final report: + +**Implemented** +- `api._disk_visible(host)` — one predicate, `host.get("smartctl_available") is not False` (explicit `False` only; docstring names all three arms + the `is not False`-not-falsy rule). +- `/api/disks/snapshot`: filters the assembled per-host list through it before the response — same entry shape, shorter list; `fleet_disk_summary()` now sees only visible hosts, so `hosts_total` is the page's row count. `states` still names every `disks:` job. +- `/api/summary` disks block: same helper over the same entries **before** the fleet sum and the per-host rows (no second copy of the predicate); the "are there disk jobs at all" guard stays on the unfiltered list so an all-hidden fleet answers a zero fleet identically in both endpoints. +- `tests/test_api.py`: new `mixed_disks_client` fixture (True / `None`-answered / `False` / never-answered stub) + 6 pins — helper three arms both ways, `false` host absent while `None` + stub present, fleet counts over visible hosts only, health records/`system.hosts` untouched, summary ≡ snapshot host set, single-hidden-host fleet is 200-with-empty-list and not a 404. Rewrote the stale "one entry per configured host" pin. +- Read-only live check against the running dev server (restarted, all 9 sources `ok`): `/api/disks/snapshot` → `deskwork, driveripper`, `hosts_total: 2`, gateway absent from disks rows but still a `system` host with `disks:gateway ok`; summary fleet ≡ snapshot fleet. + +**Verification** — `uv run pytest --cov --cov-fail-under=94` → **1477 passed, 15 skipped**, coverage **98.94 %** (`api.py` 100 %); `uv run ruff check src tests agent` → All checks passed; `uv run pyright` → 0 errors; `bash .agents/validate.sh` → all four checks green (only the advisory `fastapi` upstream-version note, not a gate). + +**Docs** — AGENTS.md: `sources/disks.py` bullet, both `/api/disks/*` and `/api/summary` `api.py` passages, the gateway `smartctl_available: false` fleet fact, gap-10 paragraph, and the `/disks` UI why-line paragraph (the `false` arm's consequence is now absence). README.md: the `/api/disks/snapshot` API bullet, the summary `disks` block, and the `/disks` page paragraph. + +**Decisions** — `page-disks.js` left byte-identical (task scope is API-only; `whyLine()`'s `false` sentence stays as the renderer should the gate be relaxed — documented as such); `sources/disks.py`, the `disks:` job, its health record and the stored `disk.*` series are untouched. One noted loose end for task 02: `/disks`' `#hosts-empty` copy still narrates "no agents configured", which is only misleading in an all-hidden fleet — pinned markup, so not changed here. + +**Next pending task**: `22_disks_hide_unconfigured/02_verification.md` (browser + API verification with screenshots in `data/logs/`). diff --git a/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__01_hide_smartctl_false_hosts.a1.md.status b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__01_hide_smartctl_false_hosts.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__01_hide_smartctl_false_hosts.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__01_hide_smartctl_false_hosts.a1.validate b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__01_hide_smartctl_false_hosts.a1.validate new file mode 100644 index 0000000..21eb981 --- /dev/null +++ b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__01_hide_smartctl_false_hosts.a1.validate @@ -0,0 +1,65 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 14%] +........................................................................ [ 19%] +........................................................................ [ 24%] +.....................................................s.................. [ 28%] +........................................................................ [ 33%] +........................................................................ [ 38%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 53%] +........................................................................ [ 57%] +........................................................................ [ 62%] +........................................................................ [ 67%] +..................sssssssssss........................................... [ 72%] +........................................................................ [ 77%] +........................................................................ [ 82%] +........................................................................ [ 86%] +........................................................................ [ 91%] +..................................................................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 440 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 3494 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1477 passed, 15 skipped, 1 warning in 154.73s (0:02:34) +== uv run ruff check src tests agent == +All checks passed! +== uv run pyright == +0 errors, 0 warnings, 0 informations +== node --check src/dashboards/web/static/*.js == +advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose): + fastapi 0.153.0 0.156.4 wheel diff --git a/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__02_verification.a1.err b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__02_verification.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__02_verification.a1.md b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__02_verification.a1.md new file mode 100644 index 0000000..9f7df1d --- /dev/null +++ b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__02_verification.a1.md @@ -0,0 +1,17 @@ +All green. Final report: + +**Implemented (task 02 — live, read-only verification)** +- Restarted the dev server (pid 3767142) and proved the gate on the live fleet: `./scripts/dev-server.sh status` → all **nine sources `ok`**, `disks:gateway` included (the job is healthy; only the page hides the host). +- Signed-in API check: `/api/disks/snapshot` → `deskwork`+`driveripper` only, both `smartctl_available: true`, `fleet.hosts_total: 2`, `states` still 3 jobs all `ok`; `/api/summary` → `disks.fleet`/`disks.hosts` the same two (fleet blocks byte-identical to the snapshot's), **`system.hosts` still lists all three hosts** (the over-reach check), `disks.states` still 3 jobs. +- New browser pass `data/logs/22-task02-disks-live.cjs` — **34/34 checks PASS**: no gateway card and no why-line anywhere, `2/2 hosts answering`, fleet header renders the API's sums (`2 of 2 hosts`, 17/17/0/1), deskwork `nvme0n1` OK 48 °C/19 201 h/11 %, driveripper 16 cards (live temps, `54/55 °C` amber via `severity.temp_c`, SSD wear + HDD `n/a`, uncoloured odometer, sole `13 uncorrectable` chip on `nvme5n1`, `16 drives · 1 alarming`), `Charts` disclosure builds both aria-labelled plots with 16 series, `/` still 3 fleet rows. +- Evidence: `disks-live-2hosts.png`, `disks-live-phone.png`, `22-task02-overview.png`, `22-task02-api.json`, `22-task02-disks-live.log`, `NOTES-22-disks-live.md`. +- `None` arm: **no live host is `None`** (all three agents post-date the SMART pass) — stated in the notes/AGENTS rather than fabricating a live case; it stays proven by task 01's pins. +- No fleet writes of any kind (GETs only). + +**Tests / lint** — `uv run pytest`: **1477 passed, 15 skipped**; `bash .agents/validate.sh`: **exit 0** — coverage **98.94 %** (floor 94 %, `api.py` 100 %), `ruff check src tests agent` all passed, `pyright` 0 errors, `node --check` clean. Only advisory: `fastapi 0.153.0 → 0.156.4` upstream (deps untouched by this task; left for a dependency task). + +**Docs** — AGENTS.md: dated live re-verification on the gateway `smartctl_available: false` fleet fact, plus "no host here is in the `None` arm today"; README + AGENTS reworded where the live feed contradicted them — the hidden host is absent from the **host cards / API host lists**, while its `disks:` job still polls, reads `ok`, and its transitions still reach the page's drive-events feed. + +**Decisions** — no code change (task is verification); task 01's flagged `#hosts-empty` loose end checked on the served bytes and is not misleading (it already names smartmontools + the disk group), so left alone. + +**Next pending task**: `23_llm_rate_responsiveness/01_llm_window_per_poll.md`. diff --git a/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__02_verification.a1.md.status b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__02_verification.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__02_verification.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__02_verification.a1.validate b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__02_verification.a1.validate new file mode 100644 index 0000000..76fcd08 --- /dev/null +++ b/.agents/reports/22_disks_hide_unconfigured/22_disks_hide_unconfigured__02_verification.a1.validate @@ -0,0 +1,65 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 4%] +........................................................................ [ 9%] +........................................................................ [ 14%] +........................................................................ [ 19%] +........................................................................ [ 24%] +.....................................................s.................. [ 28%] +........................................................................ [ 33%] +........................................................................ [ 38%] +........................................................................ [ 43%] +........................................................................ [ 48%] +........................................................................ [ 53%] +........................................................................ [ 57%] +........................................................................ [ 62%] +........................................................................ [ 67%] +..................sssssssssss........................................... [ 72%] +........................................................................ [ 77%] +........................................................................ [ 82%] +........................................................................ [ 86%] +........................................................................ [ 91%] +..................................................................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 440 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 3494 37 99% +Required test coverage of 94% reached. Total coverage: 98.94% +1477 passed, 15 skipped, 1 warning in 154.42s (0:02:34) +== uv run ruff check src tests agent == +All checks passed! +== uv run pyright == +0 errors, 0 warnings, 0 informations +== node --check src/dashboards/web/static/*.js == +advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose): + fastapi 0.153.0 0.156.4 wheel diff --git a/AGENTS.md b/AGENTS.md index 8e13172..136eec5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -289,7 +289,9 @@ Responsibilities, one line each: literal `None` means *no timeout at all*). Agent `errors` become the payload's `degraded` **except** the `smart:` add-on lines — a host without smartmontools or device read access is unconfigured, not broken (invariant 3); they stay - in `agent.errors` for the `/disks` page to explain. + in `agent.errors` for the `/disks` page to explain — which since phase 22 is only the + device-read case, because a host whose agent said `smartctl` is *missing* is not listed on + that page at all (`api._disk_visible`). - `sources/host.py` — snapshot → payload + samples; reboot detection; GPU series per card (`gpu.busy_pct@`). `agent_tls_fields()` is the phase-15 passthrough: a TLS agent's `tls`/`bundle_rev` ride into the payload's existing `agent` block, and a token-mode snapshot @@ -299,9 +301,12 @@ Responsibilities, one line each: (phase 09): one row per whole-`/sys/block` disk (never a partition, never a mount), the agent's `smart` block carried through verbatim, `health` the drive's own word normalised to `ok`/`failed`/`None`, and `smartctl_available` three-way on purpose — `True` (a disk - answered), `False` (the agent said the binary is not installed, so the page can say *install - smartmontools*), `None` (an agent predating the pass, or `--no-smart`: nobody knows, so - `n/a` plus the agent's own `smart_errors` line and never a guessed verdict). **Nothing here + answered), `False` (the agent said the binary is not installed), `None` (an agent predating + the pass, or `--no-smart`: nobody knows, so `n/a` plus the agent's own `smart_errors` line + and never a guessed verdict). Since phase 22 the flag's *page-side* consequence is not only + the why-line: `api._disk_visible` reads `False` as "not a host on `/disks` at all", while + `None` keeps its dimmed row — the computation here is unchanged and the agent still reports + all three arms whatever the API decides to list. **Nothing here is a rate**: power-on hours and the sector counters are stored counters-as-reported, so the source takes no `RateEstimator` and its payload carries no `window_seconds` (rule 2 — a rate with no estimator, no `Δ` label and no `source` string is an invented number). @@ -606,14 +611,27 @@ Responsibilities, one line each: "fix" that by hiding the event). Neither fact appears on `/api/health` — which is public — or `/api/config`. `/api/config` is a redaction boundary. `/api/disks/*` (phase 09) is the drive-health pair: `/snapshot` answers one entry per - configured host (disk rows + `smart` + `smartctl_available` + per-row `severity` + a - `fleet` block summed by the API, with a configured-but-never-answered host as an - `unreachable` stub whose counts are `None`, not `0`), and `/history` serves the + configured host **with drives to show** (disk rows + `smart` + `smartctl_available` + + per-row `severity` + a `fleet` block summed by the API, with a configured-but-never-answered + host as an `unreachable` stub whose counts are `None`, not `0`), and `/history` serves the `disk.*@dev` series with unknown names answering as empty series like the other two history endpoints. The configured gate runs **before** anything that could reach the network — `HOST_TARGETS` empty is `404 {"configured": false}` with no client, no fetch and no `disks` job in the hub at all (invariant 3), and `tests/test_api.py` proves it with - spies on `httpx.AsyncClient.get` and `collectors.fetch_snapshot`. The new surface grows no + spies on `httpx.AsyncClient.get` and `collectors.fetch_snapshot`. Configured-ness and + visibility are two different gates, and only one helper decides the second: + `api._disk_visible(host)` drops a host whose payload's `smartctl_available` is **explicitly** + `False` ("no smartmontools, no drives to report" — the VM case) from `hosts` and therefore + from `fleet` (`fleet_disk_summary` sums whatever it is handed, so `hosts_total` is the row + count), while `True`, `None` and an `unreachable` stub all stay — an agent that cannot say is + not an agent that said *no*, and a dead agent must not read as "never configured" + (invariant 3). The check is `is not False`, not falsy, and it runs in `/api/summary`'s disks + block too, before both the fleet sum and the per-host rows, so the two endpoints cannot list + different machines; `states` still names every `disks:` job (hidden included, still + polling, still `ok`) and the source, the store and the health records see no change at all — + which is also why an overview alert from a hidden host's `disks` job still names that host + (`alerts.py` reads job states, not this list): hiding a card must never hide a fault + (phase 22). The new surface grows no write of its own — a fourth dashboard must not quietly become a second one, so the app's only POST that **dials an upstream** stays `/api/ups/command` (invariant 1, pinned by enumerating every served route's verbs and sorting the writes into fleet / session / certificate-authority @@ -635,7 +653,12 @@ Responsibilities, one line each: `health_failed`/`alarm_disks`/`smartctl_available`/`failed_devices`), so a notifier learns *which* drive the hardware called `FAILED` without a second call, and `tests/test_api.py` pins every one of those key paths, with and without targets configured, - so retiring a panel can never shrink the payload. A configured + so retiring a panel can never shrink the payload. Since phase 22 those rows are the **visible** + hosts — the same `_disk_visible` predicate `/api/disks/snapshot` applies, over the same + entries, before the fleet sum — so `disks.fleet` equals `/api/disks/snapshot`'s `fleet` and a + host with no smartmontools is missing from both, while `system.hosts` still lists it (a missing + binary is a fact about the drive page and nothing else) and `disks.states` still lists its job. + A configured job with no payload yet is listed as a stub (`unreachable: true`, every value `null`) in `system.hosts`/`llm.engines` exactly as `/api/system/snapshot` does, so `hosts_total` counts the machines there are and `hosts_live` the ones answering. @@ -1002,7 +1025,13 @@ That scheme is the history API's key — treat it as a public interface. odometer, so that figure is a capture date, not a fact about the drive), 11 % of rated wear — so the wear bar and the alarm chips are drawn from live hardware, not only fixtures. `gateway` answers `smartctl_available: false` (no smartmontools in its VM: the *install it* - half of the three-way flag), and **`driveripper` is the fourth case the flag was built for: + half of the three-way flag — and, since phase 22, the arm that keeps it off the `/disks` page + entirely, so `/api/disks/snapshot` there lists deskwork + driveripper and `fleet.hosts_total` + is 2 while `host:gateway`/`disks:gateway` both stay `ok` — re-verified live read-only on + 2026-10-21, where the two cards still draw their pre-phase numbers (`deskwork nvme0n1` 48 °C / + 19 201 h / 11 %, `driveripper` 16 drives with the `nvme5n1` `13 uncorrectable` chip, `/` + unchanged at three fleet rows; `data/logs/NOTES-22-disks-live.md`), and **`driveripper` is the fourth + case the flag was built for: `smartctl_available: true` with almost no numbers.** Its agent answers (16 whole-disk rows, `nvme0n1`…`nvme7n1` + `sda`…`sdh`) and `smart` is non-null for the 8 NVMes, but every one of them carries only identity — `health`, `temp_c`, `power_on_hours`, `percentage_used` and the @@ -1047,9 +1076,16 @@ That scheme is the history API's key — treat it as a public interface. nothing but are pure noise, while with the grant they are 16 drives' worth of temperature, hours and sector counters). **The agent and the `disk` group are both in place on `driveripper`** — the `disk` group alone is not enough on kernel 7.2 (the passthrough gate, see above), so the unit additionally runs as root with the two gate capabilities bounded (gap 10) — all 16 drives now read live. `gateway` needs smartmontools first and will still read no SMART - after that, being a VM with no physical disk behind `vda`. A host that cannot read its drives - renders the designed degradation: one dimmed card per physical drive, every number `n/a`, and - the one-line why. + after that, being a VM with no physical disk behind `vda` — and it is the host phase 22's + visibility gate exists for: its `smartctl_available: false` means `/disks` draws **no gateway + card at all** and `/api/disks/snapshot` lists two hosts, not three — while its `disks:gateway` + job still polls, still reads `ok`, and its transitions still reach that page's drive-events + feed, because the gate hides a host row and not the job's history. That is a different + designed degradation from a host that cannot *read* its drives for any other reason (a denied + device, `--no-smart`, an agent predating the pass): that one keeps its dimmed cards, every + number `n/a`, and the one-line why — **no host here is in that `None` arm today** (all three + agents post-date the SMART pass), so the arm that stays visible is proven by + `tests/test_api.py`'s pins and by nothing on the fleet. `tests/fixtures/smartctl_reference_*.txt` stay as what they are: **reference shapes** (labels verbatim from the deployed binary, values synthetic), kept beside the real captures so the synthetic edge cases stay pinned. Re-record the real ones with @@ -1074,7 +1110,7 @@ set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no ./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health ./scripts/dev-server.sh logs -uv run pytest # hermetic: 1471 passed + 15 skipped (11 live, +uv run pytest # hermetic: 1477 passed + 15 skipped (11 live, # 4 store_perf), no network uv run pytest --cov # coverage gate (98.94%; floor 94%) uv run pytest -m live --live # opt-in: real fleet / engines / upsd @@ -1366,12 +1402,17 @@ counters are **`.chip.fail` alarm chips that exist only while non-zero** (the `/ rule, and why `severity.py` rates no counter): a drive at zero draws no chip row at all, because a permanent "0 pending sectors" line trains the eye to ignore it and a green one would call a quiet drive an achievement. Per host the three-way `smartctl_available` decides one `.banner.info` -why-line and nothing else — `false` says *install smartmontools and grant the disk group* (the -agent said so, so the page may), `null` prints the agent's own `smart_errors` line or names both -fixes without claiming to know which half is missing, and an agent that is not answering says -that instead — and in every case the drive grid takes the shared `.stale` dim while the host card -stays uncoloured (invariant 3: a missing binary is unconfigured, not broken, and a red panel here -would train the operator to ignore red on the drives that do report). Nothing on the page is per +why-line and nothing else — `null` prints the agent's own `smart_errors` line or names both fixes +without claiming to know which half is missing, and an agent that is not answering says that +instead — and in either case the drive grid takes the shared `.stale` dim while the host card +stays uncoloured (invariant 3: unreadable drives are unconfigured, not broken, and a red panel +here would train the operator to ignore red on the drives that do report). The `false` arm — the +agent said `smartctl` is not installed — no longer reaches the page at all: `api._disk_visible` +drops that host from `/api/disks/snapshot`, from the fleet sums and from `/api/summary`'s disks +rows (phase 22), because a card whose only content is *install smartmontools and grant the disk +group* is not a host on a drive page; `whyLine()`'s `false` sentence stays as the renderer for a +payload that says it, so relaxing the gate later cannot turn those hosts into silent blank cards. +Nothing on the page is per partition or per mount: the rows are the agent's whole-`/sys/block` list, and a filesystem field naming its way into `page-disks.js` fails a test. The fleet header renders `fleet_disk_summary()` — hidden below two hosts (one box's sums duplicate its own cards) and diff --git a/README.md b/README.md index 7ca5f79..004a618 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Four dashboards, one domain, one process, one SQLite file: | ----------- | ------------------------------------------------------------------------------- | -------------------------------------------------- | | `/` | Is anything wrong, and which dashboard do I need? | all four, in one request (`/api/summary`) | | `/system` | What are my machines doing — CPU, memory, disk, GPU, network, and the history of all of it | `agent/host_agent.py`, our own stdlib agent per host | -| `/disks` | Are any of my **physical** disks failing, and which one — health, temperature, power-on hours, wear, sector alarms | the same agents, running `smartctl -i -A -H` per whole drive (never a partition or a mount) | +| `/disks` | Are any of my **physical** disks failing, and which one — health, temperature, power-on hours, wear, sector alarms | the same agents, running `smartctl -i -A -H` per whole drive (never a partition or a mount); a host whose agent has no `smartctl` is not a host on this page | | `/llm` | Tokens/sec, active sessions, queue depth, KV-cache pressure, latency percentiles | llama.cpp-family (gufo) `/metrics` + `/slots`, vLLM `/metrics` — read-only | | `/ups` | Battery health, runtime left, power events, and battery-test buttons | NUT `upsd`, on whatever port it really listens on (its own wire-protocol client) | @@ -114,7 +114,9 @@ disk can never cost a poll more than its own timeout (only the very first pass a agent starts runs inline). A host without `smartmontools`, or whose agent user cannot read the block devices, is *not broken*: every disk reports `smart: null` with one explanatory `errors` line, and that line never makes -the system page amber. Enable SMART with `smartmontools` plus the `disk` group (which +the system page amber. The two cases differ on `/disks`: a host that only cannot **read** its +drives keeps its (dimmed, all-`n/a`) cards, while one whose agent says `smartctl` is not +installed is not listed on that page at all. Enable SMART with `smartmontools` plus the `disk` group (which `deploy/install-agent.sh` grants when it exists) — the agent shells `smartctl` as itself and never calls `sudo`, so a sudoers rule alone is not enough — and **redeploy the agent**, since one installed before phase 09 has no SMART pass to run. Opt out silently with `--no-smart`. @@ -631,16 +633,25 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n queries per series — and the JSON it answers with is the same document the per-series loop produced, series for series and in the same order (`tests/test_api.py` pins that against points read straight out of the store with `query()`). `/api/disks/snapshot` is the - drive-health surface: one entry per host with its physical disk rows, the agent's `smart` - block verbatim, `smartctl_available` (`true` / `false` = install smartmontools / `null` = - the agent never said), a `severity` beside each row's rated numbers, and a `fleet` block - summed here so the page does no arithmetic — a host that has not answered yet is listed as - an `unreachable` stub of `n/a`s and counted, never summed. `/api/config` carries + drive-health surface: one entry per host **with drives to show** — its physical disk rows, + the agent's `smart` block verbatim, `smartctl_available` (`true` / `false` = install + smartmontools / `null` = the agent never said), a `severity` beside each row's rated + numbers, and a `fleet` block summed here so the page does no arithmetic — a host that has + not answered yet is listed as an `unreachable` stub of `n/a`s and counted, never summed. + The one host that is **not** listed is the one whose agent said `false`: a box with no + smartmontools (a VM with nothing behind `vda`) has no card, no fleet contribution and no + `/api/summary` row, because the only thing its card could say is "install it". `null` and + an `unreachable` stub both stay — an agent that cannot say is not an agent that said *no* + (`api._disk_visible` is the single predicate, pinned both ways in `tests/test_api.py`; the + `disks:` job, its health record and its stored `disk.*` series still see the host). + `/api/config` carries `disks.interval_seconds`, which is why a drive card four minutes old is behaving, not broken. `/api/summary` grew a `disks` block with that page (the same `fleet` sums plus one row per host — counts, `smartctl_available`, and `failed_devices`, so a notifier learns *which* drive the hardware called `FAILED` without a second call); it is additive, exactly as `alerts` - was, and the key paths of both are pinned with and without targets configured. + was, and the key paths of both are pinned with and without targets configured. Since phase 22 + that block lists the **same hosts** `/api/disks/snapshot` does, and `fleet.hosts_total` is + that row count, so `/` and `/disks` can never describe two different fleets. * `web/` — five static dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`) plus the three admin pages (`/login`, `/settings`, `/certs`), one stylesheet, hand-rolled SVG charts (`charts.js`), and a masthead status dot per dashboard job (`disks` has its own: @@ -714,9 +725,13 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n `severity.py` rates no power-on hours. The sector counters appear as **red chips that exist only while non-zero**, so a healthy drive shows no alarm row at all rather than a wall of zeroes. A host whose SMART cannot be read keeps its cards, dimmed with every number `n/a` and one - `.banner` line saying why — `smartctl not available on ` when the agent reported the binary - missing, its own sentence when it reported something else, and "not answering" when the agent is - down — never a red panel, because a missing binary is unconfigured, not broken. The header's + `.banner` line saying why — the agent's own sentence when it reported something, and "not + answering" when the agent is down — never a red panel, because unreadable drives are + unconfigured, not broken. The one case that gets no card at all is the agent that said + `smartctl` is **not installed**: such a host is absent from the page's host cards (and from + the API's host lists) rather than a card whose only content is "install smartmontools" — its + `disks:` job still polls, still reports `ok`, and its transitions still reach the drive + events feed below the cards, because that feed is the job's history and not a host list. The header's drive counts are the API's `fleet` block (hidden below two hosts, so one box's sums never duplicate its own cards), and each host's **Charts** disclosure behaves exactly like `/system`'s: collapsed, built only while open, and fed by one `/api/disks/history` request per host per diff --git a/src/dashboards/api.py b/src/dashboards/api.py index 264195a..a180ffd 100644 --- a/src/dashboards/api.py +++ b/src/dashboards/api.py @@ -17,7 +17,7 @@ import logging import math import re import time -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field, replace from typing import Any from urllib.parse import urlsplit @@ -637,10 +637,16 @@ def common_router() -> APIRouter: # the fourth dashboard's own jobs, not the system ones: a host can answer the # 10-second utilisation poll and still have a wedged SMART pass, and `/api/summary` # is the one call an external probe makes, so it has to be able to tell those apart - disks_hosts: list[dict[str, Any]] = [ + disk_entries: list[dict[str, Any]] = [ job.state.payload or _disks_stub(job.label, job.state) for job in ctx.hub.jobs.values() if job.dashboard == DISKS_DASHBOARD ] + # …and the same visibility gate `/api/disks/snapshot` applies, applied here to the same + # entries before either the fleet sum or the rows: a host the drive page does not list + # must not appear in this block either, and `hosts_total` has to be the page's row count + # (`_disk_visible`). `disk_entries` stays unfiltered as the "are there disk jobs at all" + # question below, so an all-hidden fleet answers a zero fleet exactly as the snapshot does. + disks_hosts = [entry for entry in disk_entries if _disk_visible(entry)] ups_payload = None nut_job = ctx.hub.jobs.get("nut") if nut_job and nut_job.state.payload: @@ -686,11 +692,13 @@ def common_router() -> APIRouter: "states": ctx.hub.states("llm")}, # additive in phase 09: the drive-health view of the same fleet. Counts come from # each host's own `summary` and the fleet sum, never recomputed here, and a host - # that has not answered is a row of `None`s — "unknown" is not "no drives". + # that has not answered is a row of `None`s — "unknown" is not "no drives". Since + # phase 22 the rows are the *visible* hosts (`_disk_visible`), so this block and + # `/api/disks/snapshot` list the same machines; `states` still lists every disk job. # Nothing in this block carries a `severity`: a count of drives is not a graded # number and a non-zero sector counter is an alarm on presence (`severity.py`). "disks": {"enabled": bool(ctx.settings.host_targets), - "fleet": fleet_disk_summary(disks_hosts) if disks_hosts else None, + "fleet": fleet_disk_summary(disks_hosts) if disk_entries else None, "hosts": [{"name": h["name"], "disks": (h.get("summary") or {}).get("disks"), "with_smart": (h.get("summary") or {}).get("with_smart"), @@ -774,7 +782,9 @@ def _disks_stub(label: str, state: SourceState) -> dict[str, Any]: The same statement `/api/system/snapshot` makes with `unreachable: true`: the machine is still in the fleet (a dead agent must not read as "never configured"), it has no rows to draw, and its counts are `None` rather than `0` — "unknown" and "no drives" are different - sentences (rule 2), and the page prints `n/a` for what it cannot know. + sentences (rule 2), and the page prints `n/a` for what it cannot know. The + `smartctl_available: None` here is also why a dead agent survives `/disks`' visibility + gate (`_disk_visible`): it never said "no smartctl", it said nothing. """ return { "name": label, @@ -788,6 +798,29 @@ def _disks_stub(label: str, state: SourceState) -> dict[str, Any]: } +def _disk_visible(host: Mapping[str, Any]) -> bool: + """Is this host a row on the drive page? Only an explicit "no smartctl" hides one. + + The three arms of `smartctl_available` (see `sources/disks.py`) are three different facts, + and exactly one of them says "this machine has no drive health to show": + + * ``True`` — a disk answered, so the host is on the page. + * ``None`` — nobody knows: an agent predating the SMART pass, one started with + `--no-smart`, or a stub for an agent that has never answered. An agent that cannot say + is not an agent that said *no*, and a dead agent must not read as "never configured" + (invariant 3), so both stay — dimmed, every number `n/a`. + * ``False`` — the agent said its own way that `smartctl` is not installed. On a VM with no + smartmontools and no physical drive behind `vda` the only thing that card could say is + "install it", so the host is not a host on this page at all. + + The test is `is not False` rather than falsy: `None` and a payload with no such key must + stay visible, so only the one value the agent actually said hides a machine. The gate lives + here and nowhere else — the agent's SMART pass, the `disks:` job, its health record + and the stored `disk.*` series all still see the host. + """ + return host.get("smartctl_available") is not False + + def disks_router() -> APIRouter: """`/api/disks/*` — GETs only, like every other dashboard's surface. @@ -811,24 +844,30 @@ def disks_router() -> APIRouter: @router.get("/snapshot") async def snapshot(ctx: AppContext = Depends(get_context)) -> dict[str, Any]: - """One entry per host: its physical disks, their `smart` block, and why either is missing. + """One entry per host with drives to show: its disks, their `smart` block, why either is missing. - Every host in `HOST_TARGETS` is a row, answering or not (invariant 4). A disk row is - one whole-`/sys/block` device — never a partition, never a mount — carrying the - agent's `smartctl` parse verbatim in `smart`, that parse's verdict normalised to - `health`, a `severity` dict for the two rated numbers, and `None` for whatever the - drive does not publish (the page prints `n/a`, rule 2). Per host, - `smartctl_available` says whether SMART is readable at all — `False` means "install - smartmontools", `None` means the agent never said — and `smart_errors` carries the - agent's own sentence. Nothing here is a rate: the payload has no `window_seconds`, - because this source measures nothing over a window. + Every host in `HOST_TARGETS` is a row, answering or not (invariant 4), except the one + whose agent said it has no `smartctl` at all (`_disk_visible`) — a card whose only + sentence is "install smartmontools" is not a host on a drive page. A disk row is one + whole-`/sys/block` device — never a partition, never a mount — carrying the agent's + `smartctl` parse verbatim in `smart`, that parse's verdict normalised to `health`, a + `severity` dict for the two rated numbers, and `None` for whatever the drive does not + publish (the page prints `n/a`, rule 2). Per host, `smartctl_available` says whether + SMART is readable at all — `False` means "install smartmontools" (and hides the host, + which is the only reason that arm differs from `None`), `None` means the agent never + said — and `smart_errors` carries the agent's own sentence. Nothing here is a rate: the + payload has no `window_seconds`, because this source measures nothing over a window. """ require_hosts(ctx) - hosts: list[dict[str, Any]] = [] + assembled: list[dict[str, Any]] = [] for job in ctx.hub.jobs.values(): if job.dashboard != DISKS_DASHBOARD: continue - hosts.append(job.state.payload or _disks_stub(job.label, job.state)) + assembled.append(job.state.payload or _disks_stub(job.label, job.state)) + # one gate, the same helper `/api/summary` uses, so the page's rows and that block's + # rows cannot disagree; `states` below still reports every disk job, hidden included, + # because a job that is polling a box with no smartmontools is healthy, not absent + hosts = [host for host in assembled if _disk_visible(host)] hosts.sort(key=lambda item: item.get("name") or "") return {"ts": time.time(), "hosts": hosts, "fleet": fleet_disk_summary(hosts), "states": ctx.hub.states(DISKS_DASHBOARD)} diff --git a/tests/test_api.py b/tests/test_api.py index a13171c..67f21ff 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -28,7 +28,7 @@ from auth_client import sign_in from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2 from card_contract import HOST_SHAPES, assert_card_contract from dashboards import alerts, auth, certs -from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, parse_range +from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, _disk_visible, parse_range from dashboards.app import create_app from dashboards.collectors import UpstreamError from dashboards.config import Settings, Target @@ -3960,6 +3960,45 @@ def disks_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, store.close() +@pytest.fixture +def mixed_disks_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + disk_snapshot: Any) -> Iterator[TestClient]: + """Four configured hosts: one per answer an agent can give about `smartctl`, plus a stub. + + Phase 22's visibility gate needs all three arms of `smartctl_available` in **one** fleet, + because "hidden" only means something against what stays: + + * `driveripper` — every drive read: `smartctl_available: true` + * `deskwork` — every drive `null` with no error line: `null` (`--no-smart`, or an agent + predating the SMART pass — it answered, it just cannot say) + * `gateway` — every drive `null` plus the agent's own "not found" line: `false`, which is + the one arm that hides a host (`api._disk_visible`) + * `deadhost` — never answered: the `unreachable` stub, whose flag is `null` by shape + + Each payload is `parse_disks_snapshot`'s own output over the fixture captures, and every + target points at a dead port, so nothing dials and no poll can overwrite what is set here. + """ + db = str(tmp_path / "mixed-disks.sqlite3") + store = SampleStore(db) + app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD, + pbkdf2_iterations=FAST_PBKDF2, + host_targets=(Target("driveripper", DEAD_URL), + Target("deskwork", DEAD_URL), + Target("gateway", DEAD_URL), + Target("deadhost", DEAD_URL))), store) + with TestClient(app) as test_client: + sign_in(test_client) + now = time.time() + for name, mode in (("driveripper", "healthy"), ("deskwork", "silent"), + ("gateway", "no_binary")): + snapshot = disk_snapshot(name, mode=mode) + snapshot["ts"] = now # "the agent answered just now" + result = parse_disks_snapshot(name, snapshot, DisksCollector(), now=now) + app.state.ctx.hub.jobs[f"disks:{name}"].state.payload = result.payload + yield test_client + store.close() + + #: (`smart` field, severity metric) for the two drive numbers the table rates — the same #: pair `sources/disks.py:SEVERITY_FIELDS` declares, restated here so a rename that only #: touches one side fails rather than colouring nothing. @@ -3995,7 +4034,12 @@ def series_source(body: dict[str, Any]) -> set[str]: def test_disks_snapshot_answers_one_entry_per_host_with_its_physical_disks( disks_client) -> None: - """`GET /api/disks/snapshot` is per host: rows + `smart` + `smartctl_available`.""" + """`GET /api/disks/snapshot` is per host: rows + `smart` + `smartctl_available`. + + "Per host" means per **visible** host since phase 22 — this fleet has no `false` host in + it, so both stub and answering host are listed; the hidden case is pinned on a fleet that + has one (`mixed_disks_client`). + """ body = disks_client.get("/api/disks/snapshot") assert response_ok(body) payload = body.json() @@ -4069,6 +4113,144 @@ def test_the_disks_fleet_header_is_summed_by_the_api_not_the_page(disks_client) assert "severity" not in fleet, "a count of drives is not a graded number" +# ---- the `/disks` visibility gate (phase 22 task 01) ----------------------- # +# +# `TODO.md:13`: "VMs that don't have smartctl enabled disks should not show up. Gateway +# shouldn't even be a host on the disks page." One predicate (`api._disk_visible`) over the +# three arms of `smartctl_available`, applied to the assembled host list in both endpoints, so +# the page's rows, that block's rows and the fleet sums cannot describe three fleets. The +# source, the store, the `disks:` job and its health record still see every host. + +def test_the_disk_visibility_gate_reads_only_the_explicit_false() -> None: + """Three arms, one hide condition — and the test is `is False`, not falsy. + + `None` means "nobody knows" (an agent predating the SMART pass, `--no-smart`, or a payload + with no such key at all) and an agent that cannot say is not an agent that said *no*; a + dead agent's stub stays too, because a machine that stopped answering must never read as + "never configured" (invariant 3). + """ + assert _disk_visible({"smartctl_available": True}) is True + assert _disk_visible({"smartctl_available": None}) is True + assert _disk_visible({}) is True, "a payload that never said anything is not a 'no'" + assert _disk_visible({"unreachable": True, "smartctl_available": None}) is True, ( + "a dead agent is not a host without smartmontools") + assert _disk_visible({"smartctl_available": 0}) is True, ( + "falsy is not the predicate — only the value the agent answered with") + assert _disk_visible({"smartctl_available": False}) is False + + +def test_a_host_whose_agent_has_no_smartctl_is_not_a_host_on_the_disks_page( + mixed_disks_client) -> None: + """The absent host and the two `unknown` hosts, pinned in the same response. + + gateway's VM answers "`smartctl` not found", so it has no card and no row; deskwork's + agent cannot say either way and deadhost's never answered, and both keep their dimmed + `n/a` rows exactly as before this gate existed. + """ + payload = mixed_disks_client.get("/api/disks/snapshot").json() + visible = {host["name"]: host for host in payload["hosts"]} + assert list(visible) == ["deadhost", "deskwork", "driveripper"], ( + "only the host whose agent said 'no smartctl' is gone") + assert visible["driveripper"]["smartctl_available"] is True + assert visible["driveripper"]["disks"], "the host that stayed kept every drive row" + assert visible["deskwork"]["smartctl_available"] is None, ( + "an agent that cannot say is still a card, with its own why-line") + assert "unreachable" not in visible["deskwork"], "it answered — silently, but it answered" + assert visible["deadhost"]["unreachable"] is True + assert visible["deadhost"]["smartctl_available"] is None + + +def test_the_disks_fleet_header_counts_only_the_hosts_the_page_lists( + mixed_disks_client) -> None: + """`hosts_total` is the page's row count, and a hidden host lends it no drives.""" + payload = mixed_disks_client.get("/api/disks/snapshot").json() + fleet = payload["fleet"] + rows_by_host = {host["name"]: host["disks"] for host in payload["hosts"]} + assert fleet["hosts_total"] == len(payload["hosts"]) == 3 + assert fleet["hosts_live"] == 2, "the stub host is counted and never summed" + assert fleet["disks"] == len(rows_by_host["driveripper"]) + len(rows_by_host["deskwork"]), ( + "the hidden host's `vda` is in no fleet count") + assert fleet["with_smart"] == len(rows_by_host["driveripper"]) + assert "severity" not in fleet + + +def test_hiding_a_diskless_host_leaves_its_health_record_and_the_fleet_alone( + mixed_disks_client) -> None: + """The gate reads a payload; it does not unwatch the machine. + + `states` still lists `disks:gateway` (the job polls, and healthy is healthy), `/system` + still lists gateway as a host, and `/api/health` still answers for the job — a box with no + smartmontools is missing from the **drive** page and from nothing else (invariant 3). + """ + snapshot = mixed_disks_client.get("/api/disks/snapshot").json() + summary = mixed_disks_client.get("/api/summary").json() + assert {state["key"] for state in snapshot["states"]} == { + "disks:deadhost", "disks:deskwork", "disks:driveripper", "disks:gateway"} + assert "gateway" in {row["name"] for row in summary["system"]["hosts"]}, ( + "the system page lost a machine over a missing binary") + health = {row["key"]: row for row in + mixed_disks_client.get("/api/health").json()["sources"]} + assert "disks:gateway" in health + + +def test_summary_and_the_disks_page_hide_the_same_hosts(mixed_disks_client) -> None: + """One predicate, two endpoints: `/` and `/disks` may not list different machines.""" + body = mixed_disks_client.get("/api/summary").json() + snapshot = mixed_disks_client.get("/api/disks/snapshot").json() + disks = body["disks"] + # the **set**, not the list: `/api/disks/snapshot` sorts by name, `/api/summary` answers + # rows in hub order and always has + assert {row["name"] for row in disks["hosts"]} == { + host["name"] for host in snapshot["hosts"]} == { + "deadhost", "deskwork", "driveripper"} + assert disks["fleet"] == snapshot["fleet"], "the header would disagree with the page" + assert disks["fleet"]["hosts_total"] == 3 + assert disks["enabled"] is True + assert disks["fleet"]["hosts_total"] != len(body["system"]["hosts"]), ( + "four machines configured, three drive rows — the two numbers are different facts") + # the rows that stayed carry the shape they always did, `unknown` and all (rule 2) + rows = {row["name"]: row for row in disks["hosts"]} + assert rows["deskwork"]["smartctl_available"] is None + assert rows["deskwork"]["unreachable"] is False + assert rows["deadhost"]["unreachable"] is True and rows["deadhost"]["disks"] is None + + +def test_the_disk_visibility_gate_applies_with_only_one_host_configured( + tmp_path: Path, disk_snapshot: Any) -> None: + """A fleet of nothing but a `false` host is an empty drive page, not a 404. + + Unconfigured still means "no `HOST_TARGETS`" (a 404 that dials nothing, invariant 3); + configured-and-hidden answers with an empty list and a zero fleet, which is what the page + renders as its empty state. + """ + db = str(tmp_path / "one-hidden-host.sqlite3") + store = SampleStore(db) + app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD, + pbkdf2_iterations=FAST_PBKDF2, + host_targets=(Target("gateway", DEAD_URL),)), store) + with TestClient(app) as test_client: + sign_in(test_client) + now = time.time() + snapshot = disk_snapshot("gateway", mode="no_binary") + snapshot["ts"] = now + result = parse_disks_snapshot("gateway", snapshot, DisksCollector(), now=now) + assert result.payload["smartctl_available"] is False, "the arm this test is about" + app.state.ctx.hub.jobs["disks:gateway"].state.payload = result.payload + body = test_client.get("/api/disks/snapshot") + assert body.status_code == 200, "configured-and-hidden is not unconfigured" + payload = body.json() + assert payload["hosts"] == [] + assert payload["fleet"]["hosts_total"] == 0 + assert payload["fleet"]["disks"] == 0 + assert [state["key"] for state in payload["states"]] == ["disks:gateway"] + summary = test_client.get("/api/summary").json()["disks"] + assert summary["enabled"] is True + assert summary["hosts"] == [] + assert summary["fleet"] == payload["fleet"], ( + "an all-hidden fleet must look the same from both endpoints") + store.close() + + def test_the_disks_surface_is_gets_only_and_the_write_stays_the_ups_one( disks_client) -> None: """Invariant 1: the app writes to an **upstream** from exactly one route."""