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
269 lines
10 KiB
Python
269 lines
10 KiB
Python
# 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()
|