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:
2026-10-18 00:45:01 -04:00
parent 19cfedfddf
commit fefbeb8227
81 changed files with 5525 additions and 134 deletions
+106
View File
@@ -107,6 +107,112 @@ def host_scrape() -> Any:
return scrape
# ---- phase 09: physical disks (the /disks source and page) ----------------- #
def _load_host_agent() -> Any:
"""The stdlib agent module, loaded by path: it is not an installed package (and must
not become one — it ships as a single file to every host). Same loader as
tests/test_agent.py, kept private here because only the disk fixtures need it."""
import importlib.util # noqa: PLC0415 - only the disk fixtures load the agent
path = FIXTURES.parent.parent / "agent" / "host_agent.py"
spec = importlib.util.spec_from_file_location("host_agent", path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules.setdefault("host_agent", module)
spec.loader.exec_module(module)
return module
#: The two `smartctl` shapes the fleet has: NVMe (health-log fields) and ATA (the vendor
#: attribute table). `tests/fixtures/smartctl_reference_*.txt` — labels verbatim from the
#: deployed smartmontools 7.5, values synthetic (see the header of each file).
SMART_SHAPES = ("nvme", "ata_hdd")
#: The agent's own words for the way SMART goes missing that the page must name: the
#: binary is not installed. Mirrors `host_agent.Collector._smart_pass()`; the agent test
#: `test_missing_smartctl_binary_is_one_error_for_the_whole_pass` pins that the line it
#: really writes still contains the word `sources.disks.MISSING_BINARY_WORD` looks for, so
#: a rewording fails a test instead of turning "install smartmontools" into "unknown".
SMART_MISSING_BINARY = ("smart: /usr/sbin/smartctl not found — install smartmontools and "
"give this user device read access (disk group) for SMART data")
@pytest.fixture
def smart_shape() -> Any:
"""`smart_shape('nvme')` / `smart_shape('ata_hdd')` -> the capture, parsed by the agent.
Fixture-driven on purpose (AGENTS.md rule 7): the `smart` blocks a `/disks` test hands
to the source are the recorded `smartctl` text run through the agent's own parser, so a
field rename on either side of the wire fails a test instead of printing a dash.
"""
agent = _load_host_agent()
def shape(kind: str = "nvme") -> dict[str, Any]:
text = (FIXTURES / f"smartctl_reference_{kind}.txt").read_text()
return agent.parse_smart_output(text)
return shape
@pytest.fixture
def disk_snapshot(agent_snapshots: dict[str, dict[str, Any]], smart_shape: Any) -> Any:
"""`build(host="driveripper", mode="healthy")` — a real capture with SMART attached.
The fleet captures predate the agent's SMART pass, so there is no single capture with
both halves. This assembles the two that exist: the physical disk rows come from the
host capture verbatim, the `smart` blocks from the recorded smartmontools shapes, and
the failure modes are the ones `host_agent.smart()` actually produces.
Modes:
* `healthy` — every disk reads, alternating the NVMe and ATA shapes
* `no_binary` — every `smart` is null plus the agent's single "not found" line
* `denied` — the last disk is null with its own permission line (SMART is installed)
* `silent` — every `smart` is null with no error line (`--no-smart`, or an agent that
predates the pass: the dashboard cannot tell why, and must not guess)
* `failed` — one drive answering `FAILED`, the rest healthy
"""
def build(host: str = "driveripper", *, mode: str = "healthy",
mutate: Any = None) -> dict[str, Any]:
import copy # noqa: PLC0415 - every build starts from the untouched capture
snapshot = copy.deepcopy(agent_snapshots[host])
devices = (snapshot.get("disk_io") or {}).get("devices") or []
errors: list[str] = list(snapshot.get("errors") or [])
for index, device in enumerate(devices):
if mode == "no_binary":
device["smart"] = None
continue
if mode == "silent":
device["smart"] = None
continue
if mode == "denied" and index == len(devices) - 1:
device["smart"] = None
continue
shape = smart_shape(SMART_SHAPES[index % len(SMART_SHAPES)])
block = dict(shape)
# the identity in the capture is the disk the row is about; the shape files
# describe one drive each, so the row keeps its own model and the counters
# come from the shape
block["model"] = block.get("model") or device.get("model")
if mode == "failed" and index == 0:
block["overall_health"] = "FAILED"
device["smart"] = block
if mode == "no_binary":
errors.append(SMART_MISSING_BINARY)
if mode == "denied" and devices:
name = str(devices[-1].get("name"))
errors.append(f"smart: {name}: Smartctl open device: /dev/{name} failed: "
"Permission denied")
snapshot["errors"] = errors
if mutate is not None:
mutate(snapshot)
return snapshot
return build
@pytest.fixture
def metrics_text() -> Any:
"""metrics_text('gufo') / metrics_text('vllm') -> the captured exposition verbatim."""
+52
View File
@@ -0,0 +1,52 @@
# Verbatim smartctl -i -A -H /dev/nvme0n1 from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Number: Samsung SSD 980 PRO 2TB
Serial Number: SCRUBBEDSERIAL
Firmware Version: 5B2QGXA7
PCI Vendor/Subsystem ID: 0x144d
IEEE OUI Identifier: 0x002538
Total NVM Capacity: 2,000,398,934,016 [2.00 TB]
Unallocated NVM Capacity: 0
Controller ID: 6
NVMe Version: 1.3
Number of Namespaces: 1
Namespace 1 Size/Capacity: 2,000,398,934,016 [2.00 TB]
Namespace 1 Utilization: 1,616,151,379,968 [1.61 TB]
Namespace 1 Formatted LBA Size: 512
Namespace 1 IEEE EUI-64: SCRUBBEDEUI64
Local Time is: Sat Oct 17 23:55:46 2026 EDT
=== START OF SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART/Health Information (NVMe Log 0x02, NSID 0x1)
Critical Warning: 0x00
Temperature: 59 Celsius
Available Spare: 100%
Available Spare Threshold: 10%
Percentage Used: 14%
Data Units Read: 281,647,984 [144 TB]
Data Units Written: 323,868,857 [165 TB]
Host Read Commands: 1,597,979,105
Host Write Commands: 9,090,416,691
Controller Busy Time: 146,032
Power Cycles: 2,339
Power On Hours: 6,301
Unsafe Shutdowns: 336
Media and Data Integrity Errors: 0
Error Information Log Entries: 0
Warning Comp. Temperature Time: 24
Critical Comp. Temperature Time: 0
Temperature Sensor 1: 59 Celsius
Temperature Sensor 2: 73 Celsius
Thermal Temp. 1 Transition Count: 87
Thermal Temp. 2 Transition Count: 54
Thermal Temp. 1 Total Time: 3708
Thermal Temp. 2 Total Time: 960
+48
View File
@@ -0,0 +1,48 @@
# Verbatim smartctl -i -A -H /dev/nvme1n1 from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Number: Samsung SSD 970 EVO Plus 250GB
Serial Number: SCRUBBEDSERIAL
Firmware Version: 2B2QEXM7
PCI Vendor/Subsystem ID: 0x144d
IEEE OUI Identifier: 0x002538
Total NVM Capacity: 250,059,350,016 [250 GB]
Unallocated NVM Capacity: 0
Controller ID: 4
NVMe Version: 1.3
Number of Namespaces: 1
Namespace 1 Size/Capacity: 250,059,350,016 [250 GB]
Namespace 1 Utilization: 30,404,874,240 [30.4 GB]
Namespace 1 Formatted LBA Size: 512
Namespace 1 IEEE EUI-64: SCRUBBEDEUI64
Local Time is: Sat Oct 17 23:55:47 2026 EDT
=== START OF SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART/Health Information (NVMe Log 0x02, NSID 0x1)
Critical Warning: 0x00
Temperature: 53 Celsius
Available Spare: 100%
Available Spare Threshold: 10%
Percentage Used: 58%
Data Units Read: 43,599,098 [22.3 TB]
Data Units Written: 92,033,256 [47.1 TB]
Host Read Commands: 500,908,623
Host Write Commands: 2,587,884,718
Controller Busy Time: 8,424
Power Cycles: 319
Power On Hours: 13,161
Unsafe Shutdowns: 177
Media and Data Integrity Errors: 0
Error Information Log Entries: 285
Warning Comp. Temperature Time: 0
Critical Comp. Temperature Time: 0
Temperature Sensor 1: 53 Celsius
Temperature Sensor 2: 55 Celsius
+45
View File
@@ -0,0 +1,45 @@
# Verbatim smartctl -i -A -H /dev/nvme2n1 from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Number: WDS100T1X0E-00AFY0
Serial Number: SCRUBBEDSERIAL
Firmware Version: 613200WD
PCI Vendor/Subsystem ID: 0x15b7
IEEE OUI Identifier: 0x001b44
Total NVM Capacity: 1,000,204,886,016 [1.00 TB]
Unallocated NVM Capacity: 0
Controller ID: 8224
NVMe Version: 1.4
Number of Namespaces: 1
Namespace 1 Size/Capacity: 1,000,204,886,016 [1.00 TB]
Namespace 1 Formatted LBA Size: 512
Namespace 1 IEEE EUI-64: SCRUBBEDEUI64
Local Time is: Sat Oct 17 23:55:47 2026 EDT
=== START OF SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART/Health Information (NVMe Log 0x02, NSID 0xffffffff)
Critical Warning: 0x00
Temperature: 49 Celsius
Available Spare: 100%
Available Spare Threshold: 10%
Percentage Used: 16%
Data Units Read: 99,711,973 [51.0 TB]
Data Units Written: 493,282,793 [252 TB]
Host Read Commands: 943,634,622
Host Write Commands: 8,794,186,017
Controller Busy Time: 10,776
Power Cycles: 180
Power On Hours: 37,984
Unsafe Shutdowns: 90
Media and Data Integrity Errors: 0
Error Information Log Entries: 6
Warning Comp. Temperature Time: 0
Critical Comp. Temperature Time: 0
+45
View File
@@ -0,0 +1,45 @@
# Verbatim smartctl -i -A -H /dev/nvme3n1 from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Number: WDBRPG0010BNC-WRSN
Serial Number: SCRUBBEDSERIAL
Firmware Version: 102000WD
PCI Vendor/Subsystem ID: 0x15b7
IEEE OUI Identifier: 0x001b44
Total NVM Capacity: 1,000,204,886,016 [1.00 TB]
Unallocated NVM Capacity: 0
Controller ID: 8215
NVMe Version: 1.3
Number of Namespaces: 1
Namespace 1 Size/Capacity: 1,000,204,886,016 [1.00 TB]
Namespace 1 Formatted LBA Size: 512
Namespace 1 IEEE EUI-64: SCRUBBEDEUI64
Local Time is: Sat Oct 17 23:55:48 2026 EDT
=== START OF SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART/Health Information (NVMe Log 0x02, NSID 0xffffffff)
Critical Warning: 0x00
Temperature: 49 Celsius
Available Spare: 100%
Available Spare Threshold: 10%
Percentage Used: 0%
Data Units Read: 140,935,676 [72.1 TB]
Data Units Written: 737,387,949 [377 TB]
Host Read Commands: 3,673,573,884
Host Write Commands: 15,230,962,004
Controller Busy Time: 30,285
Power Cycles: 296
Power On Hours: 52,249
Unsafe Shutdowns: 133
Media and Data Integrity Errors: 0
Error Information Log Entries: 2,038
Warning Comp. Temperature Time: 0
Critical Comp. Temperature Time: 0
+45
View File
@@ -0,0 +1,45 @@
# Verbatim smartctl -i -A -H /dev/nvme4n1 from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Number: WDBRPG0010BNC-WRSN
Serial Number: SCRUBBEDSERIAL
Firmware Version: 102000WD
PCI Vendor/Subsystem ID: 0x15b7
IEEE OUI Identifier: 0x001b44
Total NVM Capacity: 1,000,204,886,016 [1.00 TB]
Unallocated NVM Capacity: 0
Controller ID: 8215
NVMe Version: 1.3
Number of Namespaces: 1
Namespace 1 Size/Capacity: 1,000,204,886,016 [1.00 TB]
Namespace 1 Formatted LBA Size: 512
Namespace 1 IEEE EUI-64: SCRUBBEDEUI64
Local Time is: Sat Oct 17 23:55:48 2026 EDT
=== START OF SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART/Health Information (NVMe Log 0x02, NSID 0xffffffff)
Critical Warning: 0x00
Temperature: 49 Celsius
Available Spare: 100%
Available Spare Threshold: 10%
Percentage Used: 0%
Data Units Read: 131,673,036 [67.4 TB]
Data Units Written: 737,398,022 [377 TB]
Host Read Commands: 2,970,582,194
Host Write Commands: 15,268,475,455
Controller Busy Time: 29,500
Power Cycles: 288
Power On Hours: 52,397
Unsafe Shutdowns: 134
Media and Data Integrity Errors: 0
Error Information Log Entries: 2,050
Warning Comp. Temperature Time: 0
Critical Comp. Temperature Time: 0
+48
View File
@@ -0,0 +1,48 @@
# Verbatim smartctl -i -A -H /dev/nvme5n1 from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Number: Samsung SSD 980 PRO 1TB
Serial Number: SCRUBBEDSERIAL
Firmware Version: 2B2QGXA7
PCI Vendor/Subsystem ID: 0x144d
IEEE OUI Identifier: 0x002538
Total NVM Capacity: 1,000,204,886,016 [1.00 TB]
Unallocated NVM Capacity: 0
Controller ID: 6
NVMe Version: 1.3
Number of Namespaces: 1
Namespace 1 Size/Capacity: 1,000,204,886,016 [1.00 TB]
Namespace 1 Utilization: 699,798,638,592 [699 GB]
Namespace 1 Formatted LBA Size: 512
Namespace 1 IEEE EUI-64: SCRUBBEDEUI64
Local Time is: Sat Oct 17 23:55:49 2026 EDT
=== START OF SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART/Health Information (NVMe Log 0x02, NSID 0x1)
Critical Warning: 0x00
Temperature: 49 Celsius
Available Spare: 100%
Available Spare Threshold: 10%
Percentage Used: 23%
Data Units Read: 134,905,771 [69.0 TB]
Data Units Written: 340,234,345 [174 TB]
Host Read Commands: 1,296,968,853
Host Write Commands: 6,485,156,779
Controller Busy Time: 88,424
Power Cycles: 716
Power On Hours: 26,278
Unsafe Shutdowns: 328
Media and Data Integrity Errors: 13
Error Information Log Entries: 13
Warning Comp. Temperature Time: 0
Critical Comp. Temperature Time: 0
Temperature Sensor 1: 49 Celsius
Temperature Sensor 2: 54 Celsius
+45
View File
@@ -0,0 +1,45 @@
# Verbatim smartctl -i -A -H /dev/nvme6n1 from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Number: WDBRPG0010BNC-WRSN
Serial Number: SCRUBBEDSERIAL
Firmware Version: 102000WD
PCI Vendor/Subsystem ID: 0x15b7
IEEE OUI Identifier: 0x001b44
Total NVM Capacity: 1,000,204,886,016 [1.00 TB]
Unallocated NVM Capacity: 0
Controller ID: 8215
NVMe Version: 1.3
Number of Namespaces: 1
Namespace 1 Size/Capacity: 1,000,204,886,016 [1.00 TB]
Namespace 1 Formatted LBA Size: 512
Namespace 1 IEEE EUI-64: SCRUBBEDEUI64
Local Time is: Sat Oct 17 23:55:49 2026 EDT
=== START OF SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART/Health Information (NVMe Log 0x02, NSID 0xffffffff)
Critical Warning: 0x00
Temperature: 50 Celsius
Available Spare: 100%
Available Spare Threshold: 10%
Percentage Used: 0%
Data Units Read: 132,700,971 [67.9 TB]
Data Units Written: 721,481,575 [369 TB]
Host Read Commands: 3,035,612,517
Host Write Commands: 14,384,001,247
Controller Busy Time: 29,604
Power Cycles: 263
Power On Hours: 52,894
Unsafe Shutdowns: 126
Media and Data Integrity Errors: 0
Error Information Log Entries: 1,957
Warning Comp. Temperature Time: 0
Critical Comp. Temperature Time: 0
+48
View File
@@ -0,0 +1,48 @@
# Verbatim smartctl -i -A -H /dev/nvme7n1 from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Number: Samsung SSD 970 EVO 1TB
Serial Number: SCRUBBEDSERIAL
Firmware Version: 2B2QEXE7
PCI Vendor/Subsystem ID: 0x144d
IEEE OUI Identifier: 0x002538
Total NVM Capacity: 1,000,204,886,016 [1.00 TB]
Unallocated NVM Capacity: 0
Controller ID: 4
NVMe Version: 1.3
Number of Namespaces: 1
Namespace 1 Size/Capacity: 1,000,204,886,016 [1.00 TB]
Namespace 1 Utilization: 694,159,712,256 [694 GB]
Namespace 1 Formatted LBA Size: 512
Namespace 1 IEEE EUI-64: SCRUBBEDEUI64
Local Time is: Sat Oct 17 23:55:50 2026 EDT
=== START OF SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART/Health Information (NVMe Log 0x02, NSID 0x1)
Critical Warning: 0x00
Temperature: 60 Celsius
Available Spare: 100%
Available Spare Threshold: 10%
Percentage Used: 11%
Data Units Read: 131,460,130 [67.3 TB]
Data Units Written: 390,323,927 [199 TB]
Host Read Commands: 845,847,299
Host Write Commands: 6,716,124,714
Controller Busy Time: 50,013
Power Cycles: 1,012
Power On Hours: 30,083
Unsafe Shutdowns: 492
Media and Data Integrity Errors: 0
Error Information Log Entries: 5,817
Warning Comp. Temperature Time: 0
Critical Comp. Temperature Time: 0
Temperature Sensor 1: 60 Celsius
Temperature Sensor 2: 77 Celsius
+53
View File
@@ -0,0 +1,53 @@
# Verbatim smartctl -i -A -H /dev/sda from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Family: Seagate IronWolf
Device Model: ST8000VN004-2M2101
Serial Number: SCRUBBEDSERIAL
LU WWN Device Id: SCRUBBEDWWN
Firmware Version: SC60
User Capacity: 8,001,563,222,016 bytes [8.00 TB]
Sector Sizes: 512 bytes logical, 4096 bytes physical
Rotation Rate: 7200 rpm
Form Factor: 3.5 inches
Device is: In smartctl database 7.5/6204
ATA Version is: ACS-4 (minor revision not indicated)
SATA Version is: SATA 3.3, 6.0 Gb/s (current: 6.0 Gb/s)
Local Time is: Sat Oct 17 23:55:50 2026 EDT
SMART support is: Available - device has SMART capability.
SMART support is: Enabled
=== START OF READ SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART Attributes Data Structure revision number: 10
Vendor Specific SMART Attributes with Thresholds:
ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
1 Raw_Read_Error_Rate 0x000f 083 064 044 Pre-fail Always - 0/208845750
3 Spin_Up_Time 0x0003 082 081 000 Pre-fail Always - 0
4 Start_Stop_Count 0x0032 100 100 020 Old_age Always - 127
5 Reallocated_Sector_Ct 0x0033 100 100 010 Pre-fail Always - 0
7 Seek_Error_Rate 0x000f 086 060 045 Pre-fail Always - 0/368951419
9 Power_On_Hours 0x0032 066 066 000 Old_age Always - 30423
10 Spin_Retry_Count 0x0013 100 100 097 Pre-fail Always - 0
12 Power_Cycle_Count 0x0032 100 100 020 Old_age Always - 127
18 Head_Health 0x000b 100 100 050 Pre-fail Always - 0
187 Reported_Uncorrect 0x0032 100 100 000 Old_age Always - 0
188 Command_Timeout 0x0032 100 100 000 Old_age Always - 0 0 0
190 Airflow_Temperature_Cel 0x0022 065 049 040 Old_age Always - 35 (Min/Max 34/44)
192 Power-Off_Retract_Count 0x0032 100 100 000 Old_age Always - 37
193 Load_Cycle_Count 0x0032 009 009 000 Old_age Always - 183890
194 Temperature_Celsius 0x0022 035 046 000 Old_age Always - 35 (0 13 0 0 0)
195 Hardware_ECC_Recovered 0x001a 083 064 000 Old_age Always - 0/208845750
197 Current_Pending_Sector 0x0012 100 100 000 Old_age Always - 0
198 Offline_Uncorrectable 0x0010 100 100 000 Old_age Offline - 0
199 UDMA_CRC_Error_Count 0x003e 200 200 000 Old_age Always - 0
240 Head_Flying_Hours 0x0000 100 253 000 Old_age Offline - 4365h+15m+41.227s
241 Total_LBAs_Written 0x0000 100 253 000 Old_age Offline - 88499415587
242 Total_LBAs_Read 0x0000 100 253 000 Old_age Offline - 485213567179
+51
View File
@@ -0,0 +1,51 @@
# Verbatim smartctl -i -A -H /dev/sdb from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Family: Seagate IronWolf
Device Model: ST2000VN004-2E4164
Serial Number: SCRUBBEDSERIAL
LU WWN Device Id: SCRUBBEDWWN
Firmware Version: SC60
User Capacity: 2,000,398,934,016 bytes [2.00 TB]
Sector Sizes: 512 bytes logical, 4096 bytes physical
Rotation Rate: 5900 rpm
Form Factor: 3.5 inches
Device is: In smartctl database 7.5/6204
ATA Version is: ACS-2, ACS-3 T13/2161-D revision 3b
SATA Version is: SATA 3.1, 6.0 Gb/s (current: 6.0 Gb/s)
Local Time is: Sat Oct 17 23:55:51 2026 EDT
SMART support is: Available - device has SMART capability.
SMART support is: Enabled
=== START OF READ SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART Attributes Data Structure revision number: 10
Vendor Specific SMART Attributes with Thresholds:
ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
1 Raw_Read_Error_Rate 0x000f 114 099 006 Pre-fail Always - 0/69728504
3 Spin_Up_Time 0x0003 095 095 000 Pre-fail Always - 0
4 Start_Stop_Count 0x0032 100 100 020 Old_age Always - 255
5 Reallocated_Sector_Ct 0x0033 100 100 010 Pre-fail Always - 0
7 Seek_Error_Rate 0x000f 086 060 030 Pre-fail Always - 1/440523587
9 Power_On_Hours 0x0032 044 044 000 Old_age Always - 49075
10 Spin_Retry_Count 0x0013 100 100 097 Pre-fail Always - 0
12 Power_Cycle_Count 0x0032 100 100 020 Old_age Always - 256
184 End-to-End_Error 0x0032 100 100 099 Old_age Always - 0
187 Reported_Uncorrect 0x0032 100 100 000 Old_age Always - 0
188 Command_Timeout 0x0032 100 100 000 Old_age Always - 1 1 1
189 High_Fly_Writes 0x003a 001 001 000 Old_age Always - 292
190 Airflow_Temperature_Cel 0x0022 069 060 045 Old_age Always - 31 (Min/Max 30/39)
191 G-Sense_Error_Rate 0x0032 100 100 000 Old_age Always - 0
192 Power-Off_Retract_Count 0x0032 100 100 000 Old_age Always - 147
193 Load_Cycle_Count 0x0032 100 100 000 Old_age Always - 360
194 Temperature_Celsius 0x0022 031 040 000 Old_age Always - 31 (0 8 0 0 0)
197 Current_Pending_Sector 0x0012 100 100 000 Old_age Always - 0
198 Offline_Uncorrectable 0x0010 100 100 000 Old_age Offline - 0
199 UDMA_CRC_Error_Count 0x003e 200 200 000 Old_age Always - 1
+56
View File
@@ -0,0 +1,56 @@
# Verbatim smartctl -i -A -H /dev/sdc from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Family: Seagate IronWolf
Device Model: ST6000VN001-2BB186
Serial Number: SCRUBBEDSERIAL
LU WWN Device Id: SCRUBBEDWWN
Firmware Version: SC60
User Capacity: 6,001,175,126,016 bytes [6.00 TB]
Sector Sizes: 512 bytes logical, 4096 bytes physical
Rotation Rate: 5425 rpm
Form Factor: 3.5 inches
Device is: In smartctl database 7.5/6204
ATA Version is: ACS-3 T13/2161-D revision 5
SATA Version is: SATA 3.1, 6.0 Gb/s (current: 6.0 Gb/s)
Local Time is: Sat Oct 17 23:55:52 2026 EDT
SMART support is: Available - device has SMART capability.
SMART support is: Enabled
=== START OF READ SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART Attributes Data Structure revision number: 10
Vendor Specific SMART Attributes with Thresholds:
ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
1 Raw_Read_Error_Rate 0x000f 081 064 006 Pre-fail Always - 0/125715512
3 Spin_Up_Time 0x0003 091 091 000 Pre-fail Always - 0
4 Start_Stop_Count 0x0032 100 100 020 Old_age Always - 177
5 Reallocated_Sector_Ct 0x0033 100 100 010 Pre-fail Always - 0
7 Seek_Error_Rate 0x000f 086 060 045 Pre-fail Always - 0/367065832
9 Power_On_Hours 0x0032 061 061 000 Old_age Always - 35014
10 Spin_Retry_Count 0x0013 100 100 097 Pre-fail Always - 0
12 Power_Cycle_Count 0x0032 100 100 020 Old_age Always - 177
183 Runtime_Bad_Block 0x0032 099 099 000 Old_age Always - 1
184 End-to-End_Error 0x0032 100 100 099 Old_age Always - 0
187 Reported_Uncorrect 0x0032 095 095 000 Old_age Always - 5
188 Command_Timeout 0x0032 100 099 000 Old_age Always - 1 1 1
189 High_Fly_Writes 0x003a 100 100 000 Old_age Always - 0
190 Airflow_Temperature_Cel 0x0022 064 056 040 Old_age Always - 36 (Min/Max 34/43)
191 G-Sense_Error_Rate 0x0032 100 100 000 Old_age Always - 0
192 Power-Off_Retract_Count 0x0032 100 100 000 Old_age Always - 1281
193 Load_Cycle_Count 0x0032 097 097 000 Old_age Always - 7402
194 Temperature_Celsius 0x0022 036 044 000 Old_age Always - 36 (0 15 0 0 0)
195 Hardware_ECC_Recovered 0x001a 081 064 000 Old_age Always - 0/125715512
197 Current_Pending_Sector 0x0012 100 100 000 Old_age Always - 0
198 Offline_Uncorrectable 0x0010 100 100 000 Old_age Offline - 0
199 UDMA_CRC_Error_Count 0x003e 200 200 000 Old_age Always - 1
240 Head_Flying_Hours 0x0000 100 253 000 Old_age Offline - 33995h+31m+30.750s
241 Total_LBAs_Written 0x0000 100 253 000 Old_age Offline - 89359900052
242 Total_LBAs_Read 0x0000 100 253 000 Old_age Offline - 466542530444
+51
View File
@@ -0,0 +1,51 @@
# Verbatim smartctl -i -A -H /dev/sdd from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Family: Seagate IronWolf
Device Model: ST2000VN004-2E4164
Serial Number: SCRUBBEDSERIAL
LU WWN Device Id: SCRUBBEDWWN
Firmware Version: SC60
User Capacity: 2,000,398,934,016 bytes [2.00 TB]
Sector Sizes: 512 bytes logical, 4096 bytes physical
Rotation Rate: 5900 rpm
Form Factor: 3.5 inches
Device is: In smartctl database 7.5/6204
ATA Version is: ACS-2, ACS-3 T13/2161-D revision 3b
SATA Version is: SATA 3.1, 6.0 Gb/s (current: 6.0 Gb/s)
Local Time is: Sat Oct 17 23:55:52 2026 EDT
SMART support is: Available - device has SMART capability.
SMART support is: Enabled
=== START OF READ SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART Attributes Data Structure revision number: 10
Vendor Specific SMART Attributes with Thresholds:
ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
1 Raw_Read_Error_Rate 0x000f 120 099 006 Pre-fail Always - 0/242964512
3 Spin_Up_Time 0x0003 095 095 000 Pre-fail Always - 0
4 Start_Stop_Count 0x0032 100 100 020 Old_age Always - 262
5 Reallocated_Sector_Ct 0x0033 100 100 010 Pre-fail Always - 0
7 Seek_Error_Rate 0x000f 066 056 030 Pre-fail Always - 146/471494654
9 Power_On_Hours 0x0032 044 044 000 Old_age Always - 49216
10 Spin_Retry_Count 0x0013 100 100 097 Pre-fail Always - 0
12 Power_Cycle_Count 0x0032 100 100 020 Old_age Always - 262
184 End-to-End_Error 0x0032 100 100 099 Old_age Always - 0
187 Reported_Uncorrect 0x0032 100 100 000 Old_age Always - 0
188 Command_Timeout 0x0032 100 100 000 Old_age Always - 1 1 1
189 High_Fly_Writes 0x003a 001 001 000 Old_age Always - 337
190 Airflow_Temperature_Cel 0x0022 069 060 045 Old_age Always - 31 (Min/Max 30/40)
191 G-Sense_Error_Rate 0x0032 100 100 000 Old_age Always - 0
192 Power-Off_Retract_Count 0x0032 100 100 000 Old_age Always - 152
193 Load_Cycle_Count 0x0032 100 100 000 Old_age Always - 367
194 Temperature_Celsius 0x0022 031 040 000 Old_age Always - 31 (0 10 0 0 0)
197 Current_Pending_Sector 0x0012 100 100 000 Old_age Always - 0
198 Offline_Uncorrectable 0x0010 100 100 000 Old_age Offline - 0
199 UDMA_CRC_Error_Count 0x003e 200 200 000 Old_age Always - 0
+48
View File
@@ -0,0 +1,48 @@
# Verbatim smartctl -i -A -H /dev/sde from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Device Model: WDC WD180EDGZ-11B2DA0
Serial Number: SCRUBBEDSERIAL
LU WWN Device Id: SCRUBBEDWWN
Firmware Version: 85.00A85
User Capacity: 18,000,207,937,536 bytes [18.0 TB]
Sector Sizes: 512 bytes logical, 4096 bytes physical
Rotation Rate: 7200 rpm
Form Factor: 3.5 inches
Device is: Not in smartctl database 7.5/6204
ATA Version is: ACS-4 published, ANSI INCITS 529-2018
SATA Version is: SATA 3.3, 6.0 Gb/s (current: 6.0 Gb/s)
Local Time is: Sat Oct 17 23:55:53 2026 EDT
SMART support is: Available - device has SMART capability.
SMART support is: Enabled
=== START OF READ SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART Attributes Data Structure revision number: 16
Vendor Specific SMART Attributes with Thresholds:
ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
1 Raw_Read_Error_Rate 0x000b 100 100 001 Pre-fail Always - 0
2 Throughput_Performance 0x0004 133 133 054 Old_age Offline - 108
3 Spin_Up_Time 0x0007 082 082 001 Pre-fail Always - 360 (Average 360)
4 Start_Stop_Count 0x0012 100 100 000 Old_age Always - 253
5 Reallocated_Sector_Ct 0x0033 100 100 001 Pre-fail Always - 0
7 Seek_Error_Rate 0x000a 100 100 001 Old_age Always - 0
8 Seek_Time_Performance 0x0004 140 140 020 Old_age Offline - 15
9 Power_On_Hours 0x0012 095 095 000 Old_age Always - 40014
10 Spin_Retry_Count 0x0012 100 100 001 Old_age Always - 0
12 Power_Cycle_Count 0x0032 097 097 000 Old_age Always - 253
22 Helium_Level 0x0023 100 100 025 Pre-fail Always - 100
192 Power-Off_Retract_Count 0x0032 100 100 000 Old_age Always - 2210
193 Load_Cycle_Count 0x0012 100 100 000 Old_age Always - 2210
194 Temperature_Celsius 0x0002 047 047 000 Old_age Always - 35 (Min/Max 13/56)
196 Reallocated_Event_Count 0x0032 100 100 000 Old_age Always - 0
197 Current_Pending_Sector 0x0022 100 100 000 Old_age Always - 0
198 Offline_Uncorrectable 0x0008 100 100 000 Old_age Offline - 0
199 UDMA_CRC_Error_Count 0x000a 100 100 000 Old_age Always - 0
+56
View File
@@ -0,0 +1,56 @@
# Verbatim smartctl -i -A -H /dev/sdf from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Family: Seagate IronWolf
Device Model: ST6000VN001-2BB186
Serial Number: SCRUBBEDSERIAL
LU WWN Device Id: SCRUBBEDWWN
Firmware Version: SC60
User Capacity: 6,001,175,126,016 bytes [6.00 TB]
Sector Sizes: 512 bytes logical, 4096 bytes physical
Rotation Rate: 5425 rpm
Form Factor: 3.5 inches
Device is: In smartctl database 7.5/6204
ATA Version is: ACS-3 T13/2161-D revision 5
SATA Version is: SATA 3.1, 6.0 Gb/s (current: 6.0 Gb/s)
Local Time is: Sat Oct 17 23:55:53 2026 EDT
SMART support is: Available - device has SMART capability.
SMART support is: Enabled
=== START OF READ SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART Attributes Data Structure revision number: 10
Vendor Specific SMART Attributes with Thresholds:
ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
1 Raw_Read_Error_Rate 0x000f 071 064 006 Pre-fail Always - 0/12209952
3 Spin_Up_Time 0x0003 091 091 000 Pre-fail Always - 0
4 Start_Stop_Count 0x0032 100 100 020 Old_age Always - 117
5 Reallocated_Sector_Ct 0x0033 100 100 010 Pre-fail Always - 0
7 Seek_Error_Rate 0x000f 085 060 045 Pre-fail Always - 0/283506067
9 Power_On_Hours 0x0032 066 066 000 Old_age Always - 30228
10 Spin_Retry_Count 0x0013 100 100 097 Pre-fail Always - 0
12 Power_Cycle_Count 0x0032 100 100 020 Old_age Always - 117
183 Runtime_Bad_Block 0x0032 100 100 000 Old_age Always - 0
184 End-to-End_Error 0x0032 100 100 099 Old_age Always - 0
187 Reported_Uncorrect 0x0032 100 100 000 Old_age Always - 0
188 Command_Timeout 0x0032 100 100 000 Old_age Always - 0 0 0
189 High_Fly_Writes 0x003a 100 100 000 Old_age Always - 0
190 Airflow_Temperature_Cel 0x0022 064 056 040 Old_age Always - 36 (Min/Max 34/43)
191 G-Sense_Error_Rate 0x0032 100 100 000 Old_age Always - 0
192 Power-Off_Retract_Count 0x0032 100 100 000 Old_age Always - 1094
193 Load_Cycle_Count 0x0032 097 097 000 Old_age Always - 7121
194 Temperature_Celsius 0x0022 036 044 000 Old_age Always - 36 (0 15 0 0 0)
195 Hardware_ECC_Recovered 0x001a 071 064 000 Old_age Always - 0/12209952
197 Current_Pending_Sector 0x0012 100 100 000 Old_age Always - 0
198 Offline_Uncorrectable 0x0010 100 100 000 Old_age Offline - 0
199 UDMA_CRC_Error_Count 0x003e 200 200 000 Old_age Always - 0
240 Head_Flying_Hours 0x0000 100 253 000 Old_age Offline - 29304h+52m+38.336s
241 Total_LBAs_Written 0x0000 100 253 000 Old_age Offline - 61796587979
242 Total_LBAs_Read 0x0000 100 253 000 Old_age Offline - 430008992227
+48
View File
@@ -0,0 +1,48 @@
# Verbatim smartctl -i -A -H /dev/sdg from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Family: Western Digital Blue (CMR)
Device Model: WDC WD40EZRZ-00GXCB0
Serial Number: SCRUBBEDSERIAL
LU WWN Device Id: SCRUBBEDWWN
Firmware Version: 80.00A80
User Capacity: 4,000,787,030,016 bytes [4.00 TB]
Sector Sizes: 512 bytes logical, 4096 bytes physical
Rotation Rate: 5400 rpm
Form Factor: 3.5 inches
Device is: In smartctl database 7.5/6204
ATA Version is: ACS-3 T13/2161-D revision 5
SATA Version is: SATA 3.1, 6.0 Gb/s (current: 6.0 Gb/s)
Local Time is: Sat Oct 17 23:55:54 2026 EDT
SMART support is: Available - device has SMART capability.
SMART support is: Enabled
=== START OF READ SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART Attributes Data Structure revision number: 16
Vendor Specific SMART Attributes with Thresholds:
ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
1 Raw_Read_Error_Rate 0x002f 200 200 051 Pre-fail Always - 0
3 Spin_Up_Time 0x0027 166 164 021 Pre-fail Always - 6691
4 Start_Stop_Count 0x0032 095 095 000 Old_age Always - 5256
5 Reallocated_Sector_Ct 0x0033 200 200 140 Pre-fail Always - 0
7 Seek_Error_Rate 0x002e 100 253 000 Old_age Always - 0
9 Power_On_Hours 0x0032 088 088 000 Old_age Always - 9195
10 Spin_Retry_Count 0x0032 100 100 000 Old_age Always - 0
11 Calibration_Retry_Count 0x0032 100 100 000 Old_age Always - 0
12 Power_Cycle_Count 0x0032 095 095 000 Old_age Always - 5097
192 Power-Off_Retract_Count 0x0032 200 200 000 Old_age Always - 24
193 Load_Cycle_Count 0x0032 145 145 000 Old_age Always - 166790
194 Temperature_Celsius 0x0022 119 099 000 Old_age Always - 31
196 Reallocated_Event_Count 0x0032 200 200 000 Old_age Always - 0
197 Current_Pending_Sector 0x0032 200 200 000 Old_age Always - 0
198 Offline_Uncorrectable 0x0030 200 200 000 Old_age Offline - 0
199 UDMA_CRC_Error_Count 0x0032 200 200 000 Old_age Always - 0
200 Multi_Zone_Error_Rate 0x0008 200 200 000 Old_age Offline - 4
+57
View File
@@ -0,0 +1,57 @@
# Verbatim smartctl -i -A -H /dev/sdh from driveripper, recorded by
# ./scripts/capture-fixtures.sh — Serial/WWN/EUI-64 values are SCRUBBED,
# every other byte is what the drive answered. Volatile rows (the
# Temperature raws, Local Time) move; the parse tests read shape.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Family: Seagate BarraCuda Pro
Device Model: ST10000DM0004-1ZC101
Serial Number: SCRUBBEDSERIAL
LU WWN Device Id: SCRUBBEDWWN
Firmware Version: DN01
User Capacity: 10,000,831,348,736 bytes [10.0 TB]
Sector Sizes: 512 bytes logical, 4096 bytes physical
Rotation Rate: 7200 rpm
Form Factor: 3.5 inches
Device is: In smartctl database 7.5/6204
ATA Version is: ACS-3 T13/2161-D revision 5
SATA Version is: SATA 3.1, 6.0 Gb/s (current: 6.0 Gb/s)
Local Time is: Sat Oct 17 23:55:54 2026 EDT
SMART support is: Available - device has SMART capability.
SMART support is: Enabled
=== START OF READ SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
See vendor-specific Attribute list for marginal Attributes.
SMART Attributes Data Structure revision number: 10
Vendor Specific SMART Attributes with Thresholds:
ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
1 Raw_Read_Error_Rate 0x000f 078 064 044 Pre-fail Always - 65117974
3 Spin_Up_Time 0x0003 089 089 000 Pre-fail Always - 0
4 Start_Stop_Count 0x0032 100 100 020 Old_age Always - 246
5 Reallocated_Sector_Ct 0x0033 100 100 010 Pre-fail Always - 0
7 Seek_Error_Rate 0x000f 093 060 045 Pre-fail Always - 1869403165
9 Power_On_Hours 0x0032 050 050 000 Old_age Always - 43894h+52m+24.646s
10 Spin_Retry_Count 0x0013 100 100 097 Pre-fail Always - 0
12 Power_Cycle_Count 0x0032 100 100 020 Old_age Always - 246
184 End-to-End_Error 0x0032 100 100 099 Old_age Always - 0
187 Reported_Uncorrect 0x0032 100 100 000 Old_age Always - 0
188 Command_Timeout 0x0032 100 099 000 Old_age Always - 1 1 2
189 High_Fly_Writes 0x003a 001 001 000 Old_age Always - 270
190 Airflow_Temperature_Cel 0x0022 067 040 040 Old_age Always In_the_past 33 (Min/Max 31/41)
191 G-Sense_Error_Rate 0x0032 095 095 000 Old_age Always - 11928
192 Power-Off_Retract_Count 0x0032 100 100 000 Old_age Always - 81
193 Load_Cycle_Count 0x0032 035 035 000 Old_age Always - 131758
194 Temperature_Celsius 0x0022 033 060 000 Old_age Always - 33 (0 13 0 0 0)
195 Hardware_ECC_Recovered 0x001a 006 001 000 Old_age Always - 65117974
197 Current_Pending_Sector 0x0012 100 100 000 Old_age Always - 0
198 Offline_Uncorrectable 0x0010 100 100 000 Old_age Offline - 0
199 UDMA_CRC_Error_Count 0x003e 200 200 000 Old_age Always - 0
200 Pressure_Limit 0x0023 100 100 001 Pre-fail Always - 0
240 Head_Flying_Hours 0x0000 100 253 000 Old_age Offline - 6541h+31m+23.388s
241 Total_LBAs_Written 0x0000 100 253 000 Old_age Offline - 222471819122
242 Total_LBAs_Read 0x0000 100 253 000 Old_age Offline - 628009900310
+42
View File
@@ -0,0 +1,42 @@
# REFERENCE capture, not a field capture — see smartctl_reference_nvme.txt for the
# provenance rules (labels verbatim from smartmontools 7.5, values synthetic). This file
# is the ATA shape: identity from IDENTIFY, health from the self-assessment line, and
# the numbers from the vendor SMART attribute table (raw value is the last column).
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Device Model: EXAMPLE-ST8000VN004
Serial Number: SCRUBBEDSERIAL2
LU WWN Device Id: SCRUBBEDWWN1
Firmware Version: SC60
User Capacity: 8,001,563,222,016 bytes [8.00 TB]
Sector Sizes: 512 bytes logical, 4096 bytes physical
Rotation Rate: 7200 rpm
Form Factor: 3.5 inches
Device is: Not in smartctl database [7.5-20250430]
Local Time is: (time of capture removed by scripts/capture-fixtures.sh)
SMART support is: Available - device has SMART capability.
SMART support is: Enabled
=== START OF READ SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART Attributes Data Structure revision number: 10
Vendor Specific SMART Attributes with Thresholds:
ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE
1 Raw_Read_Error_Rate 0x000f 079 066 006 Pre-fail ALWAYS - 219967840
5 Reallocated_Sector_Ct 0x0033 100 100 010 Pre-fail ALWAYS - 8
7 Seek_Error_Rate 0x000f 084 060 045 Pre-fail ALWAYS - 238386372
9 Power_On_Hours 0x0032 076 076 000 Old_age ALWAYS - 17,520
187 Reported_Uncorrect 0x0032 100 100 000 Old_age ALWAYS - 0
188 Command_Timeout 0x0012 100 099 000 Old_age ALWAYS - 6
193 Load_Cycle_Count 0x0032 100 100 000 Old_age ALWAYS - 4242
194 Temperature_Celsius 0x0022 031 055 000 Old_age ALWAYS - 31
195 Hardware_ECC_Recovered 0x001a 196 195 000 Old_age ALWAYS - 219967840
197 Current_Pending_Sector 0x0012 100 100 000 Old_age ALWAYS - 3
198 Offline_Uncorrectable 0x0010 100 100 000 Old_age Offline - 2
199 UDMA_CRC_Error_Count 0x003e 200 200 000 Old_age ALWAYS - 0
240 Head_Flying_Hours 0x0025 070 100 000 Pre-fail ALWAYS - 14624
241 Total_LBAs_Written 0x0032 100 253 000 Old_age ALWAYS - 117210553521
242 Total_LBAs_Read 0x0032 100 253 000 Old_age ALWAYS - 96652971088
+55
View File
@@ -0,0 +1,55 @@
# REFERENCE capture, not a field capture. Every LABEL here is the verbatim format string
# of the deployed smartmontools 7.5 binary (verified against its own strings and drive
# database on 2026-10-18); the values are synthetic, because no host reachable at
# reference-writing time could open a block device (fleet agents need the `disk` group —
# see README). Replace these files with hardware truth:
# SMART_HOSTS="deskwork-ops driveripper-ops" ./scripts/capture-fixtures.sh
# which records smartctl_<host>_<dev>.txt per physical disk with serials scrubbed.
# The parse tests treat this file as the NVMe shape: Temperature/Percentage Used/
# Power On Hours/Media and Data Integrity Errors come from the health log, and the ATA
# sector attributes stay absent — n/a on the page, never 0.
smartctl 7.5 2025-04-30 r5714 [x86_64-linux-7.2.11-100.fc43.x86_64] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Model Number: EXAMPLE NVMe SSD 2TB
Serial Number: SCRUBBEDSERIAL1
Firmware Version: FITMVT04
IEEE OUI Identifier: 0x000000
Total NVM Capacity: 2,000,398,934,016 (2.00 TB)
Unallocated NVM Capacity: 0
Controller ID: 0
NVMe Version: 1.4
Number of Namespaces: 1
Namespace 1 Size: 2,000,398,934,016 (2.00 TB)
Namespace 1 Capacity: 2,000,398,934,016 (2.00 TB)
Namespace 1 Utilization: 1,000,000,000,000 (1.00 TB)
Namespace 1 Formatted LBA Size: 512
Local Time is: (time of capture removed by scripts/capture-fixtures.sh)
=== START OF SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
SMART/Health Information (NVMe Log 0x02, NSID 0xffffffff)
Critical Warning: 0x00
Temperature: 38 Celsius
Available Spare: 100%
Available Spare Threshold: 10%
Percentage Used: 1%
Data Units Read: 120,000,000 [61.4 TB]
Data Units Written: 90,000,000 [46.0 TB]
Host Read Commands: 1,000,000,000
Host Write Commands: 800,000,000
Controller Busy Time: 100
Power Cycles: 57
Power On Hours: 8,760
Unsafe Shutdowns: 12
Media and Data Integrity Errors: 0
Error Information Log Entries: 0
Warning Comp. Temperature Time: 0
Critical Comp. Temperature Time: 0
Temperature Sensor 1: 42 Celsius
Temperature Sensor 2: 45 Celsius
Error Information (NVMe Log 0x01, 16 of 64 entries)
No Errors Logged
+432 -1
View File
@@ -117,6 +117,418 @@ def test_cpu_modes_total_excludes_guest_ticks(tmp_path: Path) -> None:
assert sample["total"] == pytest.approx(100 + 10 + 20 + 500 + 5 + 3 + 2 + 1)
# ---- SMART (smartctl) ------------------------------------------------------ #
FIXTURES = Path(__file__).resolve().parent / "fixtures"
NVME_CAPTURE = (FIXTURES / "smartctl_reference_nvme.txt").read_text()
ATA_HDD_CAPTURE = (FIXTURES / "smartctl_reference_ata_hdd.txt").read_text()
def read_fixture(text: str) -> str:
"""Strip the `#` provenance header the capture script writes above a fixture."""
return "\n".join(line for line in text.splitlines() if not line.startswith("#"))
@pytest.mark.parametrize("path", sorted(FIXTURES.glob("smartctl_*.txt")))
def test_every_smartctl_capture_parses_without_inventing_numbers(path: Path) -> None:
"""A capture the parser cannot read must fail here, not on a `/disks` card. A serial
in a fixture is a serial in the browser, so every captured serial is scrubbed."""
parsed = host_agent.parse_smart_output(read_fixture(path.read_text()))
assert parsed["model"], f"{path.name}: no model identity parsed"
# a recorded FAILED drive is a real drive failing, not a test to loosen: replace the
# drive, or capture its truth into a purpose-built fixture and say why here.
assert parsed["overall_health"] == "PASSED", f"{path.name}: health is {parsed['overall_health']!r}"
assert parsed["power_on_hours"] is not None, f"{path.name}: no power-on hours parsed"
assert parsed["serial"] is None or parsed["serial"].startswith("SCRUBBEDSERIAL"), path.name
for key in host_agent.SMART_FIELDS:
assert key in parsed, f"{path.name}: {key} missing from the parsed shape"
def test_smart_output_parser_reads_the_nvme_health_log() -> None:
parsed = host_agent.parse_smart_output(read_fixture(NVME_CAPTURE))
assert parsed["model"] == "EXAMPLE NVMe SSD 2TB"
assert parsed["serial"] == "SCRUBBEDSERIAL1"
assert parsed["temp_c"] == 38.0
assert parsed["power_on_hours"] == 8760.0, "the capture writes 8,760 — commas are notation"
assert parsed["percentage_used"] == 1.0
# NVMe's media-error counter IS this drive family's offline-uncorrectable fact
assert parsed["offline_uncorrectable"] == 0.0
# and what an NVMe does not publish stays None — reallocated/pending are ATA attributes
assert parsed["reallocated_sector_ct"] is None
assert parsed["current_pending_sector_ct"] is None
def test_smart_output_parser_reads_the_ata_attribute_table() -> None:
parsed = host_agent.parse_smart_output(read_fixture(ATA_HDD_CAPTURE))
assert parsed["model"] == "EXAMPLE-ST8000VN004"
assert parsed["overall_health"] == "PASSED"
assert parsed["temp_c"] == 31.0, "raw value (last column), not the normalised VALUE column"
assert parsed["power_on_hours"] == 17520.0
assert parsed["reallocated_sector_ct"] == 8.0
assert parsed["current_pending_sector_ct"] == 3.0
assert parsed["offline_uncorrectable"] == 2.0
assert parsed["percentage_used"] is None, "this HDD publishes no wear percentage"
def test_smart_output_parser_keeps_the_drives_own_verdict_words() -> None:
assert host_agent.parse_smart_output(
"SMART overall-health self-assessment test result: FAILED!\n")["overall_health"] == "FAILED"
assert host_agent.parse_smart_output(
"SMART overall-health self-assessment test result: UNKNOWN!\n")["overall_health"] == "UNKNOWN"
def test_smart_failure_note_quotes_the_error_not_the_version_banner() -> None:
"""Caught on a real box: smartctl prints its banner first and the failure later, so
'first line of output' reported the copyright instead of Permission denied."""
banner = ("smartctl 7.5 2025-04-30 r5714 [x86_64-linux] (local build)\n"
"Copyright (C) 2002-25, Bruce Allen, Christian Franke\n\n")
denied = "Smartctl open device: /dev/nvme0 failed: Permission denied\n"
assert "Permission denied" in host_agent.smart_failure_line(2, banner + denied, "")
assert host_agent.smart_failure_line(7, "", "") == "exit status 7"
assert host_agent.smart_failure_line(4, "some odd note\n", "") == "some odd note"
# stderr counts too — smartctl puts some failures there and nowhere else
assert "scsi hangup" in host_agent.smart_failure_line(2, banner, "scsi hangup")
def test_smart_output_parser_returns_all_none_for_output_it_cannot_read() -> None:
junk = ("smartctl 7.5 2025-04-30 r5714 (local build)\n"
"Smartctl open device: /dev/sdb failed: Permission denied\n")
parsed = host_agent.parse_smart_output(junk)
assert set(parsed) == set(host_agent.SMART_FIELDS)
assert all(value is None for value in parsed.values()), "a denied read is n/a, never 0"
for empty in (None, ""):
assert all(value is None for value in host_agent.parse_smart_output(empty).values())
def test_smart_output_parser_refuses_to_decode_vendor_hex_packed_raws() -> None:
"""Some vendors pack temperature into a hex raw whose byte layout is drive-specific.
Decoding it from memory would be an invented number, so it stays None."""
parsed = host_agent.parse_smart_output(
"194 Temperature_Celsius 0x0022 073 059 000 Old_age ALWAYS - 0x001f003c3430\n")
assert parsed["temp_c"] is None
def test_smart_attribute_rows_are_read_from_the_raw_column_not_the_line_end() -> None:
"""Every shape the deployed binary writes into RAW_VALUE, captured off real drives.
Reading the last token of the row — the obvious way to read a column table — loses the
temperature of every spinning disk on driveripper, because a Seagate ends its row with
`35 (Min/Max 34/44)` and the token there is `44)`. A Western Digital packs its hours as
`43894h+43m+32.519s`, which is hours first, minutes and seconds after: the drive's own
number is the leading group, and the value the page prints is that number."""
parsed = host_agent.parse_smart_output(
"190 Airflow_Temperature_Cel 0x0022 065 049 040 Old_age Always"
" In_the_past 35 (Min/Max 34/44)\n"
"194 Temperature_Celsius 0x0022 035 046 000 Old_age Always"
" - 35 (0 13 0 0 0)\n"
" 9 Power_On_Hours 0x0032 055 055 000 Old_age Always"
" - 43894h+43m+32.519s\n"
"197 Current_Pending_Sector 0x0012 100 100 000 Old_age Always"
" - 2 (of 4 failed when written)\n")
assert parsed["temp_c"] == 35.0
assert parsed["power_on_hours"] == 43894.0
assert parsed["current_pending_sector_ct"] == 2.0
def test_the_real_drives_answer_every_number_the_disks_page_prints() -> None:
"""`smartctl_driveripper_*.txt` is hardware truth, 16 drives, both shapes.
The values move on every re-capture, so this pins that each number `/disks` prints is
there and plausible rather than a particular figure — the check that a fleet which looks
healthy in a synthetic fixture is also healthy when read from a real device. A drive that
stops answering health fails here, and the honest response is to replace the drive or
re-capture, never to loosen the assertion."""
real = sorted(FIXTURES.glob("smartctl_driveripper_*.txt"))
assert real, "no real captures on disk: SMART_HOSTS=<alias> ./scripts/capture-fixtures.sh"
for path in real:
parsed = host_agent.parse_smart_output(read_fixture(path.read_text()))
assert parsed["model"], f"{path.name}: no identity"
hours = parsed["power_on_hours"]
assert hours is not None and hours > 0 and hours == int(hours), (
f"{path.name}: power-on hours read as {hours!r}")
temp = parsed["temp_c"]
assert temp is not None and 10 <= temp <= 85, (
f"{path.name}: temperature read as {temp!r}")
# The two shapes disagree the way the page renders them: no ATA drive on this fleet
# publishes a wear percentage, every NVMe does, and neither answer is a 0.
if "_sd" in path.name:
assert parsed["percentage_used"] is None, f"{path.name}: an HDD has no wear percent"
assert parsed["reallocated_sector_ct"] is not None, f"{path.name}: no ATA counters"
else:
assert parsed["percentage_used"] is not None, f"{path.name}: an NVMe must have wear"
assert parsed["offline_uncorrectable"] is not None, (
f"{path.name}: the media-error log is an NVMe's uncorrectable fact")
def make_fake_smartctl(tmp_path: Path, outputs: dict[str, str],
exit_codes: dict[str, int] | None = None,
slow: tuple[str, ...] = ()) -> str:
"""A stdlib fake `smartctl`: per-device stdout/exit code, plus an audit log of which
devices were asked — so 'virtual disks are skipped' is an assertion, not a hope."""
data = tmp_path / "smart-out"
data.mkdir(exist_ok=True)
for device, text in outputs.items():
(data / device).write_text(text)
for device, code in (exit_codes or {}).items():
(data / f"{device}.rc").write_text(str(code))
script = tmp_path / "bin" / "smartctl"
script.parent.mkdir(exist_ok=True)
script.write_text(f"""#!{sys.executable}
import os, sys, time
device = sys.argv[-1].rsplit("/", 1)[-1]
data = os.environ["FAKE_SMARTCTL_DIR"]
with open(os.path.join(data, "_called"), "a") as handle:
handle.write(device + "\\n")
if device in {list(slow)!r}:
time.sleep(30)
rc_path = os.path.join(data, device + ".rc")
code = int(open(rc_path).read()) if os.path.exists(rc_path) else 0
out_path = os.path.join(data, device)
text = open(out_path).read() if os.path.exists(out_path) else ""
if code and not text:
# real smartctl shape: banner first, failure later — the error note must quote
# the failure, never the banner
text = (f"smartctl 7.5 2025-04-30 r5714 [x86_64-linux] (local build)\\n"
f"Copyright (C) 2002-25, Bruce Allen, Christian Franke\\n\\n"
f"Smartctl open device: /dev/{{device}} failed: Permission denied\\n")
sys.stdout.write(text)
sys.exit(code)
""")
script.chmod(0o755)
return str(script)
def write_fake_disk(root: Path, name: str, model: str | None = None) -> None:
write_file(root, f"sys/block/{name}/stat", "1 2 3 4 5 6 7 8 9 10 11")
write_file(root, f"sys/block/{name}/queue/rotational", "0\n")
write_file(root, f"sys/block/{name}/size", "2048\n")
if model:
write_file(root, f"sys/block/{name}/device/model", f"{model}\n")
def fake_calls(tmp_path: Path) -> list[str]:
called = tmp_path / "smart-out" / "_called"
return called.read_text().split() if called.exists() else []
def test_smart_pass_attaches_parsed_health_per_disk(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1", model="SYSFS nvme0n1")
write_fake_disk(root, "sdb", model="SYSFS sdb")
write_fake_disk(root, "loop0") # virtual: must never be asked
write_fake_disk(root, "dm-0")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(str(root),
smartctl=make_fake_smartctl(tmp_path, {
"nvme0n1": read_fixture(NVME_CAPTURE),
"sdb": read_fixture(ATA_HDD_CAPTURE),
}))
snap = collector.snapshot()
assert snap["errors"] == [], snap["errors"]
by_name = {device["name"]: device for device in snap["disk_io"]["devices"]}
assert by_name["nvme0n1"]["smart"]["model"] == "EXAMPLE NVMe SSD 2TB"
assert by_name["nvme0n1"]["smart"]["power_on_hours"] == 8760.0
assert by_name["sdb"]["smart"]["reallocated_sector_ct"] == 8.0
called = fake_calls(tmp_path)
assert "loop0" not in called and "dm-0" not in called, "virtual entries are not physical disks"
assert sorted(called) == ["nvme0n1", "sdb"]
def test_smart_identity_falls_back_to_the_agents_own_disk_model(tmp_path, monkeypatch) -> None:
"""Some bridges refuse the identity block while still answering the health log."""
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1", model="SYSFS DISK MODEL")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(
str(root),
smartctl=make_fake_smartctl(tmp_path, {"nvme0n1": "Power On Hours: 42\n"}))
device = collector.snapshot()["disk_io"]["devices"][0]
assert device["smart"]["model"] == "SYSFS DISK MODEL"
assert device["smart"]["power_on_hours"] == 42.0
assert device["smart"]["overall_health"] is None
def test_missing_smartctl_binary_is_one_error_for_the_whole_pass(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
for device in ("nvme0n1", "sdb"):
write_fake_disk(root, device)
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl")
snap = collector.snapshot()
smart_errors = [e for e in snap["errors"] if e.startswith("smart:")]
assert len(smart_errors) == 1, "one missing binary is one fact, not one per disk"
assert "smartmontools" in smart_errors[0]
# cross-module contract: `sources/disks.py` reads this word to answer
# "is smartctl installed?" (three-way: yes / no / the agent does not say). Reword the
# line here and that test fails, instead of the /disks page losing its explanation.
assert "not found" in smart_errors[0]
assert all(device["smart"] is None for device in snap["disk_io"]["devices"])
# the rest of the snapshot is intact — SMART is an add-on, not a dependency
assert snap["disk_io"]["device_names"] == ["nvme0n1", "sdb"]
def test_one_denied_device_nulls_only_that_disk(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
write_fake_disk(root, "sdb")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(
str(root),
smartctl=make_fake_smartctl(tmp_path, {"nvme0n1": read_fixture(NVME_CAPTURE)},
exit_codes={"sdb": 2}))
snap = collector.snapshot()
by_name = {device["name"]: device for device in snap["disk_io"]["devices"]}
assert by_name["nvme0n1"]["smart"] is not None
assert by_name["sdb"]["smart"] is None
smart_errors = [e for e in snap["errors"] if e.startswith("smart:")]
assert len(smart_errors) == 1 and "sdb" in smart_errors[0] and "Permission denied" in smart_errors[0]
def test_a_wedged_device_costs_its_timeout_not_the_snapshot(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
write_fake_disk(root, "sdb")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(
str(root),
smartctl=make_fake_smartctl(tmp_path, {"nvme0n1": read_fixture(NVME_CAPTURE)},
slow=("sdb",)))
collector.smart_timeout_seconds = 0.5
snap = collector.snapshot()
by_name = {device["name"]: device for device in snap["disk_io"]["devices"]}
assert by_name["nvme0n1"]["smart"] is not None
assert by_name["sdb"]["smart"] is None
assert any("timed out" in e for e in snap["errors"])
def test_the_pass_budget_skips_the_rest_with_one_error(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
for device in ("nvme0n1", "sdb", "sdc"):
write_fake_disk(root, device)
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(
str(root), smartctl=make_fake_smartctl(tmp_path, {}))
collector.smart_budget_seconds = 0.0
snap = collector.snapshot()
assert all(device["smart"] is None for device in snap["disk_io"]["devices"])
assert any("budget" in e and "3 disk(s) skipped" in e for e in snap["errors"])
def test_unexecutable_smartctl_degrades_like_a_missing_one(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
blocked = tmp_path / "not-executable"
blocked.write_text("not a program\n")
collector = host_agent.Collector(str(root), smartctl=str(blocked))
snap = collector.snapshot()
assert all(device["smart"] is None for device in snap["disk_io"]["devices"])
assert any(e.startswith("smart:") for e in snap["errors"])
def wait_for_calls(tmp_path, expected: int, *, timeout: float = 5.0) -> list[str]:
"""Wait for the background refresher to have run `expected` passes."""
deadline = time.time() + timeout
while time.time() < deadline:
calls = fake_calls(tmp_path)
if len(calls) >= expected:
return calls
time.sleep(0.02)
return fake_calls(tmp_path)
def test_smart_results_are_cached_and_an_expired_cache_refreshes_off_the_request_path(
tmp_path, monkeypatch) -> None:
"""The system page polls every few seconds; smartctl must not re-run every poll.
And when the cache *does* expire, the refresh runs on a background thread while the
snapshot answers from the previous result: `SMARTCTL_BUDGET_SECONDS` (20) is five
times the dashboard's `HOST_TIMEOUT_SECONDS` (4), so an inline refresh would turn the
whole `system` source red every four minutes because of an add-on it does not use.
"""
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(
str(root), smartctl=make_fake_smartctl(tmp_path, {"nvme0n1": read_fixture(NVME_CAPTURE)}))
first = collector.snapshot()
second = collector.snapshot()
assert first["disk_io"]["devices"][0]["smart"]["temp_c"] == 38.0
assert second["disk_io"]["devices"][0]["smart"] == first["disk_io"]["devices"][0]["smart"]
assert fake_calls(tmp_path) == ["nvme0n1"], "one pass, then the cache"
collector.smart_cache_seconds = 0.0 # an expired cache re-runs the pass…
served = collector.snapshot()
assert served["disk_io"]["devices"][0]["smart"] == first["disk_io"]["devices"][0]["smart"]
assert wait_for_calls(tmp_path, 2) == ["nvme0n1", "nvme0n1"], "…in the background"
def test_an_expired_cache_never_stalls_the_snapshot_on_a_wedged_disk(tmp_path,
monkeypatch) -> None:
"""The regression that matters: a disk that takes 30 s must cost a poll nothing.
Asserted on wall-clock because the whole point is the request's latency — the agent
answers with the last SMART it had while the refresher sits in `smartctl`'s timeout.
"""
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
write_fake_disk(root, "sdb")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(
str(root),
smartctl=make_fake_smartctl(tmp_path, {"nvme0n1": read_fixture(NVME_CAPTURE),
"sdb": "Power On Hours: 1\n"},
slow=("sdb",)))
collector.smart_timeout_seconds = 1.0 # the slow device costs ~1 s per pass
first = collector.snapshot()
assert any("timed out" in line for line in first["errors"])
temp = first["disk_io"]["devices"][0]["smart"]["temp_c"]
collector.smart_cache_seconds = 0.0
started = time.perf_counter()
served = collector.snapshot()
elapsed = time.perf_counter() - started
assert served["disk_io"]["devices"][0]["smart"]["temp_c"] == temp
assert elapsed < 0.5, f"a wedged disk cost the snapshot {elapsed:.2f}s"
# the refresher is single-flight: a second poll while it runs gets the cache too
started = time.perf_counter()
collector.snapshot()
assert time.perf_counter() - started < 0.5
wait_for_calls(tmp_path, 4) # let the background pass finish before teardown
def test_no_smart_flag_stays_silent_and_null(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(str(root),
smartctl=make_fake_smartctl(tmp_path, {}), smart=False)
snap = collector.snapshot()
assert snap["errors"] == []
assert snap["disk_io"]["devices"][0]["smart"] is None
assert fake_calls(tmp_path) == []
def test_an_unexpected_smart_crash_degrades_the_addon_not_the_snapshot(tmp_path, monkeypatch) -> None:
collector = host_agent.Collector("/", smart=False)
monkeypatch.setattr(collector, "smart_enabled", True)
monkeypatch.setattr(collector, "smart",
lambda names: (_ for _ in ()).throw(RuntimeError("boom")))
snap = collector.snapshot()
assert any(e.startswith("smart:") and "boom" in e for e in snap["errors"])
assert snap["disk_io"]["devices"], "the disk rows themselves come from /sys, not smartctl"
def test_smartctl_captures_carry_no_unscrubbed_serials() -> None:
"""The capture script scrubs; this fails the day it stops doing its job. The NVMe
namespace EUI-64 is on the list because it embeds the serial."""
for path in sorted(FIXTURES.glob("smartctl_*.txt")):
for line in path.read_text().splitlines():
lowered = line.lower()
if "serial number" in lowered or "wwn" in lowered or "eui" in lowered:
assert "SCRUBBED" in line, f"{path.name}: unscrubbed identifier: {line!r}"
# ---- the real collector --------------------------------------------------- #
@linux_only
@@ -133,7 +545,10 @@ def test_snapshot_against_the_live_system_is_complete() -> None:
assert len(snap["cpu"]["cores"]) >= 1
assert snap["memory"]["total_bytes"] > 0
assert snap["filesystems"], "the box it runs on has at least one mounted filesystem"
assert snap["errors"] == [], f"degraded sections must be empty on a healthy box: {snap['errors']}"
# SMART notes are allowed here: the box running pytest may have no smartctl or no
# device read access, and both are documented degradations, never snapshot failures.
hard_errors = [e for e in snap["errors"] if not e.startswith("smart")]
assert hard_errors == [], f"degraded sections must be empty on a healthy box: {hard_errors}"
def test_captured_fixture_has_the_shape_the_dashboard_depends_on(agent_snapshot) -> None:
@@ -215,6 +630,22 @@ def _get(url: str, headers: dict[str, str] | None = None) -> tuple[int, dict]:
return error.code, {"raw": body.decode(errors="replace")}
def test_health_stays_up_while_smart_is_degraded(agent_server, tmp_path) -> None:
"""Phase-09 criterion: SMART failures are add-on notes, never an agent outage."""
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
collector = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl")
base, _port = agent_server()
status, health = _get(base + "/health")
assert status == 200 and health["ok"] is True
handler = host_agent.Handler
handler.collector = collector
status, snap = _get(base + "/snapshot")
assert status == 200
assert snap["disk_io"]["devices"][0]["smart"] is None
assert any(e.startswith("smart:") for e in snap["errors"])
def test_health_info_schema_snapshot_all_answer(agent_server) -> None:
base, _port = agent_server()
for path in ("/health", "/info", "/schema", "/snapshot"):
+92
View File
@@ -166,6 +166,98 @@ def test_a_dashboard_the_builder_does_not_know_about_still_gets_a_verdict_and_a_
assert "every 60s" in out[0]["message"]
def test_the_disk_job_is_stale_only_after_three_of_its_own_five_minute_polls() -> None:
"""SMART is a 300 s signal: judging it by the 10 s system interval would amber `/`
every five minutes for a source that is behaving exactly as configured."""
disks = Settings(disks_interval=300.0)
fresh = state(key="disks:driveripper", dashboard="disks", label="driveripper", age=600.0)
assert alerts.build(disks, [fresh]) == [], "600s is two polls, not an outage"
stale = {**fresh, "age_seconds": 1_000.0}
out = alerts.build(disks, [stale])
assert out[0]["severity"] == "warn"
assert "every 300s" in out[0]["message"], "the message must print the disks interval"
def test_a_disk_job_that_failed_because_its_agent_is_down_is_one_line_not_two() -> None:
"""`disks` reads the same agent snapshot `system` does.
A dead host would otherwise print the same sentence twice (`alerts` rule: one dead
source, one line), with only the internal id telling the two apart — which is a lie of
the same shape as a duplicated host name.
"""
out = alerts.build(SETTINGS, [
state(key="disks:driveripper", dashboard="disks", label="driveripper",
ok=False, error="UpstreamError: driveripper: http://h:9121/snapshot "
"unreachable (ConnectError)"),
state(ok=False, error="UpstreamError: driveripper: http://h:9121/snapshot "
"unreachable (ConnectError)"),
], now=2_000.0)
assert [item["source"] for item in out] == ["driveripper"]
assert len(out) == 1, "the echo of the same outage must not read as two problems"
assert out[0]["id"].startswith("driveripper:host:")
def test_a_disk_job_that_fails_while_its_host_answers_is_the_only_voice_it_has() -> None:
"""SMART wedged inside the agent is a `disks`-only fault, and must still be said."""
out = alerts.build(SETTINGS, [
state(key="disks:driveripper", dashboard="disks", label="driveripper",
ok=False, error="UpstreamError: driveripper: timed out"),
state(),
], now=2_000.0)
assert len(out) == 1
assert out[0]["id"].startswith("driveripper:disks:")
assert "timed out" in out[0]["message"]
def test_a_disk_only_alert_links_to_the_drive_page() -> None:
"""The overview's row is a link, so it has to point at the page that explains it.
A wedged SMART pass has no other voice (the host itself is answering), and `/system`
cannot tell the operator which drive stopped reporting — `/disks` can. Before that route
existed this link pointed at `/` on purpose; now it must not point anywhere else.
"""
out = alerts.build(SETTINGS, [
state(key="disks:driveripper", dashboard="disks", label="driveripper",
ok=False, error="UpstreamError: driveripper: timed out"),
state(),
], now=2_000.0)
assert len(out) == 1
assert out[0]["href"] == "/disks"
def test_an_unconfigured_disk_source_says_nothing_even_when_its_job_says_down() -> None:
"""No `HOST_TARGETS` means no `disks` job exists; a reported-but-unconfigured one must
still be silence (invariant 3).
The builder is fed job states rather than settings, so a hub that ever hands over a
`disks` record with `configured: false` — a config that arrives between two polls, a
future target list of its own — must not amber `/` over a page nobody installed.
"""
out = alerts.build(Settings(disks_interval=300.0), [
state(key="disks:driveripper", dashboard="disks", label="driveripper",
ok=False, configured=False, error="no hosts configured"),
], now=2_000.0)
assert out == []
def test_a_host_threshold_reads_the_system_job_not_the_disk_job(
host_scrape: Any, agent_snapshot: dict[str, Any]) -> None:
"""Two jobs share a host label, so the lookup has to be filtered by dashboard.
`disks` is registered after `system`, but the builder must not depend on that: if an
unfiltered lookup found the failing disk job first, the host would be called
unreachable and its real threshold breach would vanish.
"""
payload = host_scrape(agent_snapshot, cpu_busy=95.0) # cpu.usage_pct -> fail
out = alerts.build(SETTINGS, [
state(key="disks:driveripper", dashboard="disks", label="driveripper",
ok=False, error="timed out"),
state(),
], [payload], [], None, now=2_000.0)
ids = [item["id"] for item in out]
assert "driveripper:cpu.usage_pct" in ids, "the breach is real; the disk job is not it"
def test_a_failing_source_says_when_the_poller_first_noticed() -> None:
events = [{"ts": 1_500.0, "source": "system", "host": "driveripper", "kind": "unreachable",
"severity": "error", "message": "ConnectError", "data": None},
+672 -30
View File
@@ -14,6 +14,7 @@ from contextlib import contextmanager
from pathlib import Path
from typing import Any
import httpx
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
@@ -21,9 +22,11 @@ from fastapi.testclient import TestClient
from dashboards import alerts
from dashboards.api import RANGE_SECONDS, parse_range
from dashboards.app import create_app
from dashboards.collectors import UpstreamError
from dashboards.config import Settings, Target
from dashboards.severity import classify
from dashboards.sources import nut
from dashboards.sources.disks import SMART_SERIES, DisksCollector, parse_disks_snapshot
from dashboards.sources.llm import (
HISTORY_METRICS,
parse_gufo_metrics,
@@ -44,6 +47,11 @@ from slots_contract import (
# refused immediately, and never dials out of the test environment
DEAD_URL = "http://127.0.0.1:1"
#: Every dashboard route, in nav order. The markup pins below (no CDN, no `.sev-*`, no
#: narration, no orphan `id`) run across this list rather than a hand-written four, so a
#: fifth page cannot join the masthead and skip the checks the others had to pass.
PAGES = ("/", "/system", "/disks", "/llm", "/ups")
@contextmanager
def build_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, simulate: bool = False,
@@ -137,22 +145,36 @@ def data_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
def healthy_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
agent_snapshot: dict[str, Any],
host_scrape: Any) -> Iterator[TestClient]:
"""The all-clear app: one host answering, nothing past a threshold, no UPS configured.
"""The all-clear app: one host answering on both of its jobs, nothing past a threshold.
The job's collector is swapped for one that hands back the injected payload, so the
health record says `ok` truthfully instead of being overridden behind the poller.
health record says `ok` truthfully instead of being overridden behind the poller — and
that has to happen for the `disks` job too, which polls the same dead port: a host is
either answering or it is not, and a fixture that claims all-clear cannot leave one of
its two readers failing. Each reader gets **its own** projection of the same capture
(`parse_disks_snapshot` for the drive page), because a `disks` job holding a `system`
payload would make `/api/summary`'s drive block answer from the wrong shape and read as
a passing test that proved nothing.
"""
with build_client(tmp_path, monkeypatch,
host_targets=(Target("driveripper", DEAD_URL),)) as test_client:
from dashboards.poller import Result # noqa: PLC0415 - only this fixture needs it
payload = host_scrape(agent_snapshot, cpu_busy=10.0)
disks_payload = parse_disks_snapshot("driveripper", agent_snapshot,
DisksCollector()).payload
async def collect() -> Result:
return Result(payload=payload, samples=[])
job = test_client.app.state.ctx.hub.jobs["host:driveripper"] # type: ignore[attr-defined]
async def collect_disks() -> Result:
return Result(payload=disks_payload, samples=[])
hub = test_client.app.state.ctx.hub # type: ignore[attr-defined]
job = hub.jobs["host:driveripper"]
disks_job = hub.jobs["disks:driveripper"]
job.fn = collect
disks_job.fn = collect_disks
# let the poll already in flight against the dead port land first (it fails in
# microseconds), then take the health record over: the alerts must describe a host
# that is answering, not the half-second of startup where it was not
@@ -160,11 +182,14 @@ def healthy_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
while time.time() < deadline and job.state.attempts == 0:
time.sleep(0.02)
time.sleep(0.05)
job.state.payload = payload
job.state.ok = True
job.state.error = None
job.state.consecutive_failures = 0
job.state.last_ok = time.time()
for reader in hub.jobs.values():
if reader.dashboard not in ("system", "disks"):
continue
reader.state.payload = disks_payload if reader is disks_job else payload
reader.state.ok = True
reader.state.error = None
reader.state.consecutive_failures = 0
reader.state.last_ok = time.time()
yield test_client
@@ -204,7 +229,8 @@ def test_summary_shape_did_not_shrink(data_client) -> None:
assert fleet_keys <= set(body["system"]["fleet"]), fleet_keys - set(body["system"]["fleet"])
assert host_keys <= set(body["system"]["hosts"][0]), host_keys - set(body["system"]["hosts"][0])
assert ups_keys <= set(body["ups"]["ups"][0]), ups_keys - set(body["ups"]["ups"][0])
assert set(body) == {"ts", "system", "llm", "ups", "recent_events", "alerts"}
# `disks` joined in phase 09 and changed nothing else: the set grows, never shrinks
assert set(body) == {"ts", "system", "disks", "llm", "ups", "recent_events", "alerts"}
# ...and the new keys are there beside the old ones, not instead of them
assert "severity" in body["system"]["fleet"]
assert "severity" in body["system"]["hosts"][0]
@@ -215,6 +241,7 @@ def test_summary_shape_did_not_shrink(data_client) -> None:
# read by something that is not the overview page: the live suite, an external probe, a
# notifier later. Deleting a card must never delete a key.
SUMMARY_CONTRACT = ("system.enabled", "system.fleet", "system.hosts",
"disks.enabled", "disks.fleet", "disks.hosts",
"llm.engines", "ups.ups", "recent_events", "alerts")
@@ -245,6 +272,11 @@ def test_summary_serves_every_path_another_consumer_reads(request, fixture: str)
assert (dig(body, "system.fleet") is not None) is populated
names = {host["name"] for host in dig(body, "system.hosts")}
assert names == ({"driveripper"} if populated else set())
# the drive-health block is configured exactly when the agents it reads are, and lists the
# same hosts: a `disks` job exists for every `HOST_TARGETS` entry and for no other reason
assert dig(body, "disks.enabled") is populated
assert (dig(body, "disks.fleet") is not None) is populated
assert {host["name"] for host in dig(body, "disks.hosts")} == names
batteries = {item["name"] for item in dig(body, "ups.ups")}
assert batteries == ({"apc", "cyberpower"} if populated else set())
assert dig(body, "llm.engines") == [], "neither fixture configures an engine"
@@ -342,6 +374,9 @@ def test_config_reports_every_source_as_off(client) -> None:
body = client.get("/api/config").json()
assert body["system"]["enabled"] is False
assert body["system"]["targets"] == []
assert body["disks"] == {"enabled": False, "interval_seconds": 300.0,
"timeout_seconds": 30.0}, (
"the drive-health page is configured exactly when the host agents are")
assert body["llm"]["gufo"]["enabled"] is False
assert body["llm"]["vllm"]["enabled"] is False
assert body["ups"]["enabled"] is False
@@ -357,6 +392,31 @@ def test_health_with_nothing_configured_is_not_a_failure(client) -> None:
assert body["sources"] == []
def test_the_disk_source_has_its_own_health_record_per_host(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""`disks` is a source of its own, not a field of `system` (phase 09).
Its own record is what gives the page its own dot and its own staleness, and it is
also what makes `alerts.py` need a rule: two jobs read the same agent, so one dead
host must still be one line on the overview (see `test_summary_raises_one_alert...`).
"""
with build_client(tmp_path, monkeypatch,
host_targets=(Target("driveripper", DEAD_URL),)) as test_client:
sources = {row["key"]: row for row in test_client.get("/api/health").json()["sources"]}
assert "disks:driveripper" in sources, "no separate record, no separate dot"
assert sources["disks:driveripper"]["dashboard"] == "disks"
assert sources["disks:driveripper"]["label"] == "driveripper"
assert sources["disks:driveripper"]["configured"] is True
assert sources["host:driveripper"]["dashboard"] == "system", (
"the system job is still its own source: one slow SMART poll must not "
"decide whether the host looks alive")
def test_no_targets_means_no_disk_job_that_could_look_broken(client) -> None:
"""No host agents, no `disks` task at all — so nothing red for an unconfigured page."""
assert client.get("/api/health").json()["sources"] == []
@pytest.mark.parametrize("path", ["/api/system/snapshot", "/api/llm/snapshot",
"/api/ups/snapshot", "/api/ups/commands"])
def test_unconfigured_source_is_404_configured_false(client, path) -> None:
@@ -380,6 +440,30 @@ def test_unknown_route_is_json_not_html(client) -> None:
assert response.headers["content-type"].startswith("application/json")
assert "no such route" in response.json()["detail"]
def test_an_upstream_failure_escaping_a_route_keeps_its_own_status(tmp_path: Path) -> None:
"""`collectors.UpstreamError` is the one error type a route may raise to say "upstream".
The handler turns it into `{ok: false, detail}` with the collector's status (502, or 504
for a timeout) instead of a 500 traceback, and the message is the collector's already
redacted sentence. Nothing dials here: the error is raised by the route under test.
"""
db = str(tmp_path / "upstream-error.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db), store)
@app.get("/api/_boom")
async def boom() -> None:
raise UpstreamError("driveripper: http://127.0.0.1:9121/snapshot timed out", status=504)
with TestClient(app) as test_client:
response = test_client.get("/api/_boom")
assert response.status_code == 504, "a timeout stays a timeout, not a 500"
assert response.json() == {"ok": False,
"detail": "driveripper: http://127.0.0.1:9121/snapshot "
"timed out"}
assert "no-store" in response.headers["cache-control"]
store.close()
# ---- tokens --------------------------------------------------------------- #
@@ -433,12 +517,20 @@ def test_a_dead_host_never_writes_its_token_into_the_feed(tmp_path: Path) -> Non
time.sleep(0.05)
else:
pytest.fail("the dead host never recorded an error")
for path in ("/api/health", "/api/events", "/api/system/snapshot", "/api/summary"):
for path in ("/api/health", "/api/events", "/api/system/snapshot",
"/api/disks/snapshot", "/api/summary"):
body = client.get(path).text
assert "SEKRIT-AGENT-TOKEN" not in body, f"{path} leaked the agent token"
# and the outage is still legible — redaction is not the same as silence
assert "driveripper" in client.get("/api/health").text
assert "unreachable" in client.get("/api/events").text
# the `disks` job dials the same token-bearing URL on its own cadence, so its
# sentence is a second copy of the same risk: `fetch_snapshot` is the one place
# either dashboard builds a failure message, and it uses the redacted URL
disks_state = next(row for row in client.get("/api/health").json()["sources"]
if row["key"] == "disks:driveripper")
assert disks_state["error"] and "SEKRIT-AGENT-TOKEN" not in disks_state["error"]
assert "127.0.0.1:1/snapshot" in disks_state["error"], "the failure still names where"
store.close()
@@ -478,7 +570,8 @@ def test_target_problems_are_surfaced_not_swallowed(client) -> None:
# ---- pages ---------------------------------------------------------------- #
@pytest.mark.parametrize("path,title", [
("/", "overview"), ("/system", "system"), ("/llm", "llm"), ("/ups", "ups"),
("/", "overview"), ("/system", "system"), ("/disks", "disks"), ("/llm", "llm"),
("/ups", "ups"),
])
def test_pages_serve_and_declare_themselves(client, path, title) -> None:
response = client.get(path)
@@ -489,16 +582,14 @@ def test_pages_serve_and_declare_themselves(client, path, title) -> None:
def test_no_page_loads_anything_from_the_internet(client) -> None:
"""No CDN, no webfonts: this dashboard has to work with no route to the internet."""
for path in ("/", "/system", "/llm", "/ups"):
for path in PAGES:
html = client.get(path).text
for fragment in ("//cdn.", "https://fonts.", "//unpkg.com", "//cdnjs"):
assert fragment not in html, f"{path} references {fragment}"
def test_static_assets_are_served(client) -> None:
for asset in ("/static/dash.css", "/static/app.js", "/static/charts.js",
"/static/page-system.js", "/static/page-llm.js", "/static/page-ups.js",
"/static/page-index.js"):
for asset in ASSETS:
assert client.get(asset).status_code == 200, asset
@@ -511,14 +602,26 @@ def test_static_assets_are_served(client) -> None:
# comparing the number to a threshold in JavaScript.
ASSETS = ("/static/dash.css", "/static/app.js", "/static/charts.js",
"/static/page-index.js", "/static/page-system.js", "/static/page-llm.js",
"/static/page-ups.js")
"/static/page-index.js", "/static/page-disks.js", "/static/page-system.js",
"/static/page-llm.js", "/static/page-ups.js")
def _css(client: TestClient) -> str:
return client.get("/static/dash.css").text
def _css_section(css: str, name: str) -> str:
"""One `/* ---- name … */` section of the stylesheet, up to the **next** section header.
Slicing to a named neighbour worked while the sheet had five sections. Every page has since
added a component section, and a slice that runs across someone else's rules accuses the
fleet block of colouring numbers it never coloured — so the boundary is structural: the end
of *this* section, whoever comes next.
"""
start = css.index(f"/* ---- {name}")
return css[start:css.index("/* ----", start + 10)]
def _hero_css(css: str) -> str:
"""Just the `hero numbers` section of the stylesheet."""
return css.split("/* ---- hero numbers", 1)[1].split("/* ---- bars", 1)[0]
@@ -599,7 +702,7 @@ def _fleet_field_metrics(js: str) -> list[str]:
def test_no_parallel_severity_vocabulary_exists_anywhere(client) -> None:
"""One word per colour: `ok|warn|fail`, as in severity.py and the existing components."""
for asset in (*ASSETS, "/", "/system", "/llm", "/ups"):
for asset in (*ASSETS, *PAGES):
body = _code(client.get(asset).text)
assert ".sev-" not in body, f"{asset} invented a .sev-* class"
assert not re.search(r"classList\.(?:add|toggle)\(\s*[\"'](?:danger|good|warning)[\"']",
@@ -639,7 +742,7 @@ def test_severity_for_is_kept_as_the_documented_fallback(client) -> None:
js = client.get("/static/app.js").text
assert "FALLBACK ONLY" in js
assert "severityFor" in js, "the documented fallback itself was deleted"
for page in ("page-index.js", "page-system.js", "page-llm.js", "page-ups.js"):
for page in PAGE_SCRIPTS.values():
assert "severityFor" not in client.get(f"/static/{page}").text, \
f"{page} colours something from a threshold of its own"
@@ -924,7 +1027,7 @@ def test_every_system_chart_names_itself_for_assistive_tech(client) -> None:
# ---- event feeds: a bounded, paginated window ----------------------------- #
#
# The three `ul.events` feeds are one component (`Dash.eventsPager`) because a pager that
# Every `ul.events` feed is one component (`Dash.eventsPager`) because a pager that
# works on only one page is the bug this shape prevents: the `event` table grows for as long
# as the fleet runs, and an unbounded feed turns `/system` into a scroll to nowhere
# (`TODO.md` L6 — "All the events tables need to be scrollable and paginated"). There is no
@@ -935,6 +1038,7 @@ def test_every_system_chart_names_itself_for_assistive_tech(client) -> None:
# page -> (its script, the `source=` the poller stores that page's events under)
EVENT_FEEDS = {
"/system": ("page-system.js", "system"),
"/disks": ("page-disks.js", "disks"),
"/llm": ("page-llm.js", "llm"),
"/ups": ("page-ups.js", "ups"),
}
@@ -953,11 +1057,12 @@ def _feed_fetch_url(js: str) -> str:
return found.group(1)
def test_the_event_feeds_are_one_shared_pager_not_three(client) -> None:
"""`Dash.eventsPager()` renders all three feeds, and no page grows its own pager.
def test_the_event_feeds_are_one_shared_pager_not_one_per_page(client) -> None:
"""`Dash.eventsPager()` renders every feed, and no page grows its own pager.
Three copies of a pager is three chances for one page to keep the old behaviour — and
`/system` is the one whose feed was already too tall when this was written down.
One copy per page is one chance per page to keep the old behaviour — and `/system` is the
one whose feed was already too tall when this was written down. `/disks` joined as a
fourth call site of the same helper, not as a fifth implementation of it.
"""
app_js = _code(client.get("/static/app.js").text)
assert app_js.count("function eventsPager(") == 1
@@ -1913,7 +2018,7 @@ def test_llm_fleet_block_colours_no_number(client) -> None:
"a dead engine no longer dims the tag or darkens the block's dot"
css = client.get("/static/dash.css").text
section = css[css.index("/* ---- fleet totals"):css.index("/* ---- hero numbers")]
section = _css_section(css, "fleet totals")
for token in ("var(--ok)", "var(--warn)", "var(--fail)"):
assert token not in section, f"the fleet block coloured a number with {token}"
assert ".fleet-head" in section and ".fleet-note" in section, \
@@ -2004,7 +2109,7 @@ def test_llm_slot_grid_style_is_a_marker_and_not_a_verdict(client) -> None:
the marker survives monochrome, which is the battery glyph's rule applied here.
"""
css = client.get("/static/dash.css").text
section = css[css.index("/* ---- engine slot grid"):css.index("/* ---- fleet totals")]
section = _css_section(css, "engine slot grid")
for selector in (".slots {", ".slot {", ".slot.busy", ".slot-top", ".slot-id",
".slot-model", ".slot dt", ".slot dd", ".slots-empty", ".slots-note",
".section-label"):
@@ -2292,10 +2397,11 @@ def test_overview_dims_the_fleet_row_of_a_host_that_stopped_answering(client) ->
# Which script owns which page, for the orphan check below. `charts.js` and `app.js` are
# loaded by all four pages, so an id either page code or shared chrome may read.
# loaded by every page, so an id either page code or shared chrome may read.
PAGE_SCRIPTS = {
"/": "page-index.js",
"/system": "page-system.js",
"/disks": "page-disks.js",
"/llm": "page-llm.js",
"/ups": "page-ups.js",
}
@@ -2366,7 +2472,7 @@ def test_no_page_narrates_its_own_pipeline(client, fragment: str) -> None:
the same sentence migrating into another page's card is the same slop, and a pin that
only guards its original file would let that through.
"""
for path in ("/", "/system", "/llm", "/ups", *ASSETS):
for path in (*PAGES, *ASSETS):
body = client.get(path).text
assert fragment not in body, f"{path} narrates the app again: {fragment!r}"
@@ -2378,7 +2484,7 @@ def test_pages_leave_no_empty_narration_container(client) -> None:
renders a gap where the sentence used to be. Elements the scripts fill (`#sub`,
`#alerts-count`) carry an `id` and are not in this category.
"""
for path in ("/", "/system", "/llm", "/ups"):
for path in PAGES:
html = _code(client.get(path).text)
for cls in ("sub", "faint", "mono"):
assert f'<span class="{cls}"></span>' not in html, \
@@ -2393,12 +2499,16 @@ def test_the_copy_the_operator_acts_on_survives_the_audit(client) -> None:
is this number a measurement, is this battery real, is this host's data last-good —
and each one is the reason the audit deletes sentences and not labels.
"""
pages = {path: client.get(path).text for path in ("/", "/system", "/llm", "/ups")}
pages = {path: client.get(path).text for path in PAGES}
scripts = {name: client.get(f"/static/{name}").text for name in PAGE_SCRIPTS.values()}
# an empty panel says why, in terms the operator can act on
assert "No hosts configured" in pages["/"]
assert "HOST_TARGETS" in pages["/system"]
# …including the fourth page's: a `n/a` drive card is only actionable if the page names
# the two things that make SMART readable (the binary and the device-read grant).
assert "smartmontools" in pages["/disks"] + scripts["page-disks.js"]
assert "disk group" in scripts["page-disks.js"]
assert "No engines configured" in pages["/llm"]
assert "No UPS reporting" in pages["/ups"]
assert "instcmds" in pages["/ups"], "the UPS setup note stopped naming the grant that matters"
@@ -2612,6 +2722,538 @@ def test_metrics_endpoint_lists_what_actually_has_history(client) -> None:
assert body["metrics"] == []
# ---- the `/disks` API (phase 09 task 03) ---------------------------------- #
#
# The drive-health surface answers like every other dashboard's: GETs only, the same
# stale/stub semantics for a host that is not answering, the same history mechanism for the
# `disk.*@dev` series, and the same 404-`configured: false` sentence when no agent is
# configured at all — the last one *without dialling*, because "not deployed here" must not
# cost a connect timeout or a red dot (invariant 3).
@pytest.fixture
def disks_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
disk_snapshot: Any) -> Iterator[TestClient]:
"""Two hosts configured; one of them answering SMART, through the production parser.
The payload and the stored samples are what `parse_disks_snapshot` makes of the
fixture-shaped capture (the agent's whole-disk rows plus the recorded `smartctl` shapes),
so every path a `/disks` card will read is a path the source really emits. The other host
keeps its `unreachable` stub, which is the shape a dead agent must keep (invariant 4).
Both targets point at a dead port, so nothing dials.
"""
db = str(tmp_path / "disks.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db, host_targets=(Target("driveripper", DEAD_URL),
Target("deskwork", DEAD_URL))), store)
with TestClient(app) as test_client:
now = time.time()
snapshot = disk_snapshot("driveripper")
snapshot["ts"] = now # "the agent answered just now", so charts are in range
result = parse_disks_snapshot("driveripper", snapshot, DisksCollector(), now=now)
store.add_many(result.samples)
app.state.ctx.hub.jobs["disks:driveripper"].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.
RATED_CELLS: tuple[tuple[str, str], ...] = (
("temp_c", "disk.temp_c"),
("percentage_used", "disk.percentage_used"),
)
def disk_rows(body: dict[str, Any], host: str = "driveripper") -> list[dict[str, Any]]:
"""The disk rows of one host out of a `/api/disks/snapshot` body."""
return next(row for row in body["hosts"] if row["name"] == host)["disks"]
def flat_routes(app: Any) -> list[Any]:
"""Every route the app serves, with `include_router()` results flattened to their routes.
The FastAPI this repo pins keeps an included router as an `_IncludedRouter` wrapper with
no `path` of its own, so walking `app.routes` alone sees five anonymous objects instead of
the API surface — and the verb assertion below is only as good as this walk.
"""
out: list[Any] = []
for route in app.routes:
inner = getattr(route, "original_router", None)
out.extend(inner.routes if inner is not None else [route])
return out
def series_source(body: dict[str, Any]) -> set[str]:
"""Which sample family a history answer came from: `disks`, never the 10-second `host`."""
return {series["source"] for series in body["series"]}
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`."""
body = disks_client.get("/api/disks/snapshot")
assert response_ok(body)
payload = body.json()
assert [host["name"] for host in payload["hosts"]] == ["deskwork", "driveripper"], (
"every configured host is a row, in name order, answering or not")
rows = disk_rows(payload)
assert rows, "the capture has 16 whole-disk devices, so the page has 16 cards"
assert all("/" not in row["device"] for row in rows), "a mount leaked into the disk list"
first = rows[0]
assert first["path"] == f"/dev/{first['device']}"
assert set(first) >= {"device", "path", "model", "size_bytes", "rotational",
"health", "smart", "severity"}
assert first["smart"]["power_on_hours"] > 0, "the drive's own counters travel verbatim"
live = next(host for host in payload["hosts"] if host["name"] == "driveripper")
assert live["smartctl_available"] is True
assert live["smart_errors"] == []
assert live["summary"]["disks"] == len(rows)
assert "unreachable" not in live, "answering is the absence of the flag, as on /system"
assert [state["key"] for state in payload["states"]] == ["disks:driveripper",
"disks:deskwork"], (
"the page's dots come from the disks jobs, never the system ones")
def test_disks_snapshot_carries_the_verdicts_the_page_will_paint(disks_client) -> None:
"""Colour is in the payload: each row's `severity` is what `severity.classify` says.
This is the pin that keeps the threshold out of the page — `page-disks.js` reads these
and never compares a drive temperature to a bound.
"""
rows = disk_rows(disks_client.get("/api/disks/snapshot").json())
assert "ok" in {row["severity"]["temp_c"] for row in rows}, "the capture is healthy"
for row in rows:
smart = row["smart"] or {}
assert set(row["severity"]) == {field for field, _metric in RATED_CELLS} | {"health"}
for field, metric in RATED_CELLS:
assert row["severity"][field] == classify(metric, smart.get(field)), (
f"{row['device']}.{field} is coloured by something other than the one table")
assert not {key for row in rows for key in row["severity"]} & {
"reallocated_sector_ct", "pending", "uncorrectable", "power_on_hours"}, (
"a counter is an alarm chip on presence, never a graded cell")
def test_a_configured_host_with_no_payload_yet_is_a_row_of_nas(disks_client) -> None:
"""The stub shape `/api/system/snapshot` hands out, so `n/a` is never a missing row.
A host in `HOST_TARGETS` that has not answered is still in the fleet (invariants 3 and
4) with its counts `None` rather than `0`: "unknown" and "no drives" are different
sentences (rule 2), and the fleet header counts it without summing it.
"""
payload = disks_client.get("/api/disks/snapshot").json()
stub = next(host for host in payload["hosts"] if host["name"] == "deskwork")
assert stub["unreachable"] is True
assert stub["disks"] == [], "nothing to draw, but the host is still listed"
assert stub["smartctl_available"] is None, "an agent that never answered says nothing"
assert stub["smart_errors"] == []
assert set(stub["summary"]) == {"disks", "with_smart", "health_failed", "alarm_disks"}
assert all(value is None for value in stub["summary"].values()), "no invented zeros"
fleet = payload["fleet"]
assert (fleet["hosts_total"], fleet["hosts_live"]) == (2, 1)
assert fleet["disks"] == len(disk_rows(payload))
def test_the_disks_fleet_header_is_summed_by_the_api_not_the_page(disks_client) -> None:
"""`fleet` is the header's only arithmetic source, and it carries no verdict."""
payload = disks_client.get("/api/disks/snapshot").json()
live = next(host for host in payload["hosts"] if host["name"] == "driveripper")
fleet = payload["fleet"]
assert fleet["disks"] == live["summary"]["disks"]
assert fleet["alarm_disks"] == live["summary"]["alarm_disks"] > 0, (
"the recorded ATA shape carries reallocated sectors, so the fleet has chips to draw")
assert "severity" not in fleet, "a count of drives is not a graded number"
def test_the_disks_surface_is_gets_only_and_the_write_stays_the_ups_one(
disks_client) -> None:
"""Invariant 1: the app has exactly one POST, and a fourth dashboard does not add one."""
routes = flat_routes(disks_client.app)
disks = [route for route in routes
if str(getattr(route, "path", "")).startswith("/api/disks")]
assert {route.path for route in disks} == {"/api/disks/snapshot", "/api/disks/history"}
verbs = {verb for route in disks for verb in (getattr(route, "methods", None) or set())}
assert verbs == {"GET"}, "a write appeared on the drive-health surface"
for verb in ("POST", "PUT", "PATCH", "DELETE"):
response = disks_client.request(verb, "/api/disks/snapshot", json={})
assert response.status_code == 405, f"{verb} /api/disks/snapshot was accepted"
writes = sorted(str(route.path) for route in routes
if "POST" in (getattr(route, "methods", None) or set()))
assert writes == ["/api/ups/command"], "one write endpoint, four gates, unchanged"
def test_config_prints_the_disks_cadence_the_page_explains_itself_with(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A card four minutes old is behaving, not broken — and the interval is the proof.
`/disks` has no upstream of its own, so `enabled` follows `HOST_TARGETS` and the cadence
is its own. Both numbers come from config rather than a constant in the page, so changing
`DISKS_INTERVAL_SECONDS` moves the sentence and the poller together.
"""
with build_client(tmp_path, monkeypatch, host_targets=(Target("driveripper", DEAD_URL),),
disks_interval=120.0, disks_timeout=45.0) as test_client:
disks = test_client.get("/api/config").json()["disks"]
assert disks["enabled"] is True
assert disks["interval_seconds"] == 120.0
assert disks["timeout_seconds"] == 45.0
def test_disks_snapshot_404s_without_dialling_a_host_that_was_never_configured(
client, monkeypatch: pytest.MonkeyPatch) -> None:
"""The gate runs before anything that could reach the network (invariant 3).
Both spies raise, so a route that constructed a client or fetched a snapshot before
checking `HOST_TARGETS` fails here instead of costing a timeout and a red dot. The hub
assertion is the structural half: with no targets there is no `disks` job to dial with.
"""
calls: list[str] = []
def spy_get(self: Any, url: Any, **kwargs: Any) -> Any:
calls.append(f"GET {url}")
raise AssertionError(f"the unconfigured disks route dialled {url}")
def spy_fetch(*args: Any, **kwargs: Any) -> Any:
calls.append("fetch_snapshot")
raise AssertionError("the unconfigured disks route fetched an agent snapshot")
monkeypatch.setattr(httpx.AsyncClient, "get", spy_get)
monkeypatch.setattr("dashboards.collectors.fetch_snapshot", spy_fetch)
response = client.get("/api/disks/snapshot")
assert response.status_code == 404
body = response.json()
assert body["configured"] is False
assert "HOST_TARGETS" in body["detail"], "the sentence names the fix"
assert calls == [], "an unconfigured page must not touch the network"
assert client.app.state.ctx.hub.jobs == {}, (
"no targets means no disks job, so nothing can age out or go red")
def test_disks_history_serves_the_dev_series_the_page_charts(disks_client) -> None:
"""One request, every series a host's cards could draw, points from the stored samples."""
rows = disk_rows(disks_client.get("/api/disks/snapshot").json())
metrics = [f"{base}@{row['device']}" for row in rows for _field, base in SMART_SERIES]
body = disks_client.get("/api/disks/history", params={
"hosts": "driveripper", "metrics": ",".join(metrics), "range": "1h"}).json()
assert body["hosts"] == ["driveripper"], "one host, one panel"
assert body["range_seconds"] == 3_600.0
assert [series["metric"] for series in body["series"]] == metrics
assert series_source(body) == {"disks"}, "the disks family, never the 10-second host one"
points = {(series["metric"]): series["points"] for series in body["series"]}
assert points["disk.temp_c@nvme0n1"][0]["value"] == pytest.approx(38.0)
assert points["disk.power_on_hours@nvme0n1"][0]["value"] == pytest.approx(8_760.0)
def test_disks_history_answers_empty_for_a_series_a_drive_does_not_publish(
disks_client) -> None:
"""Unknown names are an empty series, not a 404 — the /system and /llm contract.
The recorded ATA shape reports no `percentage_used`, so that drive's wear plot has
nothing to draw, and a device name the agent never listed behaves the same way. The card
asks for every series it could draw and reads `n/a` where there is nothing, instead of
the request failing.
"""
rows = disk_rows(disks_client.get("/api/disks/snapshot").json())
unworn = next(row["device"] for row in rows
if (row["smart"] or {}).get("percentage_used") is None)
body = disks_client.get("/api/disks/history", params={
"hosts": "driveripper",
"metrics": f"disk.percentage_used@{unworn},disk.temp_c@nosuchdisk,"
"disk.temp_c@nvme0n1",
"range": "1h"}).json()
by_metric = {series["metric"]: series["points"] for series in body["series"]}
assert by_metric[f"disk.percentage_used@{unworn}"] == [], "no measurement, no line"
assert by_metric["disk.temp_c@nosuchdisk"] == []
assert by_metric["disk.temp_c@nvme0n1"], "the series that does exist still answers"
def test_disks_history_charts_every_configured_host_in_one_request(disks_client) -> None:
"""With no `hosts` the answer covers every configured agent, dead ones included (empty)."""
body = disks_client.get("/api/disks/history",
params={"metrics": "disk.temp_c@nvme0n1,disk.temp_c@sda",
"range": "24h"}).json()
assert body["hosts"] == ["driveripper", "deskwork"]
assert body["range_seconds"] == 86_400.0
assert {(series["host"], series["metric"]) for series in body["series"]} == {
("driveripper", "disk.temp_c@nvme0n1"), ("driveripper", "disk.temp_c@sda"),
("deskwork", "disk.temp_c@nvme0n1"), ("deskwork", "disk.temp_c@sda")}
assert [series for series in body["series"] if series["host"] == "deskwork"], (
"a host with no history is an empty series, never a missing one")
def test_the_metrics_endpoint_answers_for_the_disks_family(disks_client) -> None:
""""Why is this chart empty" has an answer per page: /api/metrics?source=disks."""
body = disks_client.get("/api/metrics", params={"source": "disks"}).json()
assert "disk.temp_c@nvme0n1" in body["metrics"]
assert not [name for name in body["metrics"] if not name.startswith("disk.")], (
"the disks family holds only disk series")
assert not [name for name in disks_client.get(
"/api/metrics", params={"source": "host"}).json()["metrics"]
if name.startswith("disk.temp_c")], "SMART never mixes into the system family"
# ---- the `/disks` page (phase 09 task 04) ---------------------------------- #
#
# The fourth dashboard, asserted the way the other three are: on the served bytes, because a
# JS test runner would be a build step (a locked anchor). What is pinned is not that the page
# exists but that it keeps the four promises the phase wrote down — one card per **physical**
# drive and never one per partition, alarm chips that exist only while a counter is non-zero,
# a host without `smartctl` dimmed rather than reddened, and charts that belong to one host,
# stay collapsed until opened, and cost one history request per host per refresh.
def _disks_js(client: TestClient) -> str:
return client.get("/static/page-disks.js").text
def test_the_disks_tab_is_in_the_masthead_of_every_page(client) -> None:
"""Four tabs became five, on every page, with exactly one marking itself current.
The masthead is the app's only "which dashboard do I need" surface since phase 02 deleted
the overview's table, so a tab missing from one page is a page you cannot get to, and a
tab marked current on the wrong page is a lie about where you are.
"""
for path in PAGES:
html = client.get(path).text
assert '<a href="/disks"' in html, f"{path}'s nav has no Disks tab"
assert html.count('aria-current="page"') == 1, f"{path} marks more than one tab current"
assert f'<a href="{path}" aria-current="page">' in html, f"{path} does not mark itself"
assert (html.index('href="/system"') < html.index('href="/disks"')
< html.index('href="/llm"')), "tab order is machine, drive, engine, power"
def test_the_header_status_dot_picks_up_the_disks_job(client) -> None:
"""`disks` gets its own dot instead of hiding inside `hosts`.
The same agent can answer the 10-second utilisation poll and have a wedged SMART pass;
one dot for both would have to be right about neither.
"""
app_js = client.get("/static/app.js").text
assert "{ key: 'disks', label: 'disks', href: '/disks' }" in app_js
assert "s.dashboard === job.key" in app_js, "the dot stopped filtering by dashboard"
def test_disks_page_draws_one_card_per_physical_drive_from_the_api(client) -> None:
"""The card is a drive, keyed by the agent's device name, patched in place.
A mount or a partition would need a filesystem field, and `/system` already answers "is
the filesystem full"; if one leaks back in here, the page stops being about the drive.
"""
html = client.get("/disks").text
js = _disks_js(client)
assert 'id="hosts"' in html and 'id="hosts-empty"' in html
assert "fetchJSON('/api/disks/snapshot')" in js
assert "row.path" in js and "/dev/" in js, "the card stopped naming the device it is about"
# identity + verdict lead the card: no hero row, no big single number
assert 'class="heroes"' not in html, "the disk card grew a hero row"
assert "Dash.hero(" not in js
for filesystem in ("mountpoint", "fstype", "free_bytes", "worst_mount"):
assert filesystem not in js, f"{filesystem} leaked into the drive page"
# built once per device and patched, so a 16-drive host does not rebuild 16 panels a poll
assert "seen.get(row.device)" in js and "renderDiskCard(panel, row)" in js
assert "pruneHosts(" in js, "a drive that left the host keeps its card forever"
def test_disk_cards_print_the_drive_numbers_and_colour_only_rated_ones(client) -> None:
"""Temp, power-on hours and wear, coloured by the API's verdicts and nothing else.
The odometer is the deliberate exception: `severity.THRESHOLDS` has no `disk.power_on_hours`
row, so the cell is written with a `null` verdict and cannot go green at 100 h or red at
60 000 h. A drive that publishes no wear (the recorded HDD shape) prints `n/a` because
`setTxt` is the only writer.
"""
js = _disks_js(client)
for field in ("smart.temp_c", "smart.power_on_hours", "smart.percentage_used",
"row.health"):
assert field in js, f"{field} is no longer on the card"
for verdict in ("severity.temp_c", "severity.percentage_used", "severity.health"):
assert verdict in js, f"the card stopped rendering {verdict}"
assert "severityFor" not in js, "the drive page wrote a threshold of its own"
body = js[js.index("function renderDiskCard("):js.index("/** Text that prints nothing")]
assert "severity.temp_c" in body and "severity.percentage_used" in body
assert re.search(r"setMetric\(card, 'hours',[^;]*null\)", body), (
"the odometer is being graded by something severity.py does not have")
# the drive's own word, and `unknown` for a drive that gave none — never a green pass
assert "'FAILED'" in body and "'unknown'" in body
def test_disk_alarm_chips_exist_only_while_a_counter_is_non_zero(client) -> None:
"""Zero and unknown both draw no chip; only a non-zero counter draws a red one.
The `/llm` preemption rule: a permanent "0 pending sectors" row trains the eye to ignore
it, and grading a counter green would call a quiet drive an achievement. `severity.py`
rates none of these, so the chip's presence is the entire signal here.
"""
js = _disks_js(client)
for counter in ("reallocated_sector_ct", "current_pending_sector_ct",
"offline_uncorrectable"):
assert counter in js, f"{counter} is no longer an alarm"
assert "value <= 0) continue" in js, "a zero now draws a chip"
assert "alarms.hidden = chips.length === 0" in js, "the empty chip row stays on screen"
assert 'class="chip fail"' in js
css = client.get("/static/dash.css").text
assert ".alarms {" in css and ".chip.fail" in css
def test_a_host_without_smartctl_is_dimmed_with_a_one_line_why_and_never_red(client) -> None:
"""Unconfigured is not broken (invariant 3), and the three answers stay three.
`smartctl_available` is `false` (install smartmontools — the agent said so, so the page
may say it), `null` (nobody told us either way, so name the fix without claiming to know
which half is missing), or the agent is not answering at all, which is a different
sentence again. None of the three reddens anything.
"""
js = _disks_js(client)
html = client.get("/disks").text
assert "smartctl not available on" in js
assert "disk group" in js, "the why stopped naming the grant that fixes it"
assert "smartctl_available === false" in js and "smartctl_available === true" in js
assert "host.unreachable" in js, "a dead agent must not be reported as a missing binary"
assert "smart_errors" in js, "the agent's own sentence is no longer printed"
assert "classList.toggle('stale'" in js
assert 'class="banner info why" hidden' in js, "the why-line has no home on the card"
assert "banner fail" not in js and html.count("banner") == 0, (
"a missing binary reddens the page")
def test_disks_charts_are_collapsed_lazy_and_cost_one_history_request_per_host(client) -> None:
"""`/system`'s chart discipline, reused: collapsed, painted only while open, one XHR.
The request still names every series the host could draw (temperature and wear per
drive), because `/api/disks/history` answers an unknown name as an empty series and a
panel should never have to discover series it did not ask for.
"""
js = _disks_js(client)
assert '<details class="charts-box"' in js
guard = js[js.index("function drawHostCharts("):js.index("async function renderHostCharts(")]
assert "if (!chartsOpen(card)) return;" in guard, (
"a collapsed stack is being built and drawn every poll")
history = js[js.index("async function renderHostCharts("):js.index("/**\n * Drop cards")]
assert history.count("fetchJSON(") == 1, "more than one history request for one host"
assert "/api/disks/history" in history
assert "streams.temp.concat(streams.wear)" in history, (
"the request no longer names every series the panel could draw")
prune = js[js.index("function pruneHosts("):js.index("// ---- fleet header")]
for entry in ("state.hosts.delete", "state.disks.delete", "state.charts.delete",
"state.history.delete", "state.payloads.delete"):
assert entry in prune, f"a pruned host keeps its {entry}"
def test_every_disks_chart_names_itself_for_assistive_tech(client) -> None:
"""An unlabelled plot is silence, and the legend is a sibling that labels nothing."""
js = _code(_disks_js(client))
labels = re.findall(r"ariaLabel: `([^`]+)`", js)
assert len(labels) == 2, "every drive chart must name itself (temperature, wear)"
assert all(label.startswith("${host.name}") for label in labels), (
"a chart that does not say which host it plots belongs on no page")
def test_the_disks_fleet_header_reads_the_api_sums_and_invents_none(client) -> None:
"""The header's arithmetic is the API's (`fleet_disk_summary`), never the page's.
Hidden below two hosts, because with one box in `HOST_TARGETS` these sums only duplicate
that host's own cards — `/llm`'s fleet-block rule. And no cell is coloured: a count of
drives is not a graded number (`severity.py` has no row for one).
"""
html = client.get("/disks").text
js = _disks_js(client)
for element in ("fleet", "fleet-dot", "fleet-count", "fleet-disks", "fleet-smart",
"fleet-failed", "fleet-alarms"):
assert f'id="{element}"' in html, f"the header lost #{element}"
fleet = js[js.index("function renderFleet("):js.index("// ---- snapshot")]
for path in ("fleet.hosts_live", "fleet.disks", "fleet.with_smart",
"fleet.health_failed", "fleet.alarm_disks"):
assert path in fleet, f"the header no longer reads {path}"
assert "reduce(" not in fleet and "+=" not in fleet, "the page is summing drives itself"
assert "severity" not in fleet, "a drive count grew a verdict"
assert "block.hidden = true" in fleet, "one host's sums duplicate its own cards"
def test_the_disk_panel_style_is_existing_vocabulary_with_no_new_palette(client) -> None:
"""`.disk` is the `.slot` shape, and the only colours it reaches for are the verdicts."""
css = _css(client)
block = _css_section(css, "drive panels")
for selector in (".disks {", ".disk {", ".disk-head", ".disk-dev", ".disk-model",
".disk .metrics", ".disk .alarms", ".disk .metric.warn .value",
".disk .metric.fail .value"):
assert selector in block, f"dash.css lost {selector}"
for token in ("var(--warn)", "var(--fail)", "var(--border)", "var(--bg-sunken)",
"var(--text)"):
assert token in block, f"the drive panel no longer uses {token}"
assert "--disk" not in css, "a drive-only colour token appeared"
assert ".stale" in css, "the dim that marks an unreadable host is gone"
def test_the_overview_prints_the_fourth_source_cadence(client) -> None:
"""`/` names every source's interval, so a drive card four minutes old reads as behaving."""
js = client.get("/static/page-index.js").text
assert "config.disks.interval_seconds" in js
@pytest.mark.parametrize("fixture", ["client", "data_client"])
def test_summary_carries_the_drive_block_for_programmatic_consumers(
request, fixture: str) -> None:
"""`disks` is additive: the same configured-ness, the same hosts, no key retired.
`client` has no targets, so it proves the block answers as `False`/`None`/`[]` instead of
disappearing; `data_client` proves it lists the host the fleet has.
"""
test_client = request.getfixturevalue(fixture)
body = test_client.get("/api/summary").json()
populated = fixture == "data_client"
assert body["disks"]["enabled"] is populated
assert (body["disks"]["fleet"] is not None) is populated
assert body["disks"]["hosts"] == ([] if not populated else [
{"name": "driveripper", "disks": None, "with_smart": None, "health_failed": None,
"alarm_disks": None, "smartctl_available": None, "failed_devices": [],
"unreachable": True}]), "a configured host with no payload is a row of nones"
assert [row["name"] for row in body["disks"]["hosts"]] == [
row["name"] for row in body["system"]["hosts"]]
def test_summary_serves_the_same_counts_the_disks_page_prints(disks_client) -> None:
"""One projection, two consumers: the summary's numbers *are* the snapshot's."""
body = disks_client.get("/api/summary").json()
snapshot = disks_client.get("/api/disks/snapshot").json()
disks = body["disks"]
assert disks["fleet"] == snapshot["fleet"], "the header would disagree with the page"
rows = {row["name"]: row for row in disks["hosts"]}
live = next(host for host in snapshot["hosts"] if host["name"] == "driveripper")
assert (rows["driveripper"]["disks"], rows["driveripper"]["with_smart"],
rows["driveripper"]["health_failed"], rows["driveripper"]["alarm_disks"]) == (
live["summary"]["disks"], live["summary"]["with_smart"],
live["summary"]["health_failed"], live["summary"]["alarm_disks"])
assert rows["driveripper"]["smartctl_available"] is True
assert rows["driveripper"]["unreachable"] is False
assert rows["deskwork"]["unreachable"] is True
assert rows["deskwork"]["disks"] is None, "unknown, never 0 (rule 2)"
assert sorted(state["key"] for state in disks["states"]) == ["disks:deskwork",
"disks:driveripper"]
def test_summary_names_the_drive_the_health_verdict_called_failed(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, disk_snapshot: Any) -> None:
"""Which drive the hardware itself called `FAILED` is part of the fleet answer.
A notifier reading `/api/summary` should not have to fetch a second endpoint to name the
device, and the count and the list have to agree or one of them is wrong.
"""
db = str(tmp_path / "summary-failed.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db,
host_targets=(Target("driveripper", DEAD_URL),)), store)
with TestClient(app) as test_client:
now = time.time()
snapshot = disk_snapshot("driveripper", mode="failed")
snapshot["ts"] = now
result = parse_disks_snapshot("driveripper", snapshot, DisksCollector(), now=now)
app.state.ctx.hub.jobs["disks:driveripper"].state.payload = result.payload
row = test_client.get("/api/summary").json()["disks"]["hosts"][0]
assert len(row["failed_devices"]) == 1
assert row["failed_devices"][0] == result.payload["disks"][0]["device"]
assert row["health_failed"] == result.payload["summary"]["health_failed"] == 1
store.close()
# ---- events --------------------------------------------------------------- #
def test_events_are_returned_in_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+145
View File
@@ -19,12 +19,15 @@ import pytest
from dashboards.collectors import (
NutMonitor,
UpstreamError,
fetch_disk_snapshot,
fetch_engine,
fetch_host_snapshot,
fetch_snapshot,
)
from dashboards.config import Target
from dashboards.rates import RateEstimator
from dashboards.sources import nut
from dashboards.sources.disks import DisksCollector
from dashboards.sources.host import HostCollector
FIXTURES = Path(__file__).parent / "fixtures"
@@ -203,6 +206,148 @@ async def test_a_degraded_agent_still_renders_and_says_what_broke() -> None:
assert result.payload["memory"]["total_bytes"] > 0
async def test_smart_addon_errors_do_not_degrade_the_system_source() -> None:
"""A host without smartmontools (or without device read access) is unconfigured, not
broken (invariant 3): its `smart:` lines must never hold the system page amber. They
stay in `agent.errors`, where the /disks page explains them in its own panel.
"""
body = agent_body()
body["errors"] = ["smart: sdb: Smartctl open device: /dev/sdb failed: Permission denied"]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=body)
async with client_for(handler) as client:
result = await fetch_host_snapshot(client, Target("gw", "http://agent.test"),
HostCollector(RateEstimator()))
assert "degraded" not in result.payload
assert any("smart:" in str(e) for e in result.payload["agent"]["errors"])
async def test_real_degradations_still_show_when_smart_noise_is_alongside() -> None:
body = agent_body()
body["errors"] = ["smart: smartctl not found", "temperatures: no hwmon sensors"]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=body)
async with client_for(handler) as client:
result = await fetch_host_snapshot(client, Target("gw", "http://agent.test"),
HostCollector(RateEstimator()))
assert result.payload["degraded"] == ["temperatures: no hwmon sensors"]
# --------------------------------------------------------------------------- #
# physical disks (the /disks source's fetch half)
# --------------------------------------------------------------------------- #
async def test_the_disk_fetch_reads_the_same_agent_snapshot_as_the_system_one(
disk_snapshot: Any) -> None:
body = disk_snapshot(mode="healthy")
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/snapshot", "disks has no endpoint of its own"
seen.append(request.url.path)
return httpx.Response(200, json=body)
target = Target("driveripper", "http://agent.test")
async with client_for(handler) as client:
result = await fetch_disk_snapshot(client, target, DisksCollector(), timeout=30)
assert seen == ["/snapshot"]
assert result.payload["name"] == "driveripper"
assert result.payload["disks"], "the physical rows come with the snapshot"
assert any(sample.metric.startswith("disk.temp_c@") for sample in result.samples)
async def test_the_disk_fetch_reports_the_agent_failure_like_the_system_one(
monkeypatch: pytest.MonkeyPatch) -> None:
"""One dead host must say the same sentence on both pages (shared `fetch_snapshot`)."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="boom")
target = Target("gw", "http://agent.test")
async with client_for(handler) as client:
with pytest.raises(UpstreamError) as system_error:
await fetch_host_snapshot(client, target, HostCollector(RateEstimator()))
with pytest.raises(UpstreamError) as disk_error:
await fetch_disk_snapshot(client, target, DisksCollector())
assert disk_error.value.message == system_error.value.message
async def test_an_agent_that_is_not_ok_fails_the_disk_poll_too() -> None:
"""A degraded agent that says `ok: false` has no trustworthy disk rows to hand over."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": False, "detail": "procfs unreadable"})
async with client_for(handler) as client:
with pytest.raises(UpstreamError, match="procfs unreadable"):
await fetch_disk_snapshot(client, Target("gw", "http://agent.test"),
DisksCollector())
class TimeoutRecordingClient(httpx.AsyncClient):
"""An `AsyncClient` that remembers the `timeout=` each request was handed.
A `MockTransport` replaces httpcore's connection pool, so it does not enforce read
timeouts and a slow-handler test would prove nothing. The argument is what matters
anyway: `timeout=None` means *no timeout at all* to httpx, which would hang a poll
loop forever, and the client default means "the system poll's 4 s".
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.timeouts: list[Any] = []
async def get(self, url: Any, **kwargs: Any) -> httpx.Response: # type: ignore[override]
self.timeouts.append(kwargs.get("timeout", "<unset>"))
return await super().get(url, **kwargs)
async def test_the_slow_disk_poll_carries_its_own_timeout_and_never_disables_one() -> None:
"""`HOST_TIMEOUT_SECONDS` (4 s) stays `/system`'s budget; `disks` brings its own 30 s.
The SMART pass runs inside the agent's snapshot handler, so the two dashboards cannot
share one budget: too short and `/disks` reads red on every SMART poll, too long and
`/system` stops noticing a dead host. And the default must stay httpx's sentinel —
passing a literal `None` silently turns timeout protection off altogether.
"""
body = {"ok": True, "ts": 1.0, "disk_io": {"devices": []}, "errors": []}
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=body)
client = TimeoutRecordingClient(transport=httpx.MockTransport(handler),
timeout=4.0, base_url="http://agent.test")
target = Target("gw", "http://agent.test")
async with client:
await fetch_host_snapshot(client, target, HostCollector(RateEstimator()))
assert client.timeouts == [httpx.USE_CLIENT_DEFAULT]
await fetch_disk_snapshot(client, target, DisksCollector(), timeout=30)
assert client.timeouts[-1] == 30, "the disk poll waits for the SMART pass"
await fetch_snapshot(client, target)
assert client.timeouts[-1] is httpx.USE_CLIENT_DEFAULT
assert all(timeout is not None for timeout in client.timeouts), (
"`timeout=None` disables httpx's timeouts entirely — translate it, never pass it")
async def test_smart_addon_errors_reach_the_disk_payload_verbatim(
disk_snapshot: Any) -> None:
"""`collectors` keeps the `smart:` lines out of `/system`'s `degraded`; `/disks` is
where they earn their keep — they are the page's reason for an empty card."""
body = disk_snapshot(mode="denied")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=body)
async with client_for(handler) as client:
disks = await fetch_disk_snapshot(client, Target("driveripper", "http://agent.test"),
DisksCollector())
system = await fetch_host_snapshot(client, Target("driveripper", "http://agent.test"),
HostCollector(RateEstimator()))
assert disks.payload["smart_errors"], "the /disks page gets the agent's own sentence"
assert "degraded" not in system.payload, "and /system stays unbothered by it"
# --------------------------------------------------------------------------- #
# LLM engines
# --------------------------------------------------------------------------- #
+22
View File
@@ -86,6 +86,28 @@ def test_settings_default_to_no_sources_configured() -> None:
assert settings.any_source_enabled is False
def test_the_disk_source_defaults_to_a_five_minute_cadence() -> None:
"""SMART is not a per-second signal (phase 09), and the fetch waits for the agent's
own 20 s smartctl budget. Both are env-only, like every other interval."""
settings = Settings()
assert settings.disks_interval == 300.0
assert settings.disks_timeout == 30.0
def test_the_disk_cadence_is_an_env_var_like_every_other_one(monkeypatch: pytest.MonkeyPatch) -> None:
"""`DISKS_INTERVAL_SECONDS=60` is how an operator watches a failing drive closely."""
monkeypatch.setenv("DISKS_INTERVAL_SECONDS", "60")
monkeypatch.setenv("DISKS_TIMEOUT_SECONDS", "45")
reloaded = importlib.reload(config)
try:
assert reloaded.SETTINGS.disks_interval == 60.0
assert reloaded.SETTINGS.disks_timeout == 45.0
finally:
monkeypatch.delenv("DISKS_INTERVAL_SECONDS")
monkeypatch.delenv("DISKS_TIMEOUT_SECONDS")
importlib.reload(config)
def test_actions_need_the_flag_and_are_dead_under_read_only() -> None:
assert Settings(nut_allow_actions=True, read_only=False).actions_allowed is True
assert Settings(nut_allow_actions=True, read_only=True).actions_allowed is False
+474
View File
@@ -0,0 +1,474 @@
"""The `disks` source: physical drives in, `disk.*@dev` samples out, nothing invented.
Phase 09 task 02. The behaviours this pins, in the order it hurts to break them:
* **One row per physical disk, never per partition.** The rows come from the agent's
whole-`/sys/block` list; the filesystems in the same snapshot must not grow a card.
* **A value the drive does not publish stays absent.** Not 0, not a stored sample — a
stored 0 and "this drive reports nothing" are indistinguishable on a 90-day chart, and
one of them is a lie (`AGENTS.md` rule 2).
* **Nothing is a rate.** Power-on hours and the sector counters are stored as reported;
a second poll produces the same number, not a delta.
* **Why a card is empty is a fact, not a mystery.** `smartctl_available` is three-way
(yes / no, install smartmontools / the agent does not say) because those are three
different sentences for the operator, and `smart_errors` carries the agent's own line.
* **The colour comes from the shared table.** A row carries a `severity` dict for the two
rated numbers (`temp_c`, `percentage_used`) and the drive's own `health` word, so the page
paints a class instead of comparing a number to a bound — and the sector counters get no
verdict at all, because a non-zero one is a chip and zero is not an achievement.
* **Events go under the dashboard name** `disks`, once per verdict change — the phase-03
lesson: events filed under the sample source leave the page's feed empty forever.
* **The job is its own, at a 5-minute cadence**, and no host targets means no job at all,
so an unconfigured deployment never dials (invariant 3).
"""
from __future__ import annotations
from typing import Any
import httpx
import pytest
from dashboards.collectors import disks_jobs
from dashboards.config import Settings, Target
from dashboards.severity import THRESHOLDS
from dashboards.sources.disks import (
DASHBOARD,
SEVERITY_FIELDS,
SMART_SERIES,
SOURCE,
UNGRADED_FIELDS,
DisksCollector,
disk_summary,
fleet_disk_summary,
normalize_health,
parse_disks_snapshot,
row_severity,
smartctl_available,
)
class NeverDialClient(httpx.AsyncClient): # pragma: no cover - reaching it fails a test
"""A client that fails the test the moment anything tries to dial it."""
async def get(self, *args: Any, **kwargs: Any) -> httpx.Response:
raise AssertionError("a job factory must not dial; only the poll may")
def parse(snapshot: dict[str, Any], *, name: str = "driveripper",
collector: DisksCollector | None = None,
now: float = 1_700_000_000.0):
"""One poll of the source, with the defaults the tests do not care about filled in."""
return parse_disks_snapshot(name, snapshot, collector or DisksCollector(), now=now)
def metric_names(result: Any) -> set[str]:
return {sample.metric for sample in result.samples}
def values(result: Any) -> dict[str, float]:
return {sample.metric: sample.value for sample in result.samples}
# ---- the rows: one per physical disk -------------------------------------- #
def test_every_physical_disk_the_agent_lists_becomes_a_row(disk_snapshot: Any) -> None:
snapshot = disk_snapshot()
result = parse(snapshot)
rows = result.payload["disks"]
assert [row["device"] for row in rows] == [
device["name"] for device in snapshot["disk_io"]["devices"]
], "the card set is the agent's whole-disk list, in its order"
agent_rows = {device["name"]: device for device in snapshot["disk_io"]["devices"]}
for row in rows:
source_row = agent_rows[row["device"]]
assert row["path"] == f"/dev/{row['device']}"
assert row["size_bytes"] == source_row["size_bytes"], "capacity is the agent's, not ours"
assert row["rotational"] == source_row["rotational"], "SSD-vs-spinning is a fact"
assert row["smart"] is not None
def test_filesystems_never_grow_a_disk_card(disk_snapshot: Any) -> None:
""""Not per-partition" is the phase's one hard UI constraint, so it is asserted, not hoped.
The snapshot carries a dozen mounts (`/`, `/srv`, `/boot`); a card per mount would
double-count the same platter and bury the drive that is actually failing.
"""
snapshot = disk_snapshot(mutate=lambda snap: snap["filesystems"].append(
{"device": "/dev/mapper/srv", "mountpoint": "/srv", "fstype": "xfs",
"used_pct": 91.0}))
rows = parse(snapshot).payload["disks"]
assert all("/" not in row["device"] for row in rows), "a mountpoint leaked into the cards"
assert {row["device"] for row in rows}.isdisjoint(
{fs["mountpoint"] for fs in snapshot["filesystems"]})
def test_the_smart_block_is_carried_verbatim_and_the_verdict_is_normalised(
disk_snapshot: Any) -> None:
snapshot = disk_snapshot()
rows = parse(snapshot).payload["disks"]
ata = next(row for row in rows if row["smart"] and row["smart"]["reallocated_sector_ct"])
assert ata["smart"]["reallocated_sector_ct"] == 8.0
assert ata["smart"]["current_pending_sector_ct"] == 3.0
assert ata["smart"]["offline_uncorrectable"] == 2.0
assert ata["smart"]["power_on_hours"] == 17_520.0
assert ata["smart"]["temp_c"] == 31.0
# `PASSED` is smartctl's word; the app's colour vocabulary is `ok`
assert ata["smart"]["overall_health"] == "PASSED", "the drive's own word stays verbatim"
assert ata["health"] == "ok"
@pytest.mark.parametrize("word,expected", [
("PASSED", "ok"), ("OK", "ok"), (" ok ", "ok"), ("passed", "ok"),
("FAILED", "failed"), ("failed!", "failed"),
("UNKNOWN", None), ("", None), (None, None), (7, None),
])
def test_only_the_drives_own_verdict_words_count(word: Any, expected: str | None) -> None:
"""`UNKNOWN` is not a pass: no measurement, no colour (`severity.py`'s first rule)."""
assert normalize_health(word) == expected
def test_a_host_that_reports_no_disks_answers_with_an_empty_list_not_an_error() -> None:
"""gateway is a VM on virtio with nothing behind it; `/disks` renders "no disks"."""
result = parse({"ts": 1.0, "disk_io": {"devices": []}, "errors": []}, name="gateway")
assert result.payload["disks"] == []
assert result.payload["summary"]["disks"] == 0
assert result.samples == []
assert result.payload["smartctl_available"] is None
def test_a_malformed_device_row_is_skipped_rather_than_crashing(disk_snapshot: Any) -> None:
"""`/disks` must survive an agent that answered something odd.
A device with no name would render a card called `/dev/`, which is a worse lie than a
missing card; a non-object row is what a truncated JSON body looks like.
"""
clean = parse(disk_snapshot(mode="healthy")).payload["disks"]
def mangle(snapshot: dict[str, Any]) -> None:
snapshot["disk_io"]["devices"].append("not-a-dict")
snapshot["disk_io"]["devices"].append({"name": " ", "smart": {"temp_c": 44.0}})
mangled = parse(disk_snapshot(mode="healthy", mutate=mangle)).payload["disks"]
assert mangled == clean, "only the two junk rows were dropped, in place and unchanged"
assert all(row["device"].strip() for row in mangled)
# ---- samples: the history namespace, counters as reported ----------------- #
def test_the_series_are_the_pinned_disk_names_per_device(disk_snapshot: Any) -> None:
snapshot = disk_snapshot(mode="healthy")
result = parse(snapshot)
devices = [device["name"] for device in snapshot["disk_io"]["devices"]]
# every series this source may write is `<base>@<device>` from the pinned table
allowed = {f"{base}@{device}" for device in devices for _field, base in SMART_SERIES}
assert metric_names(result) <= allowed
assert all(name.startswith("disk.") and "@" in name for name in metric_names(result)), \
"an unqualified disk name would collide with /system's per-mount series"
# all six bases show up: the ATA shape supplies the sector counters, the NVMe shape
# the wear percentage, so between them the whole table is exercised
assert {name.split("@")[0] for name in metric_names(result)} == {
base for _field, base in SMART_SERIES}
# and the family is the disks one, so /api/metrics?source=disks answers for this page
assert {sample.source for sample in result.samples} == {SOURCE} == {"disks"}
# ---- why a card is `n/a` --------------------------------------------------- #
def test_a_denied_device_nulls_only_that_disk(disk_snapshot: Any) -> None:
snapshot = disk_snapshot(mode="denied")
result = parse(snapshot)
denied = snapshot["disk_io"]["devices"][-1]["name"]
by_device = {row["device"]: row for row in result.payload["disks"]}
assert by_device[denied]["smart"] is None
assert by_device[denied]["health"] is None
assert not [name for name in metric_names(result) if name.endswith(f"@{denied}")]
assert any(row["smart"] for row in result.payload["disks"]), "its neighbours are fine"
assert result.payload["smart_errors"] == [
line for line in snapshot["errors"] if line.startswith("smart:")]
def test_a_missing_binary_is_answered_as_not_installed(disk_snapshot: Any) -> None:
""""smartctl not available" is the sentence the page needs, and it comes from here."""
result = parse(disk_snapshot(mode="no_binary"))
assert result.payload["smartctl_available"] is False
assert all(row["smart"] is None for row in result.payload["disks"])
assert len(result.payload["smart_errors"]) == 1, "one missing binary is one fact"
assert result.samples == [], "nothing was read, so nothing is stored"
def test_a_silent_opt_out_is_unknown_rather_than_not_installed(disk_snapshot: Any) -> None:
"""`--no-smart` and an agent predating the pass say nothing; guessing is worse.
The page still has an honest state: `None` renders `n/a` with no colour, and the cards
are dimmed rather than red (invariant 3).
"""
result = parse(disk_snapshot(mode="silent"))
assert result.payload["smartctl_available"] is None
assert result.payload["smart_errors"] == []
def test_smartctl_available_is_true_the_moment_any_disk_answers() -> None:
rows = [{"smart": {"temp_c": 31.0}}, {"smart": None}]
assert smartctl_available(rows, ["smart: sdb: Permission denied"]) is True
def test_smart_errors_ignore_the_lines_that_are_not_about_smart() -> None:
"""`errors` also carries /proc failures, and those belong to `/system`, not here."""
result = parse({"ts": 1.0, "disk_io": {"devices": []},
"errors": ["cpu: OSError: /proc/stat", "smart: sdb: denied"]})
assert result.payload["smart_errors"] == ["smart: sdb: denied"]
# ---- the host summary ------------------------------------------------------ #
def test_summary_counts_reads_alarm_drives_and_failures_apart(disk_snapshot: Any) -> None:
result = parse(disk_snapshot(mode="healthy"))
summary = result.payload["summary"]
devices = result.payload["disks"]
assert summary["disks"] == len(devices) == summary["with_smart"]
assert summary["health_failed"] == 0
# every ATA shape carries reallocated/pending/uncorrectable sectors; the NVMe shapes
# carry zeros, so the alarm count is exactly the number of ATA-shaped disks
ata_disks = [row for row in devices if row["smart"]["reallocated_sector_ct"]]
assert summary["alarm_disks"] == len(ata_disks) > 0
def test_a_drive_answering_failed_is_counted_and_the_neighbours_are_not(
disk_snapshot: Any) -> None:
result = parse(disk_snapshot(mode="failed"))
assert result.payload["summary"]["health_failed"] == 1
assert result.payload["disks"][0]["health"] == "failed"
def test_an_unreadable_drive_is_not_counted_as_failed(disk_snapshot: Any) -> None:
""""Cannot read it" and "it is failing" are different alarms (rule 2)."""
result = parse(disk_snapshot(mode="no_binary"))
summary = result.payload["summary"]
assert summary["disks"] > 0
assert summary["with_smart"] == summary["health_failed"] == summary["alarm_disks"] == 0
def test_disk_summary_ignores_a_zero_counter(disk_snapshot: Any) -> None:
rows = parse(disk_snapshot(mode="healthy")).payload["disks"]
clean = [{**row, "smart": {**(row["smart"] or {}),
"reallocated_sector_ct": 0.0,
"current_pending_sector_ct": 0.0,
"offline_uncorrectable": 0.0}} for row in rows]
assert disk_summary(clean)["alarm_disks"] == 0
# ---- events: one per verdict change, filed under the dashboard ------------- #
def test_a_drive_crossing_into_failed_writes_one_error_event(disk_snapshot: Any) -> None:
collector = DisksCollector()
snapshot = disk_snapshot(mode="healthy")
device = snapshot["disk_io"]["devices"][0]["name"]
assert parse(snapshot, collector=collector).events == [], "first sighting is not news"
assert parse(snapshot, collector=collector).events == [], "an unchanged verdict is quiet"
degraded = disk_snapshot(mode="failed")
events = parse(degraded, collector=collector).events
assert len(events) == 1
event = events[0]
assert event.source == DASHBOARD == "disks", (
"a feed filtered by dashboard must find these — phase 03's empty-feed bug")
assert event.host == "driveripper" and event.kind == "smart-health"
assert event.severity == "error"
assert device in event.message and "failed" in event.message
assert event.data == {"device": device, "from": "ok", "to": "failed"}
assert parse(degraded, collector=collector).events == []
def test_recovering_from_failed_is_a_quiet_info_event(disk_snapshot: Any) -> None:
collector = DisksCollector()
parse(disk_snapshot(mode="failed"), collector=collector)
events = parse(disk_snapshot(mode="healthy"), collector=collector).events
assert [event.severity for event in events] == ["info"]
def test_a_disk_that_left_and_came_back_is_a_first_sighting_again(disk_snapshot: Any) -> None:
"""State is replaced per poll, so a removed drive cannot compare against its old self."""
collector = DisksCollector()
snapshot = disk_snapshot(mode="healthy")
device = snapshot["disk_io"]["devices"][0]["name"]
parse(snapshot, collector=collector)
empty = {"ts": snapshot["ts"], "errors": [], "disk_io": {"devices": []}}
parse(empty, collector=collector)
assert collector.previous_health("driveripper") == {}
assert parse(disk_snapshot(mode="failed"), collector=collector).events == []
assert collector.previous_health("driveripper")[device] == "failed"
def test_two_hosts_do_not_share_verdict_state(disk_snapshot: Any) -> None:
collector = DisksCollector()
parse(disk_snapshot("driveripper"), name="driveripper", collector=collector)
parse(disk_snapshot("deskwork"), name="deskwork", collector=collector)
assert set(collector.hosts) == {"driveripper", "deskwork"}
assert len(collector.hosts["deskwork"]) == 1, "a host compares only against its own disks"
# ---- the job: its own, slow, and absent rather than dialling --------------- #
def test_one_disks_job_per_host_agent_at_the_slow_cadence() -> None:
targets = [Target("driveripper", "http://a:9121"), Target("deskwork", "http://b:9122")]
# the client is never used by the factory, only captured by the job closures
jobs = disks_jobs(targets, NeverDialClient(), DisksCollector(), 300.0, timeout=30.0)
assert [job.key for job in jobs] == ["disks:driveripper", "disks:deskwork"]
assert [job.dashboard for job in jobs] == ["disks", "disks"]
assert [job.label for job in jobs] == ["driveripper", "deskwork"], (
"the label is the host: it is what the event feed and the alert print")
assert [job.interval for job in jobs] == [300.0, 300.0], "SMART is not a 10 s signal"
def test_the_default_cadence_is_five_minutes_with_a_slow_fetch_timeout() -> None:
"""The defaults are the phase's decision (300 s) and the agent's SMART budget (30 s)."""
settings = Settings()
assert settings.disks_interval == 300.0
assert settings.disks_timeout == 30.0, "a wedged smartctl pass costs the agent 20 s"
def test_no_host_targets_means_no_disks_job_and_nothing_dialled() -> None:
"""`disks` has no upstream of its own, so unconfigured means *no task at all*.
The 404 `{"configured": false}` half of invariant 3 is task 03's route; this is the
half that matters first: with no targets the app must not open a socket, and
`/api/health` must not carry a `disks` source that is red because nobody installed one.
"""
assert disks_jobs([], NeverDialClient(), DisksCollector(), 300.0) == []
def test_a_host_that_has_never_polled_has_no_previous_verdicts() -> None:
assert DisksCollector().previous_health("never-seen") == {}
# ---- the verdicts a card paints (task 03's severity rows) ------------------ #
def hot_drive(disk_snapshot: Any, **fields: Any) -> dict[str, Any]:
"""The healthy capture with one drive's rated numbers moved onto a chosen bound.
Driven through the fixture's `mutate` so the numbers under test are the ones the agent's
own parser would have produced for a drive running hot and half-worn out.
"""
def tweak(snapshot: dict[str, Any]) -> None:
(snapshot["disk_io"]["devices"][0].get("smart") or {}).update(fields)
return disk_snapshot(mode="healthy", mutate=tweak)
def test_a_row_carries_a_verdict_for_each_rated_number_and_for_the_drives_word(
disk_snapshot: Any) -> None:
""""72 °C, 85 % wear, PASSED" arrives as `fail`/`warn`/`ok`, never as a bound in JS."""
rows = parse(hot_drive(disk_snapshot, temp_c=72.0, percentage_used=85.0)).payload["disks"]
assert rows[0]["severity"] == {"temp_c": "fail", "percentage_used": "warn",
"health": "ok"}
def test_a_drive_answering_failed_is_the_row_that_reads_red(disk_snapshot: Any) -> None:
"""`health` is the drive's own verdict, so its colour is a translation, not a threshold."""
rows = parse(disk_snapshot(mode="failed")).payload["disks"]
assert rows[0]["health"] == "failed" and rows[0]["severity"]["health"] == "fail"
assert rows[1]["severity"]["health"] == "ok", "its neighbours keep their own verdict"
def test_an_unreadable_drive_gets_no_verdict_at_all(disk_snapshot: Any) -> None:
"""No `smart` block, no colour: the keys are present and every one is `None`.
The card must be able to read `severity.temp_c` without branching on a missing key, and
must not render green for a drive the agent could not open (`AGENTS.md` rule 2).
"""
rows = parse(disk_snapshot(mode="no_binary")).payload["disks"]
assert rows, "the drive is still listed, dimmed"
for row in rows:
assert row["severity"] == {"temp_c": None, "percentage_used": None, "health": None}
def test_a_missing_number_stays_unjudged_rather_than_good(disk_snapshot: Any) -> None:
"""An ATA spinning disk publishes no `percentage_used`; that row reads `n/a`, not `ok`."""
rows = parse(disk_snapshot(mode="healthy")).payload["disks"]
spinning = [row for row in rows if row["smart"]["percentage_used"] is None]
assert spinning, "the recorded ATA shape has no wear field, which is the case here"
for row in spinning:
assert row["severity"]["percentage_used"] is None
assert row["severity"]["temp_c"] == "ok", "the number it does report is still judged"
def test_the_sector_counters_are_never_part_of_a_rows_verdict(disk_snapshot: Any) -> None:
"""The alarm fields appear on the row and in `summary`, never as an `ok|warn|fail`."""
rows = parse(disk_snapshot(mode="healthy")).payload["disks"]
alarming = next(row for row in rows if row["smart"]["reallocated_sector_ct"])
assert set(alarming["severity"]) == {field for field, _metric in SEVERITY_FIELDS} | {"health"}
assert not set(alarming["severity"]) & set(UNGRADED_FIELDS)
assert alarming["smart"]["reallocated_sector_ct"] == 8.0, "the number still travels"
def test_the_rated_rows_exist_in_the_one_threshold_table() -> None:
"""`SEVERITY_FIELDS` may only name metrics `severity.THRESHOLDS` actually carries.
A row here without a bound there would silently colour nothing; a bound for a field the
source never reads is the same drift seen from the other side.
"""
for field, metric in SEVERITY_FIELDS:
assert metric in THRESHOLDS, f"{field} is rated by a metric nobody defines"
assert UNGRADED_FIELDS and not set(UNGRADED_FIELDS) & {
field for field, _metric in SEVERITY_FIELDS}
assert {base for _field, base in SMART_SERIES} >= {
metric for _field, metric in SEVERITY_FIELDS}, (
"a rated number that is not stored cannot be charted in its own colour")
def test_row_severity_tolerates_a_row_with_nothing_in_it() -> None:
"""The API hands this a dict built from an agent that answered oddly; it must not raise."""
assert row_severity({}) == {"temp_c": None, "percentage_used": None, "health": None}
assert row_severity({"health": "no-such-word", "smart": {"temp_c": "hot"}}) == {
"temp_c": None, "percentage_used": None, "health": None}
# ---- the fleet header the API sums, not the page --------------------------- #
def test_the_fleet_counts_drives_across_the_hosts_that_answered(
disk_snapshot: Any) -> None:
"""`/disks` says how many drives the fleet has and how many it could read."""
hosts = [parse(disk_snapshot("driveripper"), name="driveripper").payload,
parse(disk_snapshot("deskwork"), name="deskwork").payload]
fleet = fleet_disk_summary(hosts)
disks = sum(host["summary"]["disks"] for host in hosts)
assert fleet["hosts_total"] == fleet["hosts_live"] == 2
assert fleet["disks"] == disks == len(hosts[0]["disks"]) + len(hosts[1]["disks"])
assert fleet["with_smart"] == disks
assert fleet["health_failed"] == 0
# the recorded ATA shape carries reallocated sectors, so the fleet's alarm count is the
# sum of the hosts' own chip counts, not a number this function invented
assert fleet["alarm_disks"] == sum(host["summary"]["alarm_disks"] for host in hosts) > 0
def test_a_host_that_never_answered_is_counted_but_never_summed(
disk_snapshot: Any) -> None:
"""A dead agent shrinks `hosts_live`, not the drive totals presented as current.
Adding a stub's zeros to the sums would halve the fleet's alarm count on the strength of
a host that said nothing — the `/llm` fleet block's rule, same failure shape.
"""
live = parse(disk_snapshot(mode="failed"), name="driveripper").payload
stub = {"name": "deskwork", "ts": None, "unreachable": True, "disks": []}
fleet = fleet_disk_summary([live, stub])
assert fleet["hosts_total"] == 2 and fleet["hosts_live"] == 1
assert fleet["disks"] == len(live["disks"]), "only the answering host is in the sum"
assert fleet["health_failed"] == 1
def test_a_payload_with_no_countable_summary_contributes_nothing() -> None:
""""Answered" is not "counted": a summary that is missing or junk adds zero, not a guess."""
assert fleet_disk_summary([{"name": "gw", "disks": []}])["disks"] == 0
weird = {"name": "gw", "summary": {"disks": True, "with_smart": "many",
"health_failed": None, "alarm_disks": 2}}
fleet = fleet_disk_summary([weird])
assert fleet["disks"] == fleet["with_smart"] == fleet["health_failed"] == 0
assert fleet["alarm_disks"] == 2
assert fleet["hosts_live"] == 1
def test_an_empty_fleet_answers_with_zeros_rather_than_none() -> None:
"""No hosts configured never reaches this route, but an empty list must not crash either."""
assert fleet_disk_summary([]) == {"hosts_total": 0, "hosts_live": 0, "disks": 0,
"with_smart": 0, "health_failed": 0, "alarm_disks": 0}
+85
View File
@@ -32,6 +32,7 @@ from dashboards.config import Settings, load
from dashboards.rates import RateEstimator
from dashboards.severity import classify
from dashboards.sources import nut
from dashboards.sources.disks import SMART_SERIES, DisksCollector
from dashboards.sources.host import HostCollector
from slots_contract import assert_slot_shape
from ups_contract import assert_ups_hero_contract
@@ -95,6 +96,90 @@ async def test_every_host_serves_the_hero_card_contract(settings: Settings,
)
async def test_agent_disk_rows_carry_the_smart_shape(settings: Settings) -> None:
"""Phase-09: a redeployed agent answers `smart` per physical disk of `/snapshot`.
Agents are redeployed by hand, so an agent whose rows carry no `smart` key at all
SKIPS this host rather than failing it. `smart: null` is a documented state (no
smartctl binary, or the agent user lacks device read access) — what must hold is the
shape of a non-null block and that unknowns are null, never 0. Read-only: nothing
here sends `smartctl -t`; a self-test on hardware something is plugged into is the
operator's call, not a unit test.
"""
if not settings.host_targets:
pytest.skip("HOST_TARGETS is unset — start ./scripts/dev-tunnels.sh")
seen_smart = 0
async with httpx.AsyncClient(timeout=settings.host_timeout) as client:
for target in settings.host_targets:
response = await client.get(f"{target.url}/snapshot", headers=target.headers)
if response.status_code >= 400:
continue # reachability has its own live test
devices = (response.json().get("disk_io") or {}).get("devices") or []
if devices and not all("smart" in device for device in devices):
continue # agent predates the smart pass — redeploy it to test this
for device in devices:
seen_smart += 1
smart = device.get("smart")
if smart is None:
continue # degraded by design; the errors[] entry says why
# the literal set is the pin: a rename on the agent must fail here too
assert set(smart) == {"model", "serial", "overall_health", "temp_c",
"power_on_hours", "reallocated_sector_ct",
"current_pending_sector_ct",
"offline_uncorrectable", "percentage_used"}, \
f"{target.name}/{device['name']}"
if smart["power_on_hours"] is not None:
assert smart["power_on_hours"] > 0
if smart["temp_c"] is not None:
assert -20.0 < smart["temp_c"] < 125.0
assert smart["overall_health"] in (None, "PASSED", "FAILED", "UNKNOWN")
if not seen_smart:
pytest.skip("no agent redeployed with the smart pass yet (deploy/install-agent.sh)")
async def test_disks_source_charts_the_physical_drives_the_agent_reports(
settings: Settings) -> None:
"""Phase 09 task 02: the `disks` source over a real agent snapshot.
This is the production code path — the fetch the poller runs, not a hand-built dict —
so what fails here is a rename between the agent and the page, not a typo in a fixture.
The assertions hold whether or not this fleet can read SMART yet: one row per whole
disk, `disk.*@<dev>` series only for values that exist, and no invented numbers.
"""
if not settings.host_targets:
pytest.skip("HOST_TARGETS is unset — start ./scripts/dev-tunnels.sh")
collector = DisksCollector()
checked = 0
async with httpx.AsyncClient(timeout=settings.disks_timeout) as client:
for target in settings.host_targets:
try:
result = await collectors.fetch_disk_snapshot(client, target, collector,
timeout=settings.disks_timeout)
except collectors.UpstreamError:
continue # reachability has its own live test
checked += 1
payload = result.payload
assert payload["name"] == target.name
assert payload["disks"], f"{target.name}: no physical disks at all?"
for row in payload["disks"]:
assert row["path"] == f"/dev/{row['device']}"
assert "/" not in row["device"], f"partition {row['device']} in a disk list"
assert row["health"] in (None, "ok", "failed")
devices = {row["device"] for row in payload["disks"]}
bases = {base for _field, base in SMART_SERIES}
for sample in result.samples:
assert sample.source == "disks", "the wrong family would chart nowhere"
base, _, device = sample.metric.partition("@")
assert base in bases and device in devices, (
f"{target.name}: {sample.metric} is not a pinned disk series")
assert sample.value == sample.value and abs(sample.value) != float("inf"), \
"NaN must never reach the store"
if any(row["smart"] for row in payload["disks"]):
assert payload["smartctl_available"] is True
if not checked:
pytest.skip("no host agent answered — start ./scripts/dev-tunnels.sh")
@pytest.mark.parametrize("engine", ["gufo", "vllm"])
async def test_engine_still_publishes_the_series_we_parse(engine: str,
settings: Settings) -> None:
+34
View File
@@ -113,6 +113,40 @@ async def test_grace_allows_a_blip_without_an_event(store) -> None:
assert len(store.recent_events(limit=10)) == 1, "one event per outage, not per poll"
async def test_a_failed_disks_poll_ages_its_cards_instead_of_zeroing_them(store) -> None:
"""Invariant 4 on the phase-09 job: SMART goes quiet, the numbers stay and age.
The `/disks` page is the one a operator reads *because* something looks wrong, so a
failed poll must not turn "last read 12 minutes ago" into a page of zeros — and the
outage must be filed under the `disks` dashboard, which is where that page filters.
"""
payload = {"name": "driveripper", "smartctl_available": True,
"disks": [{"device": "nvme0n1", "health": "ok",
"smart": {"temp_c": 38.0, "power_on_hours": 8760.0}}],
"summary": {"disks": 1, "with_smart": 1, "health_failed": 0,
"alarm_disks": 0}}
async def good() -> Result:
return Result(payload=payload,
samples=[Sample("disks", "driveripper", "disk.temp_c@nvme0n1",
38.0, time.time())])
job = Job("disks:driveripper", "disks", "driveripper", 300.0, good)
assert job.interval == 300.0, "SMART is not a 10 s signal"
assert await job.run_once(store) is True
assert store.query("disks", "driveripper", "disk.temp_c@nvme0n1",
start=time.time() - 60), "the series is written under `disks`"
job.fn = failing
assert await job.run_once(store) is False
assert job.state.payload == payload, "the card keeps the last temperature it read"
assert job.state.ok is False
assert job.state.as_json()["age_seconds"] is not None
events = store.recent_events(limit=10)
assert [event.source for event in events] == ["disks"], (
"the outage must land where /disks filters its feed, not under the sample source")
async def test_a_slow_poll_never_overlaps_itself(store) -> None:
calls = 0
gate = asyncio.Event()
+42
View File
@@ -22,6 +22,8 @@ RATIFIED = {
"cpu.usage_pct": (75.0, 90.0),
"mem.used_pct": (80.0, 92.0),
"disk.used_pct": (80.0, 90.0),
"disk.temp_c": (50.0, 70.0),
"disk.percentage_used": (80.0, 100.0),
"cpu.temp_c": (75.0, 90.0),
"llm.kv_cache_usage_pct": (75.0, 90.0),
"ups.charge_pct": (50.0, 20.0),
@@ -134,10 +136,50 @@ def test_non_finite_values_are_treated_as_missing(metric: str, value: float) ->
assert classify(metric, value) is None
def test_a_drive_temperature_and_its_wear_are_the_only_disk_verdicts() -> None:
"""Phase 09: the two `/disks` numbers worth a colour, with the bounds the phase set.
A spinning drive at 70 °C is a fire and an SSD that has consumed its rated wear is a
replacement, so both trip red; the amber in between is the "watch it" band. An SSD with
no temperature sensor has **no value**, and therefore no verdict — the absence of a
number is not a healthy reading (`classify(metric, None)` stays `None`).
"""
assert classify("disk.temp_c", 49.9) == OK
assert classify("disk.temp_c", 50) == WARN
assert classify("disk.temp_c", 69.9) == WARN
assert classify("disk.temp_c", 70) == FAIL
assert classify("disk.temp_c", 75) == FAIL
assert classify("disk.temp_c", None) is None
assert classify("disk.percentage_used", 79.9) == OK
assert classify("disk.percentage_used", 80) == WARN
assert classify("disk.percentage_used", 100) == FAIL
assert classify("disk.percentage_used", None) is None
def test_sector_counters_and_power_on_hours_are_never_graded() -> None:
"""A non-zero sector count is an alarm, not a threshold breach (`/llm`'s chip rule).
Colouring 0 reallocated sectors green would call a drive healthy because a number is
small, and colouring 1 amber would make the chip redundant: the page shows an alarm on
**presence** and says nothing at all at zero. `power_on_hours` is an odometer with no bad
value. So none of them may ever grow a row in `THRESHOLDS`.
"""
counters = ("disk.reallocated", "disk.pending", "disk.uncorrectable",
"disk.power_on_hours", "disk.health_ok")
assert set(counters).isdisjoint(THRESHOLDS), "a counter is not a graded number"
for metric in counters:
assert classify(metric, 0) is None
assert classify(metric, 1) is None
assert classify(metric, 99_000) is None
assert classify(f"{metric}@sda", 1) is None
def test_per_device_streams_inherit_their_base_metric() -> None:
assert classify("disk.used_pct@/srv", 95) == "fail"
assert classify("disk.used_pct@/srv", 10) == "ok"
assert classify("cpu.usage_pct@core7", 80) == "warn"
assert classify("disk.temp_c@nvme0n1", 71) == "fail"
assert classify("disk.percentage_used@nvme0n1", 80) == "warn"
assert classify("mem.used_pct@any", 95) == "fail"
+74
View File
@@ -252,3 +252,77 @@ def test_rollup_does_not_average_a_partial_minute(db) -> None:
def test_hour_constant_is_used_in_retention(db) -> None:
assert db.raw_keep_seconds == pytest.approx(48 * HOUR)
# ---- a 300-second source (phase 09: `disks` samples SMART every 5 minutes) -- #
#
# The rollup SQL groups by minute and hour buckets, so a source slower than a minute
# puts one raw row in every fifth bucket and nothing in the other four. Two things had to
# be true and are asserted rather than assumed: an empty bucket produces *no* rollup row
# (inventing a value for a minute nothing was sampled is rule 2), and the hour bucket
# still averages the samples it did get.
SMART_STEP = 300.0
def test_a_300s_source_rolls_into_minutes_without_inventing_the_empty_ones(db) -> None:
now = time.time()
# five SMART polls at exactly the phase's cadence (-30, -25, -20, -15, -10 minutes),
# so every bucket is fully in the past (+60 s slack) and maintenance may roll all five
start = int(now // MINUTE) * MINUTE - 30 * MINUTE
add_series(db, "disks", "driveripper", "disk.temp_c@nvme0n1",
[38, 39, 38, 40, 37], start=start, step=SMART_STEP)
counts = db.maintain(now=now)
minutes = db.query("disks", "driveripper", "disk.temp_c@nvme0n1", res=RES_MIN, start=0)
assert counts["to_minute"] == 5
assert [value for _ts, value in minutes] == [38.0, 39.0, 38.0, 40.0, 37.0], (
"a bucket holding one sample rolls up that sample, unchanged")
assert [int(ts) for ts, _value in minutes] == [
int(start + index * SMART_STEP // MINUTE * MINUTE) for index in range(5)], (
"every fifth minute bucket has a row; the empty ones must have none")
assert db.count(res=RES_MIN) == 5, "empty buckets became fabricated rows"
def test_a_300s_source_averages_into_the_hour_bucket_it_lands_in(db) -> None:
now = time.time()
# aligned to the epoch, and old enough that the hour bucket is complete: 12 samples
# five minutes apart inside one hour average to their mean, not to a sample count
hour = int(now // HOUR) * HOUR - 3 * HOUR
add_series(db, "disks", "driveripper", "disk.power_on_hours@nvme0n1",
[100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 124],
start=hour, step=SMART_STEP)
db.maintain(now=now)
db.maintain(now=now) # the 1h pass reads 1m rows, so the first pass only makes them
hours = db.query("disks", "driveripper", "disk.power_on_hours@nvme0n1", res=RES_HOUR,
start=0)
assert len(hours) == 1
assert hours[0][0] == hour
assert hours[0][1] == pytest.approx((11 * 100 + 124) / 12)
def test_a_300s_series_is_queryable_at_every_resolution(db) -> None:
"""A 5-minute series over a 1h window is 12 points; `pick_res` must find them raw."""
now = time.time()
add_series(db, "disks", "driveripper", "disk.reallocated@nvme0n1",
[8] * 12, start=now - 3600 + 60, step=SMART_STEP)
assert db.pick_res("disks", "driveripper", "disk.reallocated@nvme0n1", 3600, 600) == RES_RAW
points = db.history("disks", "driveripper", "disk.reallocated@nvme0n1", span=3600)
assert len(points) == 12, "the chart is empty while raw history exists"
db.maintain(now=now)
db.maintain(now=now)
# with rollups present, a long window reads the rolled-up series, not 288 raw rows
assert db.pick_res("disks", "driveripper", "disk.reallocated@nvme0n1",
24 * 3600, 600) in (RES_MIN, RES_RAW)
def test_a_300s_source_is_pruned_by_the_existing_retention(db) -> None:
"""288 rows a day per series is fine for 48h and not fine forever — rule 9."""
now = time.time()
expired = now - db.raw_keep_seconds - 6 * SMART_STEP
add_series(db, "disks", "h", "disk.temp_c@sda", [30] * 4, start=expired, step=SMART_STEP)
add_series(db, "disks", "h", "disk.temp_c@sdb", [30], start=now - SMART_STEP,
step=SMART_STEP)
counts = db.maintain(now=now)
assert counts["pruned"] >= 4
assert db.query("disks", "h", "disk.temp_c@sda", start=0) == []
assert len(db.query("disks", "h", "disk.temp_c@sdb", start=0)) == 1