# 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()