phase: 22_disks_hide_unconfigured

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`.
This commit is contained in:
2026-10-21 03:27:08 -04:00
parent 5e267737dc
commit 895f309f8e
19 changed files with 576 additions and 50 deletions
@@ -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`.
@@ -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
@@ -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:<host>` 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:<host>` 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/`).
@@ -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
@@ -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:<host>` 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`.
@@ -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
+61 -20
View File
@@ -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@<index>`). `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 `Δ<window>` 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:<host>` 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
+26 -11
View File
@@ -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:<host>` 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 <host>` 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:<host>` 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
+56 -17
View File
@@ -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:<host>` 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)}
+184 -2
View File
@@ -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:<host>` 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."""