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
36
README.md
36
README.md
|
|
@ -289,6 +289,42 @@ enodia-sentinel completion bash > ~/.local/share/bash-completion/completions/eno
|
||||||
enodia-sentinel completion zsh > ~/.zfunc/_enodia-sentinel
|
enodia-sentinel completion zsh > ~/.zfunc/_enodia-sentinel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Desktop GUI (optional)
|
||||||
|
|
||||||
|
A windowed desktop dashboard is available using only the stdlib `tkinter`
|
||||||
|
module (your distro may package it separately, e.g. `python-tk` or
|
||||||
|
`python-tkinter`). It is an optional frontend — the daemon and core agent
|
||||||
|
remain stdlib-only.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
enodia-sentinel gui
|
||||||
|
# or, when installed from pyproject metadata:
|
||||||
|
enodia-sentinel-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
The GUI reuses the same local state as the TUI and web console and adds
|
||||||
|
daemon-control buttons: start, stop, restart, quick check, open dashboard, and
|
||||||
|
periodic refresh. Tabs cover status, alerts, incidents, posture findings,
|
||||||
|
integrity/watchdog state, dry-run response plans, and the event tail. Response
|
||||||
|
plans are read-only; the GUI never executes containment commands.
|
||||||
|
|
||||||
|
### Qt desktop GUI (optional)
|
||||||
|
|
||||||
|
A richer Qt6 desktop dashboard is available with the `[qt]` optional extra
|
||||||
|
(`PySide6`). It reuses the same data model as the tkinter GUI but adds a
|
||||||
|
modern native look, resizable table columns, a response-plan detail pane, and
|
||||||
|
threaded background refresh.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install 'enodia-sentinel[qt]'
|
||||||
|
enodia-sentinel gui-qt
|
||||||
|
# or:
|
||||||
|
enodia-sentinel-gui-qt
|
||||||
|
```
|
||||||
|
|
||||||
|
Like the tray applet and tkinter GUI, this is an optional frontend and does
|
||||||
|
not change the core agent's zero-dependency design.
|
||||||
|
|
||||||
### Desktop tray (optional)
|
### Desktop tray (optional)
|
||||||
|
|
||||||
A lightweight system-tray applet is available for desktop installs. It is an
|
A lightweight system-tray applet is available for desktop installs. It is an
|
||||||
|
|
|
||||||
|
|
@ -431,6 +431,34 @@ Command mode supports `status`, `alerts`, `incidents`, `timeline`, `posture`,
|
||||||
`back`, `refresh`,
|
`back`, `refresh`,
|
||||||
`filter <text>`, `clear`, and `quit`.
|
`filter <text>`, `clear`, and `quit`.
|
||||||
|
|
||||||
|
### `gui` / `enodia-sentinel-gui`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
enodia-sentinel gui
|
||||||
|
enodia-sentinel-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens the stdlib tkinter desktop dashboard. It reads the same local state as
|
||||||
|
the TUI and web console (daemon status, alerts, incidents, posture, integrity,
|
||||||
|
events, and read-only response plans) and adds a toolbar for daemon control:
|
||||||
|
start, stop, restart, quick check, open dashboard, and refresh.
|
||||||
|
|
||||||
|
Requires a working `tkinter` installation (usually the distro
|
||||||
|
`python-tk`/`python-tkinter` package); the core agent itself has no runtime
|
||||||
|
dependencies. Response plans are displayed for review only — the GUI does not
|
||||||
|
execute commands.
|
||||||
|
|
||||||
|
### `gui-qt` / `enodia-sentinel-gui-qt`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
enodia-sentinel gui-qt
|
||||||
|
enodia-sentinel-gui-qt
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens the optional Qt6 desktop dashboard. It shows the same read-only tabs and
|
||||||
|
daemon controls as the tkinter GUI with a native look, resizable columns, and
|
||||||
|
a response-plan detail pane. Requires the `[qt]` extra (`PySide6`).
|
||||||
|
|
||||||
### `web`
|
### `web`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,15 @@ use directly, so daemon start/stop/restart prompts for polkit authorization as
|
||||||
usual. The applet is purely a convenience frontend — it is not required for the
|
usual. The applet is purely a convenience frontend — it is not required for the
|
||||||
daemon to run and adds nothing to the core agent.
|
daemon to run and adds nothing to the core agent.
|
||||||
|
|
||||||
|
A windowed desktop GUI (`enodia-sentinel gui` / `enodia-sentinel-gui`) is also
|
||||||
|
available using only stdlib `tkinter`. It shows the same read-only tabs as the
|
||||||
|
TUI plus daemon-control buttons, and like the tray applet it is optional and
|
||||||
|
does not change the core agent's zero-dependency design.
|
||||||
|
|
||||||
|
A richer Qt6 frontend (`enodia-sentinel gui-qt` / `enodia-sentinel-gui-qt`,
|
||||||
|
`[qt]` extra with `PySide6`) provides the same data with a native look,
|
||||||
|
resizable tables, and a response-plan detail pane.
|
||||||
|
|
||||||
## Alert Workflow
|
## Alert Workflow
|
||||||
|
|
||||||
When Sentinel fires:
|
When Sentinel fires:
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,15 @@ Purpose: define a stable single-host product.
|
||||||
- ✅ Add an optional desktop tray applet (`enodia-sentinel-tray`, `[tray]`
|
- ✅ Add an optional desktop tray applet (`enodia-sentinel-tray`, `[tray]`
|
||||||
extra): dashboard launcher, systemd daemon control, status at a glance, and
|
extra): dashboard launcher, systemd daemon control, status at a glance, and
|
||||||
`check --json` quick checks, with GUI-free tested logic modules.
|
`check --json` quick checks, with GUI-free tested logic modules.
|
||||||
|
- ✅ Add an optional stdlib tkinter desktop dashboard (`enodia-sentinel gui` /
|
||||||
|
`enodia-sentinel-gui`): tabs for status, alerts, incidents, posture,
|
||||||
|
integrity, response plans, and events, plus daemon-control buttons and
|
||||||
|
threaded refresh. All testable logic is GUI-free; only `app.py` imports
|
||||||
|
tkinter.
|
||||||
|
- ✅ Add an optional Qt6 desktop dashboard (`enodia-sentinel gui-qt` /
|
||||||
|
`enodia-sentinel-gui-qt`, `[qt]` extra with `PySide6`): native tabs and
|
||||||
|
tables, severity row coloring, response-plan detail pane, keyboard shortcuts,
|
||||||
|
and safe threaded background refresh.
|
||||||
- Keep the dashboard read-only unless a separate authenticated write path is
|
- Keep the dashboard read-only unless a separate authenticated write path is
|
||||||
designed and reviewed.
|
designed and reviewed.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
rules.add_argument("--json", action="store_true", help="emit JSON")
|
rules.add_argument("--json", action="store_true", help="emit JSON")
|
||||||
sub.add_parser("web", help="serve the read-only dashboard")
|
sub.add_parser("web", help="serve the read-only dashboard")
|
||||||
sub.add_parser("tui", help="open the terminal dashboard (curses)")
|
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("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-baseline", help="build the file-integrity baseline")
|
||||||
sub.add_parser("fim-update", help="refresh the FIM baseline (run by the pacman hook)")
|
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":
|
if args.cmd == "tui":
|
||||||
from .tui import run as run_tui
|
from .tui import run as run_tui
|
||||||
return run_tui(cfg)
|
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":
|
if args.cmd == "triage":
|
||||||
return _cmd_triage(cfg)
|
return _cmd_triage(cfg)
|
||||||
if args.cmd in ("fim-baseline", "fim-update"):
|
if args.cmd in ("fim-baseline", "fim-update"):
|
||||||
|
|
|
||||||
7
enodia_sentinel/gui/__init__.py
Normal file
7
enodia_sentinel/gui/__init__.py
Normal 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.
|
||||||
|
"""
|
||||||
6
enodia_sentinel/gui/__main__.py
Normal file
6
enodia_sentinel/gui/__main__.py
Normal 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
269
enodia_sentinel/gui/app.py
Normal 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()
|
||||||
187
enodia_sentinel/gui/model.py
Normal file
187
enodia_sentinel/gui/model.py
Normal 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 []),
|
||||||
|
)
|
||||||
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()
|
||||||
|
|
@ -25,10 +25,13 @@ dependencies = []
|
||||||
enodia-sentinel = "enodia_sentinel.cli:main"
|
enodia-sentinel = "enodia_sentinel.cli:main"
|
||||||
enodia-sentinel-tui = "enodia_sentinel.tui:main"
|
enodia-sentinel-tui = "enodia_sentinel.tui:main"
|
||||||
enodia-sentinel-tray = "enodia_sentinel.tray.app:main"
|
enodia-sentinel-tray = "enodia_sentinel.tray.app:main"
|
||||||
|
enodia-sentinel-gui = "enodia_sentinel.gui.app:main"
|
||||||
|
enodia-sentinel-gui-qt = "enodia_sentinel.gui.qt_app:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest"]
|
dev = ["pytest"]
|
||||||
tray = ["pystray", "Pillow"]
|
tray = ["pystray", "Pillow"]
|
||||||
|
qt = ["PySide6"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["enodia_sentinel*"]
|
include = ["enodia_sentinel*"]
|
||||||
|
|
|
||||||
40
tests/test_gui_imports.py
Normal file
40
tests/test_gui_imports.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Boundary tests for the desktop GUI package.
|
||||||
|
|
||||||
|
The logic module must not import tkinter so it can be tested on headless
|
||||||
|
runners; only app.py may import GUI libraries.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
GUI = ROOT / "enodia_sentinel" / "gui"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuiBoundaries(unittest.TestCase):
|
||||||
|
def test_model_has_no_tkinter_imports(self):
|
||||||
|
text = (GUI / "model.py").read_text()
|
||||||
|
self.assertFalse(
|
||||||
|
re.search(r"\b(import tkinter|from tkinter)\b", text),
|
||||||
|
"model.py must not import tkinter",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_model_imports_without_tkinter(self):
|
||||||
|
# Must succeed even when tkinter is unavailable.
|
||||||
|
from enodia_sentinel.gui import model # noqa: F401
|
||||||
|
|
||||||
|
def test_app_module_imports_without_tkinter(self):
|
||||||
|
# The shell is importable without a display/GUI libraries; runtime
|
||||||
|
# use raises a graceful SystemExit when tkinter is absent.
|
||||||
|
from enodia_sentinel.gui import app # noqa: F401
|
||||||
|
|
||||||
|
def test_pyproject_declares_gui_script(self):
|
||||||
|
data = tomllib.loads((ROOT / "pyproject.toml").read_text())
|
||||||
|
self.assertEqual(
|
||||||
|
data["project"]["scripts"]["enodia-sentinel-gui"],
|
||||||
|
"enodia_sentinel.gui.app:main",
|
||||||
|
)
|
||||||
|
# Core runtime deps stay empty; tkinter is stdlib.
|
||||||
|
self.assertEqual(data["project"]["dependencies"], [])
|
||||||
173
tests/test_gui_model.py
Normal file
173
tests/test_gui_model.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Tests for the GUI presentation model (GUI-free)."""
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from enodia_sentinel.config import Config
|
||||||
|
from enodia_sentinel.gui.model import (
|
||||||
|
alert_rows,
|
||||||
|
collect,
|
||||||
|
incident_rows,
|
||||||
|
integrity_lines,
|
||||||
|
plan_rows,
|
||||||
|
plan_text,
|
||||||
|
posture_rows,
|
||||||
|
status_lines,
|
||||||
|
)
|
||||||
|
from enodia_sentinel.tray.state import parse_status
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusLines(unittest.TestCase):
|
||||||
|
def test_empty_status_shows_help(self):
|
||||||
|
lines = status_lines({})
|
||||||
|
self.assertTrue(any("No status available" in line for line in lines))
|
||||||
|
self.assertTrue(any("Start the daemon" in line for line in lines))
|
||||||
|
|
||||||
|
def test_running_status_fields(self):
|
||||||
|
lines = status_lines({
|
||||||
|
"running": True,
|
||||||
|
"host": "testhost",
|
||||||
|
"version": "0.7.0",
|
||||||
|
"total_alerts": 3,
|
||||||
|
"counts": {"HIGH": 1, "MEDIUM": 2},
|
||||||
|
"last_alert": "2026-07-10T12:00:00",
|
||||||
|
"heartbeat_age": 12,
|
||||||
|
"heartbeat_stale": False,
|
||||||
|
"ebpf": "attached",
|
||||||
|
"ebpf_exec": "on",
|
||||||
|
"ebpf_syscall": "off",
|
||||||
|
})
|
||||||
|
text = "\n".join(lines)
|
||||||
|
self.assertIn("testhost", text)
|
||||||
|
self.assertIn("running", text)
|
||||||
|
self.assertIn("3 total", text)
|
||||||
|
self.assertIn("HIGH:1", text)
|
||||||
|
self.assertIn("12s ago", text)
|
||||||
|
|
||||||
|
def test_stale_heartbeat_flag(self):
|
||||||
|
lines = status_lines({"heartbeat_age": 90, "heartbeat_stale": True})
|
||||||
|
self.assertTrue(any("STALE" in line for line in lines))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlertRows(unittest.TestCase):
|
||||||
|
def test_flattens_snapshots(self):
|
||||||
|
rows = alert_rows([
|
||||||
|
{"time": "2026-07-10T12:00:00", "snapshot": "alert-1.json",
|
||||||
|
"alerts": [
|
||||||
|
{"severity": "HIGH", "sid": 100001,
|
||||||
|
"signature": "reverse_shell", "detail": "bash"},
|
||||||
|
]},
|
||||||
|
{"time": "2026-07-10T12:01:00", "snapshot": "alert-2.json",
|
||||||
|
"alerts": [
|
||||||
|
{"severity": "MEDIUM", "sid": 100002,
|
||||||
|
"signature": "ld_preload", "detail": "libevil"},
|
||||||
|
]},
|
||||||
|
])
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(rows[0][1], "HIGH")
|
||||||
|
self.assertEqual(rows[1][3], "ld_preload")
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
self.assertEqual(alert_rows([]), ())
|
||||||
|
self.assertEqual(alert_rows(None), ())
|
||||||
|
|
||||||
|
|
||||||
|
class TestIncidentRows(unittest.TestCase):
|
||||||
|
def test_maps_fields(self):
|
||||||
|
rows = incident_rows([
|
||||||
|
{"id": "INC-1", "severity": "CRITICAL",
|
||||||
|
"first_seen": "2026-07-10T12:00:00",
|
||||||
|
"last_seen": "2026-07-10T12:05:00",
|
||||||
|
"alert_count": 5,
|
||||||
|
"signatures": ["reverse_shell", "egress"]},
|
||||||
|
])
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0][0], "INC-1")
|
||||||
|
self.assertEqual(rows[0][1], "CRITICAL")
|
||||||
|
self.assertEqual(rows[0][4], 5)
|
||||||
|
self.assertIn("reverse_shell", rows[0][5])
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostureRows(unittest.TestCase):
|
||||||
|
def test_maps_findings(self):
|
||||||
|
rows = posture_rows({
|
||||||
|
"findings": [
|
||||||
|
{"severity": "HIGH", "signature": "ssh_root_login",
|
||||||
|
"detail": "PermitRootLogin yes"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0], ("HIGH", "ssh_root_login",
|
||||||
|
"PermitRootLogin yes"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegrityLines(unittest.TestCase):
|
||||||
|
def test_empty(self):
|
||||||
|
self.assertEqual(integrity_lines({}), ("No integrity state available.",))
|
||||||
|
|
||||||
|
def test_flattens_nested(self):
|
||||||
|
lines = integrity_lines({
|
||||||
|
"watchdog": {"ok": True, "last_check": "now"},
|
||||||
|
"fim": ["/etc/passwd", "/etc/shadow"],
|
||||||
|
})
|
||||||
|
self.assertTrue(any("watchdog.ok: True" in line for line in lines))
|
||||||
|
self.assertTrue(any("fim: 2 item(s)" in line for line in lines))
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanRows(unittest.TestCase):
|
||||||
|
def test_normal_plan(self):
|
||||||
|
plans = [{
|
||||||
|
"incident_id": "INC-9",
|
||||||
|
"summary": "freeze and contain",
|
||||||
|
"actions": [
|
||||||
|
{"title": "Freeze evidence", "reason": "preserve",
|
||||||
|
"commands": ["tar", "-czf", "evidence.tgz"]},
|
||||||
|
{"title": "Stop process", "commands": ["kill", "-STOP", "1234"]},
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
rows, details = plan_rows(plans)
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0], ("INC-9", 2, "freeze and contain"))
|
||||||
|
self.assertIn("Freeze evidence", details["INC-9"])
|
||||||
|
self.assertIn("$ kill", details["INC-9"])
|
||||||
|
|
||||||
|
def test_error_plan(self):
|
||||||
|
plans = [{"incident_id": "INC-X", "error": "bundle missing"}]
|
||||||
|
rows, details = plan_rows(plans)
|
||||||
|
self.assertEqual(rows[0][2], "bundle missing")
|
||||||
|
self.assertEqual(details["INC-X"], "bundle missing")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanText(unittest.TestCase):
|
||||||
|
def test_empty_actions(self):
|
||||||
|
text = plan_text({"incident_id": "INC-1", "actions": []})
|
||||||
|
self.assertIn("(no actions suggested)", text)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollect(unittest.TestCase):
|
||||||
|
def test_collect_composes_model(self):
|
||||||
|
cfg = Config.load(None)
|
||||||
|
fake = {
|
||||||
|
"status": {
|
||||||
|
"running": True, "total_alerts": 1,
|
||||||
|
"counts": {"HIGH": 1}, "host": "h", "version": "v",
|
||||||
|
},
|
||||||
|
"alerts": [{"time": "t", "alerts": [
|
||||||
|
{"severity": "HIGH", "sid": 1, "signature": "s", "detail": "d"},
|
||||||
|
]}],
|
||||||
|
"incidents": [{"id": "INC-1", "severity": "HIGH",
|
||||||
|
"first_seen": "t1", "last_seen": "t2",
|
||||||
|
"alert_count": 1, "signatures": ["s"]}],
|
||||||
|
"posture": {"findings": []},
|
||||||
|
"integrity": {"ok": True},
|
||||||
|
"events": ["line1"],
|
||||||
|
"response": [{"incident_id": "INC-1", "actions": []}],
|
||||||
|
}
|
||||||
|
with patch("enodia_sentinel.gui.model.collect_model", return_value=fake):
|
||||||
|
model = collect(cfg)
|
||||||
|
self.assertTrue(model.header.running)
|
||||||
|
self.assertEqual(len(model.alerts), 1)
|
||||||
|
self.assertEqual(len(model.incidents), 1)
|
||||||
|
self.assertIn("ok: True", model.integrity_lines)
|
||||||
|
self.assertEqual(model.events, ("line1",))
|
||||||
|
self.assertEqual(parse_status(fake["status"]).summary, model.header.summary)
|
||||||
127
tests/test_gui_qt.py
Normal file
127
tests/test_gui_qt.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Functional tests for the Qt GUI frontend using the offscreen platform."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from enodia_sentinel.config import Config
|
||||||
|
from enodia_sentinel.gui import qt_app
|
||||||
|
from enodia_sentinel.gui.model import GuiModel
|
||||||
|
from enodia_sentinel.tray.state import parse_status
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
_HAS_QT = True
|
||||||
|
except ImportError: # pragma: no cover - optional extra
|
||||||
|
_HAS_QT = False
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(_HAS_QT, "PySide6 not installed")
|
||||||
|
class TestQtGui(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.app = QtWidgets.QApplication.instance()
|
||||||
|
if cls.app is None:
|
||||||
|
cls.app = QtWidgets.QApplication([])
|
||||||
|
|
||||||
|
def _window(self):
|
||||||
|
cfg = Config.load(None)
|
||||||
|
return qt_app._MainWindow(cfg)
|
||||||
|
|
||||||
|
def _destroy(self, win):
|
||||||
|
win.close()
|
||||||
|
win.deleteLater()
|
||||||
|
self.app.processEvents()
|
||||||
|
|
||||||
|
def _model(self):
|
||||||
|
return GuiModel(
|
||||||
|
header=parse_status({
|
||||||
|
"running": True,
|
||||||
|
"total_alerts": 2,
|
||||||
|
"counts": {"HIGH": 1, "MEDIUM": 1},
|
||||||
|
"host": "testhost",
|
||||||
|
"version": "0.7.0",
|
||||||
|
}),
|
||||||
|
status_lines=("Host: testhost", "Daemon: running"),
|
||||||
|
alerts=(
|
||||||
|
("2026-07-10T12:00:00", "HIGH", 100001,
|
||||||
|
"reverse_shell", "bash"),
|
||||||
|
("2026-07-10T12:01:00", "MEDIUM", 100002,
|
||||||
|
"ld_preload", "libevil"),
|
||||||
|
),
|
||||||
|
incidents=(
|
||||||
|
("INC-1", "HIGH", "2026-07-10T12:00:00",
|
||||||
|
"2026-07-10T12:05:00", 2, "reverse_shell"),
|
||||||
|
),
|
||||||
|
posture=(("HIGH", "ssh_root_login", "PermitRootLogin yes"),),
|
||||||
|
integrity_lines=("watchdog.ok: True",),
|
||||||
|
plans=(("INC-1", 2, "freeze and contain"),),
|
||||||
|
plan_details={"INC-1": "Incident: INC-1\nSummary: freeze"},
|
||||||
|
events=("event one", "event two"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_window_title(self):
|
||||||
|
win = self._window()
|
||||||
|
self.assertEqual(win.windowTitle(), "Enodia Sentinel")
|
||||||
|
self._destroy(win)
|
||||||
|
|
||||||
|
def test_refresh_renders_model(self):
|
||||||
|
win = self._window()
|
||||||
|
model = self._model()
|
||||||
|
win._on_refreshed(model)
|
||||||
|
self.assertIn("alerts", win.summary_label.text())
|
||||||
|
self.assertIn("testhost", win.status_edit.toPlainText())
|
||||||
|
self.assertIn("Daemon: running", win.status_edit.toPlainText())
|
||||||
|
self.assertEqual(win.alerts_table.rowCount(), 2)
|
||||||
|
self.assertEqual(win.incidents_table.rowCount(), 1)
|
||||||
|
self.assertEqual(win.posture_table.rowCount(), 1)
|
||||||
|
self.assertEqual(win.plans_table.rowCount(), 1)
|
||||||
|
self.assertIn("event two", win.events_edit.toPlainText())
|
||||||
|
self.assertTrue(win.last_refreshed_label.text().startswith("refreshed"))
|
||||||
|
self._destroy(win)
|
||||||
|
|
||||||
|
def test_severity_coloring(self):
|
||||||
|
table = QtWidgets.QTableWidget()
|
||||||
|
table.setColumnCount(2)
|
||||||
|
table.setHorizontalHeaderLabels(["Severity", "Signature"])
|
||||||
|
qt_app._MainWindow._populate_table(None, table, [
|
||||||
|
("CRITICAL", "rootkit"),
|
||||||
|
("HIGH", "reverse_shell"),
|
||||||
|
("MEDIUM", "ld_preload"),
|
||||||
|
])
|
||||||
|
critical = table.item(0, 0)
|
||||||
|
self.assertEqual(
|
||||||
|
critical.background().color().name(), "#ff4444"
|
||||||
|
)
|
||||||
|
high = table.item(1, 0)
|
||||||
|
self.assertEqual(
|
||||||
|
high.background().color().name(), "#ffaa00"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_plan_selection_shows_detail(self):
|
||||||
|
win = self._window()
|
||||||
|
win._on_refreshed(self._model())
|
||||||
|
win.plans_table.selectRow(0)
|
||||||
|
self.app.processEvents()
|
||||||
|
text = win.plan_edit.toPlainText()
|
||||||
|
self.assertIn("INC-1", text)
|
||||||
|
self.assertIn("freeze", text)
|
||||||
|
self._destroy(win)
|
||||||
|
|
||||||
|
def test_shortcuts_exist(self):
|
||||||
|
win = self._window()
|
||||||
|
shortcuts = [s.key().toString() for s in win.findChildren(QtGui.QShortcut)]
|
||||||
|
self.assertIn("Ctrl+R", shortcuts)
|
||||||
|
self.assertIn("Ctrl+Q", shortcuts)
|
||||||
|
self._destroy(win)
|
||||||
|
|
||||||
|
|
||||||
|
class TestQtMainWithoutQt(unittest.TestCase):
|
||||||
|
def test_main_exits_gracefully_without_qt(self):
|
||||||
|
with patch.object(qt_app, "_HAS_QT", False):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
qt_app.main([])
|
||||||
|
self.assertIn("PySide6", str(cm.exception))
|
||||||
37
tests/test_gui_qt_imports.py
Normal file
37
tests/test_gui_qt_imports.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Boundary tests for the Qt GUI frontend.
|
||||||
|
|
||||||
|
The shared model must stay GUI-free; only qt_app.py may reference PySide6.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
GUI = ROOT / "enodia_sentinel" / "gui"
|
||||||
|
|
||||||
|
|
||||||
|
class TestQtGuiBoundaries(unittest.TestCase):
|
||||||
|
def test_model_has_no_qt_imports(self):
|
||||||
|
text = (GUI / "model.py").read_text()
|
||||||
|
self.assertFalse(
|
||||||
|
re.search(r"\b(import PySide6|from PySide6)\b", text),
|
||||||
|
"model.py must not import PySide6",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tkinter_app_has_no_qt_imports(self):
|
||||||
|
text = (GUI / "app.py").read_text()
|
||||||
|
self.assertNotIn("PySide6", text, "app.py must not import PySide6")
|
||||||
|
|
||||||
|
def test_qt_app_imports_without_runtime_gui_deps(self):
|
||||||
|
# qt_app.py guards PySide6 and is importable even when Qt is absent.
|
||||||
|
from enodia_sentinel.gui import qt_app # noqa: F401
|
||||||
|
|
||||||
|
def test_pyproject_declares_qt_extra_and_script(self):
|
||||||
|
data = tomllib.loads((ROOT / "pyproject.toml").read_text())
|
||||||
|
self.assertIn("qt", data["project"]["optional-dependencies"])
|
||||||
|
self.assertEqual(
|
||||||
|
data["project"]["scripts"]["enodia-sentinel-gui-qt"],
|
||||||
|
"enodia_sentinel.gui.qt_app:main",
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue