656 lines
23 KiB
Python
656 lines
23 KiB
Python
"""Panes TUI: keyboard-first Textual app (spec §7, TUI-1, TUI-4).
|
||
|
||
One dense screen, four normative regions:
|
||
1. Headline band (full width, top)
|
||
2. Usage-history pane (left, wider)
|
||
3. Drive-health + settings pane (right, narrower)
|
||
4. Service strip (full width, bottom)
|
||
|
||
Bindings: p (pause, asks), r (resume), c (collect now), d (disclosures), q (quit).
|
||
Privileged actions route through fenris-monitor as terminal-attached subprocesses
|
||
(LC-6, LC-8). The TUI never samples in-process.
|
||
|
||
Criteria: TUI-1, TUI-2, TUI-4, CI-1, CI-2, CI-4, IN-3, LC-6, LC-8.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
import subprocess
|
||
import sys
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from textual.app import App, ComposeResult
|
||
from textual.binding import Binding
|
||
from textual.containers import Container, Horizontal, VerticalScroll
|
||
from textual.screen import ModalScreen
|
||
from textual.widgets import Static
|
||
|
||
from .projection import (
|
||
ConfidenceState,
|
||
ProjectionResult,
|
||
ScenarioRange,
|
||
compute_projection,
|
||
)
|
||
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,
|
||
)
|
||
from .monitoring_periods import get_open_period
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _format_remaining(seconds: float) -> str:
|
||
"""Format remaining lifespan as human-readable string."""
|
||
if seconds <= 0:
|
||
return "endurance exhausted"
|
||
years = int(seconds // 31557600)
|
||
rem = seconds % 31557600
|
||
days = int(rem // 86400)
|
||
rem %= 86400
|
||
hours = int(rem // 3600)
|
||
parts = []
|
||
if years:
|
||
parts.append("%d yr" % years)
|
||
if days or years:
|
||
parts.append("%d d" % days)
|
||
parts.append("%d h" % hours)
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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(
|
||
"SELECT mn, sn, fr, temperature_c, available_spare, media_errors, "
|
||
"power_on_hours, power_cycles, unsafe_shutdowns, capacity_bytes, "
|
||
"percentage_used, data_units_written "
|
||
"FROM samples ORDER BY id DESC LIMIT 1"
|
||
)
|
||
row = cursor.fetchone()
|
||
if row is None:
|
||
return {
|
||
"model": "unknown",
|
||
"temp": 0,
|
||
"spare": 0,
|
||
"media_errors": 0,
|
||
"poh": 0,
|
||
"cycles": 0,
|
||
"unsafe_shutdowns": 0,
|
||
"capacity": "unknown",
|
||
"percentage_used": 0,
|
||
"written_tb": 0,
|
||
}
|
||
|
||
capacity = row[9]
|
||
capacity_str = "%d GB" % (capacity / 1e9) if capacity else "unknown"
|
||
written_tb = (row[11] * 512 * 1000) / 1e12 if row[11] else 0 # DUW to TB
|
||
|
||
return {
|
||
"model": row[0] or "unknown",
|
||
"temp": row[3] or 0,
|
||
"spare": row[4] or 0,
|
||
"media_errors": row[5] or 0,
|
||
"poh": row[6] or 0,
|
||
"cycles": row[7] or 0,
|
||
"unsafe_shutdowns": row[8] or 0,
|
||
"capacity": capacity_str,
|
||
"percentage_used": row[10] or 0,
|
||
"written_tb": written_tb,
|
||
}
|
||
|
||
|
||
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
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class ConfirmPause(ModalScreen[bool]):
|
||
"""Pause asks for confirmation (spec §7.4, LC-6)."""
|
||
|
||
BINDINGS = [
|
||
Binding("y", "yes", "Pause"),
|
||
Binding("n", "no", "Cancel"),
|
||
Binding("escape", "no", "Cancel", show=False),
|
||
]
|
||
|
||
def compose(self) -> ComposeResult:
|
||
yield Static(
|
||
"[bold]Pause monitoring?[/bold]\n\n"
|
||
"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]",
|
||
id="confirm-text",
|
||
)
|
||
|
||
def action_yes(self) -> None:
|
||
self.dismiss(True)
|
||
|
||
def action_no(self) -> None:
|
||
self.dismiss(False)
|
||
|
||
|
||
class DisclosuresScreen(ModalScreen[None]):
|
||
"""Disclosures view with six verbatim disclosures (spec §6.11, CI-4)."""
|
||
|
||
BINDINGS = [
|
||
Binding("escape", "close", "Close"),
|
||
Binding("d", "close", "Close"),
|
||
]
|
||
|
||
def compose(self) -> ComposeResult:
|
||
text = format_disclosures()
|
||
yield Static(text + "\n\n[dim]esc to close[/dim]", id="disc-text")
|
||
|
||
def action_close(self) -> None:
|
||
self.dismiss()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main TUI App
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class FenrisTuiApp(App):
|
||
"""Fenris Panes TUI — keyboard-first, one dense screen (spec §7)."""
|
||
|
||
TITLE = "Fenris — NVMe endurance monitor"
|
||
SUB_TITLE = ""
|
||
|
||
CSS = """
|
||
#main-grid {
|
||
layout: grid;
|
||
grid-size: 2 4;
|
||
grid-columns: 3fr 2fr;
|
||
grid-rows: 8 10 7 3;
|
||
height: auto;
|
||
}
|
||
#main-grid.paused {
|
||
grid-size: 2 5;
|
||
grid-rows: 8 5 10 7 3;
|
||
}
|
||
#dashboard-scroll { height: 1fr; }
|
||
#headline-band { column-span: 2; }
|
||
#paused-banner {
|
||
column-span: 2;
|
||
display: none;
|
||
background: $error 20%;
|
||
color: $text;
|
||
height: 100%;
|
||
}
|
||
#service-strip { column-span: 2; height: 100%; }
|
||
#quit-rail {
|
||
column-span: 2;
|
||
border: heavy $accent;
|
||
content-align: center middle;
|
||
height: 100%;
|
||
}
|
||
.pane { border: round #555555; padding: 0 1; height: 100%; }
|
||
#confirm-text { padding: 1 2; }
|
||
#disc-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("d", "disclose", "Disclosures", show=False),
|
||
Binding("q", "quit", "Quit", show=False),
|
||
]
|
||
|
||
def __init__(
|
||
self,
|
||
store_path: Optional[Path] = None,
|
||
config_path: Optional[Path] = None,
|
||
helper_path: Optional[str] = None,
|
||
refresh_interval_s: float = CADENCE_DEFAULT_S,
|
||
**kwargs,
|
||
) -> None:
|
||
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.refresh_interval_s = refresh_interval_s
|
||
self._show_auth_notice = True
|
||
self._conn: Optional[sqlite3.Connection] = None
|
||
self._clock_now = datetime.now(timezone.utc)
|
||
|
||
def compose(self) -> ComposeResult:
|
||
with VerticalScroll(id="dashboard-scroll"):
|
||
with Container(id="main-grid"):
|
||
yield Static("", id="headline-band", classes="pane")
|
||
yield Static("", id="paused-banner")
|
||
yield Static("", id="usage-history", classes="pane")
|
||
yield Static("", id="drive-health", classes="pane")
|
||
yield Static("", id="service-strip", classes="pane")
|
||
yield Static("q QUIT TUI", id="quit-rail")
|
||
|
||
def on_mount(self) -> None:
|
||
"""Set border titles and render initial state."""
|
||
self.query_one("#headline-band").border_title = "headline"
|
||
self.query_one("#usage-history").border_title = "usage history"
|
||
self.query_one("#drive-health").border_title = "drive"
|
||
self.query_one("#service-strip").border_title = "service + actions"
|
||
self._refresh_timer = self.set_interval(
|
||
self.refresh_interval_s, self.on_refresh_tick
|
||
)
|
||
self._refresh()
|
||
|
||
def on_refresh_tick(self) -> None:
|
||
"""Refresh dashboard and dismiss launch-only authentication guidance."""
|
||
self._show_auth_notice = False
|
||
self._refresh()
|
||
|
||
def _headline_prefix(self) -> str:
|
||
"""Render identity and any launch-only guidance above drive state."""
|
||
lines = ["[bold]Fenris — NVMe endurance monitor[/bold]"]
|
||
if self._show_auth_notice:
|
||
lines.append("[dim]privileged actions will prompt for authentication (polkit)[/dim]")
|
||
return "\n".join(lines)
|
||
|
||
def _render_headline(self, body: str = "") -> None:
|
||
"""Render full-width identity, guidance, and current drive state."""
|
||
text = self._headline_prefix()
|
||
if body:
|
||
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)
|
||
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"
|
||
"Enable monitoring: fenris monitor resume"
|
||
)
|
||
self.query_one("#usage-history").update("")
|
||
self.query_one("#drive-health").update("")
|
||
self.query_one("#service-strip").update(
|
||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty · "
|
||
"[dim]by Bongbetic[/dim]\n"
|
||
"[bold]CONTINUITY[/bold] %s\n"
|
||
"p pause · r resume · c collect · d disclosures"
|
||
% monitoring_continuity({"boot_enabled": False})
|
||
)
|
||
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").update("")
|
||
self.query_one("#drive-health").update("")
|
||
self.query_one("#service-strip").update(
|
||
"[dim]by Bongbetic[/dim]\n"
|
||
"p pause · r resume · c collect · d disclosures"
|
||
)
|
||
|
||
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
||
"""Render all four regions from live store data."""
|
||
# --- Headline band (§7.2) ---
|
||
try:
|
||
proj = compute_projection(conn, self._clock_now)
|
||
headline = self._format_headline(proj)
|
||
confidence = self._format_confidence(proj)
|
||
scenario = self._format_scenario(proj)
|
||
self._render_headline(headline + "\n" + confidence + "\n" + scenario)
|
||
except Exception:
|
||
self._render_headline("[bold]No projection available[/bold]")
|
||
|
||
# --- Usage-history pane (§7.2 left) ---
|
||
history = _query_usage_history(conn)
|
||
history_text = "[bold]Usage history[/bold] · %d days · %.1f–%.1f GB/day\n %s\n %s" % (
|
||
history["num_days"],
|
||
history["min_gb"],
|
||
history["max_gb"],
|
||
history["sparkline"],
|
||
history["habit_bar"],
|
||
)
|
||
self.query_one("#usage-history").update(history_text)
|
||
|
||
# --- Drive-health pane (§7.2 right) ---
|
||
health = _query_drive_health(conn)
|
||
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\n"
|
||
"[bold]Settings[/bold]\n"
|
||
" vendor wear: %d%% used · %.1f TB written"
|
||
) % (
|
||
health["model"],
|
||
health["temp"],
|
||
health["spare"],
|
||
health["media_errors"],
|
||
health["unsafe_shutdowns"],
|
||
health["poh"],
|
||
health["cycles"],
|
||
health["capacity"],
|
||
health["percentage_used"],
|
||
health["written_tb"],
|
||
)
|
||
self.query_one("#drive-health").update(health_text)
|
||
|
||
# --- Service strip (§7.2 bottom) ---
|
||
try:
|
||
svc = _query_service_facts(conn, self._clock_now)
|
||
boot = "enabled" if svc.get("boot_enabled") else "disabled"
|
||
activity = "active" if svc.get("timer_active") else "inactive"
|
||
collect = "ok" if svc.get("last_collect_ok") else "FAILED"
|
||
freshness = svc.get("freshness", "unknown")
|
||
self.query_one("#service-strip").update(
|
||
"boot: %s · timer: %s · last collect: %s · freshness: %s · "
|
||
"[dim]by Bongbetic[/dim]\n"
|
||
"[bold]CONTINUITY[/bold] %s\n"
|
||
"%s\n"
|
||
"p pause · r resume · c collect · d disclosures"
|
||
% (
|
||
boot, activity, collect, freshness,
|
||
monitoring_continuity(svc), svc.get("period", ""),
|
||
)
|
||
)
|
||
self._render_paused_banner(svc)
|
||
except Exception:
|
||
self._hide_paused_banner()
|
||
self.query_one("#service-strip").update(
|
||
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown · "
|
||
"[dim]by Bongbetic[/dim]\n"
|
||
"p pause · r resume · c collect · d disclosures"
|
||
)
|
||
|
||
def _render_paused_banner(self, service: Dict[str, Any]) -> None:
|
||
"""Show the high-contrast Deliberate disable block only when sanctioned."""
|
||
banner = self.query_one("#paused-banner")
|
||
if service.get("deliberately_paused"):
|
||
banner.update(
|
||
"[bold black on red]%s[/bold black on red]\n%s"
|
||
% tuple(deliberate_pause_lines())
|
||
)
|
||
banner.styles.display = "block"
|
||
main_grid = self.query_one("#main-grid")
|
||
main_grid.add_class("paused")
|
||
main_grid.refresh(layout=True)
|
||
else:
|
||
self._hide_paused_banner()
|
||
|
||
def _hide_paused_banner(self) -> None:
|
||
"""Ensure an unavailable store cannot retain a stale paused presentation."""
|
||
self.query_one("#paused-banner").styles.display = "none"
|
||
main_grid = self.query_one("#main-grid")
|
||
main_grid.remove_class("paused")
|
||
main_grid.refresh(layout=True)
|
||
|
||
def _format_headline(self, proj: ProjectionResult) -> str:
|
||
"""Format the lifespan headline (spec §6.11)."""
|
||
if proj.headline_remaining_seconds is None:
|
||
if proj.zero_rate_fact:
|
||
return "[bold]Usage-adjusted theoretical lifespan: [red]no finite projection from this history[/red][/bold]"
|
||
if proj.warming_fact:
|
||
return "[bold]Usage-adjusted theoretical lifespan: [yellow]%s[/yellow][/bold]" % proj.warming_fact
|
||
return "[bold]Usage-adjusted theoretical lifespan: [red]no projection available[/red][/bold]"
|
||
|
||
remaining = _format_remaining(proj.headline_remaining_seconds)
|
||
regime = ""
|
||
if proj.regime_days:
|
||
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)
|
||
)
|
||
|
||
def _format_confidence(self, proj: ProjectionResult) -> str:
|
||
"""Format confidence state with contributing facts (spec §6.7)."""
|
||
color = {
|
||
ConfidenceState.SUPPORTED: "green",
|
||
ConfidenceState.LIMITED: "yellow",
|
||
ConfidenceState.UNSUPPORTED: "red",
|
||
}[proj.confidence_state]
|
||
facts = " · ".join(proj.contributing_facts[:3]) if proj.contributing_facts else "no facts"
|
||
return "[bold]Projection confidence: [%s]%s[/%s][/bold]\n %s" % (
|
||
color,
|
||
proj.confidence_state.value,
|
||
color,
|
||
facts,
|
||
)
|
||
|
||
def _format_scenario(self, proj: ProjectionResult) -> str:
|
||
"""Format scenario range (spec §6.5)."""
|
||
if not proj.scenario_range or not proj.scenario_range.rates:
|
||
return ""
|
||
parts = []
|
||
for horizon in sorted(proj.scenario_range.rates.keys()):
|
||
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
|
||
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
|
||
return "[bold]Scenario range[/bold] · %s" % " · ".join(parts)
|
||
|
||
# --- Actions ---
|
||
|
||
def action_pause(self) -> None:
|
||
"""Pause monitoring — asks for confirmation (spec §7.4, LC-6)."""
|
||
self.push_screen(ConfirmPause(), callback=self._pause_confirmed)
|
||
|
||
def _pause_confirmed(self, confirmed: bool) -> None:
|
||
if not confirmed:
|
||
return
|
||
# Route through fenris-monitor as terminal-attached subprocess (LC-6)
|
||
self._run_helper("disable", ["--now"])
|
||
|
||
def action_resume(self) -> None:
|
||
"""Resume monitoring — no confirmation (spec §7.4, LC-6)."""
|
||
self._run_helper("enable", ["--now"])
|
||
|
||
def action_collect(self) -> None:
|
||
"""Collect now — synchronous outcome (spec §8.7, LC-8)."""
|
||
self._run_helper("collect", blocking=True)
|
||
|
||
def action_disclose(self) -> None:
|
||
"""Show disclosures (spec §6.11, CI-4)."""
|
||
self.push_screen(DisclosuresScreen())
|
||
|
||
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)
|
||
|
||
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()
|
||
|
||
|
||
def run_tui(
|
||
store_path: Optional[Path] = None,
|
||
helper_path: Optional[str] = None,
|
||
) -> None:
|
||
"""Entry point for the Fenris TUI."""
|
||
app = FenrisTuiApp(
|
||
store_path=store_path,
|
||
helper_path=helper_path,
|
||
)
|
||
app.run()
|