From e12f4a574c8bb1b965ee50f4092a43766a3b0cbb Mon Sep 17 00:00:00 2001 From: xavierk Date: Fri, 18 Sep 2026 13:17:18 +0530 Subject: [PATCH] Plot live drive activity every three minutes (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- CHANGELOG.md | 2 +- README.md | 4 +- ...service-lifecycle-and-sanctioned-toggle.md | 2 +- docs/spec/acceptance-criteria.md | 2 +- docs/spec/fenris-redesign.md | 4 +- src/fenris/init_system.py | 4 +- src/fenris/status.py | 4 +- src/fenris/tui.py | 406 ++++++++++++++++-- tests/test_issue_80.py | 29 +- tests/test_live_graph.py | 324 ++++++++++++++ tests/test_status_composition.py | 16 +- tests/test_tui.py | 2 +- units/fenris-collect.timer | 2 +- units/runit/fenris-collect/run | 4 +- 14 files changed, 735 insertions(+), 70 deletions(-) create mode 100644 tests/test_live_graph.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e20c37a..949772a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ backfill releases from before this changelog. ### Changed -- Make usage-history axes, units, UTC boundaries, active 7/14/30/90-day window, gaps, partial periods, stacked write attribution, and hourly drill-down explicit; keep live graph data refreshed on the existing five-minute cadence. +- Make usage-history axes, units, UTC boundaries, active 7/14/30/90-day window, gaps, partial periods, stacked write attribution, and hourly drill-down explicit; keep live graph data refreshed on the current three-minute cadence. ## [0.3.6] - 2026-09-16 diff --git a/README.md b/README.md index 2b465ab..c83465b 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`) ## Cadence drop-ins -The default collection cadence is **5 minutes** (`OnUnitInactiveSec=5min` in the timer unit). To change it, place a systemd drop-in: +The default collection cadence is **3 minutes** (`OnUnitInactiveSec=3min` in the timer unit). To change it, place a systemd drop-in: ```bash sudo systemctl edit fenris-collect.timer @@ -223,7 +223,7 @@ sudo systemctl edit fenris-collect.timer No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key. On Void, Fenris uses its native runit service instead: its initial collection -is delayed by two minutes and later collections run five minutes after the +is delayed by two minutes and later collections run three minutes after the previous run finishes. Inspect its state and diagnostics with: ```bash diff --git a/docs/adr/0003-service-lifecycle-and-sanctioned-toggle.md b/docs/adr/0003-service-lifecycle-and-sanctioned-toggle.md index 4cd8d43..0775db4 100644 --- a/docs/adr/0003-service-lifecycle-and-sanctioned-toggle.md +++ b/docs/adr/0003-service-lifecycle-and-sanctioned-toggle.md @@ -13,7 +13,7 @@ Fenris's current single process combines daemonization, a PID file, an HTTP dash ## Decision 1. **Units.** Two system units only: `fenris-collect.timer` (`WantedBy=timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`; no listener, no UI code). The TUI and CLI are ordinary unprivileged processes and never units. There is no `/run/fenris` coordination surface: systemd serializes runs, the observation store holds state, and failures go to the journal per [ADR 0001](0001-observation-store-sqlite.md). -2. **Cadence.** Default five minutes: `OnBootSec=2min`, `OnUnitInactiveSec=5min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration. +2. **Cadence.** Default three minutes: `OnBootSec=2min`, `OnUnitInactiveSec=3min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration. 3. **Configuration.** `/etc/fenris/fenris.conf` holds exactly one key: the device selector, a stable `/dev/disk/by-id/…` path (raw nodes accepted with an instability warning), validated at collection time. The oneshot re-reads it every run, so there is no reload path to design. An invalid selector is a bounded failed run — journal plus failed unit result, retried next interval; `status` and the TUI also read the world-readable file directly and surface a `configuration error: ` fact. 4. **Entry points.** Two privileged binaries: `/usr/libexec/fenris/fenris-collect` (device interrogation and store writes; the unit's `ExecStart`) and `/usr/libexec/fenris/fenris-monitor` (fixed operations `enable` and `disable` with optional `--now`, plus the collect trigger, monitoring-period bookkeeping, and `baseline set`/`baseline clear` persistence for the CLI-validated endurance baseline; the only binary the polkit policy authorizes). One unprivileged `fenris` for humans: no arguments opens the TUI; subcommands (`status`, `sample`, `monitor pause`, `monitor resume`) are the CLI. 5. **Sanctioned toggle.** Pause = `disable --now`; Resume = `enable --now`; both executed by `fenris-monitor`, which performs the systemctl operation and the monitoring-period bookkeeping in one step, under polkit action `com.bongbetic.fenris.monitor` (`auth_admin`, covering the collect trigger too). Root invokes the helpers directly; where no polkit agent exists the operation fails cleanly and prints the root equivalent. This amends the research's direct-systemctl toggle: a period boundary cannot be recorded by systemctl, so the toggle must be Fenris's own fixed operation. diff --git a/docs/spec/acceptance-criteria.md b/docs/spec/acceptance-criteria.md index e377111..1d740d3 100644 --- a/docs/spec/acceptance-criteria.md +++ b/docs/spec/acceptance-criteria.md @@ -49,7 +49,7 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/ ## Collector lifecycle and privilege boundaries (ADR 0003) - **LC-1** (P) Exactly two system units exist — `fenris-collect.timer` (`timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`); the TUI and CLI are ordinary unprivileged processes and never units. -- **LC-2** (P) Timer defaults ship as `OnBootSec=2min`, `OnUnitInactiveSec=5min`, `AccuracySec=30s`, `Persistent=no`, `TimeoutStartSec=90s`; cadence changes are documented drop-ins and no interval key exists in configuration. +- **LC-2** (P) Timer defaults ship as `OnBootSec=2min`, `OnUnitInactiveSec=3min`, `AccuracySec=30s`, `Persistent=no`, `TimeoutStartSec=90s`; cadence changes are documented drop-ins and no interval key exists in configuration. - **LC-3** (P) A hung device interrogation fails visibly within `TimeoutStartSec=90s` as a bounded failed run retried next interval. - **LC-4** (A/P) `/etc/fenris/fenris.conf` holds exactly the device selector (stable `/dev/disk/by-id/…` path; raw nodes warned), re-read every run; an invalid selector is a bounded failed run surfaced as `configuration error: ` in `status` and the TUI. - **LC-5** (P) Two privileged binaries ship at `/usr/libexec/fenris/fenris-collect` and `/usr/libexec/fenris/fenris-monitor`; the unprivileged `fenris` wrapper opens the TUI with no arguments. diff --git a/docs/spec/fenris-redesign.md b/docs/spec/fenris-redesign.md index 3f26f02..a3beb4f 100644 --- a/docs/spec/fenris-redesign.md +++ b/docs/spec/fenris-redesign.md @@ -16,7 +16,7 @@ Every constant is defined once, in the section named below; other sections cite, | Constant | Value | Defined in | |---|---|---| -| Collection cadence (default) | 5 min (`OnUnitInactiveSec`) | §8.2 | +| Collection cadence (default) | 3 min (`OnUnitInactiveSec`) | §8.2 | | First-boot delay | 2 min (`OnBootSec`) | §8.2 | | Timer accuracy window | 30 s (`AccuracySec`) | §8.2 | | Collection-run timeout | 90 s (`TimeoutStartSec`) | §8.2 | @@ -479,7 +479,7 @@ Two privileged binaries — `/usr/libexec/fenris/fenris-collect` (device interro Constants defined once, consumed by TUI and CLI alike; the grade derives from the **newest sample timestamp**, never a stored flag: -- **fresh** — newest sample within 2 × cadence + `AccuracySec` + 60 s (11.5 min at default cadence); +- **fresh** — newest sample within 2 × cadence + `AccuracySec` + 60 s (7.5 min at default cadence); - **missed** — between that and 48 h (a contributing fact); - **stale** — ≥ 48 h, matching the §6.7 evidence gate; - **empty store** — *"no observations yet"* with an enable hint. diff --git a/src/fenris/init_system.py b/src/fenris/init_system.py index aa84e6d..12ff191 100644 --- a/src/fenris/init_system.py +++ b/src/fenris/init_system.py @@ -10,13 +10,13 @@ responsibility shared by the privileged control path and read-only status composition. Runit service layout: - /etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 300 }) + /etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 180 }) /etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/ /var/service/fenris-collect — symlink to enable /etc/sv/fenris-collect/down — marker for dormant install Runit guarantees: - - Completion-relative 5-minute cadence (sleep 300 after each collect) + - Completion-relative 3-minute cadence (sleep 180 after each collect) - Initial 2-minute boot delay (sleep 120 before first collect) - Bounded execution (90s timeout via timeout(1)) - No catch-up (service sleeps fixed interval, no Persistent= flag) diff --git a/src/fenris/status.py b/src/fenris/status.py index 3e868bf..147004e 100644 --- a/src/fenris/status.py +++ b/src/fenris/status.py @@ -35,9 +35,9 @@ from .init_system import ( # Freshness constants (§8.9, §8.2) # --------------------------------------------------------------------------- -CADENCE_DEFAULT_S = 300 # 5 min +CADENCE_DEFAULT_S = 180 # 3 min ACCURACY_SEC = 30 -FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 690 s +FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 450 s STALENESS_THRESHOLD_S = 48 * 3600 # 48 h diff --git a/src/fenris/tui.py b/src/fenris/tui.py index 7e2b249..75b2717 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -51,6 +51,7 @@ from .status_composition import ( STATUS_POLL_INTERVAL_S, ) from .control import MONITOR_HELPER, MonitorError, run_monitor +from .derive import _parse_ts from .preferences import load_preferences, save_preferences from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors @@ -62,7 +63,7 @@ from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors _RESUME_HINT = "r Resume — enable monitoring and future boots" _ACTION_LEGEND = ( "p Pause · " + _RESUME_HINT + "\n" - "c Collect now · t Theme · m Motion · d Disclosures · ? Help" + "c Collect now · t Today · m Motion · d Disclosures · ? Help" ) def _format_remaining(seconds: float) -> str: @@ -396,7 +397,7 @@ class DailyBarGraph(Widget): bar_h = max(1, min(_BAR_HEIGHT, render_height - 2)) n = len(visible) - scale, unit = self._unit_scale(max_bytes) + scale, unit = _unit_scale(max_bytes) lines: List[str] = [ "Writes (%s) · %s → %s UTC" % ( unit, visible[0].get("day", ""), visible[-1].get("day", ""), @@ -532,7 +533,7 @@ class DailyBarGraph(Widget): render_height = self.query_one("#bar-render").region.height bar_h = max(1, min(_BAR_HEIGHT, render_height - 2)) n = len(visible) - scale, unit = self._unit_scale(max_bytes) + scale, unit = _unit_scale(max_bytes) lines: List[str] = [ "Writes (%s) · %s:00 → %s:00 UTC" % ( @@ -899,6 +900,320 @@ def _query_hourly_graph_data( return result +def _unit_scale(max_bytes: int) -> tuple[float, str]: + if max_bytes >= 1_000_000_000_000: + return 1e12, "TB" + if max_bytes >= 1_000_000_000: + return 1e9, "GB" + return 1e6, "MB" + + +# --------------------------------------------------------------------------- +# Live activity graph data (issue #91) +# --------------------------------------------------------------------------- + +LIVE_WINDOW_H = 3 # Rolling window for the live graph + + +def _query_live_graph_data( + conn: sqlite3.Connection, + now: datetime, +) -> 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. + + Returns one dict per interval, ordered oldest-first. + """ + window_start = (now - timedelta(hours=LIVE_WINDOW_H)).isoformat() + cursor = conn.execute( + "SELECT ts, bytes_written, bytes_read, segment_id " + "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 + + # Format timestamps in local-ish readable form (HH:MM) + start_label = prev_ts.strftime("%H:%M") + end_label = curr_ts.strftime("%H:%M") + + results.append({ + "start_ts": rows[i - 1][0], + "end_ts": rows[i][0], + "start_label": start_label, + "end_label": end_label, + "bytes_written": bw_delta, + "bytes_read": br_delta, + "elapsed_s": elapsed_s, + "is_gap": is_gap, + "is_zero": is_zero, + "is_segment_boundary": is_segment_boundary, + }) + + return results + + +# --------------------------------------------------------------------------- +# Live activity graph widget (issue #91) +# --------------------------------------------------------------------------- + +class LiveActivityGraph(Widget): + """Rolling last-three-hours interval volume graph with read/write toggle. + + Renders interval written (or read) volumes as vertical bars using block + glyphs. Supports keyboard and mouse navigation, a read/write toggle + (w key), and selected-point inspection showing timestamps, timezone, + measured volume, and evidence state. Reads only committed + observation-store evidence. + """ + + can_focus = True + can_focus_children = False + + CSS = """ + LiveActivityGraph { + height: 100%; + width: 100%; + layout: vertical; + } + #live-header { + height: 1; + width: 100%; + } + #live-render { + height: 1fr; + width: 100%; + overflow: hidden; + } + #live-legend { + height: 1; + width: 100%; + } + #live-readout { + height: 2; + width: 100%; + } + """ + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._data: List[Dict[str, Any]] = [] + self._selected_index: int = -1 + self._measure: str = "written" # "written" or "read" + + def compose(self) -> ComposeResult: + yield Static("", id="live-header") + yield Static("", id="live-render") + yield Static("", id="live-legend") + yield Static("", id="live-readout") + + @property + def measure(self) -> str: + return self._measure + + def toggle_measure(self) -> None: + """Toggle between written and read volumes.""" + self._measure = "read" if self._measure == "written" else "written" + self._refresh() + + def set_data(self, data: List[Dict[str, Any]]) -> None: + """Update graph with live interval data.""" + prev_selected_end = None + if 0 <= self._selected_index < len(self._data): + prev_selected_end = self._data[self._selected_index].get("end_ts") + + self._data = data + if prev_selected_end is not None: + self._selected_index = next( + (i for i, d in enumerate(data) if d.get("end_ts") == prev_selected_end), + len(data) - 1 if data else -1, + ) + else: + self._selected_index = len(data) - 1 if data else -1 + self._refresh() + + def _current_bytes(self, item: Dict[str, Any]) -> int: + if self._measure == "written": + return item.get("bytes_written", 0) + return item.get("bytes_read", 0) + + def _refresh(self) -> None: + self._render_header() + if not self._data: + self.query_one("#live-render").update("[dim]Awaiting intervals[/dim]") + self.query_one("#live-legend").update("") + self.query_one("#live-readout").update("") + return + self._render_bars() + self._render_legend() + self._render_readout() + + def _render_header(self) -> None: + label = "Writes" if self._measure == "written" else "Reads" + toggle_hint = "w Toggle W/R" + n = len(self._data) + self.query_one("#live-header").update( + "Live %s · last %dh · %d intervals · %s" + % (label.lower(), LIVE_WINDOW_H, n, toggle_hint) + ) + + def _render_bars(self) -> None: + max_bytes = max( + (self._current_bytes(d) for d in self._data), default=0 + ) or 1 + + render_height = self.query_one("#live-render").region.height + bar_h = max(1, min(_BAR_HEIGHT, render_height - 2)) + n = len(self._data) + scale, unit = _unit_scale(max_bytes) + + measure_label = "Writes" if self._measure == "written" else "Reads" + lines: List[str] = [ + "%s (%s) · %s → %s" % ( + measure_label, unit, + self._data[0].get("start_label", ""), + self._data[-1].get("end_label", ""), + ) + ] + for row in range(bar_h, 0, -1): + if row == bar_h: + axis = "%7.2f " % (max_bytes / scale) + elif row == (bar_h + 1) // 2 and row != 1: + axis = "%7.2f " % (max_bytes / scale / 2) + elif row == 1: + axis = "%7s " % "0" + else: + axis = " " + line = axis + threshold = (row / bar_h) * max_bytes + for i, item in enumerate(self._data): + bw = self._current_bytes(item) + if item.get("is_gap") or item.get("is_segment_boundary"): + glyph = _GLYPH_GAP if row <= 2 else " " + elif bw == 0 and item.get("is_zero"): + glyph = _GLYPH_ZERO if row == 1 else " " + elif bw == 0: + glyph = " " + elif threshold <= bw: + glyph = _GLYPH_ALLOCATED + else: + glyph = " " + line += glyph * _BAR_WIDTH + if i < n - 1: + line += " " * _BAR_SPACING + lines.append(line) + + # Time labels + label_line = "Time " + for i, item in enumerate(self._data): + label = item.get("start_label", "")[-2:] + if i == self._selected_index: + label = _GLYPH_SELECTED + label[-1:] + label_line += label + if i < n - 1: + label_line += " " * _BAR_SPACING + lines.append(label_line) + + self.query_one("#live-render").update("\n".join(lines)) + + def _render_legend(self) -> None: + self.query_one("#live-legend").update( + "%s Volume %s Gap %s Zero" % ( + _GLYPH_ALLOCATED, _GLYPH_GAP, _GLYPH_ZERO, + ) + ) + + def _render_readout(self) -> None: + if self._selected_index < 0 or self._selected_index >= len(self._data): + self.query_one("#live-readout").update("[dim]← → Select interval[/dim]") + return + + item = self._data[self._selected_index] + bw = item.get("bytes_written", 0) + br = item.get("bytes_read", 0) + measure_val = bw if self._measure == "written" else br + scale, unit = _unit_scale(measure_val) if measure_val > 0 else (1e6, "MB") + + state_parts = [] + if item.get("is_gap"): + state_parts.append("gap") + if item.get("is_segment_boundary"): + state_parts.append("segment boundary") + if item.get("is_zero"): + state_parts.append("measured zero") + state = " · " + ", ".join(state_parts) if state_parts else "" + + self.query_one("#live-readout").update( + "[bold]%s → %s UTC[/bold] · W %.3f GB · R %.3f GB · %ds%s" + % ( + item.get("start_label", ""), + item.get("end_label", ""), + bw / 1e9, + br / 1e9, + item.get("elapsed_s", 0), + state, + ) + ) + + def on_key(self, event: Any) -> None: + if event.key == "left": + if self._selected_index < 0: + self._selected_index = len(self._data) - 1 + else: + self._selected_index = max(0, self._selected_index - 1) + self._refresh() + event.stop() + elif event.key == "right": + if self._selected_index < 0: + self._selected_index = 0 + else: + self._selected_index = min( + len(self._data) - 1, self._selected_index + 1 + ) + self._refresh() + event.stop() + elif event.key == "w": + self.toggle_measure() + event.stop() + + def on_click(self, event: Any) -> None: + render = self.query_one("#live-render") + offset_x = event.x - render.region.x + bar_total = _BAR_WIDTH + _BAR_SPACING + idx = max(0, offset_x - 8) // bar_total + if 0 <= idx < len(self._data): + self._selected_index = idx + self._refresh() + + def on_resize(self) -> None: + self._refresh() + + # --------------------------------------------------------------------------- # Data queries for TUI regions # --------------------------------------------------------------------------- @@ -1018,7 +1333,9 @@ class HelpScreen(ModalScreen[None]): + _ACTION_LEGEND + "\n" "q Quit the dashboard — monitoring continues\n" "Tab Focus the graph · ← → Select · 1-4 Change range\n" - "Enter Show hours · Esc Return to days\n\n" + "Enter Show hours · Esc Return to days\n" + "w Toggle W/R on live graph\n" + "Theme changed via preferences file\n\n" "[dim]↑ ↓ Scroll · Esc Close[/dim]", id="help-text", ) @@ -1040,46 +1357,49 @@ class FenrisTuiApp(App): CSS = """ #main-grid { layout: grid; - grid-size: 2 4; + grid-size: 2 5; grid-columns: 3fr 2fr; - grid-rows: auto 10 auto 3; + grid-rows: auto 10 8 auto 3; height: auto; } #main-grid.local-day { - grid-size: 2 5; - grid-rows: auto 10 auto auto 3; + grid-size: 2 6; + grid-rows: auto 10 8 auto auto 3; } #main-grid.paused { - grid-size: 2 5; - grid-rows: auto auto 10 auto 3; + grid-size: 2 6; + grid-rows: auto auto 10 8 auto 3; } #main-grid.paused.local-day { - grid-size: 2 6; - grid-rows: auto auto 10 auto auto 3; + grid-size: 2 7; + grid-rows: auto auto 10 8 auto auto 3; } #main-grid.constrained { - grid-size: 1 4; + grid-size: 1 5; grid-columns: 1fr; - grid-rows: auto auto auto auto; + grid-rows: auto auto auto auto auto; } #main-grid.constrained.local-day { - grid-size: 1 5; - grid-rows: auto auto auto auto auto; - } - #main-grid.constrained.paused { - grid-size: 1 5; - grid-rows: auto auto auto auto auto; - } - #main-grid.constrained.paused.local-day { grid-size: 1 6; grid-rows: auto auto auto auto auto auto; } + #main-grid.constrained.paused { + grid-size: 1 6; + grid-rows: auto auto auto auto auto auto; + } + #main-grid.constrained.paused.local-day { + grid-size: 1 7; + grid-rows: auto auto auto auto auto auto auto; + } #main-grid.constrained #usage-history { display: none; } #main-grid.constrained #constrained-summary { display: block; } + #main-grid.constrained #live-activity { + display: none; + } #constrained-summary { display: none; border: round #555555; @@ -1087,6 +1407,13 @@ class FenrisTuiApp(App): height: auto; min-height: 3; } + #live-activity { + column-span: 2; + border: round #555555; + padding: 0 1; + height: auto; + min-height: 5; + } #local-day { column-span: 2; display: none; @@ -1123,8 +1450,9 @@ class FenrisTuiApp(App): Binding("r", "resume", "Resume", show=False), Binding("c", "collect", "Collect now", show=False), Binding("d", "disclose", "Disclosures", show=False), - Binding("t", "toggle_theme", "Theme", show=False), + Binding("t", "today", "Today", show=False), Binding("m", "toggle_motion", "Motion", show=False), + Binding("w", "toggle_measure", "W/R", show=False), Binding("?", "help", "Help", show=False), Binding("q", "quit", "Quit", show=False), ] @@ -1164,6 +1492,7 @@ class FenrisTuiApp(App): yield Static("", id="paused-banner") yield DailyBarGraph(id="usage-history", classes="pane") yield Static("", id="constrained-summary", classes="pane") + yield LiveActivityGraph(id="live-activity") yield Static("", id="drive-health", classes="pane") yield Static("", id="local-day", classes="pane") yield Static("", id="service-strip", classes="pane") @@ -1179,6 +1508,7 @@ class FenrisTuiApp(App): self.query_one("#usage-history").border_title = "Usage history" self.query_one("#constrained-summary").border_title = "Usage history" self.query_one("#drive-health").border_title = "Drive" + self.query_one("#live-activity").border_title = "Live activity" self.query_one("#local-day").border_title = "Local day" self.query_one("#service-strip").border_title = "Monitoring and actions" self._refresh_timer = self.set_interval( @@ -1386,6 +1716,11 @@ class FenrisTuiApp(App): ) self.query_one("#drive-health").update(health_text) + # --- Live activity graph (issue #91) --- + live_data = _query_live_graph_data(conn, self._clock_now) + live_graph = self.query_one("#live-activity") + live_graph.set_data(live_data) + # --- Local-day totals (issue #90) --- self._render_local_day(conn) @@ -1527,19 +1862,18 @@ class FenrisTuiApp(App): # --- Actions --- - def action_toggle_theme(self) -> None: - """Cycle through Amber → Nord → High Contrast presets (issue #80).""" - names = sorted(THEME_NAMES) - try: - idx = names.index(self._current_theme_name) - except ValueError: - idx = 0 - self._current_theme_name = names[(idx + 1) % len(names)] - self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-") - save_preferences( - theme=self._current_theme_name, - reduced_motion=self._reduced_motion, - ) + def action_toggle_measure(self) -> None: + """Toggle between written and read volumes on the live graph.""" + live = self.query_one("#live-activity") + live.toggle_measure() + self._refresh() + + def action_today(self) -> None: + """Return to today's live view from any historical browsing.""" + # Reset the daily graph to today's data + graph = self.query_one("#usage-history") + if graph.view_mode == "hourly": + graph._exit_drill() self._refresh() def action_toggle_motion(self) -> None: diff --git a/tests/test_issue_80.py b/tests/test_issue_80.py index da53ebb..e129f90 100644 --- a/tests/test_issue_80.py +++ b/tests/test_issue_80.py @@ -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): diff --git a/tests/test_live_graph.py b/tests/test_live_graph.py new file mode 100644 index 0000000..aa3dca7 --- /dev/null +++ b/tests/test_live_graph.py @@ -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") diff --git a/tests/test_status_composition.py b/tests/test_status_composition.py index 2d6a8b5..10b678b 100644 --- a/tests/test_status_composition.py +++ b/tests/test_status_composition.py @@ -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" diff --git a/tests/test_tui.py b/tests/test_tui.py index 1f95238..a10d3d4 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -376,7 +376,7 @@ class TestDenseScreen: main_grid = app.query_one("#main-grid") assert str(main_grid.styles.layout) == "" 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 diff --git a/units/fenris-collect.timer b/units/fenris-collect.timer index 407ee0b..d430fe2 100644 --- a/units/fenris-collect.timer +++ b/units/fenris-collect.timer @@ -4,7 +4,7 @@ Documentation=https://git.bongbetic.com/xavierk/Fenris [Timer] OnBootSec=2min -OnUnitInactiveSec=5min +OnUnitInactiveSec=3min AccuracySec=30s Persistent=no diff --git a/units/runit/fenris-collect/run b/units/runit/fenris-collect/run index a506ddf..267c3da 100755 --- a/units/runit/fenris-collect/run +++ b/units/runit/fenris-collect/run @@ -9,7 +9,7 @@ # # Guarantees (must match systemd timer semantics): # - Initial 2-minute boot delay (sleep 120 before first collect) -# - Completion-relative 5-minute cadence (sleep 300 after collect) +# - Completion-relative 3-minute cadence (sleep 180 after collect) # - Bounded execution (timeout 90s on each collect) # - No catch-up (fixed sleep interval, no Persistent=) # - Serialized runs (runsv does not restart until exit) @@ -20,7 +20,7 @@ set -eu COLLECT_TIMEOUT=90 BOOT_DELAY=120 -CADENCE=300 +CADENCE=180 LOCK_FILE=/var/lib/fenris/fenris-collect.lock # Ensure lock directory exists