# SPDX-License-Identifier: GPL-3.0-or-later """Pure presentation model for the desktop GUI. No tkinter imports. Turns the shared ``tui.collect_model`` dictionary into simple table rows and detail text the GUI shell can render, so every formatter is unit-testable without a display server. """ from __future__ import annotations from dataclasses import dataclass, field from ..config import Config from ..tui import collect_model from ..tray.state import TrayState, parse_status TABS = ( ("status", "Status"), ("alerts", "Alerts"), ("incidents", "Incidents"), ("posture", "Posture"), ("integrity", "Integrity"), ("response", "Response Plans"), ("events", "Events"), ) ALERT_COLUMNS = ("Time", "Severity", "SID", "Signature", "Detail") INCIDENT_COLUMNS = ("Incident", "Severity", "First seen", "Last seen", "Alerts", "Signatures") POSTURE_COLUMNS = ("Severity", "Signature", "Detail") PLAN_COLUMNS = ("Incident", "Actions", "Summary") @dataclass(frozen=True) class GuiModel: """One refresh worth of GUI-ready data.""" header: TrayState status_lines: tuple[str, ...] alerts: tuple[tuple, ...] incidents: tuple[tuple, ...] posture: tuple[tuple, ...] integrity_lines: tuple[str, ...] plans: tuple[tuple, ...] plan_details: dict = field(default_factory=dict) events: tuple[str, ...] = () def status_lines(status: dict) -> tuple[str, ...]: if not status: return ( "No status available.", "Start the daemon, or check that the GUI uses the same " "config/log_dir as the service.", ) age = status.get("heartbeat_age") heartbeat = "none" if age is None else f"{int(age)}s ago" if status.get("heartbeat_stale"): heartbeat += " (STALE)" counts = status.get("counts") or {} count_text = ", ".join( f"{key}:{value}" for key, value in sorted(counts.items())) or "none" return ( f"Host: {status.get('host', '?')}", f"Version: {status.get('version', '?')}", f"Daemon: {'running' if status.get('running') else 'stopped'}", f"Heartbeat: {heartbeat}", f"Alerts: {status.get('total_alerts', 0)} total ({count_text})", f"Last alert: {status.get('last_alert') or 'none'}", f"eBPF: {status.get('ebpf', 'unknown')}", f"Exec probe: {status.get('ebpf_exec', 'unknown')}", f"Syscall: {status.get('ebpf_syscall', 'unknown')}", ) def alert_rows(snapshots: list[dict]) -> tuple[tuple, ...]: rows = [] for snapshot in snapshots or []: for alert in snapshot.get("alerts", []): rows.append(( str(snapshot.get("time", "?"))[:24], alert.get("severity", "?"), alert.get("sid", 0), alert.get("signature", "?"), alert.get("detail", ""), )) return tuple(rows) def incident_rows(incidents: list[dict]) -> tuple[tuple, ...]: rows = [] for inc in incidents or []: signatures = inc.get("signatures") or [] rows.append(( inc.get("id", "?"), inc.get("severity", "?"), str(inc.get("first_seen", "?"))[:24], str(inc.get("last_seen", "?"))[:24], inc.get("alert_count", len(inc.get("members", []) or [])), ", ".join(sorted(set(map(str, signatures)))), )) return tuple(rows) def posture_rows(report: dict) -> tuple[tuple, ...]: rows = [] for finding in (report or {}).get("findings", []): rows.append(( finding.get("severity", "?"), finding.get("signature", "?"), finding.get("detail", ""), )) return tuple(rows) def integrity_lines(report: dict) -> tuple[str, ...]: if not report: return ("No integrity state available.",) lines: list[str] = [] _flatten("", report, lines) return tuple(lines) def _flatten(prefix: str, value, lines: list[str]) -> None: if isinstance(value, dict): for key in sorted(value): _flatten(f"{prefix}{key}.", value[key], lines) elif isinstance(value, list): lines.append(f"{prefix.rstrip('.')}: {len(value)} item(s)") else: lines.append(f"{prefix.rstrip('.')}: {value}") def plan_rows(plans: list[dict]) -> tuple[tuple[tuple, ...], dict]: """Return (table rows, incident_id -> readable dry-run plan text).""" rows = [] details: dict[str, str] = {} for plan in plans or []: incident_id = str(plan.get("incident_id", "?")) if "error" in plan: rows.append((incident_id, 0, plan["error"])) details[incident_id] = plan["error"] continue actions = plan.get("actions") or [] rows.append(( incident_id, len(actions), plan.get("summary", "") or f"{len(actions)} dry-run action(s)", )) details[incident_id] = plan_text(plan) return tuple(rows), details def plan_text(plan: dict) -> str: lines = [ f"Incident: {plan.get('incident_id', '?')}", f"Summary: {plan.get('summary', '') or '-'}", "", "Dry-run actions (review only — the GUI never executes them):", ] for index, action in enumerate(plan.get("actions") or [], start=1): title = action.get("title") or action.get("kind", "?") lines.append(f" {index}. {title}") reason = action.get("reason") if reason: lines.append(f" why: {reason}") for command in action.get("commands") or []: lines.append(f" $ {command}") if not plan.get("actions"): lines.append(" (no actions suggested)") return "\n".join(lines) def collect(cfg: Config) -> GuiModel: """Build one GUI refresh from the shared TUI data model.""" model = collect_model(cfg) plans, details = plan_rows(model.get("response") or []) return GuiModel( header=parse_status(model.get("status") or None), status_lines=status_lines(model.get("status") or {}), alerts=alert_rows(model.get("alerts") or []), incidents=incident_rows(model.get("incidents") or []), posture=posture_rows(model.get("posture") or {}), integrity_lines=integrity_lines(model.get("integrity") or {}), plans=plans, plan_details=details, events=tuple(model.get("events") or []), )