+159
-334
@@ -14,14 +14,13 @@ Criteria: TUI-1, TUI-2, TUI-4, CI-1, CI-2, CI-4, IN-3, LC-6, LC-8.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
@@ -39,27 +38,19 @@ from .status import (
|
||||
CADENCE_DEFAULT_S,
|
||||
FRESH_THRESHOLD_S,
|
||||
STALENESS_THRESHOLD_S,
|
||||
ConfigError,
|
||||
NewerSchema,
|
||||
StoreFault,
|
||||
format_disclosures,
|
||||
freshness_age_human,
|
||||
grade_freshness,
|
||||
deliberate_pause_lines,
|
||||
is_deliberately_paused,
|
||||
monitoring_continuity,
|
||||
open_store_readonly,
|
||||
query_service_state,
|
||||
read_config,
|
||||
read_status,
|
||||
_journalctl_hint,
|
||||
)
|
||||
from .monitoring_periods import get_open_period
|
||||
from .status_composition import (
|
||||
StatusState,
|
||||
StatusComposition,
|
||||
compose_status,
|
||||
render_status_tui,
|
||||
STATUS_POLL_INTERVAL_S,
|
||||
)
|
||||
from .control import MONITOR_HELPER, MonitorError, run_monitor
|
||||
from .preferences import load_preferences, save_preferences
|
||||
from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
|
||||
|
||||
@@ -68,9 +59,10 @@ from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RESUME_HINT = "r resume — enable monitoring and future boots"
|
||||
_RESUME_HINT = "r Resume — enable monitoring and future boots"
|
||||
_ACTION_LEGEND = (
|
||||
"p pause · " + _RESUME_HINT + " · c collect · t theme · m motion · d disclosures"
|
||||
"p Pause · " + _RESUME_HINT + "\n"
|
||||
"c Collect now · t Theme · m Motion · d Disclosures · ? Help"
|
||||
)
|
||||
|
||||
def _format_remaining(seconds: float) -> str:
|
||||
@@ -91,43 +83,6 @@ def _format_remaining(seconds: float) -> str:
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _sparkline(values: List[float], width: int = 40) -> str:
|
||||
"""Render a sparkline from daily bytes-written values."""
|
||||
if not values:
|
||||
return ""
|
||||
mx = max(values) or 1.0
|
||||
blocks = " ▁▂▃▄▅▆▇█"
|
||||
step = max(1, len(values) // width or 1)
|
||||
picked = values[-width * step :][::step][-width:]
|
||||
return "".join(
|
||||
blocks[min(len(blocks) - 1, int(v / mx * (len(blocks) - 1)) + (1 if v > 0 else 0))]
|
||||
for v in picked
|
||||
)
|
||||
|
||||
|
||||
def _habit_bar(a: float, i: float, o: float, u: float, width: int = 40) -> str:
|
||||
"""Render the habit-split bar with legend."""
|
||||
total = a + i + o + u or 1.0
|
||||
segs = [
|
||||
("a", a, "#33ff33"),
|
||||
("i", i, "#ffff33"),
|
||||
("o", o, "#33ffff"),
|
||||
("?", u, "#ff33ff"),
|
||||
]
|
||||
parts = []
|
||||
for label, v, _color in segs:
|
||||
n = max(1 if v > 0 else 0, round(v / total * width))
|
||||
parts.append(label * n)
|
||||
bar = "".join(parts)
|
||||
legend = " active %d%% · idle %d%% · powered-off %d%% · unknown %d%%" % (
|
||||
round(a / total * 100),
|
||||
round(i / total * 100),
|
||||
round(o / total * 100),
|
||||
round(u / total * 100),
|
||||
)
|
||||
return bar + "\n" + legend
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity and wolf glyph detection (issue #79)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -274,8 +229,8 @@ 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-render").update("[dim]awaiting first sample[/dim]")
|
||||
self.query_one("#bar-range").update("[dim]Usage history[/dim]")
|
||||
self.query_one("#bar-render").update("[dim]Awaiting first sample[/dim]")
|
||||
self.query_one("#bar-legend").update("")
|
||||
self.query_one("#bar-readout").update("")
|
||||
|
||||
@@ -296,9 +251,9 @@ 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.query_one("#bar-range").update("[dim]Usage history[/dim]")
|
||||
if not self._day_data:
|
||||
self.query_one("#bar-render").update("[dim]graph needs ≥80×24[/dim]")
|
||||
self.query_one("#bar-render").update("[dim]Graph needs ≥80×24[/dim]")
|
||||
self.query_one("#bar-legend").update("")
|
||||
self.query_one("#bar-readout").update("")
|
||||
return
|
||||
@@ -310,7 +265,7 @@ class DailyBarGraph(Widget):
|
||||
first = self._day_data[0].get("local_label", "")
|
||||
last = self._day_data[-1].get("local_label", "")
|
||||
self.query_one("#bar-render").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\n"
|
||||
"[dim]Graph needs ≥80×24[/dim]\n"
|
||||
" %d days · %d with writes · %.3f GB total\n"
|
||||
" %s → %s" % (n, days_with_data, total_bytes / 1e9, first, last)
|
||||
)
|
||||
@@ -324,7 +279,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]\u2190 \u2192 Select[/dim]")
|
||||
|
||||
def _show_constrained_hourly_summary(self) -> None:
|
||||
"""Textual fallback for hourly view when terminal is too small."""
|
||||
@@ -334,7 +289,7 @@ class DailyBarGraph(Widget):
|
||||
)
|
||||
n = len(self._hour_data)
|
||||
self.query_one("#bar-render").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\n"
|
||||
"[dim]Graph needs ≥80×24[/dim]\n"
|
||||
" %d hours · %d with writes · %.3f GB total" % (n, hours_with_data, total_bytes / 1e9)
|
||||
)
|
||||
self.query_one("#bar-legend").update("")
|
||||
@@ -346,7 +301,7 @@ class DailyBarGraph(Widget):
|
||||
% (h.get("local_label", ""), h.get("bytes_written", 0) / 1e9)
|
||||
)
|
||||
else:
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 select hour[/dim]")
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 Select hour[/dim]")
|
||||
|
||||
def on_resize(self) -> None:
|
||||
"""Re-render when terminal size changes."""
|
||||
@@ -362,9 +317,9 @@ class DailyBarGraph(Widget):
|
||||
parts.append("[bold]%d[/bold]" % r)
|
||||
else:
|
||||
parts.append(str(r))
|
||||
label = "range: " + " / ".join(parts)
|
||||
label = "Range: " + " / ".join(parts)
|
||||
if self.view_mode == "hourly":
|
||||
label += " \u00b7 [bold]%s[/bold] \u00b7 esc back" % (self.drill_day or "")
|
||||
label += " \u00b7 [bold]%s[/bold] \u00b7 Esc Back" % (self.drill_day or "")
|
||||
self.query_one("#bar-range").update(label)
|
||||
|
||||
def _render_bars(self) -> None:
|
||||
@@ -436,7 +391,7 @@ class DailyBarGraph(Widget):
|
||||
|
||||
def _render_legend(self) -> None:
|
||||
legend = (
|
||||
"%s alloc %s unalloc %s gap %s zero %s partial"
|
||||
"%s Alloc %s Unalloc %s Gap %s Zero %s Partial"
|
||||
% (
|
||||
_GLYPH_ALLOCATED,
|
||||
_GLYPH_UNALLOCATED,
|
||||
@@ -450,7 +405,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 drill[/dim]"
|
||||
"[dim]\u2190 \u2192 Select \u00b7 1-4 Range \u00b7 Enter Hourly view[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -473,7 +428,7 @@ class DailyBarGraph(Widget):
|
||||
]
|
||||
if unallocated > 0:
|
||||
parts.append(
|
||||
" allocated %.3f GB \u00b7 unallocated %.3f GB"
|
||||
" Allocated %.3f GB \u00b7 unallocated %.3f GB"
|
||||
% (allocated / 1e9, unallocated / 1e9)
|
||||
)
|
||||
self.query_one("#bar-readout").update("\n".join(parts))
|
||||
@@ -482,7 +437,7 @@ class DailyBarGraph(Widget):
|
||||
|
||||
def _refresh_hourly(self) -> None:
|
||||
if not self._hour_data:
|
||||
self.query_one("#bar-render").update("[dim]no hourly data[/dim]")
|
||||
self.query_one("#bar-render").update("[dim]No hourly data[/dim]")
|
||||
return
|
||||
|
||||
self._render_range()
|
||||
@@ -533,7 +488,7 @@ class DailyBarGraph(Widget):
|
||||
|
||||
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 \u00b7 %s Zero" % (_GLYPH_ALLOCATED, _GLYPH_ZERO)
|
||||
)
|
||||
|
||||
# Hourly readout
|
||||
@@ -548,7 +503,7 @@ class DailyBarGraph(Widget):
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 select hour[/dim]")
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 Select hour[/dim]")
|
||||
|
||||
# -- Event handling --
|
||||
|
||||
@@ -754,51 +709,6 @@ def _query_hourly_graph_data(
|
||||
# Data queries for TUI regions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _query_usage_history(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
"""Query usage-history data for the left pane."""
|
||||
cursor = conn.execute(
|
||||
"SELECT day, active_seconds, idle_seconds, powered_off_seconds, "
|
||||
"unknown_seconds, bytes_written_delta, coverage "
|
||||
"FROM day_aggregates ORDER BY day"
|
||||
)
|
||||
days = cursor.fetchall()
|
||||
if not days:
|
||||
return {
|
||||
"sparkline": "",
|
||||
"habit_bar": "",
|
||||
"num_days": 0,
|
||||
"min_gb": 0,
|
||||
"max_gb": 0,
|
||||
"habit_change": False,
|
||||
"gap_days": [],
|
||||
"day_labels": [],
|
||||
}
|
||||
|
||||
bw_values = [d[5] for d in days]
|
||||
total_a = sum(d[1] for d in days)
|
||||
total_i = sum(d[2] for d in days)
|
||||
total_o = sum(d[3] for d in days)
|
||||
total_u = sum(d[4] for d in days)
|
||||
total = total_a + total_i + total_o + total_u or 1
|
||||
|
||||
spark = _sparkline([b / 1e9 for b in bw_values]) # Convert to GB for display
|
||||
bar = _habit_bar(total_a / total, total_i / total, total_o / total, total_u / total)
|
||||
|
||||
min_gb = min(bw_values) / 1e9 if bw_values else 0
|
||||
max_gb = max(bw_values) / 1e9 if bw_values else 0
|
||||
|
||||
return {
|
||||
"sparkline": spark,
|
||||
"habit_bar": bar,
|
||||
"num_days": len(days),
|
||||
"min_gb": min_gb,
|
||||
"max_gb": max_gb,
|
||||
"habit_change": False,
|
||||
"gap_days": [],
|
||||
"day_labels": [d[0] for d in days],
|
||||
}
|
||||
|
||||
|
||||
def _query_drive_health(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
"""Query drive health data for the right pane."""
|
||||
cursor = conn.execute(
|
||||
@@ -840,41 +750,6 @@ def _query_drive_health(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _query_service_facts(conn: sqlite3.Connection, clock_now: datetime) -> Dict[str, Any]:
|
||||
"""Query service facts for the bottom strip."""
|
||||
# Get freshness
|
||||
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
newest_ts = row[0] if row else None
|
||||
freshness = grade_freshness(newest_ts, clock_now)
|
||||
|
||||
# Get monitoring period
|
||||
period = get_open_period(conn)
|
||||
period_info = "no monitoring period"
|
||||
if period:
|
||||
period_info = "open since %s" % period["started_at"][:10]
|
||||
|
||||
# Get service state
|
||||
try:
|
||||
svc = query_service_state()
|
||||
except Exception:
|
||||
svc = {
|
||||
"boot_enabled": False,
|
||||
"timer_active": False,
|
||||
"last_collect_ok": None,
|
||||
"last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"freshness": freshness,
|
||||
"freshness_age_s": None,
|
||||
"period": period_info,
|
||||
"deliberately_paused": is_deliberately_paused(conn, svc),
|
||||
**svc,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modal screens
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -894,7 +769,7 @@ class ConfirmPause(ModalScreen[bool]):
|
||||
"This closes the current monitoring period.\n"
|
||||
"Paused time is [bold]excluded[/bold] from your usage habit\n"
|
||||
"(powered-off time would still count).\n\n"
|
||||
"[dim]y pause · n cancel[/dim]",
|
||||
"[dim]y Pause · n Cancel[/dim]",
|
||||
id="confirm-text",
|
||||
)
|
||||
|
||||
@@ -915,7 +790,44 @@ class DisclosuresScreen(ModalScreen[None]):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
text = format_disclosures()
|
||||
yield Static(text + "\n\n[dim]esc to close[/dim]", id="disc-text")
|
||||
with VerticalScroll():
|
||||
yield Static(text + "\n\n[dim]Esc Close[/dim]", id="disc-text")
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.dismiss()
|
||||
|
||||
|
||||
class HelpScreen(ModalScreen[None]):
|
||||
"""Persistent keyboard and privilege guidance."""
|
||||
|
||||
BINDINGS = [Binding("escape", "close", "Close"), Binding("?", "close", "Close")]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with VerticalScroll():
|
||||
yield Static(
|
||||
"[bold]Using Fenris[/bold]\n\n"
|
||||
"Open the dashboard with [bold]fenris[/bold] as your normal user.\n"
|
||||
"The dashboard does not need sudo. Pause, resume, and collect now\n"
|
||||
"authenticate through polkit when needed.\n\n"
|
||||
"[bold]When to use sudo[/bold]\n"
|
||||
"Use sudo for installation and system configuration.\n"
|
||||
"For observation store access, an administrator can run:\n"
|
||||
' sudo usermod -aG fenris "$USER"\n'
|
||||
"Then log out and back in before opening Fenris.\n\n"
|
||||
"If polkit authentication is unavailable, quit with q and run\n"
|
||||
"only the required action in your terminal:\n"
|
||||
" sudo fenris monitor resume\n"
|
||||
" sudo fenris monitor pause\n"
|
||||
" sudo fenris sample\n"
|
||||
"Then reopen the dashboard with fenris.\n\n"
|
||||
"[bold]Keyboard controls[/bold]\n"
|
||||
+ _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"
|
||||
"[dim]↑ ↓ Scroll · Esc Close[/dim]",
|
||||
id="help-text",
|
||||
)
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.dismiss()
|
||||
@@ -936,12 +848,12 @@ class FenrisTuiApp(App):
|
||||
layout: grid;
|
||||
grid-size: 2 4;
|
||||
grid-columns: 3fr 2fr;
|
||||
grid-rows: 8 10 7 3;
|
||||
grid-rows: auto 10 auto 3;
|
||||
height: auto;
|
||||
}
|
||||
#main-grid.paused {
|
||||
grid-size: 2 5;
|
||||
grid-rows: 8 5 10 7 3;
|
||||
grid-rows: auto auto 10 auto 3;
|
||||
}
|
||||
#main-grid.constrained {
|
||||
grid-size: 1 4;
|
||||
@@ -966,15 +878,16 @@ class FenrisTuiApp(App):
|
||||
min-height: 3;
|
||||
}
|
||||
#dashboard-scroll { height: 1fr; }
|
||||
#headline-band { column-span: 2; }
|
||||
#headline-band { column-span: 2; height: auto; min-height: 8; }
|
||||
#paused-banner {
|
||||
column-span: 2;
|
||||
display: none;
|
||||
background: $error 20%;
|
||||
color: $text;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
min-height: 5;
|
||||
}
|
||||
#service-strip { column-span: 2; height: 100%; }
|
||||
#service-strip { column-span: 2; height: auto; }
|
||||
#quit-rail {
|
||||
column-span: 2;
|
||||
border: heavy $accent;
|
||||
@@ -984,15 +897,17 @@ class FenrisTuiApp(App):
|
||||
.pane { border: round #555555; padding: 0 1; height: 100%; }
|
||||
#confirm-text { padding: 1 2; }
|
||||
#disc-text { padding: 1 2; }
|
||||
#help-text { padding: 1 2; }
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("p", "pause", "Pause", show=False),
|
||||
Binding("r", "resume", "Resume", show=False),
|
||||
Binding("c", "collect", "Collect Now", show=False),
|
||||
Binding("c", "collect", "Collect now", show=False),
|
||||
Binding("d", "disclose", "Disclosures", show=False),
|
||||
Binding("t", "toggle_theme", "Theme", show=False),
|
||||
Binding("m", "toggle_motion", "Motion", show=False),
|
||||
Binding("?", "help", "Help", show=False),
|
||||
Binding("q", "quit", "Quit", show=False),
|
||||
]
|
||||
|
||||
@@ -1012,10 +927,9 @@ class FenrisTuiApp(App):
|
||||
super().__init__(**kwargs)
|
||||
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
|
||||
self.config_path = config_path
|
||||
self.helper_path = helper_path or "/usr/libexec/fenris/fenris-monitor"
|
||||
self.helper_path = helper_path or MONITOR_HELPER
|
||||
self.refresh_interval_s = refresh_interval_s
|
||||
self._show_auth_notice = True
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._clock_now = datetime.now(timezone.utc)
|
||||
|
||||
# Register all Fenris themes
|
||||
@@ -1034,7 +948,7 @@ class FenrisTuiApp(App):
|
||||
yield Static("", id="constrained-summary", classes="pane")
|
||||
yield Static("", id="drive-health", classes="pane")
|
||||
yield Static("", id="service-strip", classes="pane")
|
||||
yield Static("q QUIT TUI", id="quit-rail")
|
||||
yield Static("q Quit TUI", id="quit-rail")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set border titles, apply theme, and render initial state."""
|
||||
@@ -1042,34 +956,19 @@ class FenrisTuiApp(App):
|
||||
# Preference uses underscores (high_contrast); Textual theme uses dashes (fenris-high-contrast)
|
||||
self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-")
|
||||
|
||||
self.query_one("#headline-band").border_title = "headline"
|
||||
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("#service-strip").border_title = "service + actions"
|
||||
self.query_one("#headline-band").border_title = "Headline"
|
||||
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("#service-strip").border_title = "Monitoring and actions"
|
||||
self._refresh_timer = self.set_interval(
|
||||
self.refresh_interval_s, self.on_refresh_tick
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def on_resize(self) -> None:
|
||||
"""Handle terminal resize to switch between constrained and normal layout (issue #81)."""
|
||||
width, height = self.size
|
||||
was_constrained = self._is_constrained_mode
|
||||
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
||||
|
||||
main_grid = self.query_one("#main-grid")
|
||||
if self._is_constrained_mode:
|
||||
main_grid.add_class("constrained")
|
||||
else:
|
||||
main_grid.remove_class("constrained")
|
||||
|
||||
# Re-render constrained summary when entering constrained mode
|
||||
if self._is_constrained_mode and not was_constrained:
|
||||
self._update_constrained_summary()
|
||||
# Re-render full layout when leaving constrained mode
|
||||
elif not self._is_constrained_mode and was_constrained:
|
||||
self._refresh()
|
||||
"""Re-render with the new terminal size after Textual applies it."""
|
||||
self.call_after_refresh(self._refresh)
|
||||
|
||||
def _update_constrained_summary(self) -> None:
|
||||
"""Update the textual summary shown when terminal is too small for graph."""
|
||||
@@ -1077,7 +976,7 @@ class FenrisTuiApp(App):
|
||||
summary = self.query_one("#constrained-summary")
|
||||
|
||||
if not graph._day_data:
|
||||
summary.update("[dim]graph needs ≥80×24[/dim]\nawaiting first sample")
|
||||
summary.update("[dim]Graph needs ≥80×24[/dim]\nAwaiting first sample")
|
||||
return
|
||||
|
||||
# Summarise visible days as text (issue #81 AC2)
|
||||
@@ -1087,7 +986,7 @@ class FenrisTuiApp(App):
|
||||
first = graph._day_data[0].get("local_label", "")
|
||||
last = graph._day_data[-1].get("local_label", "")
|
||||
text = (
|
||||
"[dim]graph needs ≥80×24[/dim]\n"
|
||||
"[dim]Graph needs ≥80×24[/dim]\n"
|
||||
" %d days · %d with writes · %.3f GB total\n"
|
||||
" %s → %s" % (n, days_with_data, total_bytes / 1e9, first, last)
|
||||
)
|
||||
@@ -1116,7 +1015,7 @@ class FenrisTuiApp(App):
|
||||
identity = _IDENTITY_FALLBACK
|
||||
lines = ["[bold]%s[/bold]" % identity]
|
||||
if self._show_auth_notice:
|
||||
lines.append("[dim]privileged actions will prompt for authentication (polkit)[/dim]")
|
||||
lines.append("[dim]Open with fenris (no sudo). Actions authenticate via polkit. ? Help[/dim]")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _render_headline(self, body: str = "") -> None:
|
||||
@@ -1126,94 +1025,67 @@ class FenrisTuiApp(App):
|
||||
text += "\n\n" + body
|
||||
self.query_one("#headline-band").update(text)
|
||||
|
||||
def _open_store(self) -> Optional[sqlite3.Connection]:
|
||||
"""Open store read-only, handling faults."""
|
||||
try:
|
||||
return open_store_readonly(self.store_path)
|
||||
except (StoreFault, NewerSchema):
|
||||
return None
|
||||
|
||||
def _refresh(self) -> None:
|
||||
"""Refresh all four regions from store data."""
|
||||
self._clock_now = datetime.now(timezone.utc)
|
||||
# Also update constrained state on each refresh (issue #81)
|
||||
try:
|
||||
width, height = self.size
|
||||
was_constrained = self._is_constrained_mode
|
||||
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
||||
main_grid = self.query_one("#main-grid")
|
||||
if self._is_constrained_mode:
|
||||
main_grid.add_class("constrained")
|
||||
else:
|
||||
main_grid.remove_class("constrained")
|
||||
if self._is_constrained_mode and not was_constrained:
|
||||
self._update_constrained_summary()
|
||||
except Exception:
|
||||
pass
|
||||
conn = self._open_store()
|
||||
|
||||
if conn is None:
|
||||
self._render_empty_or_fault()
|
||||
return
|
||||
|
||||
try:
|
||||
self._conn = conn
|
||||
self._render_all_regions(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
self._conn = None
|
||||
|
||||
def _render_empty_or_fault(self) -> None:
|
||||
"""Render empty store greeting or store fault."""
|
||||
self._hide_paused_banner()
|
||||
if not self.store_path.exists():
|
||||
# Empty store — greeting with enable hint (IN-3)
|
||||
self._render_headline(
|
||||
"[bold]No observations yet[/bold]\n\n"
|
||||
"[bold]%s[/bold]" % _RESUME_HINT
|
||||
)
|
||||
self.query_one("#usage-history").set_data([])
|
||||
if self._is_constrained_mode:
|
||||
self.query_one("#constrained-summary").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\nawaiting first sample"
|
||||
)
|
||||
self.query_one("#drive-health").update("")
|
||||
self.query_one("#service-strip").update(
|
||||
(
|
||||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
||||
"[bold]CONTINUITY[/bold] %s\n" + _ACTION_LEGEND
|
||||
) % monitoring_continuity({"boot_enabled": False})
|
||||
)
|
||||
width, height = self.size
|
||||
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
||||
main_grid = self.query_one("#main-grid")
|
||||
if self._is_constrained_mode:
|
||||
main_grid.add_class("constrained")
|
||||
else:
|
||||
# Store fault (FL-4)
|
||||
self._render_headline(
|
||||
"[bold red]Observation store unreadable[/bold red]\n"
|
||||
"Check journalctl -u fenris-collect.service"
|
||||
)
|
||||
self.query_one("#usage-history").set_data([])
|
||||
if self._is_constrained_mode:
|
||||
self.query_one("#constrained-summary").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\nobservation store unreadable"
|
||||
)
|
||||
self.query_one("#drive-health").update("")
|
||||
main_grid.remove_class("constrained")
|
||||
with read_status(
|
||||
self.store_path, self._clock_now, reduced_motion=self._reduced_motion,
|
||||
) as (conn, comp):
|
||||
if conn is None:
|
||||
self._render_empty_or_fault(comp)
|
||||
else:
|
||||
self._render_all_regions(conn, comp)
|
||||
self.query_one("#service-strip").update(
|
||||
_ACTION_LEGEND
|
||||
"%s\n%s" % (render_status_tui(comp), _ACTION_LEGEND)
|
||||
)
|
||||
self._render_paused_banner(comp)
|
||||
|
||||
def _render_empty_or_fault(self, comp: StatusComposition) -> None:
|
||||
"""Render empty history or the shared store-fault classification."""
|
||||
if comp.newer_schema or comp.store_fault:
|
||||
message = comp.explanation[:1].upper() + comp.explanation[1:]
|
||||
self._render_headline("[bold red]%s[/bold red]" % message)
|
||||
if comp.store_fault:
|
||||
hint = _journalctl_hint()
|
||||
if hint:
|
||||
self.query_one("#drive-health").update(Text(hint))
|
||||
else:
|
||||
self.query_one("#drive-health").update(
|
||||
"Check observation store permissions and collector logs. ? Help"
|
||||
)
|
||||
else:
|
||||
self.query_one("#drive-health").update("")
|
||||
else:
|
||||
message = "Awaiting first sample"
|
||||
self._render_headline(
|
||||
"[bold]No observations yet[/bold]\n\n[bold]%s[/bold]" % _RESUME_HINT
|
||||
)
|
||||
self.query_one("#drive-health").update("")
|
||||
|
||||
graph = self.query_one("#usage-history")
|
||||
graph.set_data([])
|
||||
if comp.store_fault or comp.newer_schema:
|
||||
self.query_one("#bar-render").update(message)
|
||||
if self._is_constrained_mode:
|
||||
self.query_one("#constrained-summary").update(
|
||||
"[dim]Graph needs ≥80×24[/dim]\n" + message
|
||||
)
|
||||
|
||||
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
||||
def _render_all_regions(self, conn: sqlite3.Connection, comp: StatusComposition) -> None:
|
||||
"""Render all four regions from live store data."""
|
||||
# --- Sample count for single-sample state (issue #73 AC3) ---
|
||||
try:
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||
sample_count = cursor.fetchone()[0]
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||
day_count = cursor.fetchone()[0]
|
||||
except Exception:
|
||||
sample_count = 0
|
||||
day_count = 0
|
||||
|
||||
# --- Headline band (§7.2) ---
|
||||
if sample_count <= 1 and day_count == 0:
|
||||
if comp.sample_count == 0 and comp.day_count == 0:
|
||||
self._render_headline(
|
||||
"[bold]No observations yet[/bold]\n\n[bold]%s[/bold]" % _RESUME_HINT
|
||||
)
|
||||
elif comp.sample_count <= 1 and comp.day_count == 0:
|
||||
# Single sample: awaiting another sample
|
||||
self._render_headline(
|
||||
"[bold]Awaiting another sample[/bold]\n\n"
|
||||
@@ -1253,10 +1125,10 @@ class FenrisTuiApp(App):
|
||||
# Vendor wear grouped under Drive health with context
|
||||
health_text = (
|
||||
"[bold]Drive health[/bold] · %s\n"
|
||||
" temperature %d°C · spare %d%%\n"
|
||||
" media errors %d · unsafe shutdowns %d\n"
|
||||
" power-on %d h · %d cycles · %s\n"
|
||||
" vendor wear: %d%% used · %.1f TB written"
|
||||
" Temperature %d°C · spare %d%%\n"
|
||||
" Media errors %d · unsafe shutdowns %d\n"
|
||||
" Power-on %d h · %d cycles · %s\n"
|
||||
" Vendor wear: %d%% used · %.1f TB written"
|
||||
) % (
|
||||
health["model"],
|
||||
health["temp"],
|
||||
@@ -1271,30 +1143,6 @@ class FenrisTuiApp(App):
|
||||
)
|
||||
self.query_one("#drive-health").update(health_text)
|
||||
|
||||
# --- Service strip (§7.2 bottom) ---
|
||||
try:
|
||||
svc = _query_service_facts(conn, self._clock_now)
|
||||
# Use shared status composition
|
||||
comp = compose_status(
|
||||
conn, svc, self._clock_now,
|
||||
store_fault=None,
|
||||
newer_schema=None,
|
||||
reduced_motion=self._reduced_motion,
|
||||
)
|
||||
# Render using the shared composition with TUI styling
|
||||
status_text = render_status_tui(comp)
|
||||
# Add TUI-only actions
|
||||
self.query_one("#service-strip").update(
|
||||
"%s\n%s" % (status_text, _ACTION_LEGEND)
|
||||
)
|
||||
self._render_paused_banner(comp)
|
||||
except Exception:
|
||||
self._hide_paused_banner()
|
||||
self.query_one("#service-strip").update(
|
||||
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
|
||||
+ _ACTION_LEGEND
|
||||
)
|
||||
|
||||
def _render_paused_banner(self, comp) -> None:
|
||||
"""Show the high-contrast Deliberate disable block only when sanctioned.
|
||||
|
||||
@@ -1312,7 +1160,7 @@ class FenrisTuiApp(App):
|
||||
if is_paused and paused_lines:
|
||||
banner.update(
|
||||
"[bold black on red]%s[/bold black on red]\n%s"
|
||||
% tuple(paused_lines)
|
||||
% tuple(line[:1].upper() + line[1:] for line in paused_lines)
|
||||
)
|
||||
banner.styles.display = "block"
|
||||
main_grid = self.query_one("#main-grid")
|
||||
@@ -1331,16 +1179,12 @@ class FenrisTuiApp(App):
|
||||
def _on_graph_drill(self, day: str) -> None:
|
||||
"""Load hourly data when the graph enters drill-down mode."""
|
||||
graph = self.query_one("#usage-history")
|
||||
try:
|
||||
conn = self._open_store()
|
||||
if conn is not None:
|
||||
try:
|
||||
hour_data = _query_hourly_graph_data(conn, day)
|
||||
graph.set_hour_data(hour_data)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
graph.set_hour_data([])
|
||||
with read_status(
|
||||
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 []
|
||||
)
|
||||
|
||||
def _format_headline(self, proj: ProjectionResult) -> str:
|
||||
"""Format the lifespan headline (spec §6.11)."""
|
||||
@@ -1357,7 +1201,7 @@ class FenrisTuiApp(App):
|
||||
regime = " · sustained regime: %d days" % proj.regime_days
|
||||
return (
|
||||
"[bold]Usage-adjusted theoretical lifespan: [white]%s remaining[/white][/bold]"
|
||||
"\n if current habits continue%s" % (remaining, regime)
|
||||
"\n If current habits continue%s" % (remaining, regime)
|
||||
)
|
||||
|
||||
def _format_confidence(self, proj: ProjectionResult) -> str:
|
||||
@@ -1377,7 +1221,7 @@ class FenrisTuiApp(App):
|
||||
color,
|
||||
proj.confidence_state.value,
|
||||
color,
|
||||
facts,
|
||||
facts[:1].upper() + facts[1:],
|
||||
)
|
||||
|
||||
def _format_scenario(self, proj: ProjectionResult) -> str:
|
||||
@@ -1436,48 +1280,29 @@ class FenrisTuiApp(App):
|
||||
|
||||
def action_collect(self) -> None:
|
||||
"""Collect now — synchronous outcome (spec §8.7, LC-8)."""
|
||||
self._run_helper("collect", blocking=True)
|
||||
self._run_helper("collect")
|
||||
|
||||
def action_disclose(self) -> None:
|
||||
"""Show disclosures (spec §6.11, CI-4)."""
|
||||
self.push_screen(DisclosuresScreen())
|
||||
|
||||
def action_help(self) -> None:
|
||||
"""Show keyboard controls and sudo guidance."""
|
||||
self.push_screen(HelpScreen())
|
||||
|
||||
def _run_helper(
|
||||
self,
|
||||
operation: str,
|
||||
extra_args: Optional[List[str]] = None,
|
||||
blocking: bool = False,
|
||||
) -> None:
|
||||
"""Run fenris-monitor as terminal-attached subprocess (LC-6, LC-8).
|
||||
|
||||
The TUI suspends, polkit agent prompts on real terminal, control returns.
|
||||
"""
|
||||
cmd = [self.helper_path, operation]
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
if os.geteuid() != 0:
|
||||
cmd.insert(0, "pkexec")
|
||||
|
||||
"""Suspend the TUI while the shared control module owns the action."""
|
||||
try:
|
||||
with self.suspend():
|
||||
proc = subprocess.run(cmd, timeout=30)
|
||||
if proc.returncode != 0:
|
||||
self.notify(
|
||||
"Operation failed (exit %d)" % proc.returncode,
|
||||
severity="error",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
self.notify(
|
||||
"Helper not found: %s" % self.helper_path,
|
||||
severity="error",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.notify("Operation timed out", severity="error")
|
||||
except Exception as e:
|
||||
self.notify("Error: %s" % e, severity="error")
|
||||
|
||||
# Refresh after action
|
||||
self._refresh()
|
||||
run_monitor(operation, *(extra_args or []), helper_path=self.helper_path)
|
||||
except MonitorError as exc:
|
||||
self.notify(str(exc), severity="error")
|
||||
finally:
|
||||
self._refresh()
|
||||
|
||||
|
||||
def run_tui(
|
||||
|
||||
Reference in New Issue
Block a user