fix: follow live activity until inspection (#102)
This commit is contained in:
+132
-53
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user