"""Shared status composition for TUI and CLI (issue #78, TPH-2/3). Centralizes the monitoring status lattice consumed by both surfaces. States: Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale, Unknown. Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown. Collecting overlays every base except store fault. Freshness grading uses shared constants from status.py. """ import enum import sqlite3 from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional # Status poll interval (AC78-6): lightweight 5s systemctl show poll STATUS_POLL_INTERVAL_S = 5 from .status import ( FRESH_THRESHOLD_S, STALENESS_THRESHOLD_S, grade_freshness, freshness_age_human, is_deliberately_paused, monitoring_continuity, deliberate_pause_lines, open_store_readonly, StoreFault, NewerSchema, ) # --------------------------------------------------------------------------- # Status state enum with glyph and label # --------------------------------------------------------------------------- class StatusState(enum.Enum): """The eight monitoring status states (TPH-2).""" MONITORING = "monitoring" COLLECTING = "collecting" PAUSED = "paused" WAITING = "waiting" INTERRUPTED = "interrupted" ERROR = "error" STALE = "stale" UNKNOWN = "unknown" @property def glyph(self) -> str: _glyphs = { "monitoring": "●", "collecting": "◐", "paused": "‖", "waiting": "○", "interrupted": "⊘", "error": "✖", "stale": "◌", "unknown": "?", } return _glyphs[self.value] @property def label(self) -> str: return self.value.capitalize() # Precedence order: higher index = higher precedence _PRECEDENCE = [ StatusState.UNKNOWN, StatusState.MONITORING, StatusState.WAITING, StatusState.STALE, StatusState.PAUSED, StatusState.INTERRUPTED, StatusState.ERROR, ] _PRECEDENCE_RANK = {s: i for i, s in enumerate(_PRECEDENCE)} # --------------------------------------------------------------------------- # Status composition result # --------------------------------------------------------------------------- @dataclass class StatusComposition: """The composed status result shared between TUI and CLI.""" state: StatusState glyph: str label: str explanation: str # Separate facts (never folded into the status word) freshness: str = "unknown" freshness_age_s: Optional[int] = None last_collect_ok: Optional[bool] = None last_collect_age_s: Optional[int] = None last_collect_reason: Optional[str] = None boot_enabled: Optional[bool] = None timer_active: Optional[bool] = None deliberately_paused: bool = False pause_age_s: Optional[int] = None external_stop_reason: Optional[str] = None # Store fault / newer schema (suppress store-dependent views) store_fault: Optional[str] = None newer_schema: Optional[str] = None # Collecting overlay overlay_base: Optional[StatusState] = None # Sample counts for waiting explanations sample_count: int = 0 day_count: int = 0 # Whether the status dot should blink (only Monitoring) should_blink: bool = False # Continuity line continuity: str = "" # Paused lines (for TUI banner) paused_lines: List[str] = field(default_factory=list) # --------------------------------------------------------------------------- # Status composition logic # --------------------------------------------------------------------------- def _determine_base_state( freshness: str, sample_count: int, day_count: int, boot_enabled: Optional[bool], timer_active: Optional[bool], last_collect_ok: Optional[bool], deliberately_paused: bool, external_stop_reason: Optional[str], service_available: bool, ) -> StatusState: """Determine the base status state from facts. Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown. """ # Unknown: service query failed and no store-derived fact places us higher if not service_available: return StatusState.UNKNOWN # Error: last collect failed if last_collect_ok is False: return StatusState.ERROR # Interrupted: external stop (not user_disabled) if external_stop_reason is not None: return StatusState.INTERRUPTED # Paused: deliberate disable if deliberately_paused: return StatusState.PAUSED # Stale: timer active, no failure, but data ≥ 48h old if freshness == "stale" and timer_active and last_collect_ok is not False: return StatusState.STALE # Waiting: empty store, single sample, or fresh data but not yet enough evidence if sample_count == 0: return StatusState.WAITING if sample_count <= 1 and day_count == 0: return StatusState.WAITING # Monitoring: everything is fine return StatusState.MONITORING def _determine_explanation( state: StatusState, freshness: str, freshness_age_s: Optional[int], last_collect_ok: Optional[bool], last_collect_reason: Optional[str], sample_count: int, deliberately_paused: bool, store_fault: Optional[str], newer_schema: Optional[str], ) -> str: """Determine the explanation line for the status state.""" if store_fault: return "observation store unreadable — see collector logs" if newer_schema: return "observation store written by a newer Fenris — upgrade Fenris" if state == StatusState.ERROR: parts = [] if last_collect_ok is False: parts.append("last run failed") 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" % freshness_age_human(freshness_age_s)) return " ".join(parts) if parts else "last run failed" if state == StatusState.INTERRUPTED: return "collection stopped outside Fenris — monitoring period still open" if state == StatusState.PAUSED: return "monitoring paused — paused time excluded from your usage habit" if state == StatusState.STALE: if freshness_age_s is not None: return "last sample %s" % freshness_age_human(freshness_age_s) return "data is stale" if state == StatusState.WAITING: if sample_count == 0: return "awaiting first sample" if sample_count <= 1: return "awaiting another sample" return "waiting for data" if state == StatusState.MONITORING: if freshness_age_s is not None: return "last sample %s" % freshness_age_human(freshness_age_s) return "monitoring active" if state == StatusState.UNKNOWN: return "service state unavailable" return "" def _determine_collecting_overlay( base_state: StatusState, last_collect_ok: Optional[bool], deliberately_paused: bool, store_fault: Optional[str], newer_schema: Optional[str], ) -> str: """Determine the explanation line when Collecting overlays a base state.""" if store_fault or newer_schema: return "run in flight — store fault" if base_state == StatusState.PAUSED: return "run in flight — paused" if base_state == StatusState.INTERRUPTED: return "run in flight — interrupted" if base_state == StatusState.ERROR: return "run in flight — retry" if base_state == StatusState.STALE: return "run in flight — stale data" if base_state == StatusState.WAITING: return "run in flight" return "run in flight" def compose_status( conn: Optional[sqlite3.Connection], service: Optional[Dict[str, Any]], clock_now: datetime, store_fault: Optional[str] = None, newer_schema: Optional[str] = None, collecting: bool = False, reduced_motion: bool = False, ) -> StatusComposition: """Compose the shared status from service state and store data. This is the single entry point consumed by both TUI and CLI. """ 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 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" freshness_age_s = None sample_count = 0 day_count = 0 deliberately_paused = False 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() newest_ts = row[0] if row else None freshness = grade_freshness(newest_ts, clock_now) if newest_ts: try: ts = datetime.fromisoformat(newest_ts) if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc) else: ts = ts.astimezone(timezone.utc) freshness_age_s = int((clock_now - ts).total_seconds()) except (ValueError, TypeError): pass 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 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 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 (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( "SELECT 1 FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1" ).fetchone() if open_period is not None: external_stop_reason = "external_stop" except sqlite3.Error: pass # --- Determine base state --- # Store fault and newer schema always override to ERROR if store_fault is not None or newer_schema is not None: base_state = StatusState.ERROR else: base_state = _determine_base_state( freshness=freshness, sample_count=sample_count, day_count=day_count, boot_enabled=boot_enabled, timer_active=timer_active, last_collect_ok=last_collect_ok, deliberately_paused=deliberately_paused, external_stop_reason=external_stop_reason, service_available=service_available, ) # --- Apply Collecting overlay --- state = base_state overlay_base = None explanation = "" if collecting and store_fault is None and newer_schema is None: # Collecting overlays every base except store fault overlay_base = base_state state = StatusState.COLLECTING explanation = _determine_collecting_overlay( base_state, last_collect_ok, deliberately_paused, store_fault, newer_schema, ) else: explanation = _determine_explanation( base_state, freshness, freshness_age_s, last_collect_ok, last_collect_reason, sample_count, deliberately_paused, store_fault, newer_schema, ) # --- Continuity line --- continuity = "" if service_available: continuity = monitoring_continuity(service) # --- Paused lines --- paused_lines = [] if deliberately_paused: paused_lines = deliberate_pause_lines() # Determine blink flag: only Monitoring dot blinks, never text or other states should_blink = (state == StatusState.MONITORING and not reduced_motion) return StatusComposition( state=state, glyph=state.glyph, label=state.label, explanation=explanation, should_blink=should_blink, freshness=freshness, freshness_age_s=freshness_age_s, last_collect_ok=last_collect_ok, last_collect_age_s=last_collect_age_s, last_collect_reason=last_collect_reason, boot_enabled=boot_enabled, timer_active=timer_active, deliberately_paused=deliberately_paused, external_stop_reason=external_stop_reason, store_fault=store_fault, newer_schema=newer_schema, overlay_base=overlay_base, sample_count=sample_count, day_count=day_count, continuity=continuity, paused_lines=paused_lines, ) # --------------------------------------------------------------------------- # CLI rendering (static, no styling) # --------------------------------------------------------------------------- def render_status_cli(comp: StatusComposition) -> str: """Render the status composition as static CLI text.""" lines = [] # Status line lines.append("%s %s" % (comp.glyph, comp.label)) # Explanation if comp.explanation: lines.append(comp.explanation) lines.append("") # Separate facts facts = [] 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: 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") 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)) # Continuity if comp.continuity: lines.append("") lines.append("CONTINUITY: %s" % comp.continuity) # Deliberate pause if comp.paused_lines: for pl in comp.paused_lines: lines.append(pl) return "\n".join(lines) # --------------------------------------------------------------------------- # TUI rendering (with styling tokens) # --------------------------------------------------------------------------- def render_status_tui(comp: StatusComposition) -> str: """Render the status composition as TUI text with Textual markup.""" lines = [] # Status line with color color = { StatusState.MONITORING: "green", StatusState.COLLECTING: "green", StatusState.PAUSED: "yellow", StatusState.WAITING: "yellow", StatusState.INTERRUPTED: "red", StatusState.ERROR: "red", StatusState.STALE: "red", StatusState.UNKNOWN: "dim", }[comp.state] lines.append("[%s]%s %s[/%s]" % (color, comp.glyph, comp.label, color)) # Explanation if comp.explanation: lines.append(comp.explanation[:1].upper() + comp.explanation[1:]) lines.append("") # Separate facts facts = [] 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: 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") 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)) # Continuity if comp.continuity: lines.append("") lines.append("[bold]Continuity[/bold] %s" % comp.continuity) # Deliberate pause if comp.paused_lines: for pl in comp.paused_lines: lines.append(pl[:1].upper() + pl[1:]) return "\n".join(lines)