# SPDX-License-Identifier: GPL-3.0-or-later """Stdlib curses terminal UI for SSH/tmux operators.""" from __future__ import annotations import curses import json import textwrap from dataclasses import dataclass, field from typing import Iterable from . import __version__, incident, ruleops from .config import Config VIEWS = ("status", "alerts", "incidents", "rules", "help") TUI_COMMANDS = { "status": "show daemon health and heartbeat", "alerts": "show recent captured alert snapshots", "incidents": "show grouped incidents", "rules": "show event-rule catalog", "help": "show keys and commands", "refresh": "reload data from disk", "filter": "filter current view: filter ", "clear": "clear filter and message", "quit": "exit the TUI", } @dataclass class TuiState: view: str = "status" filter_text: str = "" message: str = "" scroll: int = 0 command: str = "" command_mode: bool = False should_quit: bool = False model: dict = field(default_factory=dict) def complete_command(prefix: str, commands: Iterable[str] = TUI_COMMANDS) -> str: """Return the unique command completion or the common prefix of matches.""" if not prefix: return prefix matches = sorted(c for c in commands if c.startswith(prefix)) if not matches: return prefix if len(matches) == 1: return matches[0] common = matches[0] for match in matches[1:]: i = 0 while i < min(len(common), len(match)) and common[i] == match[i]: i += 1 common = common[:i] return common or prefix def collect_model(cfg: Config) -> dict: from .web import daemon_status return { "status": daemon_status(cfg), "alerts": _recent_alerts(cfg), "incidents": sorted( incident.load_index(cfg).values(), key=lambda i: i.get("last_ts", 0.0), reverse=True, ), "rules": ruleops.list_rules(cfg), } def run_tui_command(state: TuiState, command: str) -> TuiState: raw = command.strip() if not raw: state.command_mode = False return state name, _, rest = raw.partition(" ") if name in VIEWS: state.view = name state.scroll = 0 state.message = f"view: {name}" elif name == "refresh": state.message = "refreshed" elif name == "filter": state.filter_text = rest.strip() state.scroll = 0 state.message = f"filter: {state.filter_text or 'none'}" elif name == "clear": state.filter_text = "" state.message = "" state.scroll = 0 elif name == "quit": state.should_quit = True else: state.message = f"unknown command: {name}" state.command = "" state.command_mode = False return state def render_lines(state: TuiState, width: int = 100) -> list[str]: model = state.model or {} view = state.view if state.view in VIEWS else "status" if view == "status": lines = _render_status(model.get("status") or {}, width) elif view == "alerts": lines = _render_alerts(model.get("alerts") or [], width) elif view == "incidents": lines = _render_incidents(model.get("incidents") or [], width) elif view == "rules": lines = _render_rules(model.get("rules") or [], width) else: lines = _render_help(width) if state.filter_text: needle = state.filter_text.lower() lines = [line for line in lines if needle in line.lower()] if not lines: lines = [f"No rows match filter: {state.filter_text}"] return lines def run(cfg: Config) -> int: curses.wrapper(lambda stdscr: _loop(stdscr, cfg)) return 0 def main(argv=None) -> int: import argparse parser = argparse.ArgumentParser( prog="enodia-sentinel-tui", description="Terminal dashboard for Enodia Sentinel.", ) parser.add_argument("-c", "--config", help="path to TOML config") args = parser.parse_args(argv) return run(Config.load(args.config)) def _loop(stdscr, cfg: Config) -> None: curses.curs_set(0) stdscr.nodelay(False) state = TuiState(model=collect_model(cfg)) while not state.should_quit: _draw(stdscr, state) key = stdscr.getch() if state.command_mode: _handle_command_key(state, key) if not state.command_mode and not state.should_quit: if state.message == "refreshed": state.model = collect_model(cfg) state.command = "" continue if key in (ord("q"), ord("Q")): state.should_quit = True elif key in (ord("r"), ord("R")): state.model = collect_model(cfg) state.message = "refreshed" elif key in (ord("1"),): state.view, state.scroll = "status", 0 elif key in (ord("2"),): state.view, state.scroll = "alerts", 0 elif key in (ord("3"),): state.view, state.scroll = "incidents", 0 elif key in (ord("4"),): state.view, state.scroll = "rules", 0 elif key in (ord("h"), ord("?")): state.view, state.scroll = "help", 0 elif key == ord(":"): state.command_mode = True state.command = "" curses.curs_set(1) elif key == ord("/"): state.command_mode = True state.command = "filter " curses.curs_set(1) elif key in (curses.KEY_DOWN, ord("j")): state.scroll += 1 elif key in (curses.KEY_UP, ord("k")): state.scroll = max(0, state.scroll - 1) elif key == curses.KEY_NPAGE: state.scroll += 10 elif key == curses.KEY_PPAGE: state.scroll = max(0, state.scroll - 10) elif key == 12: # Ctrl-L stdscr.clear() def _handle_command_key(state: TuiState, key: int) -> None: if key in (10, 13, curses.KEY_ENTER): run_tui_command(state, state.command) curses.curs_set(0) elif key in (27,): state.command_mode = False state.command = "" curses.curs_set(0) elif key in (9,): head, sep, tail = state.command.partition(" ") if sep: return state.command = complete_command(head) elif key in (curses.KEY_BACKSPACE, 127, 8): state.command = state.command[:-1] elif 32 <= key <= 126: state.command += chr(key) def _draw(stdscr, state: TuiState) -> None: height, width = stdscr.getmaxyx() width = max(width, 20) stdscr.erase() title = f" Enodia Sentinel TUI v{__version__} " tabs = " 1 Status 2 Alerts 3 Incidents 4 Rules h Help r Refresh q Quit" stdscr.addnstr(0, 0, (title + tabs)[:width - 1], width - 1, curses.A_REVERSE) meta = f"view={state.view} filter={state.filter_text or '-'} {state.message}" stdscr.addnstr(1, 0, meta[:width - 1], width - 1) lines = render_lines(state, width) body_h = max(0, height - 4) max_scroll = max(0, len(lines) - body_h) state.scroll = min(state.scroll, max_scroll) for row, line in enumerate(lines[state.scroll:state.scroll + body_h], start=2): stdscr.addnstr(row, 0, line[:width - 1], width - 1) prompt = ":" + state.command if state.command_mode else "Press ':' for commands; Tab completes command names." stdscr.addnstr(height - 1, 0, prompt[:width - 1], width - 1, curses.A_REVERSE) if state.command_mode: stdscr.move(height - 1, min(width - 1, len(prompt))) stdscr.refresh() def _render_status(status: dict, width: int) -> list[str]: if not status: return ["No status available."] 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"{k}:{v}" for k, v 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')}", "", "Use 2/3/4 to inspect alerts, incidents, and rules. Use ':' for commands.", ] def _render_alerts(alerts: list[dict], width: int) -> list[str]: if not alerts: return ["No alert snapshots found."] lines = [f"{'TIME':<24} {'SEV':<9} {'SID':<7} SIGNATURE / DETAIL"] for snap in alerts: for alert in snap.get("alerts", []): detail = alert.get("detail", "") line = ( f"{snap.get('time', '?')[:24]:<24} {alert.get('severity', '?'):<9} " f"{alert.get('sid', 0):<7} {alert.get('signature', '?')} " f"{detail[:max(20, width - 60)]}" ) lines.append(line) return lines def _render_incidents(incidents: list[dict], width: int) -> list[str]: if not incidents: return ["No incidents recorded."] lines = [f"{'INCIDENT':<28} {'LAST SEEN':<24} {'SEV':<9} {'#':>3} SIGNATURES"] for inc in incidents: sigs = ", ".join(inc.get("signatures", [])) lines.append( f"{inc.get('id', '?'):<28} {inc.get('last_seen', '?')[:24]:<24} " f"{inc.get('severity', '?'):<9} {inc.get('alert_count', 0):>3} {sigs}" ) return lines def _render_rules(rules: list[dict], width: int) -> list[str]: if not rules: return ["No rules loaded."] lines = [f"{'SID':<7} {'EVENT':<8} {'SEV':<9} {'CLASSTYPE':<24} MESSAGE"] for rule in rules: lines.append( f"{rule.get('sid', 0):<7} {rule.get('event', '?'):<8} " f"{rule.get('severity', '?'):<9} {rule.get('classtype', '?'):<24} " f"{rule.get('msg', '')[:max(20, width - 52)]}" ) return lines def _render_help(width: int) -> list[str]: lines = [ "Keys:", " 1/2/3/4 switch views: status, alerts, incidents, rules", " j/k or arrows scroll", " PgUp/PgDn page scroll", " r refresh", " / filter current view", " : command mode", " Tab complete command names in command mode", " Ctrl-L redraw", " q quit", "", "Commands:", ] for name, help_text in TUI_COMMANDS.items(): lines.append(f" {name:<10} {help_text}") return [wrapped for line in lines for wrapped in (textwrap.wrap(line, width) or [""])] def _recent_alerts(cfg: Config, limit: int = 50) -> list[dict]: rows = [] for path in sorted(cfg.log_dir.glob("alert-*.json"), reverse=True)[:limit]: try: data = json.loads(path.read_text()) except (OSError, ValueError): continue if isinstance(data, dict): data.setdefault("snapshot", path.name) rows.append(data) return rows if __name__ == "__main__": raise SystemExit(main())