feat(gui): add optional tkinter and Qt6 desktop dashboards
Add two optional windowed frontends over the same local state the TUI and web console read: `enodia-sentinel gui` (stdlib tkinter) and `enodia-sentinel gui-qt` (PySide6, new `[qt]` extra). Both share `gui/model.py`, a GUI-free presentation model over `tui.collect_model`, so every formatter is unit-testable without a display server. Only `app.py` and `qt_app.py` import GUI libraries, enforced by import-guard tests mirroring the tray applet's boundary. Tabs cover status, alerts, incidents, posture, integrity, response plans, and the event tail, plus daemon-control buttons via systemctl. Response plans are display-only; the GUIs never execute containment commands. Core runtime dependencies stay empty. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JX86xeoBJVBb16qHkDf53K
This commit is contained in:
parent
9e3575461b
commit
457c7604ba
15 changed files with 1270 additions and 0 deletions
187
enodia_sentinel/gui/model.py
Normal file
187
enodia_sentinel/gui/model.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# 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 []),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue