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
+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: