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:
Luna 2026-07-22 04:33:49 -07:00
parent 9e3575461b
commit 457c7604ba
No known key found for this signature in database
15 changed files with 1270 additions and 0 deletions

View file

@ -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"):

View file

@ -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.
"""

View file

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

269
enodia_sentinel/gui/app.py Normal file
View file

@ -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("<<TreeviewSelect>>", 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()

View 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 []),
)

View file

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