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:
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user