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:
@@ -50,3 +50,25 @@ def test_many_intervals_and_selection_fit_small_plot_without_losing_points():
|
||||
assert len(columns) == len(points)
|
||||
assert text.plain.splitlines()[-2][columns[100]] == "▼"
|
||||
assert all(len(row) == 32 for row in text.plain.splitlines())
|
||||
|
||||
|
||||
def test_secondary_series_shares_the_axis_and_colours_its_own_dots():
|
||||
colors = get_graph_colors("chalktone")
|
||||
writes = [VolumePoint(t, v, str(t)) for t, v in ((0, 10), (1, 10), (2, 10))]
|
||||
reads = [VolumePoint(t, v, str(t)) for t, v in ((0, 100), (1, 100), (2, 100))]
|
||||
text, columns, unit = volume_plot(
|
||||
writes, 48, 10, -1, colors, secondary=reads)
|
||||
assert "100.00" in text.plain # the larger read volume sets the ceiling
|
||||
styles = {span.style for span in text.spans}
|
||||
assert colors["allocated"] in styles and colors["secondary"] in styles
|
||||
|
||||
|
||||
def test_secondary_gap_does_not_draw_or_connect():
|
||||
colors = get_graph_colors("chalktone")
|
||||
points = [VolumePoint(t, 5, str(t)) for t in range(3)]
|
||||
reads = [VolumePoint(0, 9, "0"), VolumePoint(1, None, "1", "gap"),
|
||||
VolumePoint(2, 9, "2")]
|
||||
text, columns, _ = volume_plot(points, 48, 10, -1, colors, secondary=reads)
|
||||
rows = text.plain.splitlines()[:-2]
|
||||
top = next(r for r in rows if any(0x2801 <= ord(c) <= 0x28ff for c in r))
|
||||
assert all(c == " " for c in top[columns[0] + 1:columns[-1]])
|
||||
|
||||
@@ -13,6 +13,9 @@ import pytest
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.activity_readout import (
|
||||
critical_warning_badge,
|
||||
decode_critical_warning,
|
||||
diagnostics_text,
|
||||
day_readout_text,
|
||||
hour_readout_text,
|
||||
local_day_text,
|
||||
@@ -76,13 +79,13 @@ def _kolkata_day(conn, local_date, **kwargs):
|
||||
|
||||
|
||||
def _hour(conn, hour, written=0, read=0, active=0, idle=0, off=0, unknown=0,
|
||||
samples=1, coverage=1.0, tmin=None, tmax=None):
|
||||
samples=1, coverage=1.0, tmin=None, tmax=None, tavg=None):
|
||||
conn.execute(
|
||||
"INSERT INTO hour_observations (hour, active_seconds, idle_seconds, "
|
||||
"powered_off_seconds, unknown_seconds, bytes_written_delta, "
|
||||
"bytes_read_delta, temperature_min, temperature_max, sample_count, "
|
||||
"coverage) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(hour, active, idle, off, unknown, written, read, tmin, tmax,
|
||||
"bytes_read_delta, temperature_min, temperature_max, temperature_avg, "
|
||||
"sample_count, coverage) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(hour, active, idle, off, unknown, written, read, tmin, tmax, tavg,
|
||||
samples, coverage),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -300,3 +303,182 @@ class TestTimeFormatting:
|
||||
b = datetime(2026, 9, 30, 19, 33, tzinfo=timezone.utc)
|
||||
assert utc_range_label(a, b) == "19:30 → 19:33 UTC"
|
||||
assert utc_stamp_label(a) == "2026-09-30 19:30 UTC"
|
||||
|
||||
|
||||
def _counters(conn, ts, poh, cycles, unsafe, media, spare, critical=0):
|
||||
conn.execute(
|
||||
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||
"percentage_used, bytes_written, bytes_read, power_on_hours, "
|
||||
"power_cycles, unsafe_shutdowns, media_errors, available_spare, "
|
||||
"segment_id, temperature_c, critical_warning) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(ts, "/dev/nvme0n1", 1, 1, 1, 1, 1, poh, cycles, unsafe, media, spare,
|
||||
1, 35, critical),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
class TestCriticalWarning:
|
||||
def test_zero_decodes_to_nothing_and_each_bit_to_its_meaning(self):
|
||||
assert decode_critical_warning(0) == ()
|
||||
assert decode_critical_warning(None) == ()
|
||||
assert decode_critical_warning(0x01) == ("available spare below threshold",)
|
||||
assert decode_critical_warning(0x05) == (
|
||||
"available spare below threshold", "reliability degraded",
|
||||
)
|
||||
assert "temperature outside threshold" in decode_critical_warning(0x02)
|
||||
assert "media in read-only mode" in decode_critical_warning(0x08)
|
||||
assert "volatile memory backup failed" in decode_critical_warning(0x10)
|
||||
assert "persistent memory region read-only" in decode_critical_warning(0x20)
|
||||
assert decode_critical_warning(0x40) == ("unrecognised bits 0x40",)
|
||||
|
||||
def test_health_carries_latest_critical_warning_and_badge_follows_it(self, conn, kolkata):
|
||||
_counters(conn, "2026-09-30T19:00:00+00:00", 10, 1, 0, 0, 100, critical=0)
|
||||
assert critical_warning_badge(
|
||||
readout(conn, ActivitySelection(), NOW).health) == ""
|
||||
_counters(conn, "2026-09-30T19:30:00+00:00", 11, 1, 0, 0, 100, critical=0x04)
|
||||
health = readout(conn, ActivitySelection(), NOW).health
|
||||
assert health["critical_warning"] == 0x04
|
||||
badge = critical_warning_badge(health)
|
||||
assert "0x04" in badge and "reliability degraded" in badge
|
||||
|
||||
def test_no_samples_means_no_badge(self, conn, kolkata):
|
||||
assert critical_warning_badge(
|
||||
readout(conn, ActivitySelection(), NOW).health) == ""
|
||||
|
||||
|
||||
class TestDiagnosticsReadout:
|
||||
def test_diagnostics_are_absent_until_selected(self, conn, kolkata):
|
||||
assert readout(conn, ActivitySelection(), NOW).diagnostics is None
|
||||
selection = ActivitySelection()
|
||||
selection.toggle_diagnostics()
|
||||
assert selection.diagnostics
|
||||
assert readout(conn, selection, NOW).diagnostics is not None
|
||||
selection.toggle_diagnostics()
|
||||
assert not selection.diagnostics
|
||||
|
||||
def test_live_counters_have_deltas_over_the_live_window(self, conn, kolkata):
|
||||
_counters(conn, "2026-09-30T16:00:00+00:00", 90, 5, 1, 0, 100) # before window
|
||||
_counters(conn, "2026-09-30T18:00:00+00:00", 100, 6, 1, 0, 100)
|
||||
_counters(conn, "2026-09-30T19:50:00+00:00", 103, 8, 3, 2, 98, critical=0x02)
|
||||
selection = ActivitySelection()
|
||||
selection.toggle_diagnostics()
|
||||
diag = readout(conn, selection, NOW).diagnostics
|
||||
assert diag["window_label"] == "last 3h"
|
||||
assert diag["critical_warning"] == 0x02
|
||||
assert diag["critical_warning_flags"] == ("temperature outside threshold",)
|
||||
counters = diag["counters"]
|
||||
assert counters["power_on_hours"] == {"value": 103, "delta": 13}
|
||||
assert counters["power_cycles"] == {"value": 8, "delta": 3}
|
||||
assert counters["unsafe_shutdowns"] == {"value": 3, "delta": 2}
|
||||
assert counters["media_errors"] == {"value": 2, "delta": 2}
|
||||
assert counters["available_spare"] == {"value": 98, "delta": -2}
|
||||
|
||||
def test_no_delta_across_a_controller_segment_change(self, conn, kolkata):
|
||||
_counters(conn, "2026-09-30T18:00:00+00:00", 100, 6, 1, 0, 100)
|
||||
_counters(conn, "2026-09-30T19:50:00+00:00", 5, 1, 0, 0, 100)
|
||||
conn.execute("UPDATE samples SET segment_id = 2 WHERE ts > '2026-09-30T19'")
|
||||
conn.commit()
|
||||
selection = ActivitySelection()
|
||||
selection.toggle_diagnostics()
|
||||
counters = readout(conn, selection, NOW).diagnostics["counters"]
|
||||
assert counters["power_on_hours"] == {"value": 5, "delta": None}
|
||||
|
||||
def test_single_sample_has_no_delta(self, conn, kolkata):
|
||||
_counters(conn, "2026-09-30T19:50:00+00:00", 103, 8, 3, 2, 98)
|
||||
selection = ActivitySelection()
|
||||
selection.toggle_diagnostics()
|
||||
counters = readout(conn, selection, NOW).diagnostics["counters"]
|
||||
assert counters["power_on_hours"] == {"value": 103, "delta": None}
|
||||
|
||||
def test_day_selection_measures_deltas_across_that_local_day(self, conn, kolkata):
|
||||
selection = TestHours()._selected_day(conn)
|
||||
_counters(conn, "2026-09-28T18:00:00+00:00", 50, 2, 0, 0, 100)
|
||||
_counters(conn, "2026-09-29T06:00:00+00:00", 56, 2, 1, 0, 100)
|
||||
_counters(conn, "2026-09-29T20:00:00+00:00", 99, 9, 9, 9, 50) # after the day
|
||||
selection.toggle_diagnostics()
|
||||
diag = readout(conn, selection, NOW).diagnostics
|
||||
assert diag["window_label"] == "day 2026-09-29"
|
||||
assert diag["counters"]["power_on_hours"] == {"value": 56, "delta": 6}
|
||||
assert diag["counters"]["unsafe_shutdowns"]["delta"] == 1
|
||||
|
||||
def test_hours_carry_temperature_average(self, conn, kolkata):
|
||||
selection = TestHours()._selected_day(conn)
|
||||
_hour(conn, "2026-09-29T00:00:00+00:00", active=1800, idle=1800,
|
||||
tmin=31, tavg=36.5, tmax=44)
|
||||
hour = next(h for h in readout(conn, selection, NOW).hours
|
||||
if h["hour"] == "2026-09-29T00:00:00+00:00")
|
||||
assert (hour["temperature_min"], hour["temperature_avg"],
|
||||
hour["temperature_max"]) == (31, 36.5, 44)
|
||||
|
||||
def test_history_days_carry_split_and_temperature_range(self, conn, kolkata):
|
||||
_kolkata_day(conn, "2026-09-29")
|
||||
_hour(conn, "2026-09-29T00:00:00+00:00", active=1800, idle=1200, off=600,
|
||||
tmin=30, tavg=34.0, tmax=40)
|
||||
_hour(conn, "2026-09-29T01:00:00+00:00", active=0, idle=3000, unknown=600,
|
||||
tmin=28, tavg=30.0, tmax=33)
|
||||
selection = ActivitySelection()
|
||||
selection.set_view("history")
|
||||
selection.toggle_diagnostics()
|
||||
days = {d["day"]: d for d in readout(conn, selection, NOW).diagnostics["days"]}
|
||||
day = days["2026-09-29"]
|
||||
assert (day["active_seconds"], day["idle_seconds"],
|
||||
day["powered_off_seconds"], day["unknown_seconds"]) == (1800, 4200, 600, 600)
|
||||
assert (day["temperature_min"], day["temperature_max"]) == (28, 40)
|
||||
assert day["temperature_avg"] == pytest.approx(32.0)
|
||||
assert "2026-09-30" not in days # no evidence, no invented split
|
||||
|
||||
|
||||
class TestDiagnosticsText:
|
||||
def _diag(self, conn, selection):
|
||||
selection.toggle_diagnostics()
|
||||
return readout(conn, selection, NOW)
|
||||
|
||||
def test_panel_shows_decoded_warning_counters_and_deltas(self, conn, kolkata):
|
||||
_counters(conn, "2026-09-30T18:00:00+00:00", 100, 6, 1, 0, 100)
|
||||
_counters(conn, "2026-09-30T19:50:00+00:00", 103, 8, 3, 2, 98, critical=0x05)
|
||||
text = diagnostics_text(self._diag(conn, ActivitySelection()))
|
||||
assert "last 3h" in text
|
||||
assert "0x05" in text and "reliability degraded" in text
|
||||
assert "Power-on hours" in text and "103" in text and "+3" in text
|
||||
assert "Unsafe shutdowns" in text and "+2" in text
|
||||
assert "Available spare" in text and "98%" in text and "-2" in text
|
||||
|
||||
def test_no_warning_says_none_and_missing_delta_says_so(self, conn, kolkata):
|
||||
_counters(conn, "2026-09-30T19:50:00+00:00", 103, 8, 3, 2, 98)
|
||||
text = diagnostics_text(self._diag(conn, ActivitySelection()))
|
||||
assert "Critical warning none" in text
|
||||
assert "Δ n/a" in text
|
||||
|
||||
def test_day_view_lists_each_hour_with_temperature_split_and_coverage(self, conn, kolkata):
|
||||
selection = TestHours()._selected_day(conn)
|
||||
_hour(conn, "2026-09-29T00:00:00+00:00", active=1800, idle=1200, off=300,
|
||||
unknown=300, tmin=31, tavg=36.4, tmax=44, coverage=0.75)
|
||||
text = diagnostics_text(self._diag(conn, selection))
|
||||
row = next(line for line in text.splitlines() if line.startswith("05:30"))
|
||||
assert "31/36/44°C" in row
|
||||
assert "A30m I20m Off5m ?5m" in row
|
||||
assert "75%" in row
|
||||
assert any("gap" in line for line in text.splitlines()
|
||||
if line.startswith("06:30"))
|
||||
|
||||
def test_history_view_lists_each_day_with_split_and_temperature(self, conn, kolkata):
|
||||
_kolkata_day(conn, "2026-09-29")
|
||||
_hour(conn, "2026-09-29T00:00:00+00:00", active=7200, idle=3600, off=0,
|
||||
tmin=30, tavg=34.0, tmax=40)
|
||||
selection = ActivitySelection()
|
||||
selection.set_view("history")
|
||||
text = diagnostics_text(self._diag(conn, selection))
|
||||
row = next(line for line in text.splitlines() if line.startswith("09-29"))
|
||||
assert "A2h 00m" in row and "I1h 00m" in row and "30/34/40°C" in row
|
||||
|
||||
def test_live_view_summarises_interval_temperature(self, conn, kolkata):
|
||||
for index, (minute, temp) in enumerate(((0, 30), (3, 36), (6, 42))):
|
||||
conn.execute(
|
||||
"INSERT INTO samples (ts, device, bytes_written, bytes_read, "
|
||||
"segment_id, temperature_c) VALUES (?,?,?,?,?,?)",
|
||||
("2026-09-30T19:%02d:00+00:00" % (50 + minute), "/dev/n", index, index, 1, temp),
|
||||
)
|
||||
conn.commit()
|
||||
text = diagnostics_text(self._diag(conn, ActivitySelection()))
|
||||
assert "36/39/42°C" in text and "2 intervals" in text # interval ends
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Diagnostics panel on the dashboard (issue #109): toggle, render, badge."""
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from test_dashboard_design import dashboard, visible # noqa: F401 (fixtures)
|
||||
|
||||
from fenris.activity_plot import VolumePoint
|
||||
from fenris.tui import FenrisTuiApp, HelpScreen, HistoryGraph
|
||||
|
||||
|
||||
def _store(app):
|
||||
return sqlite3.connect(app.store_path)
|
||||
|
||||
|
||||
def _set_latest_warning(app, value):
|
||||
conn = _store(app)
|
||||
conn.execute(
|
||||
"UPDATE samples SET critical_warning = ?, power_on_hours = id, "
|
||||
"unsafe_shutdowns = id / 20 WHERE 1", (value,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
async def _settle(app, pilot):
|
||||
app.on_refresh_tick()
|
||||
await pilot.pause()
|
||||
|
||||
|
||||
def _text(app, selector):
|
||||
return str(app.query_one(selector).render())
|
||||
|
||||
|
||||
def test_i_is_a_declared_binding_and_d_stays_disclosures():
|
||||
keys = {b.key: b.action for b in FenrisTuiApp.BINDINGS}
|
||||
assert keys["i"] == "toggle_diagnostics"
|
||||
assert keys["d"] == "disclose"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("size", [(140, 44), (100, 30)])
|
||||
async def test_i_toggles_the_diagnostics_panel_and_hides_the_drive_pane(dashboard, size): # noqa: F811
|
||||
app = dashboard
|
||||
_set_latest_warning(app, 0)
|
||||
async with app.run_test(size=size) as pilot:
|
||||
await _settle(app, pilot)
|
||||
assert not app.query_one("#diagnostics-panel").display
|
||||
assert app.query_one("#drive-panel").display
|
||||
await pilot.press("i")
|
||||
await _settle(app, pilot)
|
||||
assert app.query_one("#diagnostics-panel").display
|
||||
assert not app.query_one("#drive-panel").display
|
||||
body = _text(app, "#diagnostics")
|
||||
assert "Diagnostics" in body and "Critical warning none" in body
|
||||
assert "Power-on hours 61 Δ +60" in body
|
||||
await pilot.press("i")
|
||||
await _settle(app, pilot)
|
||||
assert not app.query_one("#diagnostics-panel").display
|
||||
assert app.query_one("#drive-panel").display
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_footer_and_help_list_the_diagnostics_key(dashboard): # noqa: F811
|
||||
app = dashboard
|
||||
async with app.run_test(size=(140, 40)) as pilot:
|
||||
await _settle(app, pilot)
|
||||
assert "i Diagnostics" in _text(app, "#action-rail")
|
||||
await pilot.press("?")
|
||||
assert isinstance(app.screen, HelpScreen)
|
||||
assert "Diagnostics" in str(app.screen.query_one("#help-text").render())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_follow_the_activity_view(dashboard): # noqa: F811
|
||||
app = dashboard
|
||||
async with app.run_test(size=(140, 44)) as pilot:
|
||||
await _settle(app, pilot)
|
||||
await pilot.press("i")
|
||||
await _settle(app, pilot)
|
||||
assert "last 3h" in _text(app, "#diagnostics")
|
||||
await pilot.press("v") # Day
|
||||
await _settle(app, pilot)
|
||||
body = _text(app, "#diagnostics")
|
||||
assert "· day 20" in body and "Hours ·" in body
|
||||
await pilot.press("v") # History
|
||||
await _settle(app, pilot)
|
||||
assert "last 14 days" in _text(app, "#diagnostics")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critical_warning_badge_shows_in_the_outlook_without_the_panel(dashboard): # noqa: F811
|
||||
app = dashboard
|
||||
_set_latest_warning(app, 0x04)
|
||||
async with app.run_test(size=(140, 44)) as pilot:
|
||||
await _settle(app, pilot)
|
||||
assert not app.query_one("#diagnostics-panel").display
|
||||
band = _text(app, "#headline-band")
|
||||
assert "0x04" in band and "reliability degraded" in band
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_badge_when_the_drive_reports_no_warning(dashboard): # noqa: F811
|
||||
app = dashboard
|
||||
_set_latest_warning(app, 0)
|
||||
async with app.run_test(size=(140, 44)) as pilot:
|
||||
await _settle(app, pilot)
|
||||
assert "critical warning" not in _text(app, "#headline-band").lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_work_in_constrained_mode(dashboard): # noqa: F811
|
||||
app = dashboard
|
||||
_set_latest_warning(app, 0x01)
|
||||
async with app.run_test(size=(60, 20)) as pilot:
|
||||
await _settle(app, pilot)
|
||||
assert app._is_constrained_mode
|
||||
await pilot.press("i")
|
||||
await _settle(app, pilot)
|
||||
assert app.query_one("#diagnostics-panel").display
|
||||
body = _text(app, "#diagnostics")
|
||||
assert "available spare below threshold" in body
|
||||
assert "Power-on hours" in body
|
||||
await pilot.press("i")
|
||||
await _settle(app, pilot)
|
||||
assert app.query_one("#drive-panel").display
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggling_while_zoomed_restores_the_grid_and_keeps_focus(dashboard): # noqa: F811
|
||||
app = dashboard
|
||||
async with app.run_test(size=(140, 44)) as pilot:
|
||||
await _settle(app, pilot)
|
||||
await pilot.press("z")
|
||||
await pilot.press("i")
|
||||
await _settle(app, pilot)
|
||||
assert app._zoomed_panel is None
|
||||
assert app.focused is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_bars_draw_reads_alongside_writes_only_with_diagnostics(dashboard): # noqa: F811
|
||||
app = dashboard
|
||||
async with app.run_test(size=(140, 44)) as pilot:
|
||||
await _settle(app, pilot)
|
||||
await pilot.press("v", "v") # History
|
||||
await _settle(app, pilot)
|
||||
graph = app.query_one(HistoryGraph)
|
||||
assert "Reads" not in _text(app, "#bar-legend")
|
||||
await pilot.press("i")
|
||||
await _settle(app, pilot)
|
||||
legend = _text(app, "#bar-legend")
|
||||
assert "Writes" in legend and "Reads" in legend
|
||||
await pilot.press("i")
|
||||
await _settle(app, pilot)
|
||||
assert "Reads" not in _text(app, "#bar-legend")
|
||||
Reference in New Issue
Block a user