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
+23 -1
View File
@@ -51,8 +51,25 @@ DIAGNOSTIC_COUNTERS = (
("unsafe_shutdowns", "Unsafe shutdowns", ""),
("media_errors", "Media errors", ""),
("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 = {
"so_far": "totals so far",
"incomplete": "incomplete",
@@ -689,7 +706,10 @@ def _counter_deltas(
inside the window; with fewer than two distinct samples, or across a
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"
last = conn.execute(
"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:
counter = diag["counters"][key]
if counter["value"] is None:
if key in _OPTIONAL_DIAGNOSTICS:
continue
lines.append("%s n/a" % label)
continue
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 .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):
@@ -103,6 +103,8 @@ def acquire_from_smartctl(smartctl_data: Dict[str, Any]) -> Dict[str, Any]:
"bytes_written": log["data_units_written"] * 512000,
"bytes_read": log["data_units_read"] * 512000,
"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,
power_cycles, unsafe_shutdowns, temperature_c,
data_units_written, data_units_read, bytes_written, bytes_read,
critical_warning, segment_id, local_tz
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
critical_warning, segment_id, local_tz, %s
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)
""" % (", ".join(DIAGNOSTIC_SAMPLE_COLUMNS),
", ".join("?" * len(DIAGNOSTIC_SAMPLE_COLUMNS))),
(
sample["ts"],
sample["device"],
@@ -284,6 +287,8 @@ def write_sample(
sample["critical_warning"],
segment_id,
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 = 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
@@ -109,7 +125,17 @@ def _create_schema(conn: sqlite3.Connection):
bytes_read INTEGER,
critical_warning 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,
5: _migrate_4_to_5,
6: _migrate_5_to_6,
7: _migrate_6_to_7,
}
while current_version < SCHEMA_VERSION:
target_version = current_version + 1
@@ -408,6 +435,22 @@ def _migrate_5_to_6(conn: sqlite3.Connection) -> None:
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:
"""Apply forward-only migrations to bring the store to SCHEMA_VERSION.