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.
1054 lines
39 KiB
Python
1054 lines
39 KiB
Python
"""Activity readout: the ready-to-read account of an activity selection.
|
||
|
||
``readout(conn, selection, now)`` assembles everything the dashboard draws for
|
||
live intervals, local hours and local days from the observation store, with
|
||
every time expressed in the system's local timezone and labelled with it.
|
||
Stored evidence stays UTC; a secondary UTC line stays available when a point is
|
||
inspected. Nothing here imports Textual, so each rule is testable against a
|
||
SQLite fixture.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from typing import Any, Dict, List, Optional
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from .activity_selection import (
|
||
HISTORY_RANGE_MAX,
|
||
ActivitySelection,
|
||
HistoryDayIdentity,
|
||
)
|
||
from .derive import _parse_ts
|
||
from .local_day import query_current_local_day, query_local_day_summary
|
||
from .status import CADENCE_DEFAULT_S
|
||
from .timefmt import (
|
||
local_clock,
|
||
local_zone_name,
|
||
tz_label,
|
||
utc_range_label,
|
||
utc_stamp_label,
|
||
)
|
||
|
||
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", "%"),
|
||
("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",
|
||
"complete": "complete",
|
||
"zero": "measured zero",
|
||
"unavailable": "local activity unavailable",
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ActivityReadout:
|
||
"""Everything the dashboard draws for one activity selection."""
|
||
|
||
view: str
|
||
timezone_name: str
|
||
timezone_label: str
|
||
local_date: str
|
||
days: tuple[Dict[str, Any], ...]
|
||
hours: tuple[Dict[str, Any], ...]
|
||
live: tuple[Dict[str, Any], ...]
|
||
health: Dict[str, Any]
|
||
local_day: Optional[Dict[str, Any]]
|
||
diagnostics: Optional[Dict[str, Any]] = None
|
||
|
||
|
||
def readout(
|
||
conn: sqlite3.Connection,
|
||
selection: ActivitySelection,
|
||
now: datetime,
|
||
) -> ActivityReadout:
|
||
"""Assemble the readout for *selection* as of *now*."""
|
||
zone_name = local_zone_name()
|
||
local_date, local_day = selected_local_day(conn, selection, now, zone_name)
|
||
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=days,
|
||
hours=tuple(hours),
|
||
live=tuple(_query_live_graph_data(conn, now, zone_name)),
|
||
health=health,
|
||
local_day=local_day,
|
||
diagnostics=(
|
||
_query_diagnostics(conn, selection, now, local_day, days, health)
|
||
if selection.diagnostics else None
|
||
),
|
||
)
|
||
|
||
|
||
def selected_local_day(
|
||
conn: sqlite3.Connection,
|
||
selection: ActivitySelection,
|
||
now: datetime,
|
||
zone_name: str | None = None,
|
||
) -> tuple[str, Optional[Dict[str, Any]]]:
|
||
"""Return the browsed (or current) local date and its stored summary."""
|
||
zone_name = zone_name or local_zone_name()
|
||
browse_date = selection.browse_date
|
||
local_date = browse_date or now.astimezone(ZoneInfo(zone_name)).date().isoformat()
|
||
try:
|
||
if browse_date is not None:
|
||
identity = selection.selected_history_day
|
||
summary = query_local_day_summary(
|
||
conn, browse_date,
|
||
identity.timezone if identity is not None else None, now,
|
||
)
|
||
else:
|
||
summary = query_current_local_day(conn, now, zone_name)
|
||
except Exception:
|
||
summary = None
|
||
if summary is not None:
|
||
summary = dict(summary, habit=_day_habit(conn, summary))
|
||
return local_date, summary
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Local-day evidence (ADR 0010)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _query_local_day_graph_data(
|
||
conn: sqlite3.Connection,
|
||
now: datetime,
|
||
timezone_name: str | None = None,
|
||
days: int = HISTORY_RANGE_MAX,
|
||
selected_date: str | None = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""Read recorded local-day evidence, keeping unavailable dates visible."""
|
||
current_zone_name = timezone_name or local_zone_name()
|
||
current_zone = ZoneInfo(current_zone_name)
|
||
end_date = now.astimezone(current_zone).date()
|
||
start_date = end_date - timedelta(days=days - 1)
|
||
start_text, end_text = start_date.isoformat(), end_date.isoformat()
|
||
local_dates = {
|
||
(start_date + timedelta(days=offset)).isoformat()
|
||
for offset in range(days)
|
||
}
|
||
if selected_date is not None:
|
||
try:
|
||
date.fromisoformat(selected_date)
|
||
except ValueError:
|
||
selected_date = None
|
||
else:
|
||
local_dates.add(selected_date)
|
||
rows = conn.execute(
|
||
"SELECT local_date, tz_name FROM local_days "
|
||
"WHERE (local_date >= ? AND local_date <= ?) "
|
||
"OR local_date = ? ORDER BY local_date, id",
|
||
(start_text, end_text, selected_date or ""),
|
||
).fetchall()
|
||
recorded_zones: Dict[str, list[str]] = {}
|
||
for local_date, recorded_zone in rows:
|
||
recorded_zones.setdefault(local_date, [])
|
||
if recorded_zone not in recorded_zones[local_date]:
|
||
recorded_zones[local_date].append(recorded_zone)
|
||
|
||
result: List[Dict[str, Any]] = []
|
||
for local_date in sorted(local_dates):
|
||
zones = recorded_zones.get(local_date)
|
||
if not zones:
|
||
result.append({
|
||
"day": local_date,
|
||
"local_label": local_date,
|
||
"timezone_label": "",
|
||
"utc_label": "",
|
||
"identity": None,
|
||
"total_bytes": None,
|
||
"total_written": None,
|
||
"total_read": None,
|
||
"shared_bytes_written": 0,
|
||
"shared_bytes_read": 0,
|
||
"unallocated_bytes_written": 0,
|
||
"unallocated_bytes_read": 0,
|
||
"activity_state": "unavailable",
|
||
"is_zero": False,
|
||
"is_gap": True,
|
||
"is_partial": False,
|
||
})
|
||
continue
|
||
|
||
for recorded_zone in zones:
|
||
summary = query_local_day_summary(conn, local_date, recorded_zone, now)
|
||
if summary is None:
|
||
continue
|
||
identity = HistoryDayIdentity(
|
||
local_date,
|
||
summary["tz_name"],
|
||
summary["utc_start"],
|
||
summary["utc_end"],
|
||
)
|
||
written = summary["bytes_written"]
|
||
read = summary["bytes_read"]
|
||
state = summary["activity_state"]
|
||
result.append({
|
||
"day": local_date,
|
||
"local_label": local_date,
|
||
"timezone_label": "%s %s" % (
|
||
summary["tz_name"], summary["tz_offset"],
|
||
),
|
||
"utc_label": "%s → %s" % (
|
||
_parse_ts(summary["utc_start"]).strftime("%Y-%m-%d %H:%M"),
|
||
utc_stamp_label(_parse_ts(summary["utc_end"])),
|
||
),
|
||
"identity": identity,
|
||
"total_bytes": written,
|
||
"total_written": written,
|
||
"total_read": read,
|
||
"shared_bytes_written": summary["shared_bytes_written"],
|
||
"shared_bytes_read": summary["shared_bytes_read"],
|
||
"unallocated_bytes_written": summary["unallocated_bytes_written"],
|
||
"unallocated_bytes_read": summary["unallocated_bytes_read"],
|
||
"activity_state": state,
|
||
"is_zero": state == "zero",
|
||
"is_gap": written is None and read is None,
|
||
"is_partial": state in ("incomplete", "so_far"),
|
||
})
|
||
|
||
return result
|
||
|
||
|
||
def _query_local_hourly_graph_data(
|
||
conn: sqlite3.Connection,
|
||
summary: Dict[str, Any] | None,
|
||
now: datetime,
|
||
) -> List[Dict[str, Any]]:
|
||
"""Read complete UTC hour buckets inside recorded local-day boundaries."""
|
||
if summary is None:
|
||
return []
|
||
|
||
zone_name = summary["tz_name"]
|
||
day_start = _parse_ts(summary["utc_start"]).astimezone(timezone.utc)
|
||
day_end = _parse_ts(summary["utc_end"]).astimezone(timezone.utc)
|
||
current = now.astimezone(timezone.utc)
|
||
first_hour = day_start.replace(minute=0, second=0, microsecond=0)
|
||
if first_hour < day_start:
|
||
first_hour += timedelta(hours=1)
|
||
|
||
last_hour = day_end - timedelta(hours=1)
|
||
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, "
|
||
"temperature_avg "
|
||
"FROM hour_observations WHERE hour >= ? AND hour <= ? ORDER BY hour",
|
||
(first_hour.isoformat(), last_hour.isoformat()),
|
||
).fetchall()
|
||
by_hour = {
|
||
_parse_ts(row[0]).astimezone(timezone.utc): row
|
||
for row in rows
|
||
}
|
||
|
||
result: List[Dict[str, Any]] = []
|
||
hour_start = first_hour
|
||
while hour_start + timedelta(hours=1) <= day_end:
|
||
hour_end = hour_start + timedelta(hours=1)
|
||
row = by_hour.get(hour_start)
|
||
is_future = hour_start > current
|
||
identity = hour_start.isoformat()
|
||
active = idle = off = 0
|
||
temp_min = temp_avg = temp_max = None
|
||
if row is None:
|
||
written = read = coverage = sample_count = 0
|
||
unknown_seconds = 0
|
||
is_gap = not is_future
|
||
else:
|
||
written = row[1] or 0
|
||
read = row[2] or 0
|
||
coverage = row[3] or 0.0
|
||
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, temp_avg = row[9], row[10], row[11]
|
||
is_gap = sample_count == 0 and unknown_seconds > 0
|
||
result.append({
|
||
"hour": identity,
|
||
"local_label": local_clock(hour_start, zone_name),
|
||
"local_end_label": local_clock(hour_end, zone_name),
|
||
"timezone_label": tz_label(hour_start, zone_name),
|
||
"utc_label": utc_stamp_label(hour_start),
|
||
"bytes_written": written,
|
||
"bytes_read": read,
|
||
"coverage": coverage,
|
||
"sample_count": sample_count,
|
||
"active_seconds": active,
|
||
"idle_seconds": idle,
|
||
"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,
|
||
"is_partial": (
|
||
hour_start <= current < hour_end
|
||
or coverage < 1.0
|
||
or unknown_seconds > 0
|
||
),
|
||
"is_future": is_future,
|
||
})
|
||
hour_start = hour_end
|
||
|
||
return result
|
||
|
||
|
||
def _day_habit(conn: sqlite3.Connection, summary: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||
"""Usage-habit split and thermal range of the complete hours in a local day."""
|
||
day_start = _parse_ts(summary["utc_start"]).astimezone(timezone.utc)
|
||
day_end = _parse_ts(summary["utc_end"]).astimezone(timezone.utc)
|
||
first_hour = day_start.replace(minute=0, second=0, microsecond=0)
|
||
if first_hour < day_start:
|
||
first_hour += timedelta(hours=1)
|
||
row = conn.execute(
|
||
"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), AVG(temperature_avg) FROM hour_observations "
|
||
"WHERE hour >= ? AND hour <= ?",
|
||
(first_hour.isoformat(), (day_end - timedelta(hours=1)).isoformat()),
|
||
).fetchone()
|
||
if not row or not row[0]:
|
||
return None
|
||
return {
|
||
"active_seconds": row[1],
|
||
"idle_seconds": row[2],
|
||
"powered_off_seconds": row[3],
|
||
"unknown_seconds": row[4],
|
||
"temperature_min": row[5],
|
||
"temperature_max": row[6],
|
||
"temperature_avg": row[7],
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# UTC aggregates (store-level evidence; not shown as local days)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _query_daily_graph_data(
|
||
conn: sqlite3.Connection,
|
||
window_end: Optional[datetime] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""Query UTC day aggregates, with unallocated write evidence kept apart.
|
||
|
||
Returns one dict per UTC day with total/allocated/unallocated bytes,
|
||
coverage, evidence hours, and classification flags. The dashboard never
|
||
draws these as local days; local-day evidence is the only daily source.
|
||
"""
|
||
cursor = conn.execute(
|
||
"SELECT day, bytes_written_delta, unattributed_bytes_written, "
|
||
"coverage, sample_count, active_seconds, idle_seconds, "
|
||
"powered_off_seconds, unknown_seconds, "
|
||
"bytes_read_delta, unattributed_bytes_read "
|
||
"FROM day_aggregates ORDER BY day"
|
||
)
|
||
rows = cursor.fetchall()
|
||
|
||
by_day: Dict[str, Dict[str, Any]] = {}
|
||
for row in rows:
|
||
day = row[0]
|
||
bw_delta = row[1] or 0
|
||
unattributed_w = row[2] or 0
|
||
coverage = row[3] or 0.0
|
||
sample_count = row[4] or 0
|
||
active = row[5] or 0
|
||
idle = row[6] or 0
|
||
powered_off = row[7] or 0
|
||
unknown = row[8] or 0
|
||
br_delta = row[9] or 0
|
||
unattributed_r = row[10] or 0
|
||
|
||
total_written = bw_delta + unattributed_w
|
||
total_read = br_delta + unattributed_r
|
||
evidenced_hours = (active + idle + powered_off) // 3600
|
||
|
||
is_zero = total_written == 0 and total_read == 0
|
||
is_gap = (
|
||
sample_count == 0
|
||
and (active + idle + powered_off) == 0
|
||
and unknown > 0
|
||
)
|
||
is_partial = coverage < 0.5
|
||
|
||
by_day[day] = {
|
||
"day": day,
|
||
"local_label": day,
|
||
"total_bytes": total_written,
|
||
"total_written": total_written,
|
||
"total_read": total_read,
|
||
"allocated_bytes": bw_delta,
|
||
"unallocated_bytes": unattributed_w,
|
||
"allocated_read": br_delta,
|
||
"unallocated_read": unattributed_r,
|
||
"coverage": coverage,
|
||
"evidenced_hours": evidenced_hours,
|
||
"sample_count": sample_count,
|
||
"is_zero": is_zero,
|
||
"is_gap": is_gap,
|
||
"is_partial": is_partial,
|
||
}
|
||
|
||
if window_end is None:
|
||
return list(by_day.values())
|
||
|
||
end_date = window_end.astimezone(timezone.utc).date()
|
||
result: List[Dict[str, Any]] = []
|
||
for offset in range(HISTORY_RANGE_MAX - 1, -1, -1):
|
||
day = (end_date - timedelta(days=offset)).isoformat()
|
||
entry = by_day.get(day, {
|
||
"day": day,
|
||
"local_label": day,
|
||
"total_bytes": 0,
|
||
"total_written": 0,
|
||
"total_read": 0,
|
||
"allocated_bytes": 0,
|
||
"unallocated_bytes": 0,
|
||
"allocated_read": 0,
|
||
"unallocated_read": 0,
|
||
"coverage": 0.0,
|
||
"evidenced_hours": 0,
|
||
"sample_count": 0,
|
||
"is_zero": False,
|
||
"is_gap": True,
|
||
"is_partial": False,
|
||
})
|
||
if day == end_date.isoformat():
|
||
entry = dict(entry)
|
||
entry["is_partial"] = True
|
||
result.append(entry)
|
||
|
||
return result
|
||
|
||
|
||
def _query_hourly_graph_data(
|
||
conn: sqlite3.Connection,
|
||
day: str,
|
||
now: Optional[datetime] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""Query hour observations for one UTC day.
|
||
|
||
Returns one dict per hour with bytes written/read, coverage, and flags.
|
||
"""
|
||
cursor = conn.execute(
|
||
"SELECT hour, bytes_written_delta, bytes_read_delta, coverage, sample_count, "
|
||
"active_seconds, idle_seconds, powered_off_seconds, unknown_seconds "
|
||
"FROM hour_observations "
|
||
"WHERE hour LIKE ? ORDER BY hour",
|
||
(day + "T%",),
|
||
)
|
||
rows = cursor.fetchall()
|
||
|
||
by_hour: Dict[int, Dict[str, Any]] = {}
|
||
for row in rows:
|
||
hour = row[0]
|
||
bw = row[1] or 0
|
||
br = row[2] or 0
|
||
coverage = row[3] or 0.0
|
||
sample_count = row[4] or 0
|
||
active = row[5] or 0
|
||
idle = row[6] or 0
|
||
powered_off = row[7] or 0
|
||
unknown = row[8] or 0
|
||
|
||
is_zero = bw == 0 and br == 0
|
||
local_label = hour[11:13] if len(hour) >= 13 else hour
|
||
|
||
hour_number = int(local_label)
|
||
by_hour[hour_number] = {
|
||
"hour": hour,
|
||
"local_label": local_label,
|
||
"bytes_written": bw,
|
||
"bytes_read": br,
|
||
"coverage": coverage,
|
||
"sample_count": sample_count,
|
||
"active_seconds": active,
|
||
"idle_seconds": idle,
|
||
"powered_off_seconds": powered_off,
|
||
"unknown_seconds": unknown,
|
||
"is_zero": is_zero,
|
||
"is_gap": False,
|
||
"is_partial": False,
|
||
"is_future": False,
|
||
}
|
||
|
||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||
selected_date = datetime.fromisoformat(day).date()
|
||
result: List[Dict[str, Any]] = []
|
||
for hour_number in range(24):
|
||
hour_start = datetime.combine(
|
||
selected_date, datetime.min.time(), tzinfo=timezone.utc,
|
||
) + timedelta(hours=hour_number)
|
||
is_future = hour_start > current
|
||
entry = by_hour.get(hour_number)
|
||
if entry is None:
|
||
entry = {
|
||
"hour": hour_start.isoformat(),
|
||
"local_label": "%02d" % hour_number,
|
||
"bytes_written": 0,
|
||
"bytes_read": 0,
|
||
"coverage": 0.0,
|
||
"sample_count": 0,
|
||
"active_seconds": 0,
|
||
"idle_seconds": 0,
|
||
"powered_off_seconds": 0,
|
||
"unknown_seconds": 0,
|
||
"is_zero": False,
|
||
"is_gap": not is_future,
|
||
"is_partial": hour_start <= current < hour_start + timedelta(hours=1),
|
||
"is_future": is_future,
|
||
}
|
||
else:
|
||
entry["is_partial"] = hour_start <= current < hour_start + timedelta(hours=1)
|
||
entry["is_future"] = is_future
|
||
result.append(entry)
|
||
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Live intervals (issue #91)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _query_live_graph_data(
|
||
conn: sqlite3.Connection,
|
||
now: datetime,
|
||
zone_name: str | None = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""Query the last three hours of measured interval volumes from raw samples.
|
||
|
||
Each point represents the byte delta between two compatible consecutive
|
||
readings in the same controller segment. Points use actual elapsed
|
||
timestamps rather than pretending delayed runs are exact samples; gaps
|
||
and first-reading anchors are visually distinct. Labels are local, with
|
||
the timezone and a secondary UTC form alongside.
|
||
|
||
Returns one dict per interval, ordered oldest-first.
|
||
"""
|
||
zone_name = zone_name or local_zone_name()
|
||
window_start = (now - timedelta(hours=LIVE_WINDOW_H)).isoformat()
|
||
cursor = conn.execute(
|
||
"SELECT ts, bytes_written, bytes_read, segment_id, "
|
||
"temperature_c, critical_warning "
|
||
"FROM samples WHERE ts >= ? ORDER BY ts",
|
||
(window_start,),
|
||
)
|
||
rows = cursor.fetchall()
|
||
|
||
if len(rows) < 2:
|
||
return []
|
||
|
||
results: List[Dict[str, Any]] = []
|
||
for i in range(1, len(rows)):
|
||
prev_ts = _parse_ts(rows[i - 1][0])
|
||
curr_ts = _parse_ts(rows[i][0])
|
||
prev_bw = rows[i - 1][1] or 0
|
||
prev_br = rows[i - 1][2] or 0
|
||
curr_bw = rows[i][1] or 0
|
||
curr_br = rows[i][2] or 0
|
||
prev_seg = rows[i - 1][3]
|
||
curr_seg = rows[i][3]
|
||
|
||
elapsed_s = int((curr_ts - prev_ts).total_seconds())
|
||
is_gap = elapsed_s > (CADENCE_DEFAULT_S * 3) # more than 3× expected cadence
|
||
is_segment_boundary = prev_seg != curr_seg
|
||
|
||
bw_delta = max(0, curr_bw - prev_bw) if not is_segment_boundary else 0
|
||
br_delta = max(0, curr_br - prev_br) if not is_segment_boundary else 0
|
||
is_zero = bw_delta == 0 and br_delta == 0
|
||
|
||
results.append({
|
||
"start_ts": rows[i - 1][0],
|
||
"end_ts": rows[i][0],
|
||
"start_label": local_clock(prev_ts, zone_name),
|
||
"end_label": local_clock(curr_ts, zone_name),
|
||
"timezone_label": tz_label(curr_ts, zone_name),
|
||
"utc_label": utc_range_label(prev_ts, curr_ts),
|
||
"bytes_written": bw_delta,
|
||
"bytes_read": br_delta,
|
||
"elapsed_s": elapsed_s,
|
||
"temperature_c": rows[i][4],
|
||
"critical_warning": rows[i][5] or 0,
|
||
"is_gap": is_gap,
|
||
"is_zero": is_zero,
|
||
"is_segment_boundary": is_segment_boundary,
|
||
})
|
||
|
||
return results
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Drive health
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _query_drive_health(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||
"""Query drive health data for the right pane."""
|
||
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, critical_warning "
|
||
"FROM samples ORDER BY id DESC LIMIT 1"
|
||
)
|
||
row = cursor.fetchone()
|
||
if row is None:
|
||
return {
|
||
"model": "unknown",
|
||
"temp": 0,
|
||
"spare": 0,
|
||
"media_errors": 0,
|
||
"poh": 0,
|
||
"cycles": 0,
|
||
"unsafe_shutdowns": 0,
|
||
"capacity": "unknown",
|
||
"percentage_used": 0,
|
||
"written_tb": 0,
|
||
"critical_warning": 0,
|
||
}
|
||
|
||
capacity = row[9]
|
||
capacity_str = "%d GB" % (capacity / 1e9) if capacity else "unknown"
|
||
written_tb = (row[11] * 512 * 1000) / 1e12 if row[11] else 0 # DUW to TB
|
||
|
||
return {
|
||
"model": row[0] or "unknown",
|
||
"temp": row[3] or 0,
|
||
"spare": row[4] or 0,
|
||
"media_errors": row[5] or 0,
|
||
"poh": row[6] or 0,
|
||
"cycles": row[7] or 0,
|
||
"unsafe_shutdowns": row[8] or 0,
|
||
"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.
|
||
"""
|
||
# 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 "
|
||
"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),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Readout text
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _gb(value: int | None) -> str:
|
||
return "%.3f GB" % ((value or 0) / 1e9)
|
||
|
||
|
||
def _duration(seconds: int) -> str:
|
||
if seconds < 3600:
|
||
return "%dm" % (seconds // 60)
|
||
return "%dh %02dm" % (seconds // 3600, (seconds % 3600) // 60)
|
||
|
||
|
||
def _habit_line(
|
||
active: int, idle: int, off: int, unknown: int,
|
||
temp_min: int | None, temp_max: int | None,
|
||
) -> str:
|
||
line = "active %s · idle %s · off %s · unknown %s" % (
|
||
_duration(active), _duration(idle), _duration(off), _duration(unknown),
|
||
)
|
||
if temp_min is not None and temp_max is not None:
|
||
line += " · %d–%d°C" % (temp_min, temp_max)
|
||
return line
|
||
|
||
|
||
def _evidence_lines(
|
||
shared_written: int, shared_read: int,
|
||
unallocated_written: int, unallocated_read: int,
|
||
) -> List[str]:
|
||
lines = []
|
||
if shared_written or shared_read:
|
||
lines.append("shared at midnight W %s · R %s" % (
|
||
_gb(shared_written), _gb(shared_read)))
|
||
if unallocated_written or unallocated_read:
|
||
lines.append("unallocated W %s · R %s" % (
|
||
_gb(unallocated_written), _gb(unallocated_read)))
|
||
return lines
|
||
|
||
|
||
def interval_readout_text(point: Dict[str, Any]) -> str:
|
||
"""Readout for one live interval: local time, volumes, thermal, warning, UTC."""
|
||
span = "%s → %s %s" % (
|
||
point.get("start_label", ""), point.get("end_label", ""),
|
||
point.get("timezone_label", ""),
|
||
)
|
||
utc = point.get("utc_label", "")
|
||
if point.get("is_segment_boundary"):
|
||
lines = [span + " · segment boundary", "W unavailable · R unavailable"]
|
||
else:
|
||
state_parts = []
|
||
if point.get("is_gap"):
|
||
state_parts.append("gap")
|
||
if point.get("is_zero") and not point.get("is_gap"):
|
||
state_parts.append("measured zero")
|
||
state = " · " + ", ".join(state_parts) if state_parts else ""
|
||
lines = ["[bold]%s[/bold] · W %s · R %s · %ds%s" % (
|
||
span, _gb(point.get("bytes_written")), _gb(point.get("bytes_read")),
|
||
point.get("elapsed_s", 0), state,
|
||
)]
|
||
extras = []
|
||
if point.get("temperature_c") is not None:
|
||
extras.append("%d°C" % point["temperature_c"])
|
||
if point.get("critical_warning"):
|
||
extras.append("drive critical warning 0x%02x" % point["critical_warning"])
|
||
if extras:
|
||
lines.append(" · ".join(extras))
|
||
if utc:
|
||
lines.append(utc)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def hour_readout_text(
|
||
hour: Dict[str, Any],
|
||
local_date: str = "",
|
||
day: Dict[str, Any] | None = None,
|
||
) -> str:
|
||
"""Readout for one local hour, with day-level unallocated/shared evidence."""
|
||
day = day or {}
|
||
head = "%s %s" % (hour.get("local_label", ""), hour.get("timezone_label", ""))
|
||
notes = _evidence_lines(
|
||
day.get("shared_bytes_written", 0), day.get("shared_bytes_read", 0),
|
||
day.get("unallocated_bytes_written", 0), day.get("unallocated_bytes_read", 0),
|
||
)
|
||
if hour.get("is_gap") or hour.get("is_future"):
|
||
state = "future" if hour.get("is_future") else "gap"
|
||
lines = ["%s · %s" % (head, state), "W unavailable · R unavailable"]
|
||
lines += notes
|
||
else:
|
||
state = "incomplete" if hour.get("is_partial") else "measured"
|
||
lines = [
|
||
"[bold]%s[/bold] · %s" % (head, local_date),
|
||
"W %s · R %s · %d%% coverage · %s" % (
|
||
_gb(hour.get("bytes_written")), _gb(hour.get("bytes_read")),
|
||
hour.get("coverage", 0) * 100, state,
|
||
),
|
||
]
|
||
lines += notes
|
||
lines.append(_habit_line(
|
||
hour.get("active_seconds", 0), hour.get("idle_seconds", 0),
|
||
hour.get("powered_off_seconds", 0), hour.get("unknown_seconds", 0),
|
||
hour.get("temperature_min"), hour.get("temperature_max"),
|
||
))
|
||
if hour.get("utc_label"):
|
||
lines.append(hour["utc_label"])
|
||
return "\n".join(lines)
|
||
|
||
|
||
def day_readout_text(day: Dict[str, Any]) -> str:
|
||
"""Readout for one local day in the history view."""
|
||
evidence = _evidence_lines(
|
||
day.get("shared_bytes_written", 0), day.get("shared_bytes_read", 0),
|
||
day.get("unallocated_bytes_written", 0),
|
||
day.get("unallocated_bytes_read", 0),
|
||
)
|
||
if day.get("is_gap") and not evidence:
|
||
return "%s · local-day evidence unavailable" % day.get("day", "")
|
||
written = day.get("total_written")
|
||
read = day.get("total_read")
|
||
lines = [
|
||
"[bold]%s[/bold] · %s · %s" % (
|
||
day.get("day", ""), day.get("timezone_label", ""),
|
||
LOCAL_ACTIVITY_STATE_LABELS.get(
|
||
day.get("activity_state"), "local activity unavailable",
|
||
),
|
||
),
|
||
"W %s · R %s" % (
|
||
"unavailable" if written is None else _gb(written) + " known",
|
||
"unavailable" if read is None else _gb(read) + " known",
|
||
),
|
||
]
|
||
lines += evidence
|
||
if day.get("utc_label"):
|
||
lines.append(day["utc_label"])
|
||
return "\n".join(lines)
|
||
|
||
|
||
def local_day_text(local_date: str, local: Dict[str, Any] | None) -> str:
|
||
"""Text of the local-day totals strip for the browsed or current day."""
|
||
if local is None:
|
||
return (
|
||
"%s · local-day evidence unavailable\nW unavailable · R unavailable"
|
||
% local_date
|
||
)
|
||
written, read = local["bytes_written"], local["bytes_read"]
|
||
lines = [
|
||
"[bold]%s[/bold] · %s %s · %s" % (
|
||
local["local_date"], local["tz_name"], local["tz_offset"],
|
||
LOCAL_ACTIVITY_STATE_LABELS.get(
|
||
local["activity_state"], "local activity unavailable",
|
||
),
|
||
),
|
||
"W unavailable · R unavailable" if written is None or read is None
|
||
else "W %s known · R %s known" % (_gb(written), _gb(read)),
|
||
]
|
||
if local["shared_evidence_count"]:
|
||
lines.append("shared at midnight W %s · R %s" % (
|
||
_gb(local["shared_bytes_written"]), _gb(local["shared_bytes_read"])))
|
||
if local["unallocated_evidence_count"]:
|
||
lines.append("unallocated W %s · R %s" % (
|
||
_gb(local["unallocated_bytes_written"]),
|
||
_gb(local["unallocated_bytes_read"])))
|
||
habit = local.get("habit")
|
||
if habit:
|
||
lines.append(_habit_line(
|
||
habit["active_seconds"], habit["idle_seconds"],
|
||
habit["powered_off_seconds"], habit["unknown_seconds"],
|
||
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:
|
||
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"])
|
||
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)
|