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:
parent
9e3575461b
commit
457c7604ba
15 changed files with 1270 additions and 0 deletions
331
enodia_sentinel/gui/qt_app.py
Normal file
331
enodia_sentinel/gui/qt_app.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue