enodia-sentinal/enodia_sentinel/gui/qt_app.py
Luna 457c7604ba
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
2026-07-22 04:33:49 -07:00

331 lines
12 KiB
Python

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