@@ -1,2 +1,2 @@
|
||||
"""Fenris: NVMe wear monitor with persistent TUI."""
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.3.6"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Terminal-attached invocation of Fenris's fixed privileged operations.
|
||||
|
||||
Both human entry points use this module. Authentication has no frontend
|
||||
deadline; collection runtime is bounded by the native scheduler (ADR 0003).
|
||||
"""
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
|
||||
MONITOR_HELPER = "/usr/libexec/fenris/fenris-monitor"
|
||||
|
||||
|
||||
class MonitorError(Exception):
|
||||
"""An action failed, with a message and exit status for either renderer."""
|
||||
|
||||
def __init__(self, message: str, exit_code: int = 1):
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
def run_monitor(*args: str, helper_path: str = MONITOR_HELPER) -> None:
|
||||
"""Run one helper operation, inheriting the terminal for authentication.
|
||||
|
||||
Never invoke a shell, retry an action, or fall back to sudo automatically.
|
||||
The helper owns the operation allow-list and privileged state changes.
|
||||
"""
|
||||
helper_command = [helper_path, *args]
|
||||
needs_auth = os.geteuid() != 0
|
||||
command = ["pkexec", *helper_command] if needs_auth else helper_command
|
||||
root_hint = (
|
||||
" If authentication is unavailable, run in your terminal: "
|
||||
+ shlex.join(["sudo", *helper_command])
|
||||
) if needs_auth else ""
|
||||
|
||||
try:
|
||||
# The collector owns its 90-second runtime limit. A frontend timeout
|
||||
# would also count time spent authenticating or waiting for a run.
|
||||
result = subprocess.run(command)
|
||||
except FileNotFoundError as exc:
|
||||
raise MonitorError(
|
||||
"Command not found: %s.%s" % (exc.filename or command[0], root_hint), 127,
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise MonitorError("Cannot run monitoring action: %s.%s" % (exc, root_hint)) from exc
|
||||
except KeyboardInterrupt as exc:
|
||||
raise MonitorError(
|
||||
"Action interrupted. Check fenris status before retrying.", 130,
|
||||
) from exc
|
||||
|
||||
if result.returncode:
|
||||
exit_code = result.returncode if result.returncode > 0 else 128 - result.returncode
|
||||
raise MonitorError(
|
||||
"Action failed (exit %d). Check fenris status before retrying.%s"
|
||||
% (exit_code, root_hint), exit_code,
|
||||
)
|
||||
@@ -166,11 +166,11 @@ def _systemd_query_state() -> Dict[str, Any]:
|
||||
"ActiveState", "ExecMainStatus", "ExecMainExitTimestamp",
|
||||
)
|
||||
|
||||
boot_enabled_str = timer_props.get("UnitFileState", "")
|
||||
boot_enabled = boot_enabled_str == "enabled"
|
||||
boot_enabled_str = timer_props.get("UnitFileState")
|
||||
boot_enabled = boot_enabled_str == "enabled" if boot_enabled_str else None
|
||||
|
||||
active_state = timer_props.get("ActiveState", "inactive")
|
||||
timer_active = active_state == "active"
|
||||
active_state = timer_props.get("ActiveState")
|
||||
timer_active = active_state == "active" if active_state else None
|
||||
|
||||
last_collect_ok = None
|
||||
last_collect_age_s = None
|
||||
|
||||
+100
-229
@@ -14,10 +14,14 @@ Freshness constants are defined once here and shared with the TUI (§8.9):
|
||||
Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7.
|
||||
"""
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .status_composition import StatusComposition
|
||||
|
||||
from .projection import compute_projection, ConfidenceState, DISCLOSURES
|
||||
from .store import SCHEMA_VERSION
|
||||
@@ -98,7 +102,7 @@ def open_store_readonly(store_path: Path) -> sqlite3.Connection:
|
||||
# PermissionError before any StoreFault can be raised (issue #54).
|
||||
raise StoreFault("observation store not readable: %s" % e)
|
||||
if not exists:
|
||||
raise StoreFault("observation store not found at %s" % store_path)
|
||||
raise MissingStore("observation store not found at %s" % store_path)
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True)
|
||||
@@ -125,6 +129,10 @@ class StoreFault(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingStore(StoreFault):
|
||||
"""No observation history has been created yet."""
|
||||
|
||||
|
||||
class NewerSchema(Exception):
|
||||
"""Store has a newer user_version (§9.5)."""
|
||||
def __init__(self, version: int):
|
||||
@@ -246,27 +254,12 @@ def check_retired_flag(flag: str) -> Optional[str]:
|
||||
# Formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
drive_facts: List[str], config_error: Optional[str],
|
||||
store_fault: Optional[str], newer_schema: Optional[str],
|
||||
journal_hint: Optional[str],
|
||||
def _format_projection(proj, freshness: str, drive_facts: List[str],
|
||||
config_error: Optional[str],
|
||||
sample_count: int = 0, day_count: int = 0) -> str:
|
||||
"""Format the complete status output."""
|
||||
"""Format projection details; monitoring status has its own renderer."""
|
||||
lines = []
|
||||
|
||||
# --- Store/system fault overrides (§9.4, §9.5) ---
|
||||
if store_fault:
|
||||
lines.append("observation store unreadable")
|
||||
if journal_hint:
|
||||
lines.append("")
|
||||
lines.append("Recent journal entries:")
|
||||
lines.append(journal_hint)
|
||||
return "\n".join(lines)
|
||||
|
||||
if newer_schema:
|
||||
lines.append("observation store written by a newer Fenris — upgrade Fenris")
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Configuration error (§8.3) ---
|
||||
if config_error:
|
||||
lines.append("configuration error: %s" % config_error)
|
||||
@@ -277,7 +270,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append("no observations yet")
|
||||
lines.append("")
|
||||
lines.append("Enable monitoring: fenris monitor resume")
|
||||
_append_service_facts(lines, service)
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Single sample: awaiting another sample (issue #73 AC3) ---
|
||||
@@ -287,7 +279,10 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append("awaiting another sample")
|
||||
lines.append("")
|
||||
lines.append("Collecting usage data — the first projection requires at least two samples.")
|
||||
_append_service_facts(lines, service)
|
||||
return "\n".join(lines)
|
||||
|
||||
if proj is None:
|
||||
lines.append("no projection available")
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Projection headline ---
|
||||
@@ -332,16 +327,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append(fact)
|
||||
lines.append("")
|
||||
|
||||
# --- Four separate service facts (§7.3, LC-9) ---
|
||||
_append_service_facts(lines, service)
|
||||
|
||||
# --- Journal hint on failure or staleness (§8.8) ---
|
||||
if journal_hint:
|
||||
if freshness in ("missed", "stale"):
|
||||
lines.append("")
|
||||
lines.append("Recent journal entries:")
|
||||
lines.append(journal_hint)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -386,36 +371,6 @@ def _format_headline(proj) -> str:
|
||||
return headline
|
||||
|
||||
|
||||
def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
|
||||
"""Append service facts and dashboard-clarity monitoring state."""
|
||||
boot = "enabled" if service.get("boot_enabled") else "disabled"
|
||||
activity = "active" if service.get("timer_active") else "inactive"
|
||||
|
||||
if service.get("last_collect_ok") is True:
|
||||
collect = "ok"
|
||||
elif service.get("last_collect_ok") is False:
|
||||
collect = "FAILED"
|
||||
if service.get("last_collect_reason"):
|
||||
collect += " (%s)" % service["last_collect_reason"]
|
||||
else:
|
||||
collect = "unknown"
|
||||
|
||||
collect_age = ""
|
||||
if service.get("last_collect_age_s") is not None:
|
||||
collect_age = " %s" % freshness_age_human(service["last_collect_age_s"])
|
||||
|
||||
freshness_str = service.get("freshness", "unknown")
|
||||
freshness_age = ""
|
||||
if service.get("freshness_age_s") is not None:
|
||||
freshness_age = " (%s)" % freshness_age_human(service["freshness_age_s"])
|
||||
|
||||
lines.append("boot: %s · timer: %s · last collect: %s%s · freshness: %s%s"
|
||||
% (boot, activity, collect, collect_age, freshness_str, freshness_age))
|
||||
lines.append("CONTINUITY: %s" % monitoring_continuity(service))
|
||||
if service.get("deliberately_paused"):
|
||||
lines.extend(deliberate_pause_lines())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard clarity parity wording (DC-2, DC-3)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -430,7 +385,9 @@ _PAUSED_CONSEQUENCE = (
|
||||
|
||||
def monitoring_continuity(service: Dict[str, Any]) -> str:
|
||||
"""Return the boot-persistence wording, independent of timer runtime."""
|
||||
return _CONTINUITY_ACTIVE if service.get("boot_enabled") else _CONTINUITY_DISABLED
|
||||
if service.get("boot_enabled") is None:
|
||||
return "monitoring: boot persistence unknown"
|
||||
return _CONTINUITY_ACTIVE if service["boot_enabled"] else _CONTINUITY_DISABLED
|
||||
|
||||
|
||||
def deliberate_pause_lines() -> List[str]:
|
||||
@@ -476,121 +433,92 @@ def format_disclosures() -> str:
|
||||
# Main status entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
|
||||
query_services: bool = True, query_journal: bool = True) -> str:
|
||||
"""Render the complete read-only status (§8.8, LC-9).
|
||||
@contextmanager
|
||||
def read_status(
|
||||
store_path: Optional[Path] = None,
|
||||
clock_now: Optional[datetime] = None,
|
||||
query_services: bool = True,
|
||||
collecting: bool = False,
|
||||
reduced_motion: bool = False,
|
||||
) -> Iterator[Tuple[Optional[sqlite3.Connection], "StatusComposition"]]:
|
||||
"""Yield a read-only store snapshot and its composed monitoring status.
|
||||
|
||||
This is the single entry point for 'fenris status'. It never auto-samples,
|
||||
never prompts, and never writes to the store.
|
||||
Own acquisition, fault classification, and connection lifetime for both
|
||||
renderers. An absent store is empty; an unreadable or newer store exposes
|
||||
no connection. Unknown monitoring facts are never coerced to disabled.
|
||||
"""
|
||||
if clock_now is None:
|
||||
clock_now = datetime.now(timezone.utc)
|
||||
from .status_composition import compose_status
|
||||
|
||||
# --- Configuration (§8.3) ---
|
||||
config_error = None
|
||||
device = None
|
||||
try:
|
||||
config = read_config()
|
||||
device = config["device"]
|
||||
except ConfigError as e:
|
||||
config_error = str(e)
|
||||
|
||||
# --- Service state ---
|
||||
service = {}
|
||||
clock_now = clock_now or datetime.now(timezone.utc)
|
||||
service = None
|
||||
if query_services:
|
||||
service = query_service_state()
|
||||
|
||||
# --- Store open ---
|
||||
store_fault = None
|
||||
newer_schema = None
|
||||
conn = None
|
||||
|
||||
if store_path is None:
|
||||
store_path = Path("/var/lib/fenris/observations.db")
|
||||
|
||||
try:
|
||||
conn = open_store_readonly(store_path)
|
||||
except StoreFault as e:
|
||||
store_fault = str(e)
|
||||
except NewerSchema as e:
|
||||
newer_schema = str(e)
|
||||
|
||||
# --- Store fault / newer schema short-circuit ---
|
||||
if store_fault or newer_schema:
|
||||
journal_hint = None
|
||||
if query_journal:
|
||||
journal_hint = _journalctl_hint()
|
||||
service["freshness"] = "unknown"
|
||||
service["freshness_age_s"] = None
|
||||
return _format_projection(
|
||||
None, "unknown", service, [], config_error, store_fault, newer_schema, journal_hint
|
||||
)
|
||||
|
||||
# --- Freshness grading (§8.9) ---
|
||||
try:
|
||||
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
newest_ts = row[0] if row else None
|
||||
except sqlite3.Error:
|
||||
newest_ts = None
|
||||
|
||||
freshness = grade_freshness(newest_ts, clock_now)
|
||||
|
||||
# --- Sample count for single-sample state (issue #73 AC3) ---
|
||||
sample_count = 0
|
||||
day_count = 0
|
||||
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 sqlite3.Error:
|
||||
pass
|
||||
|
||||
# Freshness age for the service fact
|
||||
freshness_age_s = None
|
||||
if newest_ts:
|
||||
try:
|
||||
ts = datetime.fromisoformat(newest_ts)
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
freshness_age_s = int((clock_now - ts).total_seconds())
|
||||
except (ValueError, TypeError):
|
||||
service = query_service_state()
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
|
||||
service["freshness"] = freshness
|
||||
service["freshness_age_s"] = freshness_age_s
|
||||
conn = None
|
||||
store_fault = newer_schema = None
|
||||
try:
|
||||
service["deliberately_paused"] = is_deliberately_paused(conn, service)
|
||||
except sqlite3.Error:
|
||||
service["deliberately_paused"] = False
|
||||
try:
|
||||
conn = open_store_readonly(store_path or Path("/var/lib/fenris/observations.db"))
|
||||
conn.execute("BEGIN")
|
||||
except MissingStore:
|
||||
pass
|
||||
except (StoreFault, sqlite3.Error) as exc:
|
||||
store_fault = str(exc)
|
||||
except NewerSchema as exc:
|
||||
newer_schema = str(exc)
|
||||
|
||||
# --- Drive anomalies (§9.7, FL-7) ---
|
||||
drive_facts = []
|
||||
comp = compose_status(
|
||||
conn, service, clock_now, store_fault=store_fault,
|
||||
newer_schema=newer_schema, collecting=collecting,
|
||||
reduced_motion=reduced_motion,
|
||||
)
|
||||
if conn is not None and (comp.store_fault or comp.newer_schema):
|
||||
conn.close()
|
||||
conn = None
|
||||
yield conn, comp
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
|
||||
query_services: bool = True, query_journal: bool = True) -> str:
|
||||
"""Render CLI status through the shared read-only acquisition path."""
|
||||
from .status_composition import render_status_cli
|
||||
|
||||
clock_now = clock_now or datetime.now(timezone.utc)
|
||||
config_error = None
|
||||
try:
|
||||
drive_facts = _query_drive_facts(conn)
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
read_config()
|
||||
except ConfigError as exc:
|
||||
config_error = str(exc)
|
||||
|
||||
# --- Projection (§6 — recomputed on read, never stored) ---
|
||||
try:
|
||||
proj = compute_projection(conn, clock_now)
|
||||
except Exception:
|
||||
proj = None
|
||||
|
||||
# --- Journal hint on failure or staleness (§8.8) ---
|
||||
journal_hint = None
|
||||
if query_journal and freshness in ("missed", "stale"):
|
||||
journal_hint = _journalctl_hint()
|
||||
|
||||
# --- Compose output ---
|
||||
result = _format_projection(
|
||||
proj, freshness, service, drive_facts, config_error,
|
||||
None, None, journal_hint, sample_count, day_count,
|
||||
)
|
||||
|
||||
conn.close()
|
||||
return result
|
||||
with read_status(store_path, clock_now, query_services) as (conn, comp):
|
||||
parts = [render_status_cli(comp)]
|
||||
if not comp.store_fault and not comp.newer_schema:
|
||||
drive_facts = []
|
||||
proj = None
|
||||
if conn is not None:
|
||||
try:
|
||||
drive_facts = _query_drive_facts(conn)
|
||||
proj = compute_projection(conn, clock_now)
|
||||
except (sqlite3.Error, ValueError, TypeError):
|
||||
pass
|
||||
parts.append(_format_projection(
|
||||
proj, comp.freshness, drive_facts, config_error,
|
||||
comp.sample_count, comp.day_count,
|
||||
))
|
||||
if query_journal and (
|
||||
comp.store_fault or comp.last_collect_ok is False
|
||||
or comp.freshness in ("missed", "stale")
|
||||
):
|
||||
hint = _journalctl_hint()
|
||||
if hint:
|
||||
parts.append("Recent collector logs:\n" + hint)
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
|
||||
def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
|
||||
@@ -619,65 +547,8 @@ def get_status_composition(
|
||||
collecting: bool = False,
|
||||
reduced_motion: bool = False,
|
||||
) -> 'StatusComposition':
|
||||
"""Get the shared status composition consumed by both TUI and CLI.
|
||||
|
||||
This is the new entry point that centralizes the status lattice.
|
||||
"""
|
||||
# Lazy import to avoid circular dependency
|
||||
from .status_composition import (
|
||||
StatusComposition,
|
||||
compose_status,
|
||||
)
|
||||
|
||||
if clock_now is None:
|
||||
clock_now = datetime.now(timezone.utc)
|
||||
|
||||
# --- Configuration (§8.3) ---
|
||||
# Config errors are surfaced through the store fault mechanism
|
||||
|
||||
# --- Service state ---
|
||||
service = {}
|
||||
if query_services:
|
||||
try:
|
||||
service = query_service_state()
|
||||
except Exception:
|
||||
service = None
|
||||
|
||||
# --- Store open ---
|
||||
store_fault = None
|
||||
newer_schema = None
|
||||
conn = None
|
||||
|
||||
if store_path is None:
|
||||
store_path = Path("/var/lib/fenris/observations.db")
|
||||
|
||||
try:
|
||||
conn = open_store_readonly(store_path)
|
||||
except StoreFault as e:
|
||||
store_fault = str(e)
|
||||
except NewerSchema as e:
|
||||
newer_schema = str(e)
|
||||
|
||||
# --- Use shared composition ---
|
||||
if conn is not None:
|
||||
try:
|
||||
comp = compose_status(
|
||||
conn, service, clock_now,
|
||||
store_fault=store_fault,
|
||||
newer_schema=newer_schema,
|
||||
collecting=collecting,
|
||||
reduced_motion=reduced_motion,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
else:
|
||||
# Store fault or newer schema - compose without store data
|
||||
comp = compose_status(
|
||||
None, service, clock_now,
|
||||
store_fault=store_fault,
|
||||
newer_schema=newer_schema,
|
||||
collecting=collecting,
|
||||
reduced_motion=reduced_motion,
|
||||
)
|
||||
|
||||
return comp
|
||||
"""Return monitoring status without retaining the read-only snapshot."""
|
||||
with read_status(
|
||||
store_path, clock_now, query_services, collecting, reduced_motion,
|
||||
) as (_, comp):
|
||||
return comp
|
||||
|
||||
@@ -188,7 +188,7 @@ def _determine_explanation(
|
||||
) -> str:
|
||||
"""Determine the explanation line for the status state."""
|
||||
if store_fault:
|
||||
return "observation store unreadable — see journal"
|
||||
return "observation store unreadable — see collector logs"
|
||||
|
||||
if newer_schema:
|
||||
return "observation store written by a newer Fenris — upgrade Fenris"
|
||||
@@ -200,7 +200,7 @@ def _determine_explanation(
|
||||
if last_collect_reason:
|
||||
parts.append("(%s)" % last_collect_reason)
|
||||
if freshness_age_s is not None and freshness != "empty":
|
||||
parts.append("· last good sample %s ago" % freshness_age_human(freshness_age_s))
|
||||
parts.append("· last good sample %s" % freshness_age_human(freshness_age_s))
|
||||
return " ".join(parts) if parts else "last run failed"
|
||||
|
||||
if state == StatusState.INTERRUPTED:
|
||||
@@ -211,7 +211,7 @@ def _determine_explanation(
|
||||
|
||||
if state == StatusState.STALE:
|
||||
if freshness_age_s is not None:
|
||||
return "last sample %s ago" % freshness_age_human(freshness_age_s)
|
||||
return "last sample %s" % freshness_age_human(freshness_age_s)
|
||||
return "data is stale"
|
||||
|
||||
if state == StatusState.WAITING:
|
||||
@@ -223,7 +223,7 @@ def _determine_explanation(
|
||||
|
||||
if state == StatusState.MONITORING:
|
||||
if freshness_age_s is not None:
|
||||
return "last sample %s ago" % freshness_age_human(freshness_age_s)
|
||||
return "last sample %s" % freshness_age_human(freshness_age_s)
|
||||
return "monitoring active"
|
||||
|
||||
if state == StatusState.UNKNOWN:
|
||||
@@ -257,7 +257,7 @@ def _determine_collecting_overlay(
|
||||
|
||||
|
||||
def compose_status(
|
||||
conn: sqlite3.Connection,
|
||||
conn: Optional[sqlite3.Connection],
|
||||
service: Optional[Dict[str, Any]],
|
||||
clock_now: datetime,
|
||||
store_fault: Optional[str] = None,
|
||||
@@ -269,14 +269,17 @@ def compose_status(
|
||||
|
||||
This is the single entry point consumed by both TUI and CLI.
|
||||
"""
|
||||
service_available = service is not None and len(service) > 0
|
||||
service_available = bool(service) and any(
|
||||
service.get(key) is not None
|
||||
for key in ("boot_enabled", "timer_active")
|
||||
)
|
||||
|
||||
# --- Separate facts from service ---
|
||||
boot_enabled = service.get("boot_enabled") if service_available else None
|
||||
timer_active = service.get("timer_active") if service_available else None
|
||||
last_collect_ok = service.get("last_collect_ok") if service_available else None
|
||||
last_collect_age_s = service.get("last_collect_age_s") if service_available else None
|
||||
last_collect_reason = service.get("last_collect_reason") if service_available else None
|
||||
boot_enabled = service.get("boot_enabled") if service else None
|
||||
timer_active = service.get("timer_active") if service else None
|
||||
last_collect_ok = service.get("last_collect_ok") if service else None
|
||||
last_collect_age_s = service.get("last_collect_age_s") if service else None
|
||||
last_collect_reason = service.get("last_collect_reason") if service else None
|
||||
|
||||
# --- Freshness from store ---
|
||||
freshness = "unknown"
|
||||
@@ -285,7 +288,10 @@ def compose_status(
|
||||
day_count = 0
|
||||
deliberately_paused = False
|
||||
|
||||
if store_fault is None and newer_schema is None:
|
||||
if conn is None and store_fault is None and newer_schema is None:
|
||||
freshness = "empty"
|
||||
|
||||
if conn is not None and store_fault is None and newer_schema is None:
|
||||
try:
|
||||
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
@@ -301,28 +307,35 @@ def compose_status(
|
||||
freshness_age_s = int((clock_now - ts).total_seconds())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except sqlite3.Error:
|
||||
freshness = "unknown"
|
||||
except sqlite3.Error as exc:
|
||||
store_fault = str(exc)
|
||||
|
||||
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 sqlite3.Error:
|
||||
pass
|
||||
except sqlite3.Error as exc:
|
||||
store_fault = str(exc)
|
||||
|
||||
try:
|
||||
svc_for_pause = service if service_available else {}
|
||||
deliberately_paused = is_deliberately_paused(conn, svc_for_pause)
|
||||
except sqlite3.Error:
|
||||
deliberately_paused = False
|
||||
except sqlite3.Error as exc:
|
||||
store_fault = str(exc)
|
||||
|
||||
if store_fault or newer_schema:
|
||||
freshness = "unknown"
|
||||
freshness_age_s = None
|
||||
sample_count = day_count = 0
|
||||
deliberately_paused = False
|
||||
|
||||
# --- External stop detection ---
|
||||
# External stop = timer inactive + boot disabled + NOT deliberately paused
|
||||
# + period still open (the timer was stopped but Fenris didn't close the period)
|
||||
external_stop_reason = None
|
||||
if not deliberately_paused and timer_active is False and boot_enabled is False:
|
||||
if (conn is not None and not store_fault and not newer_schema
|
||||
and not deliberately_paused and timer_active is False and boot_enabled is False):
|
||||
# Check if there's an open monitoring period (external stop left it open)
|
||||
try:
|
||||
open_period = conn.execute(
|
||||
@@ -428,10 +441,9 @@ def render_status_cli(comp: StatusComposition) -> str:
|
||||
|
||||
# Separate facts
|
||||
facts = []
|
||||
if comp.freshness != "unknown":
|
||||
facts.append("freshness: %s" % comp.freshness)
|
||||
if comp.freshness_age_s is not None:
|
||||
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s) if facts else "freshness: %s" % freshness_age_human(comp.freshness_age_s)
|
||||
facts.append("freshness: %s" % comp.freshness)
|
||||
if comp.freshness_age_s is not None and facts:
|
||||
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
|
||||
if comp.last_collect_ok is True:
|
||||
facts.append("last collect: ok")
|
||||
elif comp.last_collect_ok is False:
|
||||
@@ -441,10 +453,12 @@ def render_status_cli(comp: StatusComposition) -> str:
|
||||
facts.append(collect_str)
|
||||
else:
|
||||
facts.append("last collect: unknown")
|
||||
if comp.boot_enabled is not None:
|
||||
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
|
||||
if comp.timer_active is not None:
|
||||
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
|
||||
facts.append("boot: %s" % (
|
||||
"unknown" if comp.boot_enabled is None else "enabled" if comp.boot_enabled else "disabled"
|
||||
))
|
||||
facts.append("timer: %s" % (
|
||||
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
|
||||
))
|
||||
|
||||
if facts:
|
||||
lines.append(" · ".join(facts))
|
||||
@@ -486,29 +500,30 @@ def render_status_tui(comp: StatusComposition) -> str:
|
||||
|
||||
# Explanation
|
||||
if comp.explanation:
|
||||
lines.append(comp.explanation)
|
||||
lines.append(comp.explanation[:1].upper() + comp.explanation[1:])
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Separate facts
|
||||
facts = []
|
||||
if comp.freshness != "unknown":
|
||||
facts.append("freshness: %s" % comp.freshness)
|
||||
facts.append("Freshness: %s" % comp.freshness)
|
||||
if comp.freshness_age_s is not None and facts:
|
||||
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
|
||||
if comp.last_collect_ok is True:
|
||||
facts.append("last collect: ok")
|
||||
facts.append("Last collect: ok")
|
||||
elif comp.last_collect_ok is False:
|
||||
collect_str = "last collect: FAILED"
|
||||
collect_str = "Last collect: failed"
|
||||
if comp.last_collect_reason:
|
||||
collect_str += " (%s)" % comp.last_collect_reason
|
||||
facts.append(collect_str)
|
||||
else:
|
||||
facts.append("last collect: unknown")
|
||||
if comp.boot_enabled is not None:
|
||||
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
|
||||
if comp.timer_active is not None:
|
||||
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
|
||||
facts.append("Last collect: unknown")
|
||||
facts.append("Boot: %s" % (
|
||||
"unknown" if comp.boot_enabled is None else "enabled" if comp.boot_enabled else "disabled"
|
||||
))
|
||||
facts.append("Timer: %s" % (
|
||||
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
|
||||
))
|
||||
|
||||
if facts:
|
||||
lines.append(" · ".join(facts))
|
||||
@@ -516,11 +531,11 @@ def render_status_tui(comp: StatusComposition) -> str:
|
||||
# Continuity
|
||||
if comp.continuity:
|
||||
lines.append("")
|
||||
lines.append("[bold]CONTINUITY[/bold] %s" % comp.continuity)
|
||||
lines.append("[bold]Continuity[/bold] %s" % comp.continuity)
|
||||
|
||||
# Deliberate pause
|
||||
if comp.paused_lines:
|
||||
for pl in comp.paused_lines:
|
||||
lines.append(pl)
|
||||
lines.append(pl[:1].upper() + pl[1:])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
+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