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
+70 -20
View File
@@ -34,7 +34,9 @@ from textual.widgets import Input, Static, Tab, Tabs
from .activity_readout import (
LIVE_WINDOW_H,
critical_warning_badge,
day_readout_text,
diagnostics_text,
hour_readout_text,
interval_readout_text,
local_day_text,
@@ -460,19 +462,19 @@ class HistoryGraph(Widget):
)
self.query_one("#bar-range").update(label)
def _render_bars(self) -> None:
def _day_points(self, measure: str) -> list[VolumePoint]:
points = []
for index, day in enumerate(self._day_data):
if "activity_state" in day:
volume = day.get("total_read") if self.measure == "read" else day.get("total_bytes")
volume = day.get("total_read") if measure == "read" else day.get("total_bytes")
unallocated = day.get(
"shared_bytes_read" if self.measure == "read" else "shared_bytes_written",
"shared_bytes_read" if measure == "read" else "shared_bytes_written",
0,
)
else:
volume = day.get("total_read", 0) if self.measure == "read" else day.get("total_bytes", 0)
volume = day.get("total_read", 0) if measure == "read" else day.get("total_bytes", 0)
unallocated = day.get(
"unallocated_read" if self.measure == "read" else "unallocated_bytes",
"unallocated_read" if measure == "read" else "unallocated_bytes",
0,
)
state = "unallocated" if unallocated else "gap" if day.get("is_gap") else (
@@ -480,19 +482,35 @@ class HistoryGraph(Widget):
)
points.append(VolumePoint(index, None if state == "gap" else volume,
day.get("day", "")[5:], state))
self._plot(points, self.selected_index)
return points
def _plot(self, points: list[VolumePoint], selected: int) -> None:
def _render_bars(self) -> None:
self._plot(
self._day_points(self.measure), self.selected_index,
self._day_points("written" if self.measure == "read" else "read"),
)
def _plot(
self, points: list[VolumePoint], selected: int,
other: list[VolumePoint] | None = None,
) -> None:
"""Draw *points*; with Diagnostics on, *other* is drawn beside them."""
both = self.selection.diagnostics and other is not None
target = self.query_one("#bar-render")
colors = get_graph_colors(getattr(self.app, "_current_theme_name", "chalktone"))
chart, self._point_columns, unit = volume_plot(
points, target.content_size.width or 40, target.content_size.height or 8,
selected, colors,
selected, colors, other if both else None,
)
target.update(chart)
names = ("Reads", "Writes") if self.measure == "read" else ("Writes", "Reads")
series = (
"[%s]%s[/] · [%s]%s[/]" % (
colors["allocated"], names[0], colors["secondary"], names[1])
if both else names[0]
)
self.query_one("#bar-legend").update(
("Reads" if self.measure == "read" else "Writes")
+ " (%s) · ? Gap · · Zero · ~ Partial · u Unalloc" % unit
series + " (%s) · ? Gap · · Zero · ~ Partial · u Unalloc" % unit
)
def _render_readout(self) -> None:
@@ -530,15 +548,21 @@ class HistoryGraph(Widget):
self._show_constrained_hourly_summary()
return
points = []
for index, hour in enumerate(self._hour_data):
state = "future" if hour.get("is_future") else (
"gap" if hour.get("is_gap") else "partial" if hour.get("is_partial") else "measured"
)
volume = hour.get("bytes_read" if self.measure == "read" else "bytes_written", 0)
points.append(VolumePoint(index, None if state in ("future", "gap") else volume,
hour.get("local_label", ""), state))
self._plot(points, self._hourly_selected)
def hour_points(measure: str) -> list[VolumePoint]:
points = []
for index, hour in enumerate(self._hour_data):
state = "future" if hour.get("is_future") else (
"gap" if hour.get("is_gap") else "partial" if hour.get("is_partial") else "measured"
)
volume = hour.get("bytes_read" if measure == "read" else "bytes_written", 0)
points.append(VolumePoint(index, None if state in ("future", "gap") else volume,
hour.get("local_label", ""), state))
return points
self._plot(
hour_points(self.measure), self._hourly_selected,
hour_points("written" if self.measure == "read" else "read"),
)
self._render_hourly_readout()
@@ -1241,6 +1265,7 @@ class FenrisTuiApp(App):
border-title-color: $accent;
}
#activity-panel { row-span: 2; height: 100%; }
#diagnostics-panel { display: none; }
#headline-band, #drive-health { height: auto; }
#activity-tabs { height: 1; margin: 0; }
#activity-tabs Underline { display: none; }
@@ -1267,6 +1292,7 @@ class FenrisTuiApp(App):
height: auto; min-height: 0;
}
#main-grid.constrained #endurance-panel { max-height: 16; }
#main-grid.constrained #diagnostics-panel { max-height: 20; }
#main-grid.constrained.zoomed .zoom-target { height: 24; }
#confirm-text, #disc-text, #help-text { padding: 1 2; }
"""
@@ -1277,6 +1303,7 @@ class FenrisTuiApp(App):
Binding("c", "collect", "Collect"),
Binding("b", "baseline", "Rated TBW"),
Binding("d", "disclose", "Disclosures"),
Binding("i", "toggle_diagnostics", "Diagnostics"),
Binding("z", "toggle_zoom", "Zoom"),
Binding("escape", "exit_zoom", "Restore zoom", show=False),
Binding("v", "next_view", "View"),
@@ -1324,6 +1351,7 @@ class FenrisTuiApp(App):
self._is_constrained_mode = False
self._zoomed_panel: Optional[str] = None
self._outlook_badge = ""
@property
def _browse_date(self) -> Optional[str]:
@@ -1367,6 +1395,8 @@ class FenrisTuiApp(App):
)
with VerticalScroll(id="drive-panel", classes="dashboard-pane"):
yield Static("", id="drive-health")
with VerticalScroll(id="diagnostics-panel", classes="dashboard-pane"):
yield Static("", id="diagnostics")
with VerticalScroll(id="status-area"):
yield Static("", id="service-strip")
yield Static("", id="action-rail")
@@ -1376,6 +1406,7 @@ class FenrisTuiApp(App):
self.query_one("#endurance-panel").border_title = "Endurance outlook"
self.query_one("#activity-panel").border_title = "Drive activity"
self.query_one("#drive-panel").border_title = "Drive"
self.query_one("#diagnostics-panel").border_title = "Diagnostics"
self.query_one("#live-activity").border_title = "Live activity"
# Tab visits panels only: scroll hosts and the view tabs stay out of the order.
for selector in ("#dashboard-scroll", "#status-area", "#activity-tabs"):
@@ -1494,6 +1525,8 @@ class FenrisTuiApp(App):
self._refresh()
def _render_headline(self, body: str = "") -> None:
if self._outlook_badge:
body = self._outlook_badge + "\n" + body
self.query_one("#identity").update(_IDENTITY_FULL if _can_render_wolf(self.size.width) else _IDENTITY_FALLBACK)
self.query_one("#auth-notice").display = self._show_auth_notice
self.query_one("#headline-band").update(body)
@@ -1535,6 +1568,8 @@ class FenrisTuiApp(App):
def _render_empty_or_fault(self, comp: StatusComposition) -> None:
"""Render empty history or the shared store-fault classification."""
self._outlook_badge = ""
self.query_one("#diagnostics").update("Diagnostics unavailable: no observations to read")
self._show_baseline_banner(not (comp.newer_schema or comp.store_fault))
if comp.newer_schema or comp.store_fault:
message = comp.explanation[:1].upper() + comp.explanation[1:]
@@ -1571,6 +1606,8 @@ class FenrisTuiApp(App):
def _render_all_regions(self, conn: sqlite3.Connection, comp: StatusComposition) -> None:
"""Render all four regions from live store data."""
activity = readout(conn, self._activity_selection, self._clock_now)
self._outlook_badge = critical_warning_badge(activity.health)
# --- Headline band (§7.2) ---
if comp.sample_count == 0 and comp.day_count == 0:
self._show_baseline_banner(
@@ -1596,7 +1633,6 @@ class FenrisTuiApp(App):
self._render_headline("[bold]No projection available[/bold]")
# --- Activity panel: live and historical volume plots ---
activity = readout(conn, self._activity_selection, self._clock_now)
graph = self.query_one("#usage-history")
graph.set_data(
list(activity.days), on_drill=self._on_graph_drill,
@@ -1627,6 +1663,7 @@ class FenrisTuiApp(App):
health["written_tb"],
)
self.query_one("#drive-health").update(health_text)
self.query_one("#diagnostics").update(diagnostics_text(activity))
# --- Live activity graph (issue #91) ---
self.query_one("#live-activity").set_data(list(activity.live))
@@ -1694,6 +1731,7 @@ class FenrisTuiApp(App):
activity = readout(conn, self._activity_selection, self._clock_now)
graph.set_hour_data(list(activity.hours))
self._render_local_day(activity.local_date, activity.local_day)
self.query_one("#diagnostics").update(diagnostics_text(activity))
def _format_headline(self, proj: ProjectionResult) -> str:
"""Format the lifespan headline (spec §6.11)."""
@@ -1884,6 +1922,18 @@ class FenrisTuiApp(App):
"""Show disclosures (spec §6.11, CI-4)."""
self.push_screen(DisclosuresScreen())
def action_toggle_diagnostics(self) -> None:
"""Swap the Drive pane for the Diagnostics panel and back."""
self.action_exit_zoom()
showing = self._activity_selection.toggle_diagnostics()
self.query_one("#diagnostics-panel").display = showing
self.query_one("#drive-panel").display = not showing
hidden = self.query_one("#drive-panel" if showing else "#diagnostics-panel")
if self.focused is None or hidden in self.focused.ancestors_with_self:
self.query_one("#live-activity" if self._activity_view == "live" else "#usage-history").focus(
scroll_visible=not self._is_constrained_mode)
self._refresh()
def action_help(self) -> None:
"""Show keyboard controls and sudo guidance."""
self.push_screen(HelpScreen())