Plot live drive activity every three minutes (#91)

- Change default collection cadence from 5 minutes to 3 minutes
  (CADENCE_DEFAULT_S=180, systemd OnUnitInactiveSec=3min,
  runit CADENCE=180)
- Update freshness threshold to 450s (2×180 + AccuracySec + 60)
- Add _query_live_graph_data(): queries raw samples from the last
  3 hours and computes interval byte deltas with actual timestamps
- Add LiveActivityGraph widget: vertical bar chart of interval
  volumes with read/write toggle (w key), arrow key inspection,
  and click support
- Wire live graph into TUI layout (full-width row between daily
  graph and drive health), refresh cycle, and CSS grid
- Replace t theme binding with t today/live binding; theme
  selection via preferences file
- Add w binding for read/write toggle on live graph
- Update action legend, help screen, and grid layout for new
  live-activity row
- Add 20 tests covering cadence constants, live query, widget
  rendering, toggle, and TUI integration
- Update all cadence documentation (README, ADR 0003, acceptance
  criteria LC-2, fenris-redesign constants table, CHANGELOG)
This commit is contained in:
xavierk
2026-09-18 13:17:18 +05:30
parent 4f884b4b73
commit e12f4a574c
14 changed files with 735 additions and 70 deletions
+18 -11
View File
@@ -171,8 +171,8 @@ class TestKeyBindings:
assert "m" in binding_keys
@pytest.mark.asyncio
async def test_t_cycles_through_themes(self, tmp_path):
"""Pressing t cycles through the three presets."""
async def test_t_goes_to_today(self, tmp_path):
"""Pressing t returns to today's live view (issue #91)."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
@@ -182,12 +182,17 @@ class TestKeyBindings:
# Start at amber
assert app.theme == "fenris-amber"
# Press t to cycle
# Enter drill mode on the graph
graph = app.query_one("#usage-history")
graph.view_mode = "hourly"
graph.drill_day = "2026-09-30"
# Press t to go back to today
await pilot.press("t")
await pilot.pause()
# Should be nord or high_contrast now
assert app.theme in ("fenris-nord", "fenris-high-contrast")
# Should be back in daily mode
assert graph.view_mode == "daily"
@pytest.mark.asyncio
async def test_m_toggles_reduced_motion(self, tmp_path):
@@ -226,21 +231,23 @@ class TestPersistence:
async def test_theme_survives_restart(self, tmp_path):
"""Theme preference persists across TUI restart."""
from fenris.tui import FenrisTuiApp
from fenris.preferences import save_preferences
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# First run: change theme
# First run: change theme via preferences API
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app1.run_test() as pilot:
await pilot.press("t")
await pilot.pause()
theme_after_t = app1.theme
assert theme_after_t != "fenris-amber"
save_preferences(theme="nord", reduced_motion=False)
app1._current_theme_name = "nord"
app1.theme = "fenris-nord"
theme_after = app1.theme
assert theme_after == "fenris-nord"
# Second run: theme should persist
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app2.run_test() as pilot:
assert app2.theme == theme_after_t
assert app2.theme == theme_after
@pytest.mark.asyncio
async def test_reduced_motion_survives_restart(self, tmp_path):
+324
View File
@@ -0,0 +1,324 @@
"""Tests for live activity graph (issue #91).
Covers:
- Live interval volume query from raw samples
- LiveActivityGraph widget rendering, toggle, and inspection
- Three-minute cadence constants
- TUI integration with live graph
"""
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.monitoring_periods import ensure_period_open
from fenris.tui import (
FenrisTuiApp,
LiveActivityGraph,
_query_live_graph_data,
LIVE_WINDOW_H,
_query_daily_graph_data,
_RANGE_OPTIONS,
)
from fenris.status import CADENCE_DEFAULT_S, FRESH_THRESHOLD_S, ACCURACY_SEC
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _clock(year=2026, month=9, day=30, hour=12):
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
identity_key="nqn.test", degraded=False,
mn="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1",
"0x144d", "0x144d", "pcie"),
)
conn.commit()
def _insert_sample(conn, ts, bw=512000000000, br=256000000000,
segment_id=None, pu=5, device="/dev/nvme0n1"):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(ts, device, 1000000, 500000, pu, bw, br, 8765, segment_id),
)
conn.commit()
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
# ---------------------------------------------------------------------------
# Cadence constants (issue #91 AC1)
# ---------------------------------------------------------------------------
class TestCadenceConstants:
def test_cadence_default_is_3_minutes(self):
"""Default collection cadence is 3 minutes (180 s)."""
assert CADENCE_DEFAULT_S == 180
def test_fresh_threshold_uses_3min_cadence(self):
"""Fresh threshold = 2 × 3min + AccuracySec + 60s = 450s."""
expected = 2 * 180 + ACCURACY_SEC + 60
assert FRESH_THRESHOLD_S == expected
def test_live_window_is_3_hours(self):
"""Live graph window is 3 hours."""
assert LIVE_WINDOW_H == 3
# ---------------------------------------------------------------------------
# Live graph query (issue #91 AC2-5)
# ---------------------------------------------------------------------------
class TestQueryLiveData:
def test_empty_store_returns_empty(self, tmp_path):
"""No samples → empty list."""
conn = init_store(tmp_path / "test.db")
now = _clock()
result = _query_live_graph_data(conn, now)
assert result == []
conn.close()
def test_single_sample_returns_empty(self, tmp_path):
"""One sample (no pair) → empty list."""
conn = init_store(tmp_path / "test.db")
now = _clock()
_insert_sample(conn, now.isoformat())
result = _query_live_graph_data(conn, now)
assert result == []
conn.close()
def test_two_samples_in_window(self, tmp_path):
"""Two samples within 3h window produce one interval."""
conn = init_store(tmp_path / "test.db")
now = _clock()
t1 = (now - timedelta(minutes=3)).isoformat()
t2 = (now - timedelta(minutes=0)).isoformat()
_insert_sample(conn, t1, bw=1000, br=500)
_insert_sample(conn, t2, bw=1500, br=700)
result = _query_live_graph_data(conn, now)
assert len(result) == 1
assert result[0]["bytes_written"] == 500
assert result[0]["bytes_read"] == 200
assert result[0]["elapsed_s"] == 180
assert result[0]["is_gap"] is False
assert result[0]["is_zero"] is False
conn.close()
def test_samples_outside_window_ignored(self, tmp_path):
"""Samples older than 3h are excluded."""
conn = init_store(tmp_path / "test.db")
now = _clock()
old = (now - timedelta(hours=4)).isoformat()
recent = (now - timedelta(minutes=1)).isoformat()
_insert_sample(conn, old, bw=1000, br=500)
_insert_sample(conn, recent, bw=1500, br=700)
result = _query_live_graph_data(conn, now)
assert len(result) == 0
conn.close()
def test_gap_detection(self, tmp_path):
"""Interval > 3× cadence is flagged as a gap."""
conn = init_store(tmp_path / "test.db")
now = _clock()
t1 = (now - timedelta(minutes=13)).isoformat()
t2 = (now - timedelta(minutes=3)).isoformat()
_insert_sample(conn, t1, bw=1000, br=500)
_insert_sample(conn, t2, bw=1500, br=700)
result = _query_live_graph_data(conn, now)
assert len(result) == 1
assert result[0]["is_gap"] is True
conn.close()
def test_zero_interval(self, tmp_path):
"""No byte delta → is_zero flag."""
conn = init_store(tmp_path / "test.db")
now = _clock()
t1 = (now - timedelta(minutes=3)).isoformat()
t2 = (now - timedelta(minutes=0)).isoformat()
_insert_sample(conn, t1, bw=1000, br=500)
_insert_sample(conn, t2, bw=1000, br=500)
result = _query_live_graph_data(conn, now)
assert len(result) == 1
assert result[0]["bytes_written"] == 0
assert result[0]["bytes_read"] == 0
assert result[0]["is_zero"] is True
conn.close()
def test_segment_boundary_resets_counters(self, tmp_path):
"""Counter discontinuity across segments → zero delta."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn, identity_key="key_a")
_insert_segment(conn, opened_at="2026-09-30T11:00:00+00:00",
identity_key="key_b")
now = _clock()
t1 = (now - timedelta(minutes=6)).isoformat()
t2 = (now - timedelta(minutes=3)).isoformat()
_insert_sample(conn, t1, bw=1000, br=500, segment_id=1)
_insert_sample(conn, t2, bw=500, br=200, segment_id=2)
result = _query_live_graph_data(conn, now)
assert len(result) == 1
assert result[0]["bytes_written"] == 0
assert result[0]["bytes_read"] == 0
assert result[0]["is_segment_boundary"] is True
conn.close()
def test_actual_timestamps_used(self, tmp_path):
"""Points use actual sample timestamps, not 3-min spacing."""
conn = init_store(tmp_path / "test.db")
now = _clock()
t1 = (now - timedelta(minutes=5)).isoformat()
t2 = (now - timedelta(minutes=1)).isoformat()
_insert_sample(conn, t1, bw=1000, br=500)
_insert_sample(conn, t2, bw=2000, br=800)
result = _query_live_graph_data(conn, now)
assert len(result) == 1
assert result[0]["start_ts"] == t1
assert result[0]["end_ts"] == t2
assert result[0]["elapsed_s"] == 240
conn.close()
def test_multiple_intervals_ordered_oldest_first(self, tmp_path):
"""Multiple intervals are returned in chronological order."""
conn = init_store(tmp_path / "test.db")
now = _clock()
for i in range(5):
ts = (now - timedelta(minutes=15 - i * 3)).isoformat()
_insert_sample(conn, ts, bw=1000 * (i + 1), br=500 * (i + 1))
result = _query_live_graph_data(conn, now)
assert len(result) == 4
for i in range(len(result) - 1):
assert result[i]["end_ts"] <= result[i + 1]["start_ts"]
conn.close()
# ---------------------------------------------------------------------------
# LiveActivityGraph widget (issue #91 AC3)
# ---------------------------------------------------------------------------
class TestLiveActivityGraph:
@pytest.mark.asyncio
async def test_toggle_measure(self, tmp_path):
"""Toggle switches between written and read."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
assert graph.measure == "written"
graph.toggle_measure()
assert graph.measure == "read"
graph.toggle_measure()
assert graph.measure == "written"
@pytest.mark.asyncio
async def test_empty_data_renders_awaiting(self, tmp_path):
"""Empty data shows awaiting message."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
graph.set_data([])
await pilot.pause()
render = str(graph.query_one("#live-render").render())
assert "Awaiting intervals" in render
@pytest.mark.asyncio
async def test_data_renders_header(self, tmp_path):
"""Data updates the header with interval count."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
data = [
{
"start_ts": "2026-09-30T11:57:00+00:00",
"end_ts": "2026-09-30T12:00:00+00:00",
"start_label": "11:57",
"end_label": "12:00",
"bytes_written": 500000000,
"bytes_read": 200000000,
"elapsed_s": 180,
"is_gap": False,
"is_zero": False,
"is_segment_boundary": False,
},
]
graph.set_data(data)
await pilot.pause()
header = str(graph.query_one("#live-header").render())
assert "1 intervals" in header
assert "w Toggle W/R" in header
# ---------------------------------------------------------------------------
# TUI integration (issue #91 AC2, AC6-9)
# ---------------------------------------------------------------------------
class TestTUILiveIntegration:
@pytest.mark.asyncio
async def test_live_graph_exists_in_compose(self, tmp_path):
"""Live activity graph is composed in the TUI."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
assert app.query_one("#live-activity") is not None
assert isinstance(app.query_one("#live-activity"), LiveActivityGraph)
@pytest.mark.asyncio
async def test_live_graph_has_border_title(self, tmp_path):
"""Live activity graph has a border title."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
live = app.query_one("#live-activity")
assert live.border_title == "Live activity"
@pytest.mark.asyncio
async def test_today_action_resets_drill(self, tmp_path):
"""Action today returns from hourly drill to daily view."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
graph.view_mode = "hourly"
graph.drill_day = "2026-09-30"
app.action_today()
await pilot.pause()
assert graph.view_mode == "daily"
@pytest.mark.asyncio
async def test_toggle_measure_binding(self, tmp_path):
"""The w key toggles the live graph measure."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
assert graph.measure == "written"
await pilot.press("w")
await pilot.pause()
assert graph.measure == "read"
@pytest.mark.asyncio
async def test_constrained_mode_hides_live_graph(self, tmp_path):
"""Constrained terminal hides the live graph."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(70, 20)) as pilot:
await pilot.pause()
main_grid = app.query_one("#main-grid")
assert main_grid.has_class("constrained")
+8 -8
View File
@@ -870,7 +870,7 @@ class TestRefreshTiming:
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=10)).isoformat()
ts = (now - timedelta(minutes=4)).isoformat()
_insert_sample(conn, ts)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
@@ -878,27 +878,27 @@ class TestRefreshTiming:
svc = {
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 600,
"last_collect_ok": True, "last_collect_age_s": 240,
"last_collect_reason": None,
}
# Sample is 10 min old (600s) → fresh (within 690s threshold)
# Sample is 4 min old (240s) → fresh (within 450s threshold)
comp1 = compose_status(conn, svc, now, store_fault=None, newer_schema=None)
assert comp1.freshness == "fresh"
assert comp1.state == StatusState.MONITORING
# At now + 1 min → sample is 11 min old (660s) → still fresh
now2 = now + timedelta(minutes=1)
# At now + 3 min → sample is 7 min old (420s) → still fresh
now2 = now + timedelta(minutes=3)
comp2 = compose_status(conn, svc, now2, store_fault=None, newer_schema=None)
assert comp2.freshness == "fresh"
# At now + 2 min → sample is 12 min old (720s) → missed (past 690s)
now3 = now + timedelta(minutes=2)
# At now + 5 min → sample is 9 min old (540s) → missed (past 450s)
now3 = now + timedelta(minutes=5)
comp3 = compose_status(conn, svc, now3, store_fault=None, newer_schema=None)
assert comp3.freshness == "missed"
assert comp3.state == StatusState.MONITORING
# At now + 49 hours → sample is 49h 10min old → stale
# At now + 49 hours → sample is 49h 4min old → stale
now4 = now + timedelta(hours=49)
comp4 = compose_status(conn, svc, now4, store_fault=None, newer_schema=None)
assert comp4.freshness == "stale"
+1 -1
View File
@@ -376,7 +376,7 @@ class TestDenseScreen:
main_grid = app.query_one("#main-grid")
assert str(main_grid.styles.layout) == "<grid>"
assert main_grid.has_class("paused")
assert len(main_grid.styles.grid_rows) == 5
assert len(main_grid.styles.grid_rows) == 6
banner = str(paused_banner.render()).lower()
assert "monitoring: paused — deliberate disable" in banner
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner