From 457c7604ba13a409dc7d3fe5c892cd1ebdc3de52 Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 22 Jul 2026 04:33:49 -0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01JX86xeoBJVBb16qHkDf53K --- README.md | 36 ++++ docs/COMMAND_REFERENCE.md | 28 +++ docs/OPERATIONS.md | 9 + docs/ROADMAP.md | 9 + enodia_sentinel/cli.py | 8 + enodia_sentinel/gui/__init__.py | 7 + enodia_sentinel/gui/__main__.py | 6 + enodia_sentinel/gui/app.py | 269 ++++++++++++++++++++++++++ enodia_sentinel/gui/model.py | 187 ++++++++++++++++++ enodia_sentinel/gui/qt_app.py | 331 ++++++++++++++++++++++++++++++++ pyproject.toml | 3 + tests/test_gui_imports.py | 40 ++++ tests/test_gui_model.py | 173 +++++++++++++++++ tests/test_gui_qt.py | 127 ++++++++++++ tests/test_gui_qt_imports.py | 37 ++++ 15 files changed, 1270 insertions(+) create mode 100644 enodia_sentinel/gui/__init__.py create mode 100644 enodia_sentinel/gui/__main__.py create mode 100644 enodia_sentinel/gui/app.py create mode 100644 enodia_sentinel/gui/model.py create mode 100644 enodia_sentinel/gui/qt_app.py create mode 100644 tests/test_gui_imports.py create mode 100644 tests/test_gui_model.py create mode 100644 tests/test_gui_qt.py create mode 100644 tests/test_gui_qt_imports.py diff --git a/README.md b/README.md index e09918a..6a569ef 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,42 @@ enodia-sentinel completion bash > ~/.local/share/bash-completion/completions/eno enodia-sentinel completion zsh > ~/.zfunc/_enodia-sentinel ``` +### Desktop GUI (optional) + +A windowed desktop dashboard is available using only the stdlib `tkinter` +module (your distro may package it separately, e.g. `python-tk` or +`python-tkinter`). It is an optional frontend — the daemon and core agent +remain stdlib-only. + +```bash +enodia-sentinel gui +# or, when installed from pyproject metadata: +enodia-sentinel-gui +``` + +The GUI reuses the same local state as the TUI and web console and adds +daemon-control buttons: start, stop, restart, quick check, open dashboard, and +periodic refresh. Tabs cover status, alerts, incidents, posture findings, +integrity/watchdog state, dry-run response plans, and the event tail. Response +plans are read-only; the GUI never executes containment commands. + +### Qt desktop GUI (optional) + +A richer Qt6 desktop dashboard is available with the `[qt]` optional extra +(`PySide6`). It reuses the same data model as the tkinter GUI but adds a +modern native look, resizable table columns, a response-plan detail pane, and +threaded background refresh. + +```bash +pip install 'enodia-sentinel[qt]' +enodia-sentinel gui-qt +# or: +enodia-sentinel-gui-qt +``` + +Like the tray applet and tkinter GUI, this is an optional frontend and does +not change the core agent's zero-dependency design. + ### Desktop tray (optional) A lightweight system-tray applet is available for desktop installs. It is an diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 1235d47..fea0af7 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -431,6 +431,34 @@ Command mode supports `status`, `alerts`, `incidents`, `timeline`, `posture`, `back`, `refresh`, `filter `, `clear`, and `quit`. +### `gui` / `enodia-sentinel-gui` + +```bash +enodia-sentinel gui +enodia-sentinel-gui +``` + +Opens the stdlib tkinter desktop dashboard. It reads the same local state as +the TUI and web console (daemon status, alerts, incidents, posture, integrity, +events, and read-only response plans) and adds a toolbar for daemon control: +start, stop, restart, quick check, open dashboard, and refresh. + +Requires a working `tkinter` installation (usually the distro +`python-tk`/`python-tkinter` package); the core agent itself has no runtime +dependencies. Response plans are displayed for review only — the GUI does not +execute commands. + +### `gui-qt` / `enodia-sentinel-gui-qt` + +```bash +enodia-sentinel gui-qt +enodia-sentinel-gui-qt +``` + +Opens the optional Qt6 desktop dashboard. It shows the same read-only tabs and +daemon controls as the tkinter GUI with a native look, resizable columns, and +a response-plan detail pane. Requires the `[qt]` extra (`PySide6`). + ### `web` ```bash diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 59eb8b8..5c35139 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -91,6 +91,15 @@ use directly, so daemon start/stop/restart prompts for polkit authorization as usual. The applet is purely a convenience frontend — it is not required for the daemon to run and adds nothing to the core agent. +A windowed desktop GUI (`enodia-sentinel gui` / `enodia-sentinel-gui`) is also +available using only stdlib `tkinter`. It shows the same read-only tabs as the +TUI plus daemon-control buttons, and like the tray applet it is optional and +does not change the core agent's zero-dependency design. + +A richer Qt6 frontend (`enodia-sentinel gui-qt` / `enodia-sentinel-gui-qt`, +`[qt]` extra with `PySide6`) provides the same data with a native look, +resizable tables, and a response-plan detail pane. + ## Alert Workflow When Sentinel fires: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 85afd77..18d3e23 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -116,6 +116,15 @@ Purpose: define a stable single-host product. - ✅ Add an optional desktop tray applet (`enodia-sentinel-tray`, `[tray]` extra): dashboard launcher, systemd daemon control, status at a glance, and `check --json` quick checks, with GUI-free tested logic modules. +- ✅ Add an optional stdlib tkinter desktop dashboard (`enodia-sentinel gui` / + `enodia-sentinel-gui`): tabs for status, alerts, incidents, posture, + integrity, response plans, and events, plus daemon-control buttons and + threaded refresh. All testable logic is GUI-free; only `app.py` imports + tkinter. +- ✅ Add an optional Qt6 desktop dashboard (`enodia-sentinel gui-qt` / + `enodia-sentinel-gui-qt`, `[qt]` extra with `PySide6`): native tabs and + tables, severity row coloring, response-plan detail pane, keyboard shortcuts, + and safe threaded background refresh. - Keep the dashboard read-only unless a separate authenticated write path is designed and reviewed. diff --git a/enodia_sentinel/cli.py b/enodia_sentinel/cli.py index 16a92b5..36a861f 100644 --- a/enodia_sentinel/cli.py +++ b/enodia_sentinel/cli.py @@ -209,6 +209,8 @@ def main(argv: list[str] | None = None) -> int: rules.add_argument("--json", action="store_true", help="emit JSON") sub.add_parser("web", help="serve the read-only dashboard") sub.add_parser("tui", help="open the terminal dashboard (curses)") + sub.add_parser("gui", help="open the desktop dashboard (tkinter)") + sub.add_parser("gui-qt", help="open the desktop dashboard (Qt6)") sub.add_parser("triage", help="classify captured alerts as likely-FP vs review") sub.add_parser("fim-baseline", help="build the file-integrity baseline") sub.add_parser("fim-update", help="refresh the FIM baseline (run by the pacman hook)") @@ -281,6 +283,12 @@ def main(argv: list[str] | None = None) -> int: if args.cmd == "tui": from .tui import run as run_tui return run_tui(cfg) + if args.cmd == "gui": + from .gui.app import main as run_gui + return run_gui(cfg=cfg) + if args.cmd == "gui-qt": + from .gui.qt_app import main as run_gui_qt + return run_gui_qt(cfg=cfg) if args.cmd == "triage": return _cmd_triage(cfg) if args.cmd in ("fim-baseline", "fim-update"): diff --git a/enodia_sentinel/gui/__init__.py b/enodia_sentinel/gui/__init__.py new file mode 100644 index 0000000..2fd0093 --- /dev/null +++ b/enodia_sentinel/gui/__init__.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Optional desktop GUI (tkinter) for Enodia Sentinel. + +Follows the tray-applet boundary rules: ``model.py`` holds all testable, +GUI-free logic; only ``app.py`` may import ``tkinter``. The core agent stays +stdlib-only and fully usable without this frontend. +""" diff --git a/enodia_sentinel/gui/__main__.py b/enodia_sentinel/gui/__main__.py new file mode 100644 index 0000000..359e48a --- /dev/null +++ b/enodia_sentinel/gui/__main__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Allow ``python -m enodia_sentinel.gui``.""" +from .app import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/enodia_sentinel/gui/app.py b/enodia_sentinel/gui/app.py new file mode 100644 index 0000000..8947efa --- /dev/null +++ b/enodia_sentinel/gui/app.py @@ -0,0 +1,269 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Desktop GUI shell for Enodia Sentinel (tkinter only). + +This module is the only part of the GUI package that imports ``tkinter``. +All presentation logic lives in ``model.py`` so it can be tested without a +DISPLAY. The GUI is read-only for response plans: it displays suggested +containment/recovery actions but never executes them. +""" +from __future__ import annotations + +import argparse +import threading + +from ..config import Config +from ..tray import actions +from ..tray.state import parse_status +from .model import ( + ALERT_COLUMNS, + INCIDENT_COLUMNS, + PLAN_COLUMNS, + POSTURE_COLUMNS, + GuiModel, + collect, +) + +try: + import tkinter as tk + from tkinter import messagebox, ttk + _HAS_TK = True +except ImportError: # pragma: no cover - OS packaging gap + _HAS_TK = False + tk = None # type: ignore[assignment] + messagebox = None # type: ignore[assignment] + ttk = None # type: ignore[assignment] + +REFRESH_MS = 15_000 +STATUS_COLORS = { + "green": "#2ea043", + "amber": "#d29922", + "grey": "#6e6e6e", +} + +if _HAS_TK: + class _TreeFrame(ttk.Frame): + """Reusable scrolled treeview with optional detail pane.""" + + def __init__(self, parent, columns: tuple[str, ...], height: int = 12): + super().__init__(parent) + self.columns = columns + self.tree = ttk.Treeview(self, columns=columns, show="headings", + height=height) + for col in columns: + self.tree.heading(col, text=col) + self.tree.column(col, width=120, anchor="w") + vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview) + hsb = ttk.Scrollbar(self, orient="horizontal", command=self.tree.xview) + self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + self.tree.grid(row=0, column=0, sticky="nsew") + vsb.grid(row=0, column=1, sticky="ns") + hsb.grid(row=1, column=0, sticky="ew") + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + self._on_select = None + self.tree.bind("<>", self._notify_select) + + def set_on_select(self, callback): + self._on_select = callback + + def _notify_select(self, _event=None): + if not self._on_select: + return + selected = self.tree.selection() + if selected: + values = self.tree.item(selected[0], "values") + self._on_select(values) + + def populate(self, rows: tuple[tuple, ...]): + self.tree.delete(*self.tree.get_children()) + for row in rows: + self.tree.insert("", "end", values=row) + self._autosize() + + def _autosize(self): + for idx, col in enumerate(self.columns): + max_len = len(col) + for item in self.tree.get_children(): + text = str(self.tree.set(item, col)) + max_len = max(max_len, len(text)) + self.tree.column(col, width=min(600, max(80, max_len * 7))) + + class SentinelGui: + def __init__(self, cfg: Config): + self.cfg = cfg + self.model = GuiModel(header=parse_status(None), + status_lines=(), alerts=(), incidents=(), + posture=(), integrity_lines=(), plans=()) + self.root = tk.Tk() + self.root.title("Enodia Sentinel") + self.root.geometry("1100x720") + self.root.minsize(900, 600) + + self._build_toolbar() + self._build_status() + self._build_notebook() + self._refresh_data(silent=True) + self._schedule_refresh() + + def _build_toolbar(self): + toolbar = ttk.Frame(self.root) + toolbar.pack(fill="x", padx=8, pady=4) + for label, command in ( + ("Start daemon", self._on_start), + ("Stop daemon", self._on_stop), + ("Restart daemon", self._on_restart), + ("Quick check", self._on_check), + ("Open dashboard", self._on_open_dashboard), + ("Refresh now", self._on_refresh), + ): + ttk.Button(toolbar, text=label, command=command).pack( + side="left", padx=(0, 4) + ) + ttk.Button(toolbar, text="Exit", command=self.root.quit).pack( + side="right", padx=(4, 0) + ) + + def _build_status(self): + self.status_frame = ttk.Frame(self.root) + self.status_frame.pack(fill="x", padx=8, pady=(0, 4)) + self.indicator = tk.Label( + self.status_frame, text=" ", width=2, bg=STATUS_COLORS["grey"] + ) + self.indicator.pack(side="left") + self.status_label = ttk.Label(self.status_frame, text="Loading…") + self.status_label.pack(side="left", padx=(8, 0)) + + def _build_notebook(self): + self.notebook = ttk.Notebook(self.root) + self.notebook.pack(fill="both", expand=True, padx=8, pady=4) + + # Status tab + self.status_text = tk.Text(self.notebook, wrap="none", height=12, + state="disabled") + self.notebook.add(self.status_text, text="Status") + + # Alerts tab + self.alerts_frame = _TreeFrame(self.notebook, ALERT_COLUMNS) + self.notebook.add(self.alerts_frame, text="Alerts") + + # Incidents tab + self.incidents_frame = _TreeFrame(self.notebook, INCIDENT_COLUMNS) + self.notebook.add(self.incidents_frame, text="Incidents") + + # Posture tab + self.posture_frame = _TreeFrame(self.notebook, POSTURE_COLUMNS) + self.notebook.add(self.posture_frame, text="Posture") + + # Integrity tab + self.integrity_text = tk.Text(self.notebook, wrap="none", height=12, + state="disabled") + self.notebook.add(self.integrity_text, text="Integrity") + + # Response plans tab + self.response_pane = ttk.PanedWindow(self.notebook, orient="horizontal") + self.plans_frame = _TreeFrame(self.response_pane, PLAN_COLUMNS, + height=10) + self.plan_text = tk.Text(self.response_pane, wrap="word", height=12, + state="disabled") + self.response_pane.add(self.plans_frame, weight=1) + self.response_pane.add(self.plan_text, weight=2) + self.plans_frame.set_on_select(self._show_plan_detail) + self.notebook.add(self.response_pane, text="Response Plans") + + # Events tab + self.events_text = tk.Text(self.notebook, wrap="none", height=12, + state="disabled") + self.notebook.add(self.events_text, text="Events") + + def _set_text(self, widget: tk.Text, text: str): + widget.configure(state="normal") + widget.delete("1.0", "end") + widget.insert("1.0", text) + widget.configure(state="disabled") + + def _refresh_data(self, silent: bool = False): + def _work(): + try: + self.model = collect(self.cfg) + self.root.after(0, self._render) + except Exception as exc: # pragma: no cover - runtime degradation + if not silent: + self.root.after(0, lambda: messagebox.showerror( + "Refresh failed", str(exc) + )) + threading.Thread(target=_work, daemon=True).start() + + def _render(self): + header = self.model.header + self.indicator.configure(bg=STATUS_COLORS.get(header.color, "grey")) + self.status_label.configure(text=header.summary) + self._set_text(self.status_text, "\n".join(self.model.status_lines)) + self.alerts_frame.populate(self.model.alerts) + self.incidents_frame.populate(self.model.incidents) + self.posture_frame.populate(self.model.posture) + self._set_text(self.integrity_text, + "\n".join(self.model.integrity_lines)) + self.plans_frame.populate(self.model.plans) + self._set_text(self.events_text, "\n".join(self.model.events)) + + def _show_plan_detail(self, values): + if not values: + return + incident_id = str(values[0]) + text = self.model.plan_details.get(incident_id, "No details.") + self._set_text(self.plan_text, text) + + def _schedule_refresh(self): + self._refresh_data(silent=True) + self.root.after(REFRESH_MS, self._schedule_refresh) + + def _async_action(self, fn): + def _work(): + res = fn() + self.root.after(0, lambda: messagebox.showinfo( + "Enodia Sentinel", res.message + )) + threading.Thread(target=_work, daemon=True).start() + + def _on_start(self): + self._async_action(actions.start_daemon) + + def _on_stop(self): + self._async_action(actions.stop_daemon) + + def _on_restart(self): + self._async_action(actions.restart_daemon) + + def _on_check(self): + self._async_action(actions.run_check) + + def _on_open_dashboard(self): + self._async_action(lambda: actions.open_dashboard(self.cfg)) + + def _on_refresh(self): + self._refresh_data() + + def run(self) -> int: + self.root.mainloop() + return 0 + +else: # pragma: no cover - runtime guard only + SentinelGui = None # type: ignore[assignment, misc] + + +def main(argv=None, cfg: Config | None = None) -> int: + if not _HAS_TK: + raise SystemExit( + "tkinter is required for the GUI. Install your distro's " + "python-tk package (e.g. python-tk on Debian, python-tkinter on " + "Arch/Fedora)." + ) + if cfg is None: + parser = argparse.ArgumentParser( + prog="enodia-sentinel-gui", + description="Desktop dashboard for Enodia Sentinel.", + ) + parser.add_argument("-c", "--config", help="path to TOML config") + args = parser.parse_args(argv) + cfg = Config.load(args.config) + return SentinelGui(cfg).run() diff --git a/enodia_sentinel/gui/model.py b/enodia_sentinel/gui/model.py new file mode 100644 index 0000000..a4bed02 --- /dev/null +++ b/enodia_sentinel/gui/model.py @@ -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 []), + ) diff --git a/enodia_sentinel/gui/qt_app.py b/enodia_sentinel/gui/qt_app.py new file mode 100644 index 0000000..d805f73 --- /dev/null +++ b/enodia_sentinel/gui/qt_app.py @@ -0,0 +1,331 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Desktop GUI shell for Enodia Sentinel using Qt6 (PySide6). + +This module is the only part of the GUI package that imports ``PySide6``. +Like the tkinter shell, all testable logic lives in ``model.py``. The core +agent remains stdlib-only; install the ``[qt]`` extra to use this frontend. +""" +from __future__ import annotations + +import argparse + +from ..config import Config +from ..tray import actions +from ..tray.state import parse_status +from .model import ( + ALERT_COLUMNS, + INCIDENT_COLUMNS, + PLAN_COLUMNS, + POSTURE_COLUMNS, + GuiModel, + collect, +) + +try: + from PySide6 import QtCore, QtGui, QtWidgets + _HAS_QT = True +except ImportError: # pragma: no cover - optional extra not installed + _HAS_QT = False + QtCore = None # type: ignore[assignment] + QtGui = None # type: ignore[assignment] + QtWidgets = None # type: ignore[assignment] + +REFRESH_MS = 15_000 + +if _HAS_QT: + def _severity_color(severity: str) -> str | None: + mapping = { + "CRITICAL": "#ff4444", + "HIGH": "#ffaa00", + "MEDIUM": "#ffee88", + "LOW": "#88ccff", + } + return mapping.get(str(severity).upper()) + + class _RefreshWorker(QtCore.QObject): + finished = QtCore.Signal(object) + + def __init__(self, cfg: Config): + super().__init__() + self.cfg = cfg + + def run(self): + try: + model = collect(self.cfg) + except Exception as exc: # pragma: no cover - runtime degradation + model = exc + self.finished.emit(model) + + class _ActionWorker(QtCore.QObject): + finished = QtCore.Signal(str) + + def __init__(self, fn): + super().__init__() + self.fn = fn + + def run(self): + try: + result = self.fn() + message = result.message + except Exception as exc: # pragma: no cover - runtime degradation + message = str(exc) + self.finished.emit(message) + + class _MainWindow(QtWidgets.QMainWindow): + def __init__(self, cfg: Config): + super().__init__() + self.cfg = cfg + self.model = GuiModel(header=parse_status(None), + status_lines=(), alerts=(), incidents=(), + posture=(), integrity_lines=(), plans=()) + self.setWindowTitle("Enodia Sentinel") + self.resize(1200, 800) + self._build_ui() + + def _build_ui(self): + central = QtWidgets.QWidget() + self.setCentralWidget(central) + layout = QtWidgets.QVBoxLayout(central) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(6) + + # Header + header = QtWidgets.QHBoxLayout() + self.indicator = QtWidgets.QLabel(" ") + self.indicator.setStyleSheet( + "background-color: #6e6e6e; border-radius: 8px;" + ) + self.indicator.setFixedSize(16, 16) + header.addWidget(self.indicator) + self.summary_label = QtWidgets.QLabel("Loading…") + header.addWidget(self.summary_label) + header.addStretch() + self.last_refreshed_label = QtWidgets.QLabel("") + self.last_refreshed_label.setStyleSheet("color: #888;") + header.addWidget(self.last_refreshed_label) + layout.addLayout(header) + + # Toolbar + toolbar = QtWidgets.QToolBar() + self.addToolBar(toolbar) + for label, callback in ( + ("Start daemon", self._on_start), + ("Stop daemon", self._on_stop), + ("Restart daemon", self._on_restart), + ("Quick check", self._on_check), + ("Open dashboard", self._on_open_dashboard), + ("Refresh now", self.refresh), + ): + toolbar.addAction(label, callback) + toolbar.addSeparator() + toolbar.addAction("Exit", self.close) + + # Keyboard shortcuts + QtGui.QShortcut( + QtGui.QKeySequence("Ctrl+R"), self, self.refresh + ) + QtGui.QShortcut( + QtGui.QKeySequence("Ctrl+Q"), self, self.close + ) + + # Tabs + self.tabs = QtWidgets.QTabWidget() + layout.addWidget(self.tabs) + + self.status_edit = QtWidgets.QPlainTextEdit() + self.status_edit.setReadOnly(True) + self._set_monospace(self.status_edit) + self.tabs.addTab(self.status_edit, "Status") + + self.alerts_table = self._make_table(ALERT_COLUMNS) + self.tabs.addTab(self.alerts_table, "Alerts") + + self.incidents_table = self._make_table(INCIDENT_COLUMNS) + self.tabs.addTab(self.incidents_table, "Incidents") + + self.posture_table = self._make_table(POSTURE_COLUMNS) + self.tabs.addTab(self.posture_table, "Posture") + + self.integrity_edit = QtWidgets.QPlainTextEdit() + self.integrity_edit.setReadOnly(True) + self._set_monospace(self.integrity_edit) + self.tabs.addTab(self.integrity_edit, "Integrity") + + self.response_splitter = QtWidgets.QSplitter() + self.plans_table = self._make_table(PLAN_COLUMNS) + self.plan_edit = QtWidgets.QPlainTextEdit() + self.plan_edit.setReadOnly(True) + self._set_monospace(self.plan_edit) + self.response_splitter.addWidget(self.plans_table) + self.response_splitter.addWidget(self.plan_edit) + self.response_splitter.setSizes([400, 800]) + self.tabs.addTab(self.response_splitter, "Response Plans") + self.plans_table.itemSelectionChanged.connect(self._show_plan_detail) + + self.events_edit = QtWidgets.QPlainTextEdit() + self.events_edit.setReadOnly(True) + self._set_monospace(self.events_edit) + self.tabs.addTab(self.events_edit, "Events") + + def _make_table(self, columns): + table = QtWidgets.QTableWidget() + table.setColumnCount(len(columns)) + table.setHorizontalHeaderLabels(columns) + table.horizontalHeader().setStretchLastSection(True) + table.setSelectionBehavior( + QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows + ) + table.setSelectionMode( + QtWidgets.QAbstractItemView.SelectionMode.SingleSelection + ) + return table + + @staticmethod + def _set_monospace(widget): + font = QtGui.QFontDatabase.systemFont( + QtGui.QFontDatabase.SystemFont.FixedFont + ) + widget.setFont(font) + + def _populate_table(self, table, rows): + table.setRowCount(len(rows)) + severity_col = None + for c in range(table.columnCount()): + header = table.horizontalHeaderItem(c) + if header and header.text() == "Severity": + severity_col = c + break + for r, row in enumerate(rows): + for c, value in enumerate(row): + item = QtWidgets.QTableWidgetItem(str(value)) + item.setFlags( + item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable + ) + if c == severity_col: + color = _severity_color(value) + if color: + item.setBackground(QtGui.QColor(color)) + item.setForeground(QtGui.QColor("#000000")) + table.setItem(r, c, item) + table.resizeColumnsToContents() + + def refresh(self): + self._refresh_thread = QtCore.QThread(self) + self.worker = _RefreshWorker(self.cfg) + self.worker.moveToThread(self._refresh_thread) + self._refresh_thread.started.connect(self.worker.run) + self.worker.finished.connect(self._on_refreshed) + self.worker.finished.connect(self._refresh_thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self._refresh_thread.finished.connect(self._refresh_thread.deleteLater) + self._refresh_thread.start() + + def _on_refreshed(self, model): + if isinstance(model, Exception): # pragma: no cover + QtWidgets.QMessageBox.critical( + self, "Refresh failed", str(model) + ) + else: + self.model = model + color = model.header.color + hex_color = { + "green": "#2ea043", + "amber": "#d29922", + "grey": "#6e6e6e", + }.get(color, "#6e6e6e") + self.indicator.setStyleSheet( + f"background-color: {hex_color}; border-radius: 8px;" + ) + self.summary_label.setText(model.header.summary) + from datetime import datetime + self.last_refreshed_label.setText( + f"refreshed {datetime.now().strftime('%H:%M:%S')}" + ) + self.status_edit.setPlainText("\n".join(model.status_lines)) + self._populate_table(self.alerts_table, model.alerts) + self._populate_table(self.incidents_table, model.incidents) + self._populate_table(self.posture_table, model.posture) + self.integrity_edit.setPlainText( + "\n".join(model.integrity_lines) + ) + self._populate_table(self.plans_table, model.plans) + self.events_edit.setPlainText("\n".join(model.events)) + scrollbar = self.events_edit.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + QtCore.QTimer.singleShot(REFRESH_MS, self.refresh) + + def _show_plan_detail(self): + selected = self.plans_table.selectedItems() + if not selected: + return + incident_id = self.plans_table.item(selected[0].row(), 0).text() + self.plan_edit.setPlainText( + self.model.plan_details.get(incident_id, "No details.") + ) + + def _run_action(self, fn): + self._action_thread = QtCore.QThread(self) + worker = _ActionWorker(fn) + worker.moveToThread(self._action_thread) + self._action_thread.started.connect(worker.run) + worker.finished.connect(self._action_thread.quit) + worker.finished.connect(worker.deleteLater) + self._action_thread.finished.connect(self._action_thread.deleteLater) + worker.finished.connect( + lambda msg: QtWidgets.QMessageBox.information( + self, "Enodia Sentinel", msg + ) + ) + self._action_thread.start() + + def closeEvent(self, event): + for thread in (getattr(self, "_refresh_thread", None), + getattr(self, "_action_thread", None)): + if thread is None: + continue + try: + if thread.isRunning(): + thread.wait(3000) + except RuntimeError: + # C++ object already deleted by deleteLater. + pass + event.accept() + + def _on_start(self): + self._run_action(actions.start_daemon) + + def _on_stop(self): + self._run_action(actions.stop_daemon) + + def _on_restart(self): + self._run_action(actions.restart_daemon) + + def _on_check(self): + self._run_action(actions.run_check) + + def _on_open_dashboard(self): + self._run_action(lambda: actions.open_dashboard(self.cfg)) + +else: # pragma: no cover - runtime guard only + _MainWindow = None # type: ignore[assignment, misc] + + +def main(argv=None, cfg: Config | None = None) -> int: + if not _HAS_QT: + raise SystemExit( + "PySide6 is required for the Qt GUI. Install it with: " + "pip install 'enodia-sentinel[qt]'" + ) + if cfg is None: + parser = argparse.ArgumentParser( + prog="enodia-sentinel-gui-qt", + description="Qt desktop dashboard for Enodia Sentinel.", + ) + parser.add_argument("-c", "--config", help="path to TOML config") + args = parser.parse_args(argv) + cfg = Config.load(args.config) + app = QtWidgets.QApplication([]) + window = _MainWindow(cfg) + window.show() + window.refresh() + return app.exec() diff --git a/pyproject.toml b/pyproject.toml index cfd4020..4365371 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,10 +25,13 @@ dependencies = [] enodia-sentinel = "enodia_sentinel.cli:main" enodia-sentinel-tui = "enodia_sentinel.tui:main" enodia-sentinel-tray = "enodia_sentinel.tray.app:main" +enodia-sentinel-gui = "enodia_sentinel.gui.app:main" +enodia-sentinel-gui-qt = "enodia_sentinel.gui.qt_app:main" [project.optional-dependencies] dev = ["pytest"] tray = ["pystray", "Pillow"] +qt = ["PySide6"] [tool.setuptools.packages.find] include = ["enodia_sentinel*"] diff --git a/tests/test_gui_imports.py b/tests/test_gui_imports.py new file mode 100644 index 0000000..f499e20 --- /dev/null +++ b/tests/test_gui_imports.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Boundary tests for the desktop GUI package. + +The logic module must not import tkinter so it can be tested on headless +runners; only app.py may import GUI libraries. +""" +import re +import tomllib +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GUI = ROOT / "enodia_sentinel" / "gui" + + +class TestGuiBoundaries(unittest.TestCase): + def test_model_has_no_tkinter_imports(self): + text = (GUI / "model.py").read_text() + self.assertFalse( + re.search(r"\b(import tkinter|from tkinter)\b", text), + "model.py must not import tkinter", + ) + + def test_model_imports_without_tkinter(self): + # Must succeed even when tkinter is unavailable. + from enodia_sentinel.gui import model # noqa: F401 + + def test_app_module_imports_without_tkinter(self): + # The shell is importable without a display/GUI libraries; runtime + # use raises a graceful SystemExit when tkinter is absent. + from enodia_sentinel.gui import app # noqa: F401 + + def test_pyproject_declares_gui_script(self): + data = tomllib.loads((ROOT / "pyproject.toml").read_text()) + self.assertEqual( + data["project"]["scripts"]["enodia-sentinel-gui"], + "enodia_sentinel.gui.app:main", + ) + # Core runtime deps stay empty; tkinter is stdlib. + self.assertEqual(data["project"]["dependencies"], []) diff --git a/tests/test_gui_model.py b/tests/test_gui_model.py new file mode 100644 index 0000000..1c4c9bf --- /dev/null +++ b/tests/test_gui_model.py @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tests for the GUI presentation model (GUI-free).""" +import unittest +from unittest.mock import patch + +from enodia_sentinel.config import Config +from enodia_sentinel.gui.model import ( + alert_rows, + collect, + incident_rows, + integrity_lines, + plan_rows, + plan_text, + posture_rows, + status_lines, +) +from enodia_sentinel.tray.state import parse_status + + +class TestStatusLines(unittest.TestCase): + def test_empty_status_shows_help(self): + lines = status_lines({}) + self.assertTrue(any("No status available" in line for line in lines)) + self.assertTrue(any("Start the daemon" in line for line in lines)) + + def test_running_status_fields(self): + lines = status_lines({ + "running": True, + "host": "testhost", + "version": "0.7.0", + "total_alerts": 3, + "counts": {"HIGH": 1, "MEDIUM": 2}, + "last_alert": "2026-07-10T12:00:00", + "heartbeat_age": 12, + "heartbeat_stale": False, + "ebpf": "attached", + "ebpf_exec": "on", + "ebpf_syscall": "off", + }) + text = "\n".join(lines) + self.assertIn("testhost", text) + self.assertIn("running", text) + self.assertIn("3 total", text) + self.assertIn("HIGH:1", text) + self.assertIn("12s ago", text) + + def test_stale_heartbeat_flag(self): + lines = status_lines({"heartbeat_age": 90, "heartbeat_stale": True}) + self.assertTrue(any("STALE" in line for line in lines)) + + +class TestAlertRows(unittest.TestCase): + def test_flattens_snapshots(self): + rows = alert_rows([ + {"time": "2026-07-10T12:00:00", "snapshot": "alert-1.json", + "alerts": [ + {"severity": "HIGH", "sid": 100001, + "signature": "reverse_shell", "detail": "bash"}, + ]}, + {"time": "2026-07-10T12:01:00", "snapshot": "alert-2.json", + "alerts": [ + {"severity": "MEDIUM", "sid": 100002, + "signature": "ld_preload", "detail": "libevil"}, + ]}, + ]) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0][1], "HIGH") + self.assertEqual(rows[1][3], "ld_preload") + + def test_empty(self): + self.assertEqual(alert_rows([]), ()) + self.assertEqual(alert_rows(None), ()) + + +class TestIncidentRows(unittest.TestCase): + def test_maps_fields(self): + rows = incident_rows([ + {"id": "INC-1", "severity": "CRITICAL", + "first_seen": "2026-07-10T12:00:00", + "last_seen": "2026-07-10T12:05:00", + "alert_count": 5, + "signatures": ["reverse_shell", "egress"]}, + ]) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], "INC-1") + self.assertEqual(rows[0][1], "CRITICAL") + self.assertEqual(rows[0][4], 5) + self.assertIn("reverse_shell", rows[0][5]) + + +class TestPostureRows(unittest.TestCase): + def test_maps_findings(self): + rows = posture_rows({ + "findings": [ + {"severity": "HIGH", "signature": "ssh_root_login", + "detail": "PermitRootLogin yes"}, + ], + }) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0], ("HIGH", "ssh_root_login", + "PermitRootLogin yes")) + + +class TestIntegrityLines(unittest.TestCase): + def test_empty(self): + self.assertEqual(integrity_lines({}), ("No integrity state available.",)) + + def test_flattens_nested(self): + lines = integrity_lines({ + "watchdog": {"ok": True, "last_check": "now"}, + "fim": ["/etc/passwd", "/etc/shadow"], + }) + self.assertTrue(any("watchdog.ok: True" in line for line in lines)) + self.assertTrue(any("fim: 2 item(s)" in line for line in lines)) + + +class TestPlanRows(unittest.TestCase): + def test_normal_plan(self): + plans = [{ + "incident_id": "INC-9", + "summary": "freeze and contain", + "actions": [ + {"title": "Freeze evidence", "reason": "preserve", + "commands": ["tar", "-czf", "evidence.tgz"]}, + {"title": "Stop process", "commands": ["kill", "-STOP", "1234"]}, + ], + }] + rows, details = plan_rows(plans) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0], ("INC-9", 2, "freeze and contain")) + self.assertIn("Freeze evidence", details["INC-9"]) + self.assertIn("$ kill", details["INC-9"]) + + def test_error_plan(self): + plans = [{"incident_id": "INC-X", "error": "bundle missing"}] + rows, details = plan_rows(plans) + self.assertEqual(rows[0][2], "bundle missing") + self.assertEqual(details["INC-X"], "bundle missing") + + +class TestPlanText(unittest.TestCase): + def test_empty_actions(self): + text = plan_text({"incident_id": "INC-1", "actions": []}) + self.assertIn("(no actions suggested)", text) + + +class TestCollect(unittest.TestCase): + def test_collect_composes_model(self): + cfg = Config.load(None) + fake = { + "status": { + "running": True, "total_alerts": 1, + "counts": {"HIGH": 1}, "host": "h", "version": "v", + }, + "alerts": [{"time": "t", "alerts": [ + {"severity": "HIGH", "sid": 1, "signature": "s", "detail": "d"}, + ]}], + "incidents": [{"id": "INC-1", "severity": "HIGH", + "first_seen": "t1", "last_seen": "t2", + "alert_count": 1, "signatures": ["s"]}], + "posture": {"findings": []}, + "integrity": {"ok": True}, + "events": ["line1"], + "response": [{"incident_id": "INC-1", "actions": []}], + } + with patch("enodia_sentinel.gui.model.collect_model", return_value=fake): + model = collect(cfg) + self.assertTrue(model.header.running) + self.assertEqual(len(model.alerts), 1) + self.assertEqual(len(model.incidents), 1) + self.assertIn("ok: True", model.integrity_lines) + self.assertEqual(model.events, ("line1",)) + self.assertEqual(parse_status(fake["status"]).summary, model.header.summary) diff --git a/tests/test_gui_qt.py b/tests/test_gui_qt.py new file mode 100644 index 0000000..6593d10 --- /dev/null +++ b/tests/test_gui_qt.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Functional tests for the Qt GUI frontend using the offscreen platform.""" +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import unittest +from unittest.mock import patch + +from enodia_sentinel.config import Config +from enodia_sentinel.gui import qt_app +from enodia_sentinel.gui.model import GuiModel +from enodia_sentinel.tray.state import parse_status + +try: + from PySide6 import QtCore, QtGui, QtWidgets + _HAS_QT = True +except ImportError: # pragma: no cover - optional extra + _HAS_QT = False + + +@unittest.skipUnless(_HAS_QT, "PySide6 not installed") +class TestQtGui(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QtWidgets.QApplication.instance() + if cls.app is None: + cls.app = QtWidgets.QApplication([]) + + def _window(self): + cfg = Config.load(None) + return qt_app._MainWindow(cfg) + + def _destroy(self, win): + win.close() + win.deleteLater() + self.app.processEvents() + + def _model(self): + return GuiModel( + header=parse_status({ + "running": True, + "total_alerts": 2, + "counts": {"HIGH": 1, "MEDIUM": 1}, + "host": "testhost", + "version": "0.7.0", + }), + status_lines=("Host: testhost", "Daemon: running"), + alerts=( + ("2026-07-10T12:00:00", "HIGH", 100001, + "reverse_shell", "bash"), + ("2026-07-10T12:01:00", "MEDIUM", 100002, + "ld_preload", "libevil"), + ), + incidents=( + ("INC-1", "HIGH", "2026-07-10T12:00:00", + "2026-07-10T12:05:00", 2, "reverse_shell"), + ), + posture=(("HIGH", "ssh_root_login", "PermitRootLogin yes"),), + integrity_lines=("watchdog.ok: True",), + plans=(("INC-1", 2, "freeze and contain"),), + plan_details={"INC-1": "Incident: INC-1\nSummary: freeze"}, + events=("event one", "event two"), + ) + + def test_window_title(self): + win = self._window() + self.assertEqual(win.windowTitle(), "Enodia Sentinel") + self._destroy(win) + + def test_refresh_renders_model(self): + win = self._window() + model = self._model() + win._on_refreshed(model) + self.assertIn("alerts", win.summary_label.text()) + self.assertIn("testhost", win.status_edit.toPlainText()) + self.assertIn("Daemon: running", win.status_edit.toPlainText()) + self.assertEqual(win.alerts_table.rowCount(), 2) + self.assertEqual(win.incidents_table.rowCount(), 1) + self.assertEqual(win.posture_table.rowCount(), 1) + self.assertEqual(win.plans_table.rowCount(), 1) + self.assertIn("event two", win.events_edit.toPlainText()) + self.assertTrue(win.last_refreshed_label.text().startswith("refreshed")) + self._destroy(win) + + def test_severity_coloring(self): + table = QtWidgets.QTableWidget() + table.setColumnCount(2) + table.setHorizontalHeaderLabels(["Severity", "Signature"]) + qt_app._MainWindow._populate_table(None, table, [ + ("CRITICAL", "rootkit"), + ("HIGH", "reverse_shell"), + ("MEDIUM", "ld_preload"), + ]) + critical = table.item(0, 0) + self.assertEqual( + critical.background().color().name(), "#ff4444" + ) + high = table.item(1, 0) + self.assertEqual( + high.background().color().name(), "#ffaa00" + ) + + def test_plan_selection_shows_detail(self): + win = self._window() + win._on_refreshed(self._model()) + win.plans_table.selectRow(0) + self.app.processEvents() + text = win.plan_edit.toPlainText() + self.assertIn("INC-1", text) + self.assertIn("freeze", text) + self._destroy(win) + + def test_shortcuts_exist(self): + win = self._window() + shortcuts = [s.key().toString() for s in win.findChildren(QtGui.QShortcut)] + self.assertIn("Ctrl+R", shortcuts) + self.assertIn("Ctrl+Q", shortcuts) + self._destroy(win) + + +class TestQtMainWithoutQt(unittest.TestCase): + def test_main_exits_gracefully_without_qt(self): + with patch.object(qt_app, "_HAS_QT", False): + with self.assertRaises(SystemExit) as cm: + qt_app.main([]) + self.assertIn("PySide6", str(cm.exception)) diff --git a/tests/test_gui_qt_imports.py b/tests/test_gui_qt_imports.py new file mode 100644 index 0000000..cf20690 --- /dev/null +++ b/tests/test_gui_qt_imports.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Boundary tests for the Qt GUI frontend. + +The shared model must stay GUI-free; only qt_app.py may reference PySide6. +""" +import re +import tomllib +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GUI = ROOT / "enodia_sentinel" / "gui" + + +class TestQtGuiBoundaries(unittest.TestCase): + def test_model_has_no_qt_imports(self): + text = (GUI / "model.py").read_text() + self.assertFalse( + re.search(r"\b(import PySide6|from PySide6)\b", text), + "model.py must not import PySide6", + ) + + def test_tkinter_app_has_no_qt_imports(self): + text = (GUI / "app.py").read_text() + self.assertNotIn("PySide6", text, "app.py must not import PySide6") + + def test_qt_app_imports_without_runtime_gui_deps(self): + # qt_app.py guards PySide6 and is importable even when Qt is absent. + from enodia_sentinel.gui import qt_app # noqa: F401 + + def test_pyproject_declares_qt_extra_and_script(self): + data = tomllib.loads((ROOT / "pyproject.toml").read_text()) + self.assertIn("qt", data["project"]["optional-dependencies"]) + self.assertEqual( + data["project"]["scripts"]["enodia-sentinel-gui-qt"], + "enodia_sentinel.gui.qt_app:main", + )