fix: follow live activity until inspection (#102)

This commit is contained in:
xavierk
2026-09-28 16:32:13 +05:30
parent 3e1ecc90e4
commit f780212a02
4 changed files with 442 additions and 60 deletions
+132 -53
View File
@@ -26,6 +26,11 @@ from textual.message import Message
from textual.widget import Widget
from textual.widgets import Input, Static, Tab, Tabs
from .activity_selection import (
HISTORY_RANGE_DEFAULT,
ActivitySelection,
IntervalIdentity,
)
from .activity_plot import VolumePoint, volume_plot
from .projection import (
@@ -109,7 +114,7 @@ def _can_render_wolf(width: int) -> bool:
# ---------------------------------------------------------------------------
_RANGE_OPTIONS = (7, 14, 30, 90)
_RANGE_DEFAULT = 14
_RANGE_DEFAULT = HISTORY_RANGE_DEFAULT
# Terminal size thresholds for constrained layout (issue #81)
_MIN_WIDTH = 80
@@ -157,9 +162,11 @@ class HistoryGraph(Widget):
}
"""
def __init__(self, **kwargs: Any) -> None:
def __init__(
self, selection: ActivitySelection | None = None, **kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.range_days: int = _RANGE_DEFAULT
self.selection = selection or ActivitySelection()
self.selected_index: int = -1
self.view_mode: str = "daily"
self.drill_day: Optional[str] = None
@@ -170,10 +177,27 @@ class HistoryGraph(Widget):
self._hourly_selected: int = -1
self._drill_unallocated_bytes: int = 0
self._on_drill: Optional[Callable[[str], None]] = None
self.measure = "written"
self._point_columns: list[int] = []
self._empty_message = "Awaiting first sample"
@property
def range_days(self) -> int:
return self.selection.history_range_days
@range_days.setter
def range_days(self, value: int) -> None:
self.selection.history_range_days = value
@property
def measure(self) -> str:
return self.selection.measure
@measure.setter
def measure(self, value: str) -> None:
if value not in ("written", "read"):
raise ValueError("unknown activity measurement: %s" % value)
self.selection.measure = value
def compose(self) -> ComposeResult:
yield Static("", id="bar-range")
yield Static("", id="bar-render")
@@ -189,7 +213,7 @@ class HistoryGraph(Widget):
) -> None:
"""Update graph with day data. on_drill(day) called on drill entry."""
self._empty_message = empty_message
selected_day = selected_date
selected_day = selected_date or self.selection.browse_date
if selected_day is None and 0 <= self.selected_index < len(self._day_data):
selected_day = self._day_data[self.selected_index].get("day")
self._all_day_data = day_data
@@ -206,15 +230,18 @@ class HistoryGraph(Widget):
None,
)
self.selected_index = matching if matching is not None else len(self._day_data) - 1
if self.selection.view != "live":
self.selection.select_history_date(
self._day_data[self.selected_index].get("day")
)
else:
self.selected_index = -1
self._refresh()
def set_hour_data(self, hour_data: List[Dict[str, Any]]) -> None:
"""Set hourly data for drill-down view."""
selected_hour = None
if 0 <= self._hourly_selected < len(self._hour_data):
selected_hour = self._hour_data[self._hourly_selected].get("hour")
selected_hour = self.selection.selected_history_hour
selected_hour = selected_hour or self._selected_history_hour()
self._hour_data = hour_data
matching = next(
(i for i, item in enumerate(hour_data) if item.get("hour") == selected_hour),
@@ -225,6 +252,7 @@ class HistoryGraph(Widget):
else:
selectable = [i for i, item in enumerate(hour_data) if not item.get("is_future")]
self._hourly_selected = selectable[-1] if selectable else -1
self.selection.select_history_hour(self._selected_history_hour())
self._refresh_hourly()
def _trim_to_range(self) -> None:
@@ -517,13 +545,17 @@ class HistoryGraph(Widget):
def _notify_selection(self) -> None:
if 0 <= self.selected_index < len(self._day_data):
self.post_message(self.DaySelected(self._day_data[self.selected_index]["day"]))
selected_day = self._day_data[self.selected_index]["day"]
self.selection.select_history_date(selected_day)
self.post_message(self.DaySelected(selected_day))
def _enter_drill(self) -> None:
if self.selected_index < 0 or self.selected_index >= len(self._day_data):
return
day = self._day_data[self.selected_index]
self.drill_day = day.get("day")
self.selection.select_history_date(self.drill_day)
self.selection.select_history_hour(None)
self._drill_unallocated_bytes = day.get("unallocated_bytes", 0)
self.view_mode = "hourly"
self._hourly_selected = -1
@@ -536,6 +568,7 @@ class HistoryGraph(Widget):
self.drill_day = None
self._hour_data = []
self._hourly_selected = -1
self.selection.select_history_hour(None)
self._refresh()
def _handle_hourly_key(self, event: Any) -> None:
@@ -551,6 +584,7 @@ class HistoryGraph(Widget):
self._hourly_selected = len(self._hour_data) - 1
else:
self._hourly_selected = max(0, self._hourly_selected - 1)
self._select_history_hour()
self._refresh_hourly()
event.stop()
elif event.key == "right":
@@ -560,9 +594,18 @@ class HistoryGraph(Widget):
self._hourly_selected = min(
len(self._hour_data) - 1, self._hourly_selected + 1
)
self._select_history_hour()
self._refresh_hourly()
event.stop()
def _select_history_hour(self) -> None:
self.selection.select_history_hour(self._selected_history_hour())
def _selected_history_hour(self) -> Optional[str]:
if 0 <= self._hourly_selected < len(self._hour_data):
return self._hour_data[self._hourly_selected].get("hour")
return None
def navigate_to_date(self, date_str: str) -> bool:
"""Navigate to a specific date, adjusting range if needed.
@@ -594,6 +637,7 @@ class HistoryGraph(Widget):
idx,
)
new_day = self._day_data[self.selected_index].get("day")
self.selection.select_history_date(new_day)
return new_day != old_day
def on_click(self, event: Any) -> None:
@@ -608,12 +652,16 @@ class HistoryGraph(Widget):
global_idx = idx
if 0 <= global_idx < len(self._day_data):
self.selected_index = global_idx
self.selection.select_history_date(
self._day_data[global_idx].get("day")
)
self._refresh()
self._notify_selection()
else:
global_idx = idx
if 0 <= global_idx < len(self._hour_data):
self._hourly_selected = global_idx
self._select_history_hour()
self._refresh_hourly()
@@ -920,11 +968,13 @@ class LiveActivityGraph(Widget):
}
"""
def __init__(self, **kwargs: Any) -> None:
def __init__(
self, selection: ActivitySelection | None = None, **kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.selection = selection or ActivitySelection()
self._data: List[Dict[str, Any]] = []
self._selected_index: int = -1
self._measure: str = "written" # "written" or "read"
self._point_columns: list[int] = []
self._empty_message = "Awaiting intervals"
@@ -936,32 +986,36 @@ class LiveActivityGraph(Widget):
@property
def measure(self) -> str:
return self._measure
return self.selection.measure
def toggle_measure(self) -> None:
"""Toggle between written and read volumes."""
self._measure = "read" if self._measure == "written" else "written"
self.selection.toggle_measure()
self._refresh()
def set_data(self, data: List[Dict[str, Any]], empty_message: str = "Awaiting intervals") -> None:
def set_data(
self,
data: List[Dict[str, Any]],
empty_message: str = "Awaiting intervals",
reconcile_selection: bool = True,
) -> None:
"""Update graph with live interval data."""
self._empty_message = empty_message
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
intervals = self._interval_ids()
if reconcile_selection:
self.selection.update_live(intervals)
self._selected_index = self.selection.selected_live_index(intervals)
self._refresh()
def _interval_ids(self) -> list[IntervalIdentity]:
return [
IntervalIdentity(item["start_ts"], item["end_ts"])
for item in self._data
]
def _current_bytes(self, item: Dict[str, Any]) -> int:
if self._measure == "written":
if self.measure == "written":
return item.get("bytes_written", 0)
return item.get("bytes_read", 0)
@@ -970,13 +1024,15 @@ class LiveActivityGraph(Widget):
if not self._data:
self.query_one("#live-render").update(Text(self._empty_message))
self.query_one("#live-legend").update("")
self.query_one("#live-readout").update("")
self.query_one("#live-readout").update(
self._expiration_notice() + self._empty_message
)
return
self._render_bars()
self._render_readout()
def _render_header(self) -> None:
label = "Writes" if self._measure == "written" else "Reads"
label = "Writes" if self.measure == "written" else "Reads"
n = len(self._data)
self.query_one("#live-header").update(
"%s · last %dh · %d intervals"
@@ -1009,18 +1065,21 @@ class LiveActivityGraph(Widget):
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]")
self.query_one("#live-readout").update(
self._expiration_notice() + "[dim]← → Select interval[/dim]"
)
return
item = self._data[self._selected_index]
if item.get("is_segment_boundary"):
self.query_one("#live-readout").update(
"%s → %s UTC · segment boundary\nW unavailable · R unavailable" % (
self._expiration_notice()
+ "%s → %s UTC · segment boundary\nW unavailable · R unavailable" % (
item.get("start_label", ""), item.get("end_label", "")))
return
bw = item.get("bytes_written", 0)
br = item.get("bytes_read", 0)
measure_val = bw if self._measure == "written" else br
measure_val = bw if self.measure == "written" else br
scale, unit = _unit_scale(measure_val) if measure_val > 0 else (1e6, "MB")
state_parts = []
@@ -1033,7 +1092,8 @@ class LiveActivityGraph(Widget):
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"
self._expiration_notice()
+ "[bold]%s → %s UTC[/bold] · W %.3f GB · R %.3f GB · %ds%s"
% (
item.get("start_label", ""),
item.get("end_label", ""),
@@ -1044,21 +1104,17 @@ class LiveActivityGraph(Widget):
)
)
def _expiration_notice(self) -> str:
if self.selection.live_interval_expired:
return "[bold yellow]Inspected interval expired; following live[/bold yellow]\n"
return ""
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
)
offset = -1 if event.key == "left" else 1 if event.key == "right" else 0
if offset:
intervals = self._interval_ids()
self.selection.move_live(intervals, offset)
self._selected_index = self.selection.selected_live_index(intervals)
self._refresh()
event.stop()
@@ -1070,7 +1126,9 @@ class LiveActivityGraph(Widget):
offset_x = event.screen_x - render.content_region.x
idx = min(range(len(self._point_columns)), key=lambda i: abs(self._point_columns[i] - offset_x))
if 0 <= idx < len(self._data):
self._selected_index = idx
intervals = self._interval_ids()
self.selection.inspect_live(intervals, intervals[idx])
self._selected_index = self.selection.selected_live_index(intervals)
self._refresh()
def on_resize(self) -> None:
@@ -1364,6 +1422,7 @@ class FenrisTuiApp(App):
**kwargs,
) -> None:
# Load user preferences before super().__init__ so theme is available
self._activity_selection = ActivitySelection()
self._prefs = load_preferences()
self._current_theme_name = self._prefs["theme"]
self._reduced_motion = self._prefs["reduced_motion"]
@@ -1383,11 +1442,24 @@ class FenrisTuiApp(App):
# Track constrained state for resize handling (issue #81)
self._is_constrained_mode = False
# Track the date the user is browsing (None = live/today view)
self._browse_date: Optional[str] = None
self._activity_view = "live"
self._zoomed_panel: Optional[str] = None
@property
def _browse_date(self) -> Optional[str]:
return self._activity_selection.browse_date
@_browse_date.setter
def _browse_date(self, date: Optional[str]) -> None:
self._activity_selection.select_history_date(date)
@property
def _activity_view(self) -> str:
return self._activity_selection.view
@_activity_view.setter
def _activity_view(self, view: str) -> None:
self._activity_selection.set_view(view)
def compose(self) -> ComposeResult:
yield Static(_IDENTITY_FULL, id="identity")
yield Static("Open with fenris (no sudo). Actions authenticate via polkit. ? Help", id="auth-notice")
@@ -1404,8 +1476,12 @@ class FenrisTuiApp(App):
"[@click=app.prev_day]← Day[/] [@click=app.next_day]Day →[/] "
"[@click=app.go_to_date]g Date[/] [@click=app.today]t Today[/] "
"[@click=app.toggle_measure]w W/R[/]", id="activity-tools")
yield LiveActivityGraph(id="live-activity")
yield HistoryGraph(id="usage-history")
yield LiveActivityGraph(
id="live-activity", selection=self._activity_selection,
)
yield HistoryGraph(
id="usage-history", selection=self._activity_selection,
)
with VerticalScroll(id="drive-panel", classes="dashboard-pane"):
yield Static("", id="drive-health")
with VerticalScroll(id="status-area"):
@@ -1569,7 +1645,10 @@ class FenrisTuiApp(App):
graph = self.query_one("#usage-history")
graph.set_data([], empty_message=message)
self.query_one("#live-activity").set_data(
[], empty_message=message if comp.store_fault or comp.newer_schema else "Awaiting intervals")
[],
empty_message=message if comp.store_fault or comp.newer_schema else "Awaiting intervals",
reconcile_selection=False,
)
# Hide local-day widget when no data
local_day_widget = self.query_one("#local-day")
local_day_widget.styles.display = "none"