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:
xavierk
2026-10-05 21:12:25 +05:30
parent 948e097952
commit ab1d894adc
7 changed files with 332 additions and 10 deletions
+1
View File
@@ -18,6 +18,7 @@ backfill releases from before this changelog.
- Jump to the first or last point with Home and End on every graph, and a week at a time with PgUp and PgDn in the Day and History views. - Jump to the first or last point with Home and End on every graph, and a week at a time with PgUp and PgDn in the Day and History views.
- Move through graph points with the mouse wheel, focus any panel by clicking it, and click the footer and activity tool chips to run their actions. - Move through graph points with the mouse wheel, focus any panel by clicking it, and click the footer and activity tool chips to run their actions.
- Open a Diagnostics panel with `i`: the decoded critical warning, power-on hours, power cycles, unsafe shutdowns, media errors and available spare with their change over the selection, plus hourly or daily temperature, active/idle/powered-off/unknown split and coverage. History bars draw reads beside writes while it is open, and a set critical warning shows as a badge in the endurance outlook. - Open a Diagnostics panel with `i`: the decoded critical warning, power-on hours, power cycles, unsafe shutdowns, media errors and available spare with their change over the selection, plus hourly or daily temperature, active/idle/powered-off/unknown split and coverage. History bars draw reads beside writes while it is open, and a set critical warning shows as a badge in the endurance outlook.
- Record more drive evidence from each collection run (host read and write commands, controller busy time, error log entries, warning and critical temperature time, and thermal management transitions where the drive reports them) and show it in the Diagnostics panel. Existing stores migrate automatically; earlier samples show n/a for the new counters.
### Changed ### Changed
+23 -1
View File
@@ -51,8 +51,25 @@ DIAGNOSTIC_COUNTERS = (
("unsafe_shutdowns", "Unsafe shutdowns", ""), ("unsafe_shutdowns", "Unsafe shutdowns", ""),
("media_errors", "Media errors", ""), ("media_errors", "Media errors", ""),
("available_spare", "Available spare", "%"), ("available_spare", "Available spare", "%"),
("host_reads", "Host reads", ""),
("host_writes", "Host writes", ""),
("controller_busy_time", "Controller busy time", " min"),
("num_err_log_entries", "Error log entries", ""),
("warning_temp_time", "Warning temperature time", " min"),
("critical_comp_time", "Critical temperature time", " min"),
("thm_temp1_trans_count", "Thermal management 1 transitions", ""),
("thm_temp1_total_time", "Thermal management 1 time", " s"),
("thm_temp2_trans_count", "Thermal management 2 transitions", ""),
("thm_temp2_total_time", "Thermal management 2 time", " s"),
) )
# Reported only by drives that implement thermal management; with no value the
# panel omits them instead of listing four permanent n/a rows.
_OPTIONAL_DIAGNOSTICS = frozenset((
"thm_temp1_trans_count", "thm_temp1_total_time",
"thm_temp2_trans_count", "thm_temp2_total_time",
))
LOCAL_ACTIVITY_STATE_LABELS = { LOCAL_ACTIVITY_STATE_LABELS = {
"so_far": "totals so far", "so_far": "totals so far",
"incomplete": "incomplete", "incomplete": "incomplete",
@@ -689,7 +706,10 @@ def _counter_deltas(
inside the window; with fewer than two distinct samples, or across a inside the window; with fewer than two distinct samples, or across a
controller-segment change, there is no delta. controller-segment change, there is no delta.
""" """
columns = ", ".join(key for key, _, _ in DIAGNOSTIC_COUNTERS) # A store not yet migrated lacks the newer counter columns: read them as unknown.
present = {row[1] for row in conn.execute("PRAGMA table_info(samples)")}
columns = ", ".join(
key if key in present else "NULL" for key, _, _ in DIAGNOSTIC_COUNTERS)
columns += ", segment_id" columns += ", segment_id"
last = conn.execute( last = conn.execute(
"SELECT id, %s FROM samples WHERE ts <= ? ORDER BY ts DESC, id DESC " "SELECT id, %s FROM samples WHERE ts <= ? ORDER BY ts DESC, id DESC "
@@ -1016,6 +1036,8 @@ def diagnostics_text(activity: ActivityReadout) -> str:
for key, label, unit in DIAGNOSTIC_COUNTERS: for key, label, unit in DIAGNOSTIC_COUNTERS:
counter = diag["counters"][key] counter = diag["counters"][key]
if counter["value"] is None: if counter["value"] is None:
if key in _OPTIONAL_DIAGNOSTICS:
continue
lines.append("%s n/a" % label) lines.append("%s n/a" % label)
continue continue
delta = "Δ n/a" if counter["delta"] is None else "Δ " + _signed(counter["delta"]) delta = "Δ n/a" if counter["delta"] is None else "Δ " + _signed(counter["delta"])
+9 -4
View File
@@ -19,7 +19,7 @@ from typing import Any, Dict, Optional
from .derive import derive_hours_from_interval, find_previous_sample from .derive import derive_hours_from_interval, find_previous_sample
from .monitoring_periods import ensure_period_open from .monitoring_periods import ensure_period_open
from .store import init_store, get_store_path from .store import DIAGNOSTIC_SAMPLE_COLUMNS, init_store, get_store_path
class AcquisitionError(Exception): class AcquisitionError(Exception):
@@ -103,6 +103,8 @@ def acquire_from_smartctl(smartctl_data: Dict[str, Any]) -> Dict[str, Any]:
"bytes_written": log["data_units_written"] * 512000, "bytes_written": log["data_units_written"] * 512000,
"bytes_read": log["data_units_read"] * 512000, "bytes_read": log["data_units_read"] * 512000,
"critical_warning": log.get("critical_warning", 0), "critical_warning": log.get("critical_warning", 0),
# Diagnostic counters (issue #111); absent keys stay None, never zero.
**{column: log.get(column) for column in DIAGNOSTIC_SAMPLE_COLUMNS},
} }
@@ -259,9 +261,10 @@ def write_sample(
percentage_used, available_spare, media_errors, power_on_hours, percentage_used, available_spare, media_errors, power_on_hours,
power_cycles, unsafe_shutdowns, temperature_c, power_cycles, unsafe_shutdowns, temperature_c,
data_units_written, data_units_read, bytes_written, bytes_read, data_units_written, data_units_read, bytes_written, bytes_read,
critical_warning, segment_id, local_tz critical_warning, segment_id, local_tz, %s
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)
""", """ % (", ".join(DIAGNOSTIC_SAMPLE_COLUMNS),
", ".join("?" * len(DIAGNOSTIC_SAMPLE_COLUMNS))),
( (
sample["ts"], sample["ts"],
sample["device"], sample["device"],
@@ -284,6 +287,8 @@ def write_sample(
sample["critical_warning"], sample["critical_warning"],
segment_id, segment_id,
sample.get("local_tz"), sample.get("local_tz"),
# Samples staged by an earlier version lack these keys.
*(sample.get(column) for column in DIAGNOSTIC_SAMPLE_COLUMNS),
), ),
) )
+45 -2
View File
@@ -11,7 +11,23 @@ from typing import Optional
# Schema version - increment on each migration # Schema version - increment on each migration
SCHEMA_VERSION = 6 SCHEMA_VERSION = 7
# Diagnostic counters added in schema 7 (issue #111), named after the
# NVMe health-log keys they come from. All nullable: legacy rows and drives that
# do not report a counter stay unknown rather than zero.
DIAGNOSTIC_SAMPLE_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",
)
# Packaged default placement (spec §8.3). The config may override it, but a # Packaged default placement (spec §8.3). The config may override it, but a
@@ -109,7 +125,17 @@ def _create_schema(conn: sqlite3.Connection):
bytes_read INTEGER, bytes_read INTEGER,
critical_warning INTEGER, critical_warning INTEGER,
segment_id INTEGER, segment_id INTEGER,
local_tz TEXT local_tz TEXT,
host_reads INTEGER,
host_writes INTEGER,
controller_busy_time INTEGER,
num_err_log_entries INTEGER,
warning_temp_time INTEGER,
critical_comp_time INTEGER,
thm_temp1_trans_count INTEGER,
thm_temp1_total_time INTEGER,
thm_temp2_trans_count INTEGER,
thm_temp2_total_time INTEGER
) )
""") """)
@@ -303,6 +329,7 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
4: _migrate_3_to_4, 4: _migrate_3_to_4,
5: _migrate_4_to_5, 5: _migrate_4_to_5,
6: _migrate_5_to_6, 6: _migrate_5_to_6,
7: _migrate_6_to_7,
} }
while current_version < SCHEMA_VERSION: while current_version < SCHEMA_VERSION:
target_version = current_version + 1 target_version = current_version + 1
@@ -408,6 +435,22 @@ def _migrate_5_to_6(conn: sqlite3.Connection) -> None:
repair_legacy_local_day_evidence(conn) repair_legacy_local_day_evidence(conn)
def _migrate_6_to_7(conn: sqlite3.Connection) -> None:
"""Add nullable diagnostic counters to samples (issue #111).
Additive only: existing rows are untouched and read NULL (unknown).
Rollback (SQLite 3.35+, with the collector stopped): for each column in
DIAGNOSTIC_SAMPLE_COLUMNS run ``ALTER TABLE samples DROP COLUMN <name>``,
then ``PRAGMA user_version=6``. Dropping loses only the new counters.
"""
cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()}
if not cols:
return
for column in DIAGNOSTIC_SAMPLE_COLUMNS:
if column not in cols:
conn.execute(f"ALTER TABLE samples ADD COLUMN {column} INTEGER")
def migrate_to_latest(store_path: Path) -> int: def migrate_to_latest(store_path: Path) -> int:
"""Apply forward-only migrations to bring the store to SCHEMA_VERSION. """Apply forward-only migrations to bring the store to SCHEMA_VERSION.
+1 -1
View File
@@ -154,7 +154,7 @@ class TestSchemaMigration:
# Migrate # Migrate
steps = migrate_to_latest(db) steps = migrate_to_latest(db)
assert steps == 5 # v1→v2→v3→v4→v5→v6 assert steps == 6 # v1→v2→v3→v4→v5→v6→v7
# Verify data preserved # Verify data preserved
conn = sqlite3.connect(str(db)) conn = sqlite3.connect(str(db))
+251
View File
@@ -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
+2 -2
View File
@@ -12,7 +12,7 @@ from fenris import collector
from fenris.projection import compute_projection from fenris.projection import compute_projection
from fenris.status import _query_drive_facts, get_status, read_status from fenris.status import _query_drive_facts, get_status, read_status
from fenris.status_composition import render_status_tui from fenris.status_composition import render_status_tui
from fenris.store import init_store from fenris.store import SCHEMA_VERSION, init_store
class FakeClock: class FakeClock:
@@ -288,7 +288,7 @@ def test_v3_readers_ignore_pending_table_until_store_migrates(tmp_path):
migrated = init_store(store_path) migrated = init_store(store_path)
try: try:
assert migrated.execute("PRAGMA user_version").fetchone()[0] == 6 assert migrated.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION
assert migrated.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0 assert migrated.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
finally: finally:
migrated.close() migrated.close()