Release Fenris 0.3.6
Release / release (push) Failing after 6s

This commit is contained in:
xavierk
2026-09-16 08:05:28 +05:30
parent f06424f3b8
commit 44c57b70dd
29 changed files with 788 additions and 810 deletions
+159 -334
View File
@@ -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(