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:
+70
-20
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user