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:
@@ -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`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+65
@@ -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
|
||||
+16
@@ -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/`).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+65
@@ -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
|
||||
+17
@@ -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`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+65
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user