feat(tui): add Diagnostics panel for stored evidence

Toggle a Diagnostics panel with i, built on the Activity readout. It swaps
in for the Drive pane and shows the decoded critical_warning, power-on
hours, power cycles, unsafe shutdowns, media errors and available spare
with their change over the selection, and per-hour (Day) or per-day
(History) temperature min/avg/max, 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 without
affecting the projection. Counter deltas are withheld across a controller
segment change.
This commit is contained in:
xavierk
2026-10-05 21:05:44 +05:30
parent 6c517e4ecb
commit 948e097952
10 changed files with 730 additions and 43 deletions
+246 -9
View File
@@ -33,6 +33,26 @@ from .timefmt import (
LIVE_WINDOW_H = 3 # Rolling window for the live graph
# NVMe SMART critical warning bits (NVMe base specification, Get Log Page 02h).
CRITICAL_WARNING_FLAGS = (
(0x01, "available spare below threshold"),
(0x02, "temperature outside threshold"),
(0x04, "reliability degraded"),
(0x08, "media in read-only mode"),
(0x10, "volatile memory backup failed"),
(0x20, "persistent memory region read-only"),
)
_KNOWN_WARNING_BITS = sum(bit for bit, _ in CRITICAL_WARNING_FLAGS)
# Drive counters shown with their change over the selection.
DIAGNOSTIC_COUNTERS = (
("power_on_hours", "Power-on hours", ""),
("power_cycles", "Power cycles", ""),
("unsafe_shutdowns", "Unsafe shutdowns", ""),
("media_errors", "Media errors", ""),
("available_spare", "Available spare", "%"),
)
LOCAL_ACTIVITY_STATE_LABELS = {
"so_far": "totals so far",
"incomplete": "incomplete",
@@ -55,6 +75,7 @@ class ActivityReadout:
live: tuple[Dict[str, Any], ...]
health: Dict[str, Any]
local_day: Optional[Dict[str, Any]]
diagnostics: Optional[Dict[str, Any]] = None
def readout(
@@ -68,18 +89,24 @@ def readout(
hours: List[Dict[str, Any]] = []
if selection.view == "day" and selection.browse_date is not None:
hours = _query_local_hourly_graph_data(conn, local_day, now)
days = tuple(_query_local_day_graph_data(
conn, now, zone_name, selected_date=selection.browse_date,
))
health = _query_drive_health(conn)
return ActivityReadout(
view=selection.view,
timezone_name=zone_name,
timezone_label=tz_label(now, zone_name),
local_date=local_date,
days=tuple(_query_local_day_graph_data(
conn, now, zone_name, selected_date=selection.browse_date,
)),
days=days,
hours=tuple(hours),
live=tuple(_query_live_graph_data(conn, now, zone_name)),
health=_query_drive_health(conn),
health=health,
local_day=local_day,
diagnostics=(
_query_diagnostics(conn, selection, now, local_day, days, health)
if selection.diagnostics else None
),
)
@@ -234,7 +261,8 @@ def _query_local_hourly_graph_data(
rows = conn.execute(
"SELECT hour, bytes_written_delta, bytes_read_delta, coverage, "
"sample_count, unknown_seconds, active_seconds, idle_seconds, "
"powered_off_seconds, temperature_min, temperature_max "
"powered_off_seconds, temperature_min, temperature_max, "
"temperature_avg "
"FROM hour_observations WHERE hour >= ? AND hour <= ? ORDER BY hour",
(first_hour.isoformat(), last_hour.isoformat()),
).fetchall()
@@ -251,7 +279,7 @@ def _query_local_hourly_graph_data(
is_future = hour_start > current
identity = hour_start.isoformat()
active = idle = off = 0
temp_min = temp_max = None
temp_min = temp_avg = temp_max = None
if row is None:
written = read = coverage = sample_count = 0
unknown_seconds = 0
@@ -263,7 +291,7 @@ def _query_local_hourly_graph_data(
sample_count = row[4] or 0
unknown_seconds = row[5] or 0
active, idle, off = row[6] or 0, row[7] or 0, row[8] or 0
temp_min, temp_max = row[9], row[10]
temp_min, temp_max, temp_avg = row[9], row[10], row[11]
is_gap = sample_count == 0 and unknown_seconds > 0
result.append({
"hour": identity,
@@ -280,6 +308,7 @@ def _query_local_hourly_graph_data(
"powered_off_seconds": off,
"unknown_seconds": unknown_seconds,
"temperature_min": temp_min,
"temperature_avg": temp_avg,
"temperature_max": temp_max,
"is_zero": written == 0 and read == 0 and not is_gap,
"is_gap": is_gap,
@@ -306,7 +335,7 @@ def _day_habit(conn: sqlite3.Connection, summary: Dict[str, Any]) -> Optional[Di
"SELECT COUNT(*), COALESCE(SUM(active_seconds), 0), "
"COALESCE(SUM(idle_seconds), 0), COALESCE(SUM(powered_off_seconds), 0), "
"COALESCE(SUM(unknown_seconds), 0), MIN(temperature_min), "
"MAX(temperature_max) FROM hour_observations "
"MAX(temperature_max), AVG(temperature_avg) FROM hour_observations "
"WHERE hour >= ? AND hour <= ?",
(first_hour.isoformat(), (day_end - timedelta(hours=1)).isoformat()),
).fetchone()
@@ -319,6 +348,7 @@ def _day_habit(conn: sqlite3.Connection, summary: Dict[str, Any]) -> Optional[Di
"unknown_seconds": row[4],
"temperature_min": row[5],
"temperature_max": row[6],
"temperature_avg": row[7],
}
@@ -586,7 +616,7 @@ def _query_drive_health(conn: sqlite3.Connection) -> Dict[str, Any]:
cursor = conn.execute(
"SELECT mn, sn, fr, temperature_c, available_spare, media_errors, "
"power_on_hours, power_cycles, unsafe_shutdowns, capacity_bytes, "
"percentage_used, data_units_written "
"percentage_used, data_units_written, critical_warning "
"FROM samples ORDER BY id DESC LIMIT 1"
)
row = cursor.fetchone()
@@ -602,6 +632,7 @@ def _query_drive_health(conn: sqlite3.Connection) -> Dict[str, Any]:
"capacity": "unknown",
"percentage_used": 0,
"written_tb": 0,
"critical_warning": 0,
}
capacity = row[9]
@@ -619,6 +650,124 @@ def _query_drive_health(conn: sqlite3.Connection) -> Dict[str, Any]:
"capacity": capacity_str,
"percentage_used": row[10] or 0,
"written_tb": written_tb,
"critical_warning": row[12] or 0,
}
# ---------------------------------------------------------------------------
# Diagnostics (issue #109)
# ---------------------------------------------------------------------------
def decode_critical_warning(value: int | None) -> tuple[str, ...]:
"""Name each NVMe critical-warning bit that is set; reserved bits are flagged."""
value = value or 0
flags = [name for bit, name in CRITICAL_WARNING_FLAGS if value & bit]
if value & ~_KNOWN_WARNING_BITS:
flags.append("unrecognised bits 0x%02x" % (value & ~_KNOWN_WARNING_BITS))
return tuple(flags)
def critical_warning_badge(health: Dict[str, Any]) -> str:
"""Outlook badge for a set critical warning; empty when the drive reports none.
A drive-reported fact only (ADR 0005): it never changes the projection.
"""
value = health.get("critical_warning") or 0
if not value:
return ""
return "[bold yellow]![/bold yellow] [bold]Drive reports critical warning 0x%02x[/bold] · %s" % (
value, ", ".join(decode_critical_warning(value)),
)
def _counter_deltas(
conn: sqlite3.Connection, start: datetime | None, end: datetime,
) -> Dict[str, Dict[str, Optional[int]]]:
"""Latest counter values and their change across a window of raw samples.
The baseline is the last sample at or before *start*, else the first sample
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)
columns += ", segment_id"
last = conn.execute(
"SELECT id, %s FROM samples WHERE ts <= ? ORDER BY ts DESC, id DESC "
"LIMIT 1" % columns, (end.isoformat(),),
).fetchone()
first = None
if start is not None and last is not None:
first = conn.execute(
"SELECT id, %s FROM samples WHERE ts <= ? ORDER BY ts DESC, id DESC "
"LIMIT 1" % columns, (start.isoformat(),),
).fetchone() or conn.execute(
"SELECT id, %s FROM samples WHERE ts > ? AND ts <= ? "
"ORDER BY ts, id LIMIT 1" % columns,
(start.isoformat(), end.isoformat()),
).fetchone()
result: Dict[str, Dict[str, Optional[int]]] = {}
for offset, (key, _, _) in enumerate(DIAGNOSTIC_COUNTERS, start=1):
value = last[offset] if last is not None else None
delta = None
if first is not None and first[0] != last[0] and first[-1] == last[-1] and None not in (
value, first[offset],
):
delta = value - first[offset]
result[key] = {"value": value, "delta": delta}
return result
def _diagnostics_window(
selection: ActivitySelection,
now: datetime,
local_day: Optional[Dict[str, Any]],
days: tuple[Dict[str, Any], ...],
) -> tuple[str, datetime | None, datetime]:
"""Label and UTC bounds of what the selection covers."""
if selection.view == "live":
return "last %dh" % LIVE_WINDOW_H, now - timedelta(hours=LIVE_WINDOW_H), now
if selection.view == "day":
label = "day %s" % (selection.browse_date or "")
if local_day is None:
return label, None, now
end = min(_parse_ts(local_day["utc_end"]), now)
return label, _parse_ts(local_day["utc_start"]), end
start = next((
_parse_ts(day["identity"].utc_start)
for day in days if day["identity"] is not None
), None)
return "last %d days" % selection.history_range_days, start, now
def _query_diagnostics(
conn: sqlite3.Connection,
selection: ActivitySelection,
now: datetime,
local_day: Optional[Dict[str, Any]],
days: tuple[Dict[str, Any], ...],
health: Dict[str, Any],
) -> Dict[str, Any]:
"""Stored diagnostic evidence for the selection: warning, counters, habit."""
visible = days[-selection.history_range_days:]
label, start, end = _diagnostics_window(selection, now, local_day, visible)
warning = health["critical_warning"]
habit_days: List[Dict[str, Any]] = []
if selection.view == "history":
for day in visible:
identity = day["identity"]
if identity is None:
continue
summary = query_local_day_summary(
conn, identity.local_date, identity.timezone, now)
habit = _day_habit(conn, summary) if summary is not None else None
if habit is not None:
habit_days.append(dict(habit, day=identity.local_date))
return {
"window_label": label,
"critical_warning": warning,
"critical_warning_flags": decode_critical_warning(warning),
"counters": _counter_deltas(conn, start, end),
"days": tuple(habit_days),
}
@@ -792,3 +941,91 @@ def local_day_text(local_date: str, local: Dict[str, Any] | None) -> str:
habit["temperature_min"], habit["temperature_max"],
))
return "\n".join(lines)
def _temperature_triplet(
low: float | None, avg: float | None, high: float | None,
) -> str:
"""Temperature as min/avg/max, or a plain note when none was recorded."""
if low is None or high is None:
return "no temp"
mid = "%d" % round(avg) if avg is not None else "-"
return "%d/%s/%d°C" % (low, mid, high)
def _signed(delta: int) -> str:
"""Counter change with an explicit sign; zero reads as no change."""
return "%+d" % delta if delta else "±0"
def _hour_diagnostic_line(hour: Dict[str, Any]) -> str:
"""One compact row: hour, temperature, state split in minutes, coverage."""
head = hour.get("local_label", "")
if hour.get("is_future"):
return "%s future" % head
if hour.get("is_gap"):
return "%s gap" % head
minutes = lambda key: (hour.get(key) or 0) // 60 # noqa: E731
return "%s %s A%dm I%dm Off%dm ?%dm %d%%" % (
head,
_temperature_triplet(
hour.get("temperature_min"), hour.get("temperature_avg"),
hour.get("temperature_max")),
minutes("active_seconds"), minutes("idle_seconds"),
minutes("powered_off_seconds"), minutes("unknown_seconds"),
hour.get("coverage", 0) * 100,
)
def _day_diagnostic_line(day: Dict[str, Any]) -> str:
"""One compact row: day, state split, temperature."""
return "%s A%s I%s Off%s ?%s %s" % (
day["day"][5:],
_duration(day["active_seconds"]), _duration(day["idle_seconds"]),
_duration(day["powered_off_seconds"]), _duration(day["unknown_seconds"]),
_temperature_triplet(
day["temperature_min"], day["temperature_avg"], day["temperature_max"]),
)
def _live_temperature_line(live: tuple[Dict[str, Any], ...]) -> str:
"""Interval count and interval-end temperature range for the live window."""
temps = [p["temperature_c"] for p in live if p.get("temperature_c") is not None]
line = "%d intervals" % len(live)
if temps:
line = "%d intervals · %s" % (len(live), _temperature_triplet(
min(temps), sum(temps) / len(temps), max(temps)))
warned = sum(1 for p in live if p.get("critical_warning"))
if warned:
line += " · %d with critical warning" % warned
return line
def diagnostics_text(activity: ActivityReadout) -> str:
"""Dense Diagnostics panel text for the readout's selection."""
diag = activity.diagnostics
if diag is None:
return "Diagnostics unavailable"
warning = diag["critical_warning"]
lines = [
"[bold]Diagnostics[/bold] · %s" % diag["window_label"],
"Critical warning " + (
"0x%02x · %s" % (warning, ", ".join(diag["critical_warning_flags"]))
if warning else "none"),
]
for key, label, unit in DIAGNOSTIC_COUNTERS:
counter = diag["counters"][key]
if counter["value"] is None:
lines.append("%s n/a" % label)
continue
delta = "Δ n/a" if counter["delta"] is None else "Δ " + _signed(counter["delta"])
lines.append("%s %d%s %s" % (label, counter["value"], unit, delta))
if activity.view == "live":
lines += ["", _live_temperature_line(activity.live)]
elif activity.view == "day":
lines += ["", "Hours · temp min/avg/max · A active I idle Off off ? unknown · coverage"]
lines += [_hour_diagnostic_line(hour) for hour in activity.hours]
else:
lines += ["", "Days · A active I idle Off off ? unknown · temp min/avg/max"]
lines += [_day_diagnostic_line(day) for day in diag["days"]]
return "\n".join(lines)