feat(collector): capture additional smartctl diagnostics
Record host read and write commands, controller busy time, error log entries, warning and critical temperature time, and thermal management transitions from the existing smartctl -a -j acquisition. Schema 7 adds nullable columns to samples so legacy rows read as unknown, and the Diagnostics panel shows each counter with its change.
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
"""Additional smartctl diagnostics (issue #111).
|
||||
|
||||
Field list fixed from real `smartctl -a -j` output (smartctl 7.5, NVMe 1.4):
|
||||
nvme_smart_health_information_log carries warning_temp_time, critical_comp_time,
|
||||
num_err_log_entries, host_reads, host_writes and controller_busy_time. The
|
||||
thermal-management transition fields (thm_temp1/2_trans_count, thm_temp1/2_total_time)
|
||||
appear only on drives that implement them, so they stay nullable.
|
||||
"""
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.activity_readout import diagnostics_text, readout
|
||||
from fenris.activity_selection import ActivitySelection
|
||||
from fenris.collector import acquire_from_smartctl, run_collection
|
||||
from fenris.store import SCHEMA_VERSION, init_store, migrate_to_latest
|
||||
|
||||
NEW_COLUMNS = (
|
||||
"host_reads", "host_writes", "controller_busy_time", "num_err_log_entries",
|
||||
"warning_temp_time", "critical_comp_time",
|
||||
"thm_temp1_trans_count", "thm_temp1_total_time",
|
||||
"thm_temp2_trans_count", "thm_temp2_total_time",
|
||||
)
|
||||
|
||||
|
||||
def _smartctl(**extra_log):
|
||||
"""Shape of real smartctl -a -j output for an NVMe drive."""
|
||||
log = {
|
||||
"critical_warning": 0, "temperature": 37, "available_spare": 100,
|
||||
"available_spare_threshold": 5, "percentage_used": 52,
|
||||
"data_units_read": 207986339, "data_units_written": 202978899,
|
||||
"host_reads": 3543447796, "host_writes": 2458441042,
|
||||
"controller_busy_time": 61626, "power_cycles": 5324,
|
||||
"power_on_hours": 11906, "unsafe_shutdowns": 159, "media_errors": 0,
|
||||
"num_err_log_entries": 0, "warning_temp_time": 0, "critical_comp_time": 0,
|
||||
"temperature_sensors": [37],
|
||||
}
|
||||
log.update(extra_log)
|
||||
return {
|
||||
"nvme_smart_health_information_log": log,
|
||||
"user_capacity": {"bytes": 512110190592},
|
||||
"model_name": "Drive", "serial_number": "SN1", "firmware_version": "FW1",
|
||||
}
|
||||
|
||||
|
||||
class TestAcquisition:
|
||||
def test_real_fields_are_captured(self):
|
||||
got = acquire_from_smartctl(_smartctl())
|
||||
assert got["host_reads"] == 3543447796
|
||||
assert got["host_writes"] == 2458441042
|
||||
assert got["controller_busy_time"] == 61626
|
||||
assert got["num_err_log_entries"] == 0
|
||||
assert got["warning_temp_time"] == 0
|
||||
assert got["critical_comp_time"] == 0
|
||||
|
||||
def test_absent_thermal_management_fields_are_none_not_zero(self):
|
||||
got = acquire_from_smartctl(_smartctl())
|
||||
for key in ("thm_temp1_trans_count", "thm_temp1_total_time",
|
||||
"thm_temp2_trans_count", "thm_temp2_total_time"):
|
||||
assert got[key] is None
|
||||
|
||||
def test_thermal_management_fields_captured_when_reported(self):
|
||||
got = acquire_from_smartctl(_smartctl(
|
||||
thm_temp1_trans_count=3, thm_temp1_total_time=120,
|
||||
thm_temp2_trans_count=1, thm_temp2_total_time=45))
|
||||
assert (got["thm_temp1_trans_count"], got["thm_temp1_total_time"]) == (3, 120)
|
||||
assert (got["thm_temp2_trans_count"], got["thm_temp2_total_time"]) == (1, 45)
|
||||
|
||||
def test_missing_optional_counters_do_not_fail_the_run(self):
|
||||
data = _smartctl()
|
||||
for key in ("host_reads", "host_writes", "controller_busy_time",
|
||||
"num_err_log_entries", "warning_temp_time", "critical_comp_time"):
|
||||
del data["nvme_smart_health_information_log"][key]
|
||||
got = acquire_from_smartctl(data)
|
||||
assert got["host_reads"] is None and got["warning_temp_time"] is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sysfs(tmp_path):
|
||||
ctrl = tmp_path / "nvme0"
|
||||
ctrl.mkdir()
|
||||
for name, value in (("subsysnqn", "nqn.test"), ("model", "Drive"),
|
||||
("serial", "SN1"), ("firmware_rev", "FW1")):
|
||||
(ctrl / name).write_text(value + "\n")
|
||||
return ctrl
|
||||
|
||||
|
||||
class _Clock:
|
||||
def utcnow(self):
|
||||
return datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_collection_persists_new_fields(tmp_path, sysfs):
|
||||
config = {"device": "/dev/nvme0", "store_path": str(tmp_path / "o.db")}
|
||||
result = run_collection(smartctl_data=_smartctl(thm_temp1_trans_count=3),
|
||||
sysfs_path=sysfs, config=config, clock=_Clock())
|
||||
assert result["ok"], result
|
||||
conn = sqlite3.connect(config["store_path"])
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute("SELECT * FROM samples").fetchone()
|
||||
assert row["host_reads"] == 3543447796
|
||||
assert row["controller_busy_time"] == 61626
|
||||
assert row["thm_temp1_trans_count"] == 3
|
||||
assert row["thm_temp2_trans_count"] is None
|
||||
|
||||
|
||||
def _legacy_store(path):
|
||||
"""A populated schema-6 store, as shipped before this change."""
|
||||
conn = init_store(path)
|
||||
for column in reversed(NEW_COLUMNS):
|
||||
conn.execute(f"ALTER TABLE samples DROP COLUMN {column}")
|
||||
conn.execute("PRAGMA user_version=6")
|
||||
for i in range(3):
|
||||
conn.execute(
|
||||
"INSERT INTO samples (ts, device, bytes_written, bytes_read, "
|
||||
"power_on_hours, critical_warning, segment_id) VALUES (?,?,?,?,?,?,1)",
|
||||
("2026-09-30T1%d:00:00+00:00" % i, "/dev/nvme0", 10 + i, 5, 100 + i, 0))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestMigration:
|
||||
def test_schema_version_is_seven(self):
|
||||
assert SCHEMA_VERSION == 7
|
||||
|
||||
def test_populated_legacy_store_keeps_rows_and_gains_null_columns(self, tmp_path):
|
||||
db = tmp_path / "o.db"
|
||||
_legacy_store(db)
|
||||
before = sqlite3.connect(db).execute(
|
||||
"SELECT id, ts, device, bytes_written, power_on_hours FROM samples "
|
||||
"ORDER BY id").fetchall()
|
||||
assert migrate_to_latest(db) == 1
|
||||
conn = sqlite3.connect(db)
|
||||
assert conn.execute("PRAGMA user_version").fetchone()[0] == 7
|
||||
assert conn.execute(
|
||||
"SELECT id, ts, device, bytes_written, power_on_hours FROM samples "
|
||||
"ORDER BY id").fetchall() == before
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(samples)")}
|
||||
assert set(NEW_COLUMNS) <= cols
|
||||
nulls = conn.execute(
|
||||
"SELECT %s FROM samples" % " OR ".join(c + " IS NOT NULL" for c in NEW_COLUMNS)
|
||||
).fetchall()
|
||||
assert nulls == [(0,)] * 3 # legacy rows stay unknown, never zero
|
||||
|
||||
def test_migration_is_idempotent_and_init_store_applies_it(self, tmp_path):
|
||||
db = tmp_path / "o.db"
|
||||
_legacy_store(db)
|
||||
conn = init_store(db)
|
||||
assert conn.execute("PRAGMA user_version").fetchone()[0] == 7
|
||||
conn.close()
|
||||
assert migrate_to_latest(db) == 0
|
||||
|
||||
def test_pending_payload_without_new_fields_still_publishes(self, tmp_path):
|
||||
"""Observations staged by the previous version lack the new keys."""
|
||||
db = tmp_path / "o.db"
|
||||
conn = init_store(db)
|
||||
from fenris.collector import write_sample
|
||||
sample = {k: v for k, v in acquire_from_smartctl(_smartctl()).items()
|
||||
if k not in NEW_COLUMNS}
|
||||
sample.update(ts="2026-09-01T12:00:00+00:00", device="/dev/nvme0")
|
||||
write_sample(sample, {"subnqn": "nqn.test"}, conn,
|
||||
datetime(2026, 9, 1, 12, tzinfo=timezone.utc))
|
||||
assert conn.execute("SELECT host_reads FROM samples").fetchone() == (None,)
|
||||
|
||||
def test_rollback_notes_procedure_restores_prior_schema(self, tmp_path):
|
||||
"""Documented rollback: drop the columns, set user_version back to 6."""
|
||||
db = tmp_path / "o.db"
|
||||
_legacy_store(db)
|
||||
migrate_to_latest(db)
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute(
|
||||
"UPDATE samples SET host_reads = 1 WHERE id = 1")
|
||||
for column in NEW_COLUMNS:
|
||||
conn.execute(f"ALTER TABLE samples DROP COLUMN {column}")
|
||||
conn.execute("PRAGMA user_version=6")
|
||||
conn.commit()
|
||||
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone() == (3,)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(samples)")}
|
||||
assert not set(NEW_COLUMNS) & cols
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 30, 20, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _diag_sample(conn, ts, **values):
|
||||
cols = ["ts", "device", "bytes_written", "bytes_read", "segment_id"] + list(values)
|
||||
conn.execute(
|
||||
"INSERT INTO samples (%s) VALUES (%s)" % (
|
||||
",".join(cols), ",".join("?" * len(cols))),
|
||||
[ts, "/dev/nvme0n1", 1, 1, 1] + list(values.values()))
|
||||
conn.commit()
|
||||
|
||||
|
||||
class TestDiagnosticsPanel:
|
||||
@pytest.fixture
|
||||
def conn(self, tmp_path):
|
||||
c = init_store(tmp_path / "o.db")
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
def _text(self, conn):
|
||||
selection = ActivitySelection()
|
||||
selection.toggle_diagnostics()
|
||||
return diagnostics_text(readout(conn, selection, NOW))
|
||||
|
||||
def test_new_counters_show_value_and_delta(self, conn):
|
||||
_diag_sample(conn, "2026-09-30T18:00:00+00:00", host_reads=1000,
|
||||
host_writes=500, controller_busy_time=60,
|
||||
num_err_log_entries=0, warning_temp_time=2,
|
||||
critical_comp_time=0)
|
||||
_diag_sample(conn, "2026-09-30T19:50:00+00:00", host_reads=1300,
|
||||
host_writes=550, controller_busy_time=65,
|
||||
num_err_log_entries=1, warning_temp_time=2,
|
||||
critical_comp_time=0)
|
||||
text = self._text(conn)
|
||||
assert "Host reads 1300 Δ +300" in text
|
||||
assert "Host writes 550 Δ +50" in text
|
||||
assert "Controller busy time 65 min Δ +5" in text
|
||||
assert "Error log entries 1 Δ +1" in text
|
||||
assert "Warning temperature time 2 min Δ ±0" in text
|
||||
assert "Critical temperature time 0 min Δ ±0" in text
|
||||
|
||||
def test_legacy_rows_read_n_a_and_absent_thermal_management_is_hidden(self, conn):
|
||||
_diag_sample(conn, "2026-09-30T19:50:00+00:00", power_on_hours=5)
|
||||
text = self._text(conn)
|
||||
assert "Host reads n/a" in text
|
||||
assert "Thermal management" not in text
|
||||
|
||||
def test_thermal_management_shown_when_the_drive_reports_it(self, conn):
|
||||
_diag_sample(conn, "2026-09-30T19:50:00+00:00", thm_temp1_trans_count=3,
|
||||
thm_temp1_total_time=120, thm_temp2_trans_count=1,
|
||||
thm_temp2_total_time=45)
|
||||
text = self._text(conn)
|
||||
assert "Thermal management 1 transitions 3 Δ n/a" in text
|
||||
assert "Thermal management 1 time 120 s Δ n/a" in text
|
||||
assert "Thermal management 2 transitions 1 Δ n/a" in text
|
||||
|
||||
def test_unmigrated_store_still_renders_diagnostics(self, tmp_path):
|
||||
"""The dashboard may open a store before the upgrade migrates it."""
|
||||
db = tmp_path / "legacy.db"
|
||||
_legacy_store(db)
|
||||
conn = sqlite3.connect(db)
|
||||
selection = ActivitySelection()
|
||||
selection.toggle_diagnostics()
|
||||
text = diagnostics_text(readout(conn, selection, NOW))
|
||||
assert "Host reads n/a" in text and "Power-on hours" in text
|
||||
Reference in New Issue
Block a user