phase: 09_disks_smartctl
**Phase 09 — final verification pass (all 4 task files already in `complete/`; nothing re-implemented)**
- Verified `GET /api/disks/snapshot` live: 3 hosts / 18 whole-`/sys/block` rows, `smartctl_available` three-way, per-row `severity`, API-summed `fleet`; 404 `{"configured": false}` proven *without dialling* (spies on `httpx.AsyncClient.get` + `collectors.fetch_snapshot`).
- Verified `/api/disks/history` live (`disk.temp_c@dev`, unknown names → empty series) + `test_store.py`'s 300 s rollup/prune buckets; `/api/metrics?source=disks` answers the `disks` family.
- Browser against live data: 18 drive cards (never a partition/mount), collapsed per-host `Charts` with `aria-label`, 3 `.stale` grids + one `.banner.info` why-line and **0 `.fail` classes / green `disks` dot** → criteria 3 and 4 hold; new shots `data/logs/09-verify-disks-live-dimmed.png`, `09-verify-overview-live.png` (13 total for the phase).
- Real-hardware criterion: read 4 driveripper drives through `ops`' `sudo -n smartctl -i -A -H` and ran the live output through `host_agent.parse_smart_output` → `sources/disks.parse_disks_snapshot`: `sda` 35 °C / 30 423 h, `nvme1n1` 58 % wear, `nvme5n1` 13 uncorrectable → `alarm_disks: 1`, temps graded `warn`; consistent with the 16 committed fixtures.
- `bash .agents/validate.sh` → **EXIT=0**; `uv run pytest --cov` → **769 passed, 11 skipped, coverage 98.33%** (floor 94); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean; hygiene + dependency advisories clean. `uv run pytest -m live --live` → 10 passed, 1 skipped (agents still predate the SMART pass). Dev server healthy: `/api/health` `ok`, 9/9 sources.
- Docs fixed: AGENTS.md test count 698→769 and coverage 98.2→98.33 %; `disks` added to the event dashboard-name list and to the "every feed renders `Dash.eventText`" sentence; README `POLL_MS_*` row now states `/disks`' deliberate fixed 30 s; new Known gap 4 records the dev-DB `database is locked` writes (reported/recovered per invariant 4, ~590 MB DB / ~70 MB WAL, not reproduced by a controlled 25 s external read txn).
- Deviations: **no live-infra change** — granting the agent user the `disk` group and redeploying `host_agent.py` on driveripper/deskwork stay the documented paired operator action (redeploying without the grant yields 16 permission-denied lines; a sudo-spawning agent would break the confirmed "direct read, no sudo" assumption). Store-lock issue left to its own phase rather than patched speculatively.
- Next pending phase: `10_dev_direct_dial`.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# Phase 09 — A /disks page for physical disk health (smartctl)
|
||||
|
||||
**Source:** `TODO.md` L8 — "a truly physical disk monitoring page": "Add a 'disks' tab dedicated to smartctl and per-disk metrics. Note: not per-partition, but truly a physical disk monitoring page that collects data about all my physical disks across my fleet (should only be deskwork and driveripper right now)."
|
||||
**Story:** n/a
|
||||
**Context:** `agent/host_agent.py` (physical whole disks via `/sys/block`, ~L400 — already partition-free; stdlib-only, `ProtectSystem=strict`), `src/dashboards/config.py` (env only, sources default to unset), `src/dashboards/poller.py` (one task per source, stale/last-good, events under the dashboard name), `src/dashboards/store.py` (WAL, rollups raw→1m→1h, retention — rule 9), `src/dashboards/api.py` (GETs, 404-when-unconfigured without dialling), the three existing dashboards + `/` overview (a new tab joins the nav and the overview's "which of the three" table)
|
||||
|
||||
## Objective
|
||||
|
||||
A fourth dashboard: `/disks` — one card per **physical** disk (the agent's whole-
|
||||
`/sys/block` enumeration, never partitions), each carrying `smartctl` health: overall
|
||||
health, temperature, power-on hours, and the sector/wear alarms. Its own `disks` source
|
||||
and poller task at a slow 5-minute cadence (SMART is not a per-second signal), its own
|
||||
API (`/api/disks/snapshot`, `/api/disks/history`), rollups and retention, and a page that
|
||||
reads `n/a` for what `smartctl` cannot see — never red for a missing binary.
|
||||
|
||||
- ASSUMPTION (phase, confirmed): the agent reads SMART **directly** (`smartctl` as the
|
||||
agent user, e.g. `disk` group) — no sudo — and `DISKS_INTERVAL_SECONDS` defaults to
|
||||
**300** s. `smartctl` absent or a disk unreadable → that disk's `smart` is `null`
|
||||
with an error entry; the page says "smartctl not available" dimmed (invariants 3/4:
|
||||
unconfigured is not broken; a dead source must not blink another).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `08_events_paging_and_copy` (todo) — sequence only: it settles `app.js`/`dash.css`
|
||||
(the pager + copy audit) that the new page builds on; no hard code dependency.
|
||||
|
||||
## Tasks
|
||||
|
||||
1. `01_agent_smartctl_collection.md` — `host_agent.py` gains a `smartctl` pass over its existing whole-disk list (per-device `smartctl -i -A -H`, stdlib `subprocess`, timeout-bounded); fixture from the real disks; deploy docs.
|
||||
2. `02_disks_source_poller_store.md` — the `disks` source (payload + `disk.*@dev` samples), its own poller job at `DISKS_INTERVAL_SECONDS` (default 300), rollups + retention in `store.py`.
|
||||
3. `03_disks_api_and_config.md` — `/api/disks/snapshot` + `/api/disks/history`, config wiring, `404 {"configured": false}` without dialling, severity rows for the health/temperature metrics.
|
||||
4. `04_disks_page_and_overview.md` — the `/disks` page (per-disk cards, alarm chips, per-host history charts), nav tab + overview entry + header status dot.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Hermetic: fixture-driven agent tests (`tests/test_agent.py` + recorded `smartctl`
|
||||
output), `tests/test_disks_source.py` (new), `tests/test_api.py` key-path pins (with
|
||||
and without config), `node --check`.
|
||||
- Live: `uv run pytest -m live --live` — the agent on deskwork/driveripper answers
|
||||
`smart` fields for its real disks; **live tests must not write** (no `smartctl -t`, no
|
||||
self-test on hardware something is plugged into).
|
||||
- Browser (DoD): screenshots in `data/logs/` — per-disk cards on live data, the
|
||||
"smartctl not available" state, the overview with the new tab.
|
||||
- Coverage: **>90%** on new/modified code, ≥94% overall gate maintained.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `GET /api/disks/snapshot` answers per-disk `smart` data for every physical disk the agents report; `404 {"configured": false}` with no agents configured, without dialling
|
||||
- [ ] `/api/disks/history` serves the `disk.*@dev` series through the existing rollups
|
||||
- [ ] the `/disks` page renders one card per physical disk (never per-partition) with health, temp, power-on hours and non-zero alarm chips
|
||||
- [ ] a host without `smartctl` (or a denied read) shows dimmed `n/a` cards, never a red page
|
||||
- [ ] `bash .agents/validate.sh` green; screenshots in `data/logs/`
|
||||
- [ ] you have verified with real smartctl data from driveripper. The `ops` user has `sudo smartctl*` access via sudoers.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Task 01 — The agent collects smartctl per physical disk
|
||||
|
||||
**Phase:** `09_disks_smartctl` · **Source:** `TODO.md:8` — "…collects data about all my physical disks across my fleet" (the collection half)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
`agent/host_agent.py` answers SMART data for every physical disk it already enumerates
|
||||
(whole `/sys/block` devices — the existing partition-free list), via a direct,
|
||||
timeout-bounded `smartctl` call, without ever breaking the agent: no binary, a denied
|
||||
read, or a wedgy device degrades that disk's `smart` to `null` with an `errors[]`
|
||||
entry and leaves the rest of the snapshot intact.
|
||||
|
||||
## Work
|
||||
|
||||
1. `agent/host_agent.py` — a `smart()` pass inside the existing disk collection: for each
|
||||
physical whole disk (the existing `/sys/block` enumeration, skipping the same
|
||||
virtual/`dm-*` entries it already skips), run `smartctl -i -A -H <device>` via stdlib
|
||||
`subprocess` with a per-call timeout (ASSUMPTION: **5 s per device**; the whole pass is
|
||||
bounded so a wedgy disk cannot stall the snapshot). Parse, from the recorded output:
|
||||
`model`/`serial` (from `-i`, falling back to the agent's existing disk identity when
|
||||
smartctl's identity block is absent), `overall_health` (OK/FAILED), `temp_c`
|
||||
(air-temperature line, absent on many SSDs → `None`), `power_on_hours`,
|
||||
`reallocated_sector_ct`, `current_pending_sector_ct`, `offline_uncorrectable` (media
|
||||
errors), `percentage_used` (SSD wear). Attach as `smart: {…}` per disk row in the
|
||||
`GET /snapshot` docstring and payload.
|
||||
2. Failure semantics: `smartctl` missing → **one** `errors[]` entry per snapshot (not one
|
||||
per disk), every disk's `smart` is `null`; a single denied/wedgy device → only that
|
||||
disk's `smart` is `null` with its own error entry. The agent's `/health` stays up:
|
||||
SMART is a best-effort add-on, exactly like the existing per-section `_safe()`
|
||||
handling.
|
||||
3. Stdlib-only is kept (no new agent dependency; `subprocess` is stdlib, consistent with
|
||||
the locked "our own stdlib host agent" decision). `GET /info` is unchanged (smartctl
|
||||
is a per-snapshot capability, not identity).
|
||||
4. Fixtures (rule 7 — never trust a memory of an upstream): record `smartctl -i -A -H`
|
||||
output from the **real** disks on deskwork and driveripper (one representative SSD and
|
||||
one HDD if the fleet has both) into `tests/fixtures/smartctl_<dev>.txt`, captured by
|
||||
an extension of `scripts/capture-fixtures.sh` (same script, same PII/serial scrubbing
|
||||
as the existing captures — a serial scrubbed in a fixture is not a serial in the
|
||||
browser). The parse tests assert against these verbatim captures; a `smartctl` version
|
||||
drift that breaks a field fails the fixture test before a dashboard does.
|
||||
5. `tests/test_agent.py` — a fixture-rooted collector case: happy path (fields present),
|
||||
binary-missing (one error entry, all `smart: null`), single-device failure (only that
|
||||
disk null), non-physical device skipped.
|
||||
6. Deployment docs: `deploy/install-agent.sh` (or its README section) and `README.md` note
|
||||
the requirement — `smartmontools` installed and the agent user able to read SMART
|
||||
(e.g. `disk` group); absence is a documented graceful degradation, not an install
|
||||
failure (DoD: fleet facts travel with the change).
|
||||
|
||||
- ASSUMPTION: **direct read, no sudo** — `smartctl` runs as the agent user (confirmed
|
||||
with the operator); if a host's permissions deny it, that host's disks degrade to the
|
||||
"smartctl not available" state, and the fix is a group grant, not a sudoers entry
|
||||
(command-scoped sudo exists for NUT, but SMART reads do not need root and the agent's
|
||||
`ProtectSystem=strict` unit stays as-is).
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Unit: `tests/test_agent.py` against the recorded fixtures (all four failure/happy modes
|
||||
above); the real-`/proc` test keeps touching only the box running pytest (existing
|
||||
policy — a box without `smartctl` exercises the binary-missing path, which is itself
|
||||
a valid assertion).
|
||||
- Live: `uv run pytest -m live --live` — the live agents' snapshots carry `smart` fields
|
||||
for the real disks (read-only; no `smartctl -t` anywhere in the suite).
|
||||
- Coverage: **>90%** on the agent's new code; overall gate (94%) maintained.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `GET /snapshot` disk rows carry `smart` (model/serial/health/temp/power-on/sectors/wear) on a host with a readable `smartctl`
|
||||
- [ ] missing binary → one error entry + `smart: null` everywhere; one bad device → only that disk null; agent `/health` stays up
|
||||
- [ ] `tests/fixtures/smartctl_*.txt` in the repo, reproducible via `./scripts/capture-fixtures.sh`, serials scrubbed
|
||||
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
|
||||
@@ -0,0 +1,66 @@
|
||||
# Task 02 — The `disks` source, its slow poller job, and the store's rollups
|
||||
|
||||
**Phase:** `09_disks_smartctl` · **Source:** `TODO.md:8` — the storage + polling half of the physical-disk dashboard
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
`disks` becomes a first-class source: one payload per host (the physical disks and their
|
||||
`smart` fields), one `disk.*@dev` sample set per poll, its **own** asyncio poller task at
|
||||
a slow 5-minute cadence — SMART is not a per-second signal, and a dead `disks` job must
|
||||
age its numbers in the UI exactly like any other source (invariant 4), with the store's
|
||||
existing rollups and retention doing the history work (rule 9).
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/config.py` — `DISKS_INTERVAL_SECONDS` (default **300**; env-only, the
|
||||
existing pattern). The `disks` source is *configured* when at least one host agent
|
||||
target is configured — it reads the same `/snapshot` the `system` source does, so it
|
||||
has no targets of its own; with no host targets it is unconfigured and 404s without
|
||||
dialling (invariant 3).
|
||||
2. `src/dashboards/sources/disks.py` (new) — snapshot → payload per host: the physical
|
||||
disk rows (device, model, size, rotation from the agent's existing `disks` fields)
|
||||
plus `smart` (task 01's fields, `None` where the agent had nothing — the page prints
|
||||
`n/a`, never `0`), plus a `smartctl_available` flag per host so the page can say
|
||||
*why* a card is `n/a` instead of leaving it a mystery. Samples (dotted, `@dev` stream
|
||||
suffix, the existing naming scheme — the history API's key):
|
||||
`disk.health_ok@<dev>` is **not** a series (it is a verdict, not a number); the
|
||||
series are `disk.temp_c@<dev>`, `disk.power_on_hours@<dev>`,
|
||||
`disk.reallocated@<dev>`, `disk.pending@<dev>`, `disk.uncorrectable@<dev>`,
|
||||
`disk.percentage_used@<dev>` (a count/percent metric, not a rate — no `Δ` window
|
||||
applies). Events under the **dashboard** name `disks` (the phase-03 lesson: a page
|
||||
filtering `/api/events` by source gets an empty feed otherwise).
|
||||
3. `src/dashboards/poller.py` — register the job (one task per source, its own timeout
|
||||
and health record; the 300 s cadence means the stale threshold — 3× its own interval —
|
||||
is 15 min, and `alerts.py`'s existing ok-but-stale `warn` rule covers it with no
|
||||
change: the alert reads the job's own interval).
|
||||
4. `src/dashboards/store.py` — the new series follow the existing rollup path
|
||||
(raw → 1m → 1h) and retention; at a 300 s cadence the raw rows are 288/day/host/disk —
|
||||
the retention story is the existing one, but the task must verify the rollup window
|
||||
arithmetic handles a 300 s raw resolution (a 1-minute rollup bucket straddles two
|
||||
polls; the existing "latest wins" semantics must keep holding, asserted by a test).
|
||||
5. `tests/test_disks_source.py` (new) — fixture-snapshot → payload/samples (fields present,
|
||||
`null` where the agent had none, `smartctl_available` false → all `n/a`), the 300 s
|
||||
cadence in the job registration, the unconfigured-without-dialling 404 path, the
|
||||
rollup-bucket test.
|
||||
|
||||
- ASSUMPTION: `power_on_hours` and the sector counters are stored as **counters-as
|
||||
reported** (monotonic by nature) — no `Δ` rate is derived; a rate the upstream does not
|
||||
publish is `n/a` (rule 2). `disk.health_ok` (OK/FAILED) lives in the payload only;
|
||||
colour comes from `severity.classify` on the numeric rows (task 03).
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Unit: `tests/test_disks_source.py` (the full matrix above), `tests/test_poller.py`
|
||||
additions (the job's own timeout/health, a failed poll keeps the last good payload and
|
||||
marks stale — the invariant-4 behaviour on the new job), `tests/test_store.py`
|
||||
(rollup/retention on the new series at 300 s resolution).
|
||||
- Coverage: **>90%** on new/modified code; overall gate (94%) maintained.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `uv run pytest` green with the new source/job/store tests
|
||||
- [ ] with no host targets, the `disks` job is unconfigured and 404s without dialling
|
||||
- [ ] a failed `disks` poll ages the payload (stale in the UI) instead of zeroing it
|
||||
- [ ] 1-minute rollup buckets are correct at 300 s raw resolution (asserted, not assumed)
|
||||
- [ ] `uv run ruff check src tests agent && uv run pyright` clean
|
||||
@@ -0,0 +1,62 @@
|
||||
# Task 03 — The disks API, config wiring, and the severity rows
|
||||
|
||||
**Phase:** `09_disks_smartctl` · **Source:** `TODO.md:8` — the API half of the physical-disk dashboard
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
`/api/disks/snapshot` and `/api/disks/history` answer like every other dashboard's
|
||||
endpoints (GETs only, 404-when-unconfigured, redacted error strings, the health record in
|
||||
`/api/health`), and the numbers that *are* worth a verdict get one row in
|
||||
`severity.py` — the only place thresholds live — while the alarm counters get none
|
||||
(a non-zero sector count is an incident, not a graded number: presence, like the
|
||||
`/llm` preemption chips, never a threshold).
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/api.py` — `GET /api/disks/snapshot` (per-host: disk rows + `smart` +
|
||||
`smartctl_available`) and `GET /api/disks/history` (the `disk.*@dev` series through
|
||||
the existing history mechanism; unknown names answer as empty series on purpose, the
|
||||
same contract `/api/system/history` and `/api/llm/history` have). No POST anywhere on
|
||||
this surface (invariant 1 — the one write in the app stays `POST /api/ups/command`).
|
||||
2. `404 {"configured": false}` with **no dialling** when no host agent is configured
|
||||
(invariant 3), asserted with a test that would catch a client being constructed before
|
||||
the gate. `UpstreamError` messages carry redacted URLs only (rule 6 — the existing
|
||||
`Target.display_url` path; the disks source reuses the host targets' redaction, so
|
||||
nothing new leaves the process with a token).
|
||||
3. `src/dashboards/severity.py` — the new rows (ASSUMPTION — proposed bounds, pinned by
|
||||
test, adjustable in one place without touching the page):
|
||||
- `disk.temp_c`: warn **50**, fail **70** (a HDD spinning at 70 °C is a fire; an SSD
|
||||
without a sensor simply has no value and gets no verdict — no measurement, no
|
||||
verdict);
|
||||
- `disk.percentage_used`: warn **80**, fail **100** (a 100 %-used SSD is a
|
||||
replacement, not a warning).
|
||||
The sector counters (`disk.reallocated`, `disk.pending`,
|
||||
`disk.uncorrectable`) get **no row**: non-zero is an alarm chip, zero is the
|
||||
absence of one (never green).
|
||||
4. `src/dashboards/alerts.py` — no change expected: the ok-but-stale `warn` and the
|
||||
dead-source `fail` rules read the job's own interval and reachability, and the
|
||||
unconfigured-never-alerts rule covers the unconfigured case. **If** a test shows the
|
||||
new job needs nothing, say so in the report; an alert with its own threshold is a
|
||||
bug (the module is a pure projection).
|
||||
5. `src/dashboards/app.py` — assembly wiring (settings/store injection, so tests build an
|
||||
app with an in-memory DB and no targets, the existing pattern).
|
||||
6. `tests/test_api.py` — key-path pins for `/api/disks/snapshot` (with and without
|
||||
targets; a configured job with no payload yet lists the hosts as stubs, `n/a`
|
||||
everywhere — the same stub shape `/api/system/snapshot` has), `/api/disks/history`
|
||||
(known + unknown series), the 404-without-dialling case, and `/api/health` carrying
|
||||
the new job's record.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Unit: `tests/test_api.py` (the pins above), `tests/test_severity.py` (the two new rows,
|
||||
boundary ordering, the counter metrics deliberately absent from `THRESHOLDS`),
|
||||
`tests/test_alerts.py` (the new job's stale/dead/unconfigured states).
|
||||
- Coverage: **>90%** on modified code; overall gate (94%) maintained.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `curl /api/disks/snapshot` answers per-host disk + smart data (or `404 {"configured": false}` with no dial)
|
||||
- [ ] `/api/disks/history` serves the `disk.*@dev` series; unknown names → empty series
|
||||
- [ ] `classify("disk.temp_c", 75)` is `fail`, `classify("disk.reallocated", 1)` is `None` (presence, not a verdict)
|
||||
- [ ] `bash .agents/validate.sh` green
|
||||
@@ -0,0 +1,74 @@
|
||||
# Task 04 — The /disks page, the nav tab, and the overview entry
|
||||
|
||||
**Phase:** `09_disks_smartctl` · **Source:** `TODO.md:8` — "Add a 'disks' tab dedicated to smartctl and per-disk metrics" (the page half)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
The fourth dashboard: `/disks` — one card per **physical** disk (the agent's whole-
|
||||
`/sys/block` enumeration; a partition is never a card), led by what the operator needs
|
||||
while a disk is acting up: health, temperature, power-on hours, and the sector alarms —
|
||||
plus per-host history charts under the same discipline as `/system` (collapsed by
|
||||
default, phase 06; one history request per host per refresh; charts belong to the host
|
||||
they chart).
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/web/pages/disks.html` + `src/dashboards/web/static/page-disks.js`
|
||||
(new) — the page, in the existing vocabulary (masthead nav + a `Disks` tab with
|
||||
`aria-current` on itself, `.card`, `.heroes`-free layout — the disk card leads with
|
||||
the disk identity, not a big number):
|
||||
- **per-disk card**: host + device + model (+ size / rotation from the agent's
|
||||
existing fields), a health line (OK/FAILED from `smart.overall_health`, coloured
|
||||
from the payload's severity verdict — a FAILED health is `fail` by the existing
|
||||
`disk.percentage_used`/`temp_c` rows or the explicit health field; **never** a
|
||||
verdict the API did not send), temperature, power-on hours, percentage-used where
|
||||
the device has it (HDDs may not — `n/a`, uncoloured);
|
||||
- **alarm chips that exist only while non-zero** (the `/llm` `.chip.fail` idiom):
|
||||
reallocated, pending, uncorrectable — a disk at zero shows no chip row at all,
|
||||
because a permanent "0 pending sectors" row trains the eye to ignore it;
|
||||
- **`smartctl` unavailable** (per host, `smartctl_available: false`): the host's
|
||||
cards render dimmed (`.stale`) with `n/a` and a one-line why — "smartctl not
|
||||
available on <host>" — never a red panel (invariant 3);
|
||||
- **per-host chart section** (phase-06 discipline): collapsed `<details>` "Charts" by
|
||||
default, lazy-drawn `LineChart`s for `disk.temp_c@<dev>` (and
|
||||
`disk.percentage_used@<dev>` where present) through **one**
|
||||
`/api/disks/history` request per host per refresh naming every series it could draw;
|
||||
a host that stops reporting loses its cards *and* charts (`pruneHosts` pattern).
|
||||
2. `src/dashboards/web/pages/*.html` — the `Disks` tab joins the masthead nav on all
|
||||
four pages (the orphan-`id` rule: every new `id` is referenced); the overview's
|
||||
"which dashboard do you need" table gains the `/disks` row
|
||||
("are any of my physical disks failing, and which one") and the header's
|
||||
per-source status dot picks up the `disks` job.
|
||||
3. `src/dashboards/web/pages/index.html` + `page-index.js` — `/api/summary` is the
|
||||
overview's single call; add the `disks` payload to its contract **additively**
|
||||
(the existing key paths stay pinned — `tests/test_api.py` fails if a summary key
|
||||
retires), and the Fleet/Warnings panels treat the `disks` source like the others
|
||||
(an alert from `alerts.py` needs nothing new; a dead `disks` job is one line).
|
||||
4. `src/dashboards/web/static/dash.css` — the disk-card + chip styling from existing
|
||||
tokens (no new palette, no `.sev-*` family); `node --check` on every touched script.
|
||||
5. `tests/test_api.py` — pin the served markup (nav tab on all four pages, overview row,
|
||||
the page's card/empty/stale states) and the `/api/summary` additive key path.
|
||||
|
||||
- ASSUMPTION: the page's hero per card is **identity + health**, not a single big number
|
||||
(a disk has no one-number story the way a UPS has charge %); the numbers that matter
|
||||
(temp, hours, wear) are equal-size `.metric` cells, and the verdicts come only from
|
||||
task 03's `severity` rows — `n/a` where the device does not publish the field.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Hermetic: `tests/test_api.py` (markup + summary contract), `node --check`, the
|
||||
orphan-`id`/orphan-selector rules.
|
||||
- Live: `uv run pytest -m live --live` (read-only); the real fleet today has physical
|
||||
disks on deskwork and driveripper — the page must show both hosts' cards.
|
||||
- Browser (DoD): screenshots in `data/logs/` — live cards on both hosts, the
|
||||
"smartctl not available" state (a host with the binary removed, or a `smartctl_available:
|
||||
false` fixture), collapsed charts expanded, the overview with the new tab + row.
|
||||
- Coverage: **>90%** on new/modified code; overall gate (94%) maintained.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `/disks` renders one card per physical disk on live data (never per-partition), with health/ temp/hours/wear and non-zero-only alarm chips
|
||||
- [ ] a `smartctl`-less host is dimmed with a one-line why — never red
|
||||
- [ ] nav tab on all four pages; the overview's table lists `/disks`; the header dot tracks the job
|
||||
- [ ] `bash .agents/validate.sh` green; screenshots in `data/logs/`
|
||||
Reference in New Issue
Block a user