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