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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
@@ -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: <reason>` 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.
+1 -1
View File
@@ -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: <reason>` 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.
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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
+370 -36
View File
@@ -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:
+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
+1 -1
View File
@@ -4,7 +4,7 @@ Documentation=https://git.bongbetic.com/xavierk/Fenris
[Timer]
OnBootSec=2min
OnUnitInactiveSec=5min
OnUnitInactiveSec=3min
AccuracySec=30s
Persistent=no
+2 -2
View File
@@ -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