+251
-83
@@ -108,12 +108,13 @@ def _can_render_wolf(width: int) -> bool:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GLYPH_ALLOCATED = "\u2588" # \u2588 full block
|
||||
_GLYPH_UNALLOCATED = "\u2593" # \u2593 dark shade
|
||||
_GLYPH_UNALLOCATED = "\u2592" # \u2592 medium shade
|
||||
_GLYPH_GAP = "\u2591" # \u2591 light shade
|
||||
_GLYPH_ZERO = "\u00b7" # \u00b7 middle dot
|
||||
_GLYPH_PARTIAL = "\u258c" # \u258c left half block
|
||||
_GLYPH_PARTIAL = "\u2504" # \u2504 partial-period cap
|
||||
_GLYPH_SELECTED = "\u25bc" # \u25bc selection marker
|
||||
|
||||
_RANGE_OPTIONS = (7, 14, 28, 90)
|
||||
_RANGE_OPTIONS = (7, 14, 30, 90)
|
||||
_RANGE_DEFAULT = 14
|
||||
_BAR_HEIGHT = 6
|
||||
_BAR_WIDTH = 2
|
||||
@@ -132,7 +133,7 @@ class DailyBarGraph(Widget):
|
||||
"""Interactive daily writes bar graph with hourly drill-down.
|
||||
|
||||
Renders writes-only daily bars using block glyphs, supports keyboard
|
||||
and mouse navigation, range switching (7/14/28/90 days), day selection,
|
||||
and mouse navigation, range switching (7/14/30/90 days), day selection,
|
||||
and hourly drill-down. No plotting dependency (issue #75 AC5).
|
||||
"""
|
||||
|
||||
@@ -159,7 +160,7 @@ class DailyBarGraph(Widget):
|
||||
width: 100%;
|
||||
}
|
||||
#bar-readout {
|
||||
height: 3;
|
||||
height: 2;
|
||||
width: 100%;
|
||||
}
|
||||
"""
|
||||
@@ -175,6 +176,8 @@ class DailyBarGraph(Widget):
|
||||
self._hour_data: List[Dict[str, Any]] = []
|
||||
self._max_bytes: int = 0
|
||||
self._hourly_selected: int = -1
|
||||
self._drill_unallocated_bytes: int = 0
|
||||
self._visible_start: int = 0
|
||||
self._on_drill: Optional[Callable[[str], None]] = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
@@ -189,6 +192,9 @@ class DailyBarGraph(Widget):
|
||||
on_drill: Any = None,
|
||||
) -> None:
|
||||
"""Update graph with day data. on_drill(day) called on drill entry."""
|
||||
selected_day = None
|
||||
if 0 <= self.selected_index < len(self._day_data):
|
||||
selected_day = self._day_data[self.selected_index].get("day")
|
||||
self._all_day_data = day_data
|
||||
self._on_drill = on_drill
|
||||
self._trim_to_range()
|
||||
@@ -196,12 +202,32 @@ class DailyBarGraph(Widget):
|
||||
self.drill_day = None
|
||||
self._hour_data = []
|
||||
self._hourly_selected = -1
|
||||
if self._day_data:
|
||||
matching = next(
|
||||
(i for i, item in enumerate(self._day_data)
|
||||
if item.get("day") == selected_day),
|
||||
None,
|
||||
)
|
||||
self.selected_index = matching if matching is not None else len(self._day_data) - 1
|
||||
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")
|
||||
self._hour_data = hour_data
|
||||
self._hourly_selected = -1
|
||||
matching = next(
|
||||
(i for i, item in enumerate(hour_data) if item.get("hour") == selected_hour),
|
||||
None,
|
||||
)
|
||||
if matching is not None:
|
||||
self._hourly_selected = matching
|
||||
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._refresh_hourly()
|
||||
|
||||
def _trim_to_range(self) -> None:
|
||||
@@ -210,10 +236,8 @@ class DailyBarGraph(Widget):
|
||||
if len(self._all_day_data) > self.range_days
|
||||
else list(self._all_day_data)
|
||||
)
|
||||
# Bar height scales to allocated bytes only; unallocated are shown
|
||||
# separately so cross-day unknowns never inflate a day bar (AC3).
|
||||
self._max_bytes = max(
|
||||
(d.get("allocated_bytes", 0) for d in self._day_data), default=0
|
||||
(d.get("total_bytes", 0) for d in self._day_data), default=0
|
||||
)
|
||||
|
||||
def _refresh(self) -> None:
|
||||
@@ -229,7 +253,9 @@ class DailyBarGraph(Widget):
|
||||
self._render_readout()
|
||||
|
||||
def _show_empty(self) -> None:
|
||||
self.query_one("#bar-range").update("[dim]Usage history[/dim]")
|
||||
self.query_one("#bar-range").update(
|
||||
"[dim]Usage history · %d days · UTC[/dim]" % self.range_days
|
||||
)
|
||||
self.query_one("#bar-render").update("[dim]Awaiting first sample[/dim]")
|
||||
self.query_one("#bar-legend").update("")
|
||||
self.query_one("#bar-readout").update("")
|
||||
@@ -251,7 +277,7 @@ class DailyBarGraph(Widget):
|
||||
|
||||
def _show_constrained_summary(self) -> None:
|
||||
"""Textual fallback for terminals below 80×24."""
|
||||
self.query_one("#bar-range").update("[dim]Usage history[/dim]")
|
||||
self._render_range()
|
||||
if not self._day_data:
|
||||
self.query_one("#bar-render").update("[dim]Graph needs ≥80×24[/dim]")
|
||||
self.query_one("#bar-legend").update("")
|
||||
@@ -279,7 +305,7 @@ class DailyBarGraph(Widget):
|
||||
% (day.get("local_label", ""), day.get("total_bytes", 0) / 1e9)
|
||||
)
|
||||
else:
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 Select[/dim]")
|
||||
self.query_one("#bar-readout").update("[dim]No selectable day[/dim]")
|
||||
|
||||
def _show_constrained_hourly_summary(self) -> None:
|
||||
"""Textual fallback for hourly view when terminal is too small."""
|
||||
@@ -312,48 +338,88 @@ class DailyBarGraph(Widget):
|
||||
|
||||
def _render_range(self) -> None:
|
||||
parts = []
|
||||
for r in _RANGE_OPTIONS:
|
||||
for key, r in enumerate(_RANGE_OPTIONS, start=1):
|
||||
label = "%d: %dd" % (key, r)
|
||||
if r == self.range_days:
|
||||
parts.append("[bold]%d[/bold]" % r)
|
||||
parts.append("[reverse bold]\\[%s][/reverse bold]" % label)
|
||||
else:
|
||||
parts.append(str(r))
|
||||
label = "Range: " + " / ".join(parts)
|
||||
parts.append("[%s]" % label)
|
||||
label = "Usage history · %d days · UTC %s" % (
|
||||
self.range_days, " ".join(parts),
|
||||
)
|
||||
if self.view_mode == "hourly":
|
||||
label += " \u00b7 [bold]%s[/bold] \u00b7 Esc Back" % (self.drill_day or "")
|
||||
label = "Usage history · Hourly · UTC · [bold]%s[/bold] · Esc Back" % (
|
||||
self.drill_day or "",
|
||||
)
|
||||
self.query_one("#bar-range").update(label)
|
||||
|
||||
def _visible_items(self, items: List[Dict[str, Any]], selected: int) -> tuple[int, List[Dict[str, Any]]]:
|
||||
"""Return the readable viewport containing the selected item."""
|
||||
width = getattr(getattr(self, "region", None), "width", 0) or 52
|
||||
capacity = max(1, (width - 12) // (_BAR_WIDTH + _BAR_SPACING))
|
||||
capacity = min(capacity, len(items))
|
||||
if capacity >= len(items):
|
||||
return 0, items
|
||||
selected = max(0, selected)
|
||||
start = min(max(0, selected - capacity + 1), len(items) - capacity)
|
||||
return start, items[start:start + capacity]
|
||||
|
||||
@staticmethod
|
||||
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"
|
||||
|
||||
def _render_bars(self) -> None:
|
||||
visible = self._day_data
|
||||
start, visible = self._visible_items(self._day_data, self.selected_index)
|
||||
self._visible_start = start
|
||||
if not visible:
|
||||
self.query_one("#bar-render").update("")
|
||||
return
|
||||
|
||||
max_bytes = self._max_bytes or 1
|
||||
bar_h = _BAR_HEIGHT
|
||||
render_height = self.query_one("#bar-render").region.height
|
||||
bar_h = max(1, min(_BAR_HEIGHT, render_height - 2))
|
||||
n = len(visible)
|
||||
|
||||
lines: List[str] = []
|
||||
scale, unit = self._unit_scale(max_bytes)
|
||||
lines: List[str] = [
|
||||
"Writes (%s) · %s → %s UTC" % (
|
||||
unit, visible[0].get("day", ""), visible[-1].get("day", ""),
|
||||
)
|
||||
]
|
||||
for row in range(bar_h, 0, -1):
|
||||
line = ""
|
||||
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, day in enumerate(visible):
|
||||
allocated = day.get("allocated_bytes", 0)
|
||||
total = day.get("total_bytes", 0)
|
||||
is_zero = day.get("is_zero", False)
|
||||
is_gap = day.get("is_gap", False)
|
||||
is_partial = day.get("is_partial", False)
|
||||
|
||||
if is_zero and row == 1:
|
||||
glyph = _GLYPH_ZERO
|
||||
elif is_gap:
|
||||
glyph = _GLYPH_GAP if row <= 2 else " "
|
||||
elif allocated == 0:
|
||||
elif total == 0:
|
||||
glyph = " "
|
||||
elif row == 1 and i == self.selected_index:
|
||||
glyph = "\u25b6" # selection arrow
|
||||
elif threshold > 0 and threshold <= allocated:
|
||||
glyph = _GLYPH_ALLOCATED
|
||||
elif is_partial and row == bar_h:
|
||||
elif day.get("is_partial") and row == max(
|
||||
1, (total * bar_h + max_bytes - 1) // max_bytes,
|
||||
):
|
||||
glyph = _GLYPH_PARTIAL
|
||||
elif threshold <= allocated:
|
||||
glyph = _GLYPH_ALLOCATED
|
||||
elif threshold <= total:
|
||||
glyph = _GLYPH_UNALLOCATED
|
||||
else:
|
||||
glyph = " "
|
||||
|
||||
@@ -363,25 +429,12 @@ class DailyBarGraph(Widget):
|
||||
|
||||
lines.append(line)
|
||||
|
||||
# Unallocated indicator row: show ▓ for days with cross-day unknowns
|
||||
unalloc_line = ""
|
||||
has_unalloc = False
|
||||
for i, day in enumerate(visible):
|
||||
unalloc = day.get("unallocated_bytes", 0)
|
||||
if unalloc > 0:
|
||||
unalloc_line += _GLYPH_UNALLOCATED * _BAR_WIDTH
|
||||
has_unalloc = True
|
||||
else:
|
||||
unalloc_line += " " * _BAR_WIDTH
|
||||
if i < n - 1:
|
||||
unalloc_line += " " * _BAR_SPACING
|
||||
if has_unalloc:
|
||||
lines.append(unalloc_line)
|
||||
|
||||
# Date labels
|
||||
label_line = ""
|
||||
label_line = "Day UTC "
|
||||
for i, day in enumerate(visible):
|
||||
label = day.get("local_label", day.get("day", ""))[-2:]
|
||||
if start + i == self.selected_index:
|
||||
label = _GLYPH_SELECTED + label[-1:]
|
||||
label_line += label
|
||||
if i < n - 1:
|
||||
label_line += " " * _BAR_SPACING
|
||||
@@ -404,9 +457,7 @@ class DailyBarGraph(Widget):
|
||||
|
||||
def _render_readout(self) -> None:
|
||||
if self.selected_index < 0 or self.selected_index >= len(self._day_data):
|
||||
self.query_one("#bar-readout").update(
|
||||
"[dim]\u2190 \u2192 Select \u00b7 1-4 Range \u00b7 Enter Hourly view[/dim]"
|
||||
)
|
||||
self.query_one("#bar-readout").update("[dim]No selectable day[/dim]")
|
||||
return
|
||||
|
||||
day = self._day_data[self.selected_index]
|
||||
@@ -417,13 +468,17 @@ class DailyBarGraph(Widget):
|
||||
hours = day.get("evidenced_hours", 0)
|
||||
|
||||
total_gb = total / 1e9
|
||||
state = " · partial" if day.get("is_partial") else (
|
||||
" · gap" if day.get("is_gap") else ""
|
||||
)
|
||||
parts = [
|
||||
"[bold]%s[/bold] \u00b7 %.3f GB total \u00b7 %d hours \u00b7 %.0f%% coverage"
|
||||
"[bold]%s UTC[/bold] \u00b7 %.3f GB total \u00b7 %d hours \u00b7 %.0f%% coverage%s"
|
||||
% (
|
||||
day.get("local_label", day.get("day", "")),
|
||||
total_gb,
|
||||
hours,
|
||||
coverage * 100,
|
||||
state,
|
||||
),
|
||||
]
|
||||
if unallocated > 0:
|
||||
@@ -449,57 +504,89 @@ class DailyBarGraph(Widget):
|
||||
max_bytes = max(
|
||||
(h.get("bytes_written", 0) for h in self._hour_data), default=0
|
||||
) or 1
|
||||
bar_h = _BAR_HEIGHT
|
||||
n = len(self._hour_data)
|
||||
start, visible = self._visible_items(self._hour_data, self._hourly_selected)
|
||||
self._visible_start = start
|
||||
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)
|
||||
|
||||
lines: List[str] = []
|
||||
lines: List[str] = [
|
||||
"Writes (%s) · %s:00 → %s:00 UTC" % (
|
||||
unit,
|
||||
visible[0].get("local_label", ""),
|
||||
visible[-1].get("local_label", ""),
|
||||
)
|
||||
]
|
||||
for row in range(bar_h, 0, -1):
|
||||
line = ""
|
||||
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, hour in enumerate(self._hour_data):
|
||||
for i, hour in enumerate(visible):
|
||||
bw = hour.get("bytes_written", 0)
|
||||
is_zero = hour.get("is_zero", False)
|
||||
|
||||
if is_zero and row == 1:
|
||||
if hour.get("is_future"):
|
||||
glyph = " "
|
||||
elif hour.get("is_gap"):
|
||||
glyph = _GLYPH_GAP if row <= 2 else " "
|
||||
elif hour.get("is_zero") and row == 1:
|
||||
glyph = _GLYPH_ZERO
|
||||
elif bw == 0:
|
||||
glyph = " "
|
||||
elif row == 1 and i == self._hourly_selected:
|
||||
glyph = "\u25b6"
|
||||
elif threshold > 0 and threshold <= bw:
|
||||
elif hour.get("is_partial") and row == max(
|
||||
1, (bw * bar_h + max_bytes - 1) // max_bytes,
|
||||
):
|
||||
glyph = _GLYPH_PARTIAL
|
||||
elif threshold <= bw:
|
||||
glyph = _GLYPH_ALLOCATED
|
||||
else:
|
||||
glyph = " "
|
||||
|
||||
line += glyph * _BAR_WIDTH
|
||||
if i < n - 1:
|
||||
line += " " * _BAR_SPACING
|
||||
|
||||
lines.append(line)
|
||||
|
||||
# Hour labels
|
||||
label_line = ""
|
||||
for i, hour in enumerate(self._hour_data):
|
||||
label_line = "Hour UTC"
|
||||
for i, hour in enumerate(visible):
|
||||
label = hour.get("local_label", hour.get("hour", ""))[-2:]
|
||||
label_line += label
|
||||
if start + i == self._hourly_selected:
|
||||
label = _GLYPH_SELECTED + label[-1:]
|
||||
label_line += " " + label
|
||||
if i < n - 1:
|
||||
label_line += " " * _BAR_SPACING
|
||||
label_line += " " * max(0, _BAR_SPACING - 1)
|
||||
lines.append(label_line)
|
||||
|
||||
self.query_one("#bar-render").update("\n".join(lines))
|
||||
self.query_one("#bar-legend").update(
|
||||
"%s Writes \u00b7 %s Zero" % (_GLYPH_ALLOCATED, _GLYPH_ZERO)
|
||||
"%s Writes · %s Gap · %s Zero · %s Partial"
|
||||
% (_GLYPH_ALLOCATED, _GLYPH_GAP, _GLYPH_ZERO, _GLYPH_PARTIAL)
|
||||
)
|
||||
|
||||
# Hourly readout
|
||||
if 0 <= self._hourly_selected < len(self._hour_data):
|
||||
h = self._hour_data[self._hourly_selected]
|
||||
state = " · partial" if h.get("is_partial") else (
|
||||
" · gap" if h.get("is_gap") else ""
|
||||
)
|
||||
note = ""
|
||||
if self._drill_unallocated_bytes:
|
||||
note = "\n%.3f GB could not be assigned to an hour" % (
|
||||
self._drill_unallocated_bytes / 1e9,
|
||||
)
|
||||
self.query_one("#bar-readout").update(
|
||||
"[bold]%s[/bold] \u00b7 %.3f GB \u00b7 %d%% coverage"
|
||||
"[bold]%s:00 UTC[/bold] \u00b7 %.3f GB \u00b7 %d%% coverage%s%s"
|
||||
% (
|
||||
h.get("local_label", h.get("hour", "")),
|
||||
h.get("bytes_written", 0) / 1e9,
|
||||
h.get("coverage", 0) * 100,
|
||||
state,
|
||||
note,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -535,8 +622,12 @@ class DailyBarGraph(Widget):
|
||||
event.stop()
|
||||
elif event.key in ("1", "2", "3", "4"):
|
||||
self.range_days = _RANGE_OPTIONS[int(event.key) - 1]
|
||||
self.selected_index = -1
|
||||
selected_day = self._day_data[self.selected_index].get("day") if self._day_data else None
|
||||
self._trim_to_range()
|
||||
self.selected_index = next(
|
||||
(i for i, item in enumerate(self._day_data) if item.get("day") == selected_day),
|
||||
len(self._day_data) - 1,
|
||||
)
|
||||
self._refresh()
|
||||
event.stop()
|
||||
|
||||
@@ -545,6 +636,7 @@ class DailyBarGraph(Widget):
|
||||
return
|
||||
day = self._day_data[self.selected_index]
|
||||
self.drill_day = day.get("day")
|
||||
self._drill_unallocated_bytes = day.get("unallocated_bytes", 0)
|
||||
self.view_mode = "hourly"
|
||||
self._hourly_selected = -1
|
||||
if self._on_drill:
|
||||
@@ -586,15 +678,17 @@ class DailyBarGraph(Widget):
|
||||
render = self.query_one("#bar-render")
|
||||
offset_x = event.x - render.region.x
|
||||
bar_total = _BAR_WIDTH + _BAR_SPACING
|
||||
idx = offset_x // bar_total
|
||||
idx = max(0, offset_x - 8) // bar_total
|
||||
|
||||
if self.view_mode == "daily":
|
||||
if 0 <= idx < len(self._day_data):
|
||||
self.selected_index = idx
|
||||
global_idx = self._visible_start + idx
|
||||
if 0 <= global_idx < len(self._day_data):
|
||||
self.selected_index = global_idx
|
||||
self._refresh()
|
||||
else:
|
||||
if 0 <= idx < len(self._hour_data):
|
||||
self._hourly_selected = idx
|
||||
global_idx = self._visible_start + idx
|
||||
if 0 <= global_idx < len(self._hour_data):
|
||||
self._hourly_selected = global_idx
|
||||
self._refresh_hourly()
|
||||
|
||||
|
||||
@@ -604,6 +698,7 @@ class DailyBarGraph(Widget):
|
||||
|
||||
def _query_daily_graph_data(
|
||||
conn: sqlite3.Connection,
|
||||
window_end: Optional[datetime] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Query day aggregates for the bar graph.
|
||||
|
||||
@@ -618,7 +713,7 @@ def _query_daily_graph_data(
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
by_day: Dict[str, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
day = row[0]
|
||||
bw_delta = row[1] or 0
|
||||
@@ -641,7 +736,7 @@ def _query_daily_graph_data(
|
||||
)
|
||||
is_partial = coverage < 0.5
|
||||
|
||||
result.append({
|
||||
by_day[day] = {
|
||||
"day": day,
|
||||
"local_label": day,
|
||||
"total_bytes": total_bytes,
|
||||
@@ -653,7 +748,32 @@ def _query_daily_graph_data(
|
||||
"is_zero": is_zero,
|
||||
"is_gap": is_gap,
|
||||
"is_partial": is_partial,
|
||||
}
|
||||
|
||||
if window_end is None:
|
||||
return list(by_day.values())
|
||||
|
||||
end_date = window_end.astimezone(timezone.utc).date()
|
||||
result: List[Dict[str, Any]] = []
|
||||
for offset in range(max(_RANGE_OPTIONS) - 1, -1, -1):
|
||||
day = (end_date - timedelta(days=offset)).isoformat()
|
||||
entry = by_day.get(day, {
|
||||
"day": day,
|
||||
"local_label": day,
|
||||
"total_bytes": 0,
|
||||
"allocated_bytes": 0,
|
||||
"unallocated_bytes": 0,
|
||||
"coverage": 0.0,
|
||||
"evidenced_hours": 0,
|
||||
"sample_count": 0,
|
||||
"is_zero": False,
|
||||
"is_gap": True,
|
||||
"is_partial": False,
|
||||
})
|
||||
if day == end_date.isoformat():
|
||||
entry = dict(entry)
|
||||
entry["is_partial"] = True
|
||||
result.append(entry)
|
||||
|
||||
return result
|
||||
|
||||
@@ -661,6 +781,7 @@ def _query_daily_graph_data(
|
||||
def _query_hourly_graph_data(
|
||||
conn: sqlite3.Connection,
|
||||
day: str,
|
||||
now: Optional[datetime] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Query hour observations for a specific day.
|
||||
|
||||
@@ -675,7 +796,7 @@ def _query_hourly_graph_data(
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
by_hour: Dict[int, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
hour = row[0]
|
||||
bw = row[1] or 0
|
||||
@@ -689,7 +810,8 @@ def _query_hourly_graph_data(
|
||||
is_zero = bw == 0
|
||||
local_label = hour[11:13] if len(hour) >= 13 else hour
|
||||
|
||||
result.append({
|
||||
hour_number = int(local_label)
|
||||
by_hour[hour_number] = {
|
||||
"hour": hour,
|
||||
"local_label": local_label,
|
||||
"bytes_written": bw,
|
||||
@@ -700,7 +822,40 @@ def _query_hourly_graph_data(
|
||||
"powered_off_seconds": powered_off,
|
||||
"unknown_seconds": unknown,
|
||||
"is_zero": is_zero,
|
||||
})
|
||||
"is_gap": False,
|
||||
"is_partial": False,
|
||||
"is_future": False,
|
||||
}
|
||||
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
selected_date = datetime.fromisoformat(day).date()
|
||||
result: List[Dict[str, Any]] = []
|
||||
for hour_number in range(24):
|
||||
hour_start = datetime.combine(
|
||||
selected_date, datetime.min.time(), tzinfo=timezone.utc,
|
||||
) + timedelta(hours=hour_number)
|
||||
is_future = hour_start > current
|
||||
entry = by_hour.get(hour_number)
|
||||
if entry is None:
|
||||
entry = {
|
||||
"hour": hour_start.isoformat(),
|
||||
"local_label": "%02d" % hour_number,
|
||||
"bytes_written": 0,
|
||||
"coverage": 0.0,
|
||||
"sample_count": 0,
|
||||
"active_seconds": 0,
|
||||
"idle_seconds": 0,
|
||||
"powered_off_seconds": 0,
|
||||
"unknown_seconds": 0,
|
||||
"is_zero": False,
|
||||
"is_gap": not is_future,
|
||||
"is_partial": hour_start <= current < hour_start + timedelta(hours=1),
|
||||
"is_future": is_future,
|
||||
}
|
||||
else:
|
||||
entry["is_partial"] = hour_start <= current < hour_start + timedelta(hours=1)
|
||||
entry["is_future"] = is_future
|
||||
result.append(entry)
|
||||
|
||||
return result
|
||||
|
||||
@@ -1103,16 +1258,28 @@ class FenrisTuiApp(App):
|
||||
|
||||
# --- Usage-history pane (§7.2 left): interactive bar graph ---
|
||||
graph = self.query_one("#usage-history")
|
||||
day_data = _query_daily_graph_data(conn)
|
||||
day_data = _query_daily_graph_data(conn, self._clock_now)
|
||||
|
||||
# Preserve drill-down state across refresh if still valid
|
||||
if graph.view_mode == "hourly" and graph.drill_day:
|
||||
saved_drill_day = graph.drill_day
|
||||
hour_data = _query_hourly_graph_data(conn, saved_drill_day)
|
||||
saved_hour = None
|
||||
if 0 <= graph._hourly_selected < len(graph._hour_data):
|
||||
saved_hour = graph._hour_data[graph._hourly_selected].get("hour")
|
||||
hour_data = _query_hourly_graph_data(
|
||||
conn, saved_drill_day, self._clock_now,
|
||||
)
|
||||
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
||||
graph.view_mode = "hourly"
|
||||
graph.drill_day = saved_drill_day
|
||||
graph.set_hour_data(hour_data)
|
||||
if saved_hour is not None:
|
||||
graph._hourly_selected = next(
|
||||
(i for i, item in enumerate(hour_data)
|
||||
if item.get("hour") == saved_hour),
|
||||
graph._hourly_selected,
|
||||
)
|
||||
graph._refresh_hourly()
|
||||
else:
|
||||
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
||||
|
||||
@@ -1183,7 +1350,8 @@ class FenrisTuiApp(App):
|
||||
self.store_path, self._clock_now, query_services=False,
|
||||
) as (conn, _):
|
||||
graph.set_hour_data(
|
||||
_query_hourly_graph_data(conn, day) if conn is not None else []
|
||||
_query_hourly_graph_data(conn, day, self._clock_now)
|
||||
if conn is not None else []
|
||||
)
|
||||
|
||||
def _format_headline(self, proj: ProjectionResult) -> str:
|
||||
|
||||
Reference in New Issue
Block a user