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:
+370
-36
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user