From 98a17d9b7e7e9173b647f203561a9b784902823f Mon Sep 17 00:00:00 2001 From: samjage Date: Sun, 22 Mar 2026 21:33:40 -0400 Subject: [PATCH] Initial commit: Metro Warden TUI network operations center --- .claude/settings.local.json | 20 ++ .vscode/extensions.json | 24 ++ .vscode/launch.json | 24 ++ .vscode/settings.json | 46 +++ README.md | 136 ++++++++ assets/styles/metro.tcss | 331 +++++++++++++++++++ config/defaults.toml | 41 +++ core/__init__.py | 7 + core/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 351 bytes core/__pycache__/bus.cpython-314.pyc | Bin 0 -> 13600 bytes core/__pycache__/registry.cpython-314.pyc | Bin 0 -> 12478 bytes core/__pycache__/state.cpython-314.pyc | Bin 0 -> 10219 bytes core/app.py | 172 ++++++++++ core/bus.py | 223 +++++++++++++ core/registry.py | 223 +++++++++++++ core/state.py | 164 +++++++++ dev.sh | 65 ++++ main.py | 88 +++++ plugins/__init__.py | 5 + plugins/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 259 bytes plugins/__pycache__/base.cpython-314.pyc | Bin 0 -> 6864 bytes plugins/base.py | 105 ++++++ plugins/dns/__init__.py | 5 + plugins/dns/plugin.py | 130 ++++++++ plugins/firewall/__init__.py | 5 + plugins/firewall/plugin.py | 200 +++++++++++ plugins/network/__init__.py | 5 + plugins/network/plugin.py | 129 ++++++++ plugins/system/__init__.py | 5 + plugins/system/plugin.py | 164 +++++++++ pyproject.toml | 73 ++++ tests/__init__.py | 1 + tests/test_bus.py | 273 +++++++++++++++ tests/test_state.py | 269 +++++++++++++++ ui/__init__.py | 1 + ui/tabs/__init__.py | 19 ++ ui/tabs/chronicle.py | 164 +++++++++ ui/tabs/garrison.py | 151 +++++++++ ui/tabs/lines.py | 107 ++++++ ui/tabs/registry.py | 128 +++++++ ui/tabs/routes.py | 185 +++++++++++ ui/tabs/settings.py | 260 +++++++++++++++ ui/tabs/signals.py | 140 ++++++++ ui/widgets/__init__.py | 5 + ui/widgets/header.py | 122 +++++++ 45 files changed, 4215 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 assets/styles/metro.tcss create mode 100644 config/defaults.toml create mode 100644 core/__init__.py create mode 100644 core/__pycache__/__init__.cpython-314.pyc create mode 100644 core/__pycache__/bus.cpython-314.pyc create mode 100644 core/__pycache__/registry.cpython-314.pyc create mode 100644 core/__pycache__/state.cpython-314.pyc create mode 100644 core/app.py create mode 100644 core/bus.py create mode 100644 core/registry.py create mode 100644 core/state.py create mode 100755 dev.sh create mode 100644 main.py create mode 100644 plugins/__init__.py create mode 100644 plugins/__pycache__/__init__.cpython-314.pyc create mode 100644 plugins/__pycache__/base.cpython-314.pyc create mode 100644 plugins/base.py create mode 100644 plugins/dns/__init__.py create mode 100644 plugins/dns/plugin.py create mode 100644 plugins/firewall/__init__.py create mode 100644 plugins/firewall/plugin.py create mode 100644 plugins/network/__init__.py create mode 100644 plugins/network/plugin.py create mode 100644 plugins/system/__init__.py create mode 100644 plugins/system/plugin.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_bus.py create mode 100644 tests/test_state.py create mode 100644 ui/__init__.py create mode 100644 ui/tabs/__init__.py create mode 100644 ui/tabs/chronicle.py create mode 100644 ui/tabs/garrison.py create mode 100644 ui/tabs/lines.py create mode 100644 ui/tabs/registry.py create mode 100644 ui/tabs/routes.py create mode 100644 ui/tabs/settings.py create mode 100644 ui/tabs/signals.py create mode 100644 ui/widgets/__init__.py create mode 100644 ui/widgets/header.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..18d1668 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash(python:*)", + "Bash(pip install:*)", + "Bash(python3 -m pip install --quiet textual psutil toml pytest pytest-asyncio)", + "Bash(python3 -c \"import sys; print\\(sys.path\\)\")", + "Bash(python3.14 -m pytest --version)", + "Bash(python3 -c \"import pytest; print\\(pytest.__version__\\)\")", + "Bash(ls /usr/lib/python3*/site-packages/)", + "Bash(pacman -S --noconfirm python-pytest python-pytest-asyncio)", + "Bash(sudo pacman:*)", + "Bash(python3:*)", + "Bash(pip show:*)", + "Bash(chmod:*)", + "Bash(echo $TERM $TERM_PROGRAM)", + "Bash(tmux -V)" + ] + } +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..7bc4a8e --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,24 @@ +{ + "recommendations": [ + // Python + "ms-python.python", + "ms-python.black-formatter", + "ms-python.pylint", + + // Better error highlighting and type checking + "ms-pyright.pyright", + + // Git decorations in the gutter + "eamodio.gitlens", + + // Colored indent guides — crucial for reading Python + "oderwat.indent-rainbow", + + // Highlight TODO/FIXME/HACK comments + "gruntfuss.vscode-phpdoc-comment-snippets", + "wayou.vscode-todo-highlight", + + // TOML support (config/defaults.toml) + "tamasfe.even-better-toml" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..095ac91 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Metro Warden", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/main.py", + "python": "${workspaceFolder}/.venv/bin/python", + "console": "integratedTerminal", + "justMyCode": true + }, + { + "name": "Run Tests", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "args": ["-v"], + "console": "integratedTerminal", + "justMyCode": false + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cd88966 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,46 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.terminal.activateEnvironment": true, + + // Format on save with black + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true, + "editor.rulers": [100] + }, + + // Textual CSS gets syntax highlighting as plain CSS + "[tcss]": { + "editor.defaultFormatter": "vscode.css-language-features" + }, + "files.associations": { + "*.tcss": "css" + }, + + // Keep the explorer clean + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true, + ".venv": true, + "*.egg-info": true + }, + + // Terminal opens in project root + "terminal.integrated.cwd": "${workspaceFolder}", + + // Font that makes the TUI source code feel right + "editor.fontFamily": "'JetBrains Mono', 'Fira Code', monospace", + "editor.fontLigatures": true, + "editor.fontSize": 14, + "editor.lineHeight": 1.6, + + // Show whitespace so indentation errors are obvious in Python + "editor.renderWhitespace": "boundary", + + // Dark theme that won't clash with spending time in the TUI + "workbench.colorTheme": "Default Dark Modern", + + "editor.minimap.enabled": false, + "breadcrumbs.enabled": true, + "explorer.compactFolders": false +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f33e594 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# Metro Warden + +**Industrial dark metro map Network Operations Centre — Python Textual TUI** + +Metro Warden is a keyboard-driven terminal UI for monitoring network +interfaces, routing tables, firewall rules, DNS health, and system resources. +It is built on [Textual](https://github.com/Textualize/textual) and follows a +pub/sub event-bus architecture with a single source of truth state store and +a plugin system. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ MetroWarden (App) │ +│ ┌──────────┐ ┌───────────┐ ┌──────────────────────────┐ │ +│ │ EventBus │ │StateStore │ │ PluginRegistry │ │ +│ │ pub/sub │◄─│ reactive │ │ network dns firewall sys │ │ +│ └──────────┘ └───────────┘ └──────────────────────────┘ │ +│ ▲ ▲ │ │ +│ │ │ publishes │ │ +│ ┌────┴───────────────────────────────┐ │ │ +│ │ UI Tabs │◄─┘ │ +│ │ Lines · Routes · Signals │ │ +│ │ Chronicle · Registry · Garrison │ │ +│ │ Settings │ │ +│ └────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Core + +| Module | Purpose | +|--------|---------| +| `core/bus.py` | Asyncio pub/sub event bus with wildcard topic support | +| `core/state.py` | Reactive state store; watchers + bus publication on change | +| `core/registry.py` | Plugin discovery, loading, lifecycle management | +| `core/app.py` | Main `MetroWarden(App)` class — wires everything together | + +### Plugins + +| Plugin | Topics published | +|--------|-----------------| +| `network` | `network.interfaces`, `network.stats` | +| `dns` | `dns.resolvers`, `dns.health`, `dns.query.result` | +| `firewall` | `firewall.rules`, `firewall.chains` | +| `system` | `system.cpu`, `system.memory`, `system.disk`, `system.load`, `system.snapshot` | + +### Tabs + +| Tab | Key | Content | +|-----|-----|---------| +| Lines | `1` | Network interfaces — name, status, IP, RX/TX | +| Routes | `2` | System routing table | +| Signals | `3` | Live scrolling event monitor | +| Chronicle | `4` | Persistent log viewer with filtering | +| Registry | `5` | Plugin status and toggle | +| Garrison | `6` | Firewall chains and rules | +| Settings | `7` | Configuration form | + +--- + +## Installation + +```bash +# Create a virtual environment +python -m venv .venv +source .venv/bin/activate # or .venv\Scripts\activate on Windows + +# Install dependencies +pip install -e ".[dev]" +``` + +## Running + +```bash +python main.py + +# With debug logging +python main.py --debug + +# Log to file +python main.py --log-file metro-warden.log + +# Custom config +python main.py --config /path/to/config.toml +``` + +Or via the installed script: + +```bash +metro-warden +``` + +## Keybindings + +| Key | Action | +|-----|--------| +| `1–7` | Switch tab | +| `q` | Quit | +| `Ctrl+R` | Reload all plugins | +| `r` | Refresh current tab (where supported) | +| `c` | Clear log (Signals / Chronicle) | +| `p` | Pause Signals stream | +| `f` | Focus filter input (Chronicle) | +| `Enter` | Toggle plugin load (Registry) | +| `s` | Save settings (Settings) | + +## Testing + +```bash +pytest +pytest --cov +``` + +## Configuration + +Edit `config/defaults.toml` to adjust poll intervals, enable/disable plugins, +and set display limits. The Settings tab writes back to this file on save. + +## Theme + +The industrial dark metro map aesthetic uses: + +| Role | Colour | +|------|--------| +| Background | `#1a1a2e` deep charcoal | +| Surface / panels | `#16213e` | +| Primary / electric blue | `#00d4ff` | +| Active / green | `#00ff88` | +| Warning / amber | `#ff8c00` | +| Error / red | `#ff4444` | + +CSS overrides live in `assets/styles/metro.tcss`. diff --git a/assets/styles/metro.tcss b/assets/styles/metro.tcss new file mode 100644 index 0000000..b9d0cc1 --- /dev/null +++ b/assets/styles/metro.tcss @@ -0,0 +1,331 @@ +/** + * Metro Warden — Industrial Dark Metro Map Theme + * + * Colour palette: + * Deep charcoal background : #1a1a2e + * Surface / panels : #16213e + * Border / accent : #0f3460 + * Electric blue primary : #00d4ff + * Amber warning : #ff8c00 + * Green active : #00ff88 + * Muted text : #8892a4 + * Error / danger : #ff4444 + */ + +/* ── Root variables ─────────────────────────────────────────────────── */ +$background: #1a1a2e; +$surface: #16213e; +$panel: #0f3460; +$primary: #00d4ff; +$secondary: #0099bb; +$accent: #00ff88; +$warning: #ff8c00; +$error: #ff4444; +$success: #00ff88; +$text: #e0e6f0; +$text-muted: #8892a4; +$border: #0f3460; +$cursor: #00d4ff 30%; + +/* ── App shell ──────────────────────────────────────────────────────── */ +App { + background: $background; + color: $text; +} + +Screen { + background: $background; + layers: base overlay; +} + +/* ── Header ─────────────────────────────────────────────────────────── */ +MetroHeader { + height: 3; + background: $surface; + border-bottom: heavy $primary; + color: $text; +} + +MetroHeader .header-brand { + color: $primary; + text-style: bold; + width: 1fr; + content-align: left middle; + padding-left: 1; +} + +MetroHeader .header-clock { + color: $text-muted; + content-align: center middle; + min-width: 26; +} + +MetroHeader .header-status { + color: $success; + content-align: right middle; + width: 1fr; + padding-right: 1; +} + +MetroHeader .status-warn { + color: $warning; +} + +/* ── Footer ─────────────────────────────────────────────────────────── */ +Footer { + background: $surface; + color: $text-muted; + border-top: heavy $border; +} + +Footer .footer-key--label { + color: $primary; +} + +/* ── TabbedContent ──────────────────────────────────────────────────── */ +TabbedContent { + height: 1fr; + background: $background; +} + +TabPane { + background: $background; + padding: 0; +} + +Tabs { + background: $surface; + border-bottom: heavy $border; +} + +Tab { + color: $text-muted; + background: $surface; + padding: 0 2; +} + +Tab:hover { + color: $text; + background: $panel; +} + +Tab.-active { + color: $primary; + background: $background; + border-top: heavy $primary; + text-style: bold; +} + +Tab.-active:hover { + color: $primary; +} + +/* ── DataTable ──────────────────────────────────────────────────────── */ +DataTable { + background: $surface; + color: $text; + border: round $border; + scrollbar-color: $panel; + scrollbar-background: $background; +} + +DataTable > .datatable--header { + background: $panel; + color: $primary; + text-style: bold; +} + +DataTable > .datatable--cursor { + background: $primary 20%; + color: $text; +} + +DataTable > .datatable--hover { + background: $panel; +} + +DataTable > .datatable--fixed { + background: $surface; + color: $primary; +} + +DataTable > .datatable--zebra { + background: $background; +} + +/* ── Input / Form controls ──────────────────────────────────────────── */ +Input { + background: $surface; + border: round $border; + color: $text; + padding: 0 1; +} + +Input:focus { + border: round $primary; + color: $text; +} + +Input.-invalid { + border: round $error; +} + +Switch { + background: $surface; + border: round $border; +} + +Switch:focus { + border: round $primary; +} + +Switch.-on { + color: $accent; +} + +Select { + background: $surface; + border: round $border; + color: $text; +} + +Select:focus { + border: round $primary; +} + +/* ── Button ─────────────────────────────────────────────────────────── */ +Button { + background: $panel; + color: $text; + border: round $border; + padding: 0 2; +} + +Button:hover { + background: $border; + border: round $primary; +} + +Button:focus { + border: round $primary; +} + +Button.-primary { + background: $primary 40%; + color: $primary; + border: round $primary; + text-style: bold; +} + +Button.-primary:hover { + background: $primary 60%; +} + +/* ── RichLog ────────────────────────────────────────────────────────── */ +RichLog { + background: $surface; + border: round $border; + color: $text; + scrollbar-color: $panel; + padding: 0 1; +} + +/* ── Labels / Static ────────────────────────────────────────────────── */ +Label { + color: $text; +} + +Static { + color: $text; +} + +/* ── Shared tab utilities ──────────────────────────────────────────── */ +.tab-title { + color: $primary; + text-style: bold; + height: 1; + margin-bottom: 1; + padding-left: 1; +} + +.section-header { + color: $accent; + text-style: bold; + height: 1; + margin-top: 1; + margin-bottom: 1; +} + +.hint { + color: $text-muted; + height: 1; + margin-top: 1; + padding-left: 1; +} + +.filter-label { + color: $text-muted; + content-align: left middle; +} + +/* ── Pulse animation (for active indicators) ────────────────────────── */ +.pulse { + color: $accent; +} + +.pulse-off { + color: $panel; +} + +/* ── Status colours ─────────────────────────────────────────────────── */ +.status-up { + color: $success; + text-style: bold; +} + +.status-down { + color: $error; + text-style: bold; +} + +.status-warn { + color: $warning; +} + +.status-nominal { + color: $success; +} + +/* ── Scrollbar ──────────────────────────────────────────────────────── */ +ScrollBar { + background: $background; + color: $panel; +} + +ScrollBar > .scrollbar--bar { + color: $border; + background: $surface; +} + +ScrollBar > .scrollbar--bar:hover { + color: $primary; +} + +/* ── Modal / Overlay ────────────────────────────────────────────────── */ +ModalScreen { + background: $background 80%; + align: center middle; +} + +/* ── Notification (toast) ───────────────────────────────────────────── */ +Toast { + background: $surface; + border: round $primary; + color: $text; +} + +/* ── Loading indicator ──────────────────────────────────────────────── */ +LoadingIndicator { + background: $background; + color: $primary; +} diff --git a/config/defaults.toml b/config/defaults.toml new file mode 100644 index 0000000..3397b9e --- /dev/null +++ b/config/defaults.toml @@ -0,0 +1,41 @@ +# Metro Warden — Default Configuration +# ───────────────────────────────────── +# Edit this file to override defaults. +# Settings tab in the UI can also write back here. + +[app] +title = "Metro Warden" +log_level = "INFO" # DEBUG | INFO | WARNING | ERROR +log_file = "" # path to log file; empty = stderr only + +[polling] +# How often (in seconds) each plugin polls for new data +network_interval = 5.0 +dns_interval = 30.0 +system_interval = 3.0 +firewall_interval = 60.0 + +[display] +signal_limit = 500 # max events visible in Signals tab +chronicle_buffer = 2000 # max events retained in Chronicle tab + +[plugins] +# Set to false to disable a plugin at startup +network = true +dns = true +system = true +firewall = true + +[dns] +# Hostname used for periodic DNS health checks +health_check_host = "one.one.one.one" + +[theme] +# Metro map colour palette (Textual CSS variables can override these) +background = "#1a1a2e" +surface = "#16213e" +panel = "#0f3460" +primary = "#00d4ff" +accent = "#00ff88" +warning = "#ff8c00" +error = "#ff4444" diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..c5e7598 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,7 @@ +"""Metro Warden core package.""" + +from .bus import EventBus +from .state import StateStore +from .registry import PluginRegistry + +__all__ = ["EventBus", "StateStore", "PluginRegistry"] diff --git a/core/__pycache__/__init__.cpython-314.pyc b/core/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..54f8adbe6f9125537ad151932fe2cd18f592b1ce GIT binary patch literal 351 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/registry.cpython-314.pyc b/core/__pycache__/registry.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ea163208725a741cc50148c51559161c3867026 GIT binary patch literal 12478 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 Kxy~r%JMjNpH7c$E literal 0 HcmV?d00001 diff --git a/core/app.py b/core/app.py new file mode 100644 index 0000000..f93fc50 --- /dev/null +++ b/core/app.py @@ -0,0 +1,172 @@ +""" +Metro Warden — main Textual application class. + +Wires together the event bus, state store, plugin registry, and all UI tabs. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer, TabbedContent, TabPane + +from core.bus import EventBus +from core.state import StateStore +from core.registry import PluginRegistry +from ui.widgets.header import MetroHeader +from ui.tabs.lines import LinesTab +from ui.tabs.routes import RoutesTab +from ui.tabs.signals import SignalsTab +from ui.tabs.chronicle import ChronicleTab +from ui.tabs.registry import RegistryTab +from ui.tabs.garrison import GarrisonTab +from ui.tabs.settings import SettingsTab + +log = logging.getLogger(__name__) + +CSS_PATH = Path(__file__).parent.parent / "assets" / "styles" / "metro.tcss" + + +class MetroWarden(App): + """ + Metro Warden Network Operations Centre. + + Tabs: + Lines — network interface stats + Routes — routing table + Signals — live event monitor + Chronicle — persistent log viewer + Registry — plugin management + Garrison — firewall rules + Settings — configuration + """ + + CSS_PATH = CSS_PATH + + TITLE = "Metro Warden // NOC" + SUB_TITLE = "Network Operations Centre" + + BINDINGS = [ + Binding("q", "quit", "Quit", show=True), + Binding("1", "switch_tab('lines')", "Lines"), + Binding("2", "switch_tab('routes')", "Routes"), + Binding("3", "switch_tab('signals')", "Signals"), + Binding("4", "switch_tab('chronicle')", "Chronicle"), + Binding("5", "switch_tab('registry')", "Registry"), + Binding("6", "switch_tab('garrison')", "Garrison"), + Binding("7", "switch_tab('settings')", "Settings"), + Binding("ctrl+r", "reload_plugins", "Reload plugins"), + ] + + def __init__(self, config: dict | None = None, **kwargs) -> None: + super().__init__(**kwargs) + self._config = config or {} + # Core singletons — accessible as app.bus, app.state, app.registry + self.bus = EventBus() + self.state = StateStore(bus=self.bus) + self.registry = PluginRegistry(bus=self.bus, state=self.state) + + # ------------------------------------------------------------------ + # Composition + # ------------------------------------------------------------------ + + def compose(self) -> ComposeResult: + yield MetroHeader() + with TabbedContent(id="main-tabs"): + with TabPane("Lines", id="lines"): + yield LinesTab() + with TabPane("Routes", id="routes"): + yield RoutesTab() + with TabPane("Signals", id="signals"): + yield SignalsTab() + with TabPane("Chronicle", id="chronicle"): + yield ChronicleTab() + with TabPane("Registry", id="registry"): + yield RegistryTab() + with TabPane("Garrison", id="garrison"): + yield GarrisonTab() + with TabPane("Settings", id="settings"): + yield SettingsTab() + yield Footer() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def on_mount(self) -> None: + """Wire up plugins and apply initial state after UI is ready.""" + self._apply_config() + self._init_plugins() + log.info("Metro Warden started") + self.bus.publish_sync("app.started", {"title": self.TITLE}) + + def _apply_config(self) -> None: + """Load config file and seed state store with defaults.""" + config_path = Path(__file__).parent.parent / "config" / "defaults.toml" + merged: dict = { + "settings.network_interval": 5.0, + "settings.dns_interval": 30.0, + "settings.system_interval": 3.0, + "settings.firewall_interval": 60.0, + "settings.signal_limit": 500, + "settings.chronicle_buffer": 2000, + } + + if config_path.exists(): + try: + import toml + raw = toml.loads(config_path.read_text()) + polling = raw.get("polling", {}) + display = raw.get("display", {}) + if polling.get("network_interval"): + merged["settings.network_interval"] = float(polling["network_interval"]) + if polling.get("dns_interval"): + merged["settings.dns_interval"] = float(polling["dns_interval"]) + if polling.get("system_interval"): + merged["settings.system_interval"] = float(polling["system_interval"]) + if polling.get("firewall_interval"): + merged["settings.firewall_interval"] = float(polling["firewall_interval"]) + if display.get("signal_limit"): + merged["settings.signal_limit"] = int(display["signal_limit"]) + if display.get("chronicle_buffer"): + merged["settings.chronicle_buffer"] = int(display["chronicle_buffer"]) + except Exception as exc: + log.warning("could not load config: %s", exc) + + # Override with any constructor-supplied config + for key, value in self._config.items(): + merged[key] = value + + self.state.update(merged) + + def _init_plugins(self) -> None: + """Discover and load all plugins.""" + found = self.registry.discover() + log.info("discovered %d plugins", found) + results = self.registry.load_all() + loaded = sum(1 for ok in results.values() if ok) + log.info("loaded %d/%d plugins", loaded, len(results)) + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + def action_switch_tab(self, tab_id: str) -> None: + tabs = self.query_one("#main-tabs", TabbedContent) + tabs.active = tab_id + + def action_reload_plugins(self) -> None: + self.registry.unload_all() + self.registry.discover() + self.registry.load_all() + self.notify("Plugins reloaded", title="Registry") + + # ------------------------------------------------------------------ + # Bus helpers exposed for widgets + # ------------------------------------------------------------------ + + def publish(self, topic: str, data=None) -> None: + self.bus.publish_sync(topic, data) diff --git a/core/bus.py b/core/bus.py new file mode 100644 index 0000000..ef72d40 --- /dev/null +++ b/core/bus.py @@ -0,0 +1,223 @@ +""" +Metro Warden Event Bus — asyncio pub/sub with wildcard topic support. + +Topics follow a dot-separated hierarchy: "network.interfaces", "system.cpu", etc. +Wildcard "*" matches a single segment; "**" matches any number of segments. +""" + +from __future__ import annotations + +import asyncio +import fnmatch +import logging +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple +import uuid + +log = logging.getLogger(__name__) + +Handler = Callable[[str, Any], Awaitable[None] | None] + + +@dataclass +class Subscription: + """Represents a single topic subscription.""" + + id: str + topic_pattern: str + handler: Handler + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass +class Event: + """An event published to the bus.""" + + id: str + topic: str + data: Any + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict: + return { + "id": self.id, + "topic": self.topic, + "data": self.data, + "timestamp": self.timestamp.isoformat(), + } + + +class EventBus: + """ + Asyncio-based pub/sub event bus supporting wildcard topics. + + Usage:: + + bus = EventBus() + + async def on_network(topic, data): + print(f"{topic}: {data}") + + sub_id = bus.subscribe("network.*", on_network) + await bus.publish("network.interfaces", {"eth0": "up"}) + bus.unsubscribe(sub_id) + """ + + def __init__(self) -> None: + self._subscriptions: Dict[str, Subscription] = {} + # index from pattern to set of subscription ids for fast lookup + self._pattern_index: Dict[str, Set[str]] = defaultdict(set) + self._history: List[Event] = [] + self._history_limit: int = 1000 + self._lock = asyncio.Lock() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def subscribe(self, topic_pattern: str, handler: Handler) -> str: + """ + Subscribe *handler* to all events whose topic matches *topic_pattern*. + + Patterns support fnmatch-style wildcards: + - ``network.*`` matches ``network.interfaces`` but not ``network.dns.query`` + - ``network.**`` matches any subtopic under ``network`` + - ``*`` matches any single-segment topic + + Returns a subscription ID that can be passed to :meth:`unsubscribe`. + """ + sub_id = str(uuid.uuid4()) + sub = Subscription(id=sub_id, topic_pattern=topic_pattern, handler=handler) + self._subscriptions[sub_id] = sub + self._pattern_index[topic_pattern].add(sub_id) + log.debug("subscribed %s -> pattern=%r", sub_id[:8], topic_pattern) + return sub_id + + def unsubscribe(self, subscription_id: str) -> bool: + """ + Remove a subscription by its ID. + + Returns ``True`` if the subscription existed and was removed. + """ + sub = self._subscriptions.pop(subscription_id, None) + if sub is None: + return False + self._pattern_index[sub.topic_pattern].discard(subscription_id) + if not self._pattern_index[sub.topic_pattern]: + del self._pattern_index[sub.topic_pattern] + log.debug("unsubscribed %s", subscription_id[:8]) + return True + + def unsubscribe_all(self, handler: Handler) -> int: + """Remove all subscriptions registered for *handler*. Returns count removed.""" + to_remove = [ + sid for sid, sub in self._subscriptions.items() if sub.handler is handler + ] + for sid in to_remove: + self.unsubscribe(sid) + return len(to_remove) + + async def publish(self, topic: str, data: Any = None) -> int: + """ + Publish an event to *topic*. + + All matching handlers are dispatched concurrently via asyncio.gather. + Returns the number of handlers notified. + """ + event = Event(id=str(uuid.uuid4()), topic=topic, data=data) + self._record(event) + + matching = self._find_matching_subs(topic) + if not matching: + log.debug("publish %r — no subscribers", topic) + return 0 + + tasks = [] + for sub in matching: + tasks.append(self._dispatch(sub, event)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + errors = [r for r in results if isinstance(r, Exception)] + for err in errors: + log.error("handler error on topic %r: %s", topic, err) + + log.debug("publish %r — notified %d handlers", topic, len(matching)) + return len(matching) + + def publish_sync(self, topic: str, data: Any = None) -> None: + """ + Fire-and-forget publish that schedules an async publish on the running loop. + Safe to call from synchronous code when a loop is running. + """ + try: + loop = asyncio.get_running_loop() + loop.create_task(self.publish(topic, data)) + except RuntimeError: + # No running loop — run synchronously in a new one + asyncio.run(self.publish(topic, data)) + + def get_history( + self, + topic_filter: Optional[str] = None, + limit: int = 100, + ) -> List[Event]: + """Return recent events, optionally filtered by topic pattern.""" + events = self._history + if topic_filter: + events = [e for e in events if self._topic_matches(e.topic, topic_filter)] + return events[-limit:] + + @property + def subscription_count(self) -> int: + return len(self._subscriptions) + + @property + def patterns(self) -> List[str]: + return list(self._pattern_index.keys()) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _record(self, event: Event) -> None: + self._history.append(event) + if len(self._history) > self._history_limit: + self._history = self._history[-self._history_limit :] + + def _find_matching_subs(self, topic: str) -> List[Subscription]: + matched: List[Subscription] = [] + seen: Set[str] = set() + for pattern, ids in self._pattern_index.items(): + if self._topic_matches(topic, pattern): + for sid in ids: + if sid not in seen and sid in self._subscriptions: + seen.add(sid) + matched.append(self._subscriptions[sid]) + return matched + + @staticmethod + def _topic_matches(topic: str, pattern: str) -> bool: + """ + Match *topic* against *pattern*. + + ``**`` is expanded to ``*`` repeated across segments so that + ``network.**`` matches ``network.interfaces.eth0``. + """ + if pattern == topic: + return True + # Convert "**" to a greedy glob that matches path separators too + if "**" in pattern: + glob_pattern = pattern.replace("**", "*") + return fnmatch.fnmatch(topic, glob_pattern) + return fnmatch.fnmatch(topic, pattern) + + @staticmethod + async def _dispatch(sub: Subscription, event: Event) -> None: + try: + result = sub.handler(event.topic, event.data) + if asyncio.iscoroutine(result): + await result + except Exception as exc: + raise exc diff --git a/core/registry.py b/core/registry.py new file mode 100644 index 0000000..1b447c7 --- /dev/null +++ b/core/registry.py @@ -0,0 +1,223 @@ +""" +Metro Warden Plugin Registry — discovers, loads, and manages plugins. + +Plugins are discovered from the ``plugins/`` package hierarchy. Each plugin +module must expose a class that inherits from :class:`plugins.base.BasePlugin`. +The registry stores plugin metadata and lifecycle state. +""" + +from __future__ import annotations + +import importlib +import inspect +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum, auto +from typing import Dict, List, Optional, Type + +log = logging.getLogger(__name__) + + +class PluginStatus(Enum): + DISCOVERED = auto() + LOADED = auto() + ACTIVE = auto() + STOPPED = auto() + ERROR = auto() + + +@dataclass +class PluginRecord: + """Metadata + runtime state for a single plugin.""" + + name: str + version: str + description: str + plugin_class: Type + module_path: str + status: PluginStatus = PluginStatus.DISCOVERED + instance: Optional[object] = None + error: Optional[str] = None + loaded_at: Optional[datetime] = None + tags: List[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "name": self.name, + "version": self.version, + "description": self.description, + "module_path": self.module_path, + "status": self.status.name, + "error": self.error, + "loaded_at": self.loaded_at.isoformat() if self.loaded_at else None, + "tags": self.tags, + } + + +# Built-in plugin module paths to scan on startup +BUILTIN_PLUGIN_MODULES = [ + "plugins.network.plugin", + "plugins.dns.plugin", + "plugins.firewall.plugin", + "plugins.system.plugin", +] + + +class PluginRegistry: + """ + Discovers, instantiates, and manages the lifecycle of Metro Warden plugins. + + Usage:: + + registry = PluginRegistry(bus=bus, state=state) + registry.discover() + registry.load_all() + """ + + def __init__(self, bus=None, state=None) -> None: + self._bus = bus + self._state = state + self._records: Dict[str, PluginRecord] = {} + + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + + def discover(self, extra_modules: Optional[List[str]] = None) -> int: + """ + Scan built-in plugin modules (plus any *extra_modules*) and register + discovered plugin classes. + + Returns the number of newly discovered plugins. + """ + modules = list(BUILTIN_PLUGIN_MODULES) + if extra_modules: + modules.extend(extra_modules) + + found = 0 + for module_path in modules: + count = self._scan_module(module_path) + found += count + + log.info("discovery complete — %d plugins found", found) + return found + + def _scan_module(self, module_path: str) -> int: + """Import *module_path* and register any BasePlugin subclasses found.""" + from plugins.base import BasePlugin # local import to avoid circular deps + + try: + module = importlib.import_module(module_path) + except ImportError as exc: + log.warning("could not import plugin module %r: %s", module_path, exc) + return 0 + + found = 0 + for _name, obj in inspect.getmembers(module, inspect.isclass): + if ( + issubclass(obj, BasePlugin) + and obj is not BasePlugin + and not inspect.isabstract(obj) + ): + record = PluginRecord( + name=obj.name, + version=obj.version, + description=obj.description, + plugin_class=obj, + module_path=module_path, + tags=getattr(obj, "tags", []), + ) + if record.name in self._records: + log.debug("plugin %r already registered, skipping", record.name) + continue + self._records[record.name] = record + log.debug("discovered plugin %r v%s", record.name, record.version) + found += 1 + + return found + + # ------------------------------------------------------------------ + # Loading / unloading + # ------------------------------------------------------------------ + + def load(self, name: str) -> bool: + """Instantiate and call on_load() for the named plugin.""" + record = self._records.get(name) + if record is None: + log.error("plugin %r not found in registry", name) + return False + + if record.status in (PluginStatus.ACTIVE, PluginStatus.LOADED): + log.debug("plugin %r already loaded", name) + return True + + try: + instance = record.plugin_class(bus=self._bus, state=self._state) + instance.on_load() + record.instance = instance + record.status = PluginStatus.ACTIVE + record.loaded_at = datetime.now(timezone.utc) + record.error = None + log.info("loaded plugin %r v%s", name, record.version) + self._notify_bus("registry.plugin.loaded", record.to_dict()) + return True + except Exception as exc: + record.status = PluginStatus.ERROR + record.error = str(exc) + log.error("failed to load plugin %r: %s", name, exc) + return False + + def unload(self, name: str) -> bool: + """Call on_unload() and deactivate the named plugin.""" + record = self._records.get(name) + if record is None or record.instance is None: + return False + + try: + record.instance.on_unload() + except Exception as exc: + log.error("error during unload of %r: %s", name, exc) + + record.instance = None + record.status = PluginStatus.STOPPED + log.info("unloaded plugin %r", name) + self._notify_bus("registry.plugin.unloaded", record.to_dict()) + return True + + def load_all(self) -> Dict[str, bool]: + """Load all discovered plugins. Returns {name: success} mapping.""" + results = {} + for name in list(self._records.keys()): + results[name] = self.load(name) + return results + + def unload_all(self) -> None: + """Unload every active plugin.""" + for name in list(self._records.keys()): + self.unload(name) + + # ------------------------------------------------------------------ + # Query + # ------------------------------------------------------------------ + + def get(self, name: str) -> Optional[PluginRecord]: + return self._records.get(name) + + def all_records(self) -> List[PluginRecord]: + return list(self._records.values()) + + def active_plugins(self) -> List[PluginRecord]: + return [r for r in self._records.values() if r.status == PluginStatus.ACTIVE] + + def plugin_instance(self, name: str): + record = self._records.get(name) + return record.instance if record else None + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _notify_bus(self, topic: str, data: dict) -> None: + if self._bus: + self._bus.publish_sync(topic, data) diff --git a/core/state.py b/core/state.py new file mode 100644 index 0000000..17d776c --- /dev/null +++ b/core/state.py @@ -0,0 +1,164 @@ +""" +Metro Warden State Store — single source of truth with reactive watchers. + +The StateStore holds application state in a nested dict-like structure. +Keys use dot-separated paths: "network.interfaces.eth0.rx_bytes". +Watchers are notified synchronously (and the bus is published to) on every set(). +""" + +from __future__ import annotations + +import copy +import logging +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional, Set +import uuid + +log = logging.getLogger(__name__) + +Watcher = Callable[[str, Any, Any], None] # (key, old_value, new_value) + + +class StateStore: + """ + Reactive key/value state store. + + Keys are dot-separated paths. Watchers are called with + ``(key, old_value, new_value)`` whenever a value changes. + + If an :class:`~core.bus.EventBus` is supplied, every state mutation + also publishes a ``state.`` event to the bus so that UI widgets + can react via bus subscriptions. + + Usage:: + + store = StateStore(bus=bus) + store.set("network.active", True) + store.watch("network.active", lambda k, old, new: print(new)) + value = store.get("network.active") + """ + + def __init__(self, bus=None) -> None: + self._data: Dict[str, Any] = {} + self._watchers: Dict[str, List[tuple[str, Watcher]]] = {} + self._bus = bus # optional EventBus + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def get(self, key: str, default: Any = None) -> Any: + """Return the value at *key*, or *default* if not set.""" + return copy.deepcopy(self._data.get(key, default)) + + def set(self, key: str, value: Any) -> None: + """ + Set *key* to *value*. + + Fires watchers and publishes to the bus if the value changed. + """ + old = self._data.get(key) + self._data[key] = value + + if old == value: + return # no-op — value unchanged + + log.debug("state set %r: %r -> %r", key, old, value) + self._notify_watchers(key, old, value) + self._publish_to_bus(key, value) + + def delete(self, key: str) -> bool: + """Remove *key* from the store. Returns True if it existed.""" + if key not in self._data: + return False + old = self._data.pop(key) + self._notify_watchers(key, old, None) + self._publish_to_bus(key, None) + return True + + def update(self, mapping: Dict[str, Any]) -> None: + """Set multiple keys at once from a dict.""" + for key, value in mapping.items(): + self.set(key, value) + + def watch(self, key: str, callback: Watcher) -> str: + """ + Register *callback* to be called whenever *key* changes. + + Supports ``*`` wildcard at the end: ``"network.*"`` will fire + for any key whose first segment is ``"network"``. + + Returns a watcher ID that can be passed to :meth:`unwatch`. + """ + watcher_id = str(uuid.uuid4()) + if key not in self._watchers: + self._watchers[key] = [] + self._watchers[key].append((watcher_id, callback)) + log.debug("watch registered %s on key=%r", watcher_id[:8], key) + return watcher_id + + def unwatch(self, watcher_id: str) -> bool: + """Remove a watcher by its ID. Returns True if it was found.""" + for key, entries in self._watchers.items(): + for entry in entries: + if entry[0] == watcher_id: + entries.remove(entry) + return True + return False + + def snapshot(self) -> Dict[str, Any]: + """Return a deep copy of the entire state.""" + return copy.deepcopy(self._data) + + def keys(self) -> List[str]: + """Return all keys currently in the store.""" + return list(self._data.keys()) + + def __contains__(self, key: str) -> bool: + return key in self._data + + def __repr__(self) -> str: + return f"StateStore(keys={len(self._data)}, watchers={sum(len(v) for v in self._watchers.values())})" + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _notify_watchers(self, key: str, old: Any, new: Any) -> None: + """Notify all watchers whose pattern matches *key*.""" + notified: Set[str] = set() + + # Exact match + for wid, cb in self._watchers.get(key, []): + if wid not in notified: + notified.add(wid) + try: + cb(key, old, new) + except Exception as exc: + log.error("watcher %s error: %s", wid[:8], exc) + + # Wildcard match — check each registered pattern + for pattern, entries in self._watchers.items(): + if pattern == key: + continue # already handled above + if self._key_matches(key, pattern): + for wid, cb in entries: + if wid not in notified: + notified.add(wid) + try: + cb(key, old, new) + except Exception as exc: + log.error("watcher %s error: %s", wid[:8], exc) + + def _publish_to_bus(self, key: str, value: Any) -> None: + if self._bus is None: + return + topic = f"state.{key}" + self._bus.publish_sync(topic, {"key": key, "value": value}) + + @staticmethod + def _key_matches(key: str, pattern: str) -> bool: + """Simple glob matching for state keys.""" + import fnmatch + + return fnmatch.fnmatch(key, pattern) diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..facc6a1 --- /dev/null +++ b/dev.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Metro Warden — dev environment launcher +# Opens a tmux session with 3 panes inside Ghostty: +# +# ┌─────────────────────┬───────────────────────┐ +# │ │ │ +# │ textual console │ metro warden (app) │ +# │ (DevTools) │ textual run --dev │ +# │ │ │ +# │ ├───────────────────────┤ +# │ │ │ +# │ │ watchexec │ +# │ │ (auto-restart .py) │ +# │ │ │ +# └─────────────────────┴───────────────────────┘ +# +# Usage: ./dev.sh + +set -e + +PROJECT="$(cd "$(dirname "$0")" && pwd)" +VENV="$PROJECT/.venv/bin/activate" +SESSION="metro-warden" + +if [ ! -f "$VENV" ]; then + echo "ERROR: .venv not found. Run:" + echo " python3 -m venv .venv && source .venv/bin/activate && pip install -e '.[dev]'" + exit 1 +fi + +# Kill existing session if it exists +tmux kill-session -t "$SESSION" 2>/dev/null || true + +# Create session with DevTools pane (left, full height) +tmux new-session -d -s "$SESSION" -x 220 -y 50 \ + -n "metro" \ + "bash -c 'source $VENV && textual console'" + +# Split right — App pane (top right) +tmux split-window -t "$SESSION:0" -h \ + "bash -c 'source $VENV && textual run --dev $PROJECT/main.py'" + +# Split the right pane vertically — Watchexec pane (bottom right) +tmux split-window -t "$SESSION:0.1" -v \ + "bash -c 'source $VENV && watchexec --exts py --ignore assets/ --ignore .venv/ -r -- textual run --dev $PROJECT/main.py'" + +# Give the left pane 38% width +tmux resize-pane -t "$SESSION:0.0" -x "38%" + +# Focus the app pane +tmux select-pane -t "$SESSION:0.1" + +# Open in Ghostty (or attach in current terminal if already inside tmux) +if [ -n "$TMUX" ]; then + tmux switch-client -t "$SESSION" +elif command -v ghostty &>/dev/null; then + ghostty -e tmux attach-session -t "$SESSION" & + # Open VS Code after a beat + sleep 0.5 + code "$PROJECT" 2>/dev/null || true +else + tmux attach-session -t "$SESSION" +fi + +echo "Metro Warden dev environment started. Session: $SESSION" diff --git a/main.py b/main.py new file mode 100644 index 0000000..5446d69 --- /dev/null +++ b/main.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +Metro Warden — entry point. + +Usage: + python main.py [--debug] [--log-file PATH] + +The application reads config/defaults.toml on startup and can be +overridden via CLI flags. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + + +def _configure_logging(level: str, log_file: str) -> None: + fmt = "%(asctime)s %(levelname)-8s %(name)s %(message)s" + handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)] + if log_file: + handlers.append(logging.FileHandler(log_file)) + logging.basicConfig(level=getattr(logging, level.upper(), logging.INFO), format=fmt, handlers=handlers) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="metro-warden", + description="Metro Warden — Industrial Dark Network Operations Centre", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable DEBUG log level", + ) + parser.add_argument( + "--log-file", + default="", + metavar="PATH", + help="Write logs to this file in addition to stderr", + ) + parser.add_argument( + "--config", + default="", + metavar="PATH", + help="Path to TOML config file (default: config/defaults.toml)", + ) + return parser.parse_args() + + +def _load_config(path: str) -> dict: + config_path = Path(path) if path else Path(__file__).parent / "config" / "defaults.toml" + if not config_path.exists(): + return {} + try: + import toml + return toml.loads(config_path.read_text()) + except Exception as exc: + logging.warning("could not load config %s: %s", config_path, exc) + return {} + + +def main() -> None: + args = _parse_args() + log_level = "DEBUG" if args.debug else "INFO" + _configure_logging(log_level, args.log_file) + + raw_config = _load_config(args.config) + + # Flatten config sections into state-key style for the app + config: dict = {} + polling = raw_config.get("polling", {}) + display = raw_config.get("display", {}) + for k, v in polling.items(): + config[f"settings.{k}"] = v + for k, v in display.items(): + config[f"settings.{k}"] = v + + from core.app import MetroWarden + + app = MetroWarden(config=config) + app.run() + + +if __name__ == "__main__": + main() diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..cdeca67 --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1,5 @@ +"""Metro Warden plugins package.""" + +from .base import BasePlugin + +__all__ = ["BasePlugin"] diff --git a/plugins/__pycache__/__init__.cpython-314.pyc b/plugins/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e7c15bea61f1d7c9e6d87a106400e825f4e7a79 GIT binary patch literal 259 zcmdPqo|YDrPPLU>|PN@|`$K~8CUW?r#EL1J=tVtT5cCgUwGr^Mpa0I+xw$Y4KB zmRs!c@hSPq@$t7u(wAXXNLm>X&5Xmt^Q08XKGI=Ynj~ zEeG4A54A}@K0Y%qvm`!Vub}c4hfQvNN@-52T@feH1V$h(76K9C%vZ!#!9WsrHm OE#9Hm#9qV>6b1l{m&K|9fh6SX*ef34=e^EjBjv8+A*%fj7Ix6#mBACLPf+7x!FMCW+2h}~p zUen7<$&`5MGkqw%!~T~8X5i(Z8SD_oeWUg6lBKd!7!MDMcH69K6gmY(Y7!LRxOeoi z5|vvU^g5JL#nUbH`SUWShA(I)%gI+&meO?jjB04|Xf{8c(dBRFKa$l+!(?jGlqYE* znN}jMt;&J9^%E&1TAc@J0YujN=|808C{dj zw6p55taeSyhE>yKnaRAV8S=!0uFhx^6aA=ReIt|8sX>z`CQ_P_WSLo$cgAJ;d}c~Z z7Lr*_PUmuO8R3bEoSw+$)Ra9RjlAwQ?G@049d&bJLQbl>JgLb!?7}iBVuNbPx~8SH zR6N`#T2-p9=S-D0Ge-J6miM$?u!0x)?rJvavX@GS!{YWP8uP-qV2VK8^JdKS_5gKL zQaq+l@s9fysTUNA&kXREpSL__khcQViVJ2)34$`j$7H1n$VQrkY;9IF!%7sbYHFEP zN)76@ydF{NP_O6ps1ifHf!C{*PNfm81JuI&8nm0xe#~jtD$Qse6F|x zQ#fW%?6SLLxm(WbnYZ(roB>#7W-6mGKG6{qcj6QAiOS+V&-Nu z$$mMdn(D3%gJKRtWIHEK_ zb}2qvup)_sqorCRVKtS+GhROMIF>eI{DeD#-*;+@vYQeww$Uvl9R;SkK&Bh*(Dt{e z&^L`z@1AtnX-{|2myQ+Zk2!{4z&zJPkNWpaMl%AJ@TAA3NQ+l%b;+f=D(l5n;#fy7 z&L69GX+~?^TB(E^5G$WHQnnY?3%PT+L-5XZxZ22#vnn4Zi8w45t@2#J=6igkFU)AN z6~3Zo^V*Ay z3u`^y=d~n5%T8Hdn&{*64B}{SZ!c@4?XY-8&zM*|whLmrlf!!zbo0VyO>C*KR9Gl{ zUElgA>Bi+hhBxFB>+*@Uj={CTOKWYH@6^Ax7I|&6q3L>P^FYh>s?F|03)1!I*9|Qj z4To+w99jw7ls*f68d`aGt@pV*4JQ|*--j4E)gLGJtOipViC&+`8c^6`&cBKsps^-o zPtgFi=7k-><9kKi;`wreU6%F-2~UoQMz_;wK1@VMi$dwlLcs;3C}^dAh)q$*O1*C6 z(1BJ9MN3f{;2>9m)0tm{^Na&$1tcdm?jd>fgm_3ko7E65=T;73<3fAae3>vw}sm$>cnl=uN|i`zYXjmn-%@hXzyQ~U#7 zc&q>;8*BNvpJhR+OXO@B=@qM^jAHYBwfpBFGCGlwc1ym<7G=xHdsM8%cySY&j+T9D zTHZ}`dM+b$d|V?$sG(=B5O#ngR}VLjD7@(K<#*CK#O(ag&_V^J8m)sDtT(%Ay2z719 z9m!#kFZuCZxI7*o$&(i7IWDYr$V)e@l2p>9L#!{r+CXP}h5!OTod*p?*=5htDA{|< zug=bDdaBQn$CJDdgcNVsA}QOhmV|DXT`8`{LBE7y*p;f9rDqqPyVuxGQTaUuGkeE(o8#K-~cE4d1Y%1lf@S(EHqY4XkoO-xaM$2b!z z3)&R8*~-mNMda=(8z~NZg$}<9oDdJ+6Cm8JZ*&=;UjDf~{J5LYx91k0yIs}389TTzLb3b1 zi|;PIxA@+&ai^w#E!h9lFyd|;)`Z+R3$F?a$k^){htONEw)za@NR7F^R;gY$ZquTqO>`P zcyf$dX#3A-e!pT6-=G-fKHXWQR(J`R%s%8!T`BU0*QSUKsY5s?;|)P*f5_UEJa;M9 zFJUR!zL0ynsb+u6)(!7?*cB$lrO_RQdv3+=y;;|2tpXz{uL_p3_s?c61ccnRl*+cGlVeA<@je-VV~{NIj z;Cap(9HAIlaf8mIs6Nlgh;29;`QfZ^A~BOo<+Jpdg9o;^^J><4+Nnt-rZUVhvv|bU za~KXJ5~*AgM-Jgk7KY2-%+X7MCaa>1!b{dmSwCe@P{!jp5<&J7Wh0bbri?z2O(3%x z6Z{z|lS~vJqYSJ|c7T789o-QUj56vsGRS@>Y2f_P~(bLQQAnxq None: + if not self.name: + raise ValueError(f"{type(self).__name__} must define a 'name' attribute") + self._bus = bus + self._state = state + self._sub_ids: list[str] = [] + self._log = logging.getLogger(f"plugin.{self.name}") + + # ------------------------------------------------------------------ + # Lifecycle hooks + # ------------------------------------------------------------------ + + def on_load(self) -> None: + """Called once when the plugin is loaded by the registry.""" + self._log.info("plugin %r loaded (v%s)", self.name, self.version) + + def on_unload(self) -> None: + """Called when the plugin is being unloaded. Clean up resources here.""" + self._unsubscribe_all() + self._log.info("plugin %r unloaded", self.name) + + def on_event(self, topic: str, data: Any) -> None: + """ + Called for every bus event whose topic this plugin has subscribed to. + Override in subclasses to handle specific events. + """ + + # ------------------------------------------------------------------ + # Helper utilities + # ------------------------------------------------------------------ + + def subscribe(self, topic_pattern: str) -> Optional[str]: + """Subscribe this plugin's on_event handler to *topic_pattern*.""" + if self._bus is None: + self._log.warning("no bus — cannot subscribe to %r", topic_pattern) + return None + sub_id = self._bus.subscribe(topic_pattern, self.on_event) + self._sub_ids.append(sub_id) + return sub_id + + def publish(self, topic: str, data: Any = None) -> None: + """Publish an event to the bus.""" + if self._bus is None: + return + self._bus.publish_sync(topic, data) + + def state_get(self, key: str, default: Any = None) -> Any: + if self._state is None: + return default + return self._state.get(key, default) + + def state_set(self, key: str, value: Any) -> None: + if self._state is not None: + self._state.set(key, value) + + def _unsubscribe_all(self) -> None: + if self._bus is None: + return + for sid in self._sub_ids: + self._bus.unsubscribe(sid) + self._sub_ids.clear() + + def __repr__(self) -> str: + return f"<{type(self).__name__} name={self.name!r} v{self.version}>" diff --git a/plugins/dns/__init__.py b/plugins/dns/__init__.py new file mode 100644 index 0000000..2c0f47c --- /dev/null +++ b/plugins/dns/__init__.py @@ -0,0 +1,5 @@ +"""DNS monitoring plugin package.""" + +from .plugin import DNSPlugin + +__all__ = ["DNSPlugin"] diff --git a/plugins/dns/plugin.py b/plugins/dns/plugin.py new file mode 100644 index 0000000..ff8f560 --- /dev/null +++ b/plugins/dns/plugin.py @@ -0,0 +1,130 @@ +""" +DNS Plugin — monitors DNS resolution, configured resolvers, and query health. + +Publishes to: + dns.resolvers — list of configured nameservers + dns.health — last query round-trip time and result + dns.query.result — result of a DNS query +""" + +from __future__ import annotations + +import asyncio +import logging +import socket +import time +from typing import Any, Dict, List, Optional + +from plugins.base import BasePlugin + +log = logging.getLogger(__name__) + +DEFAULT_POLL_INTERVAL = 30.0 +HEALTH_CHECK_HOST = "one.one.one.one" + + +def _read_resolvers() -> List[str]: + """Parse /etc/resolv.conf for nameserver entries.""" + resolvers: List[str] = [] + try: + with open("/etc/resolv.conf") as fh: + for line in fh: + line = line.strip() + if line.startswith("nameserver"): + parts = line.split() + if len(parts) >= 2: + resolvers.append(parts[1]) + except OSError: + pass + return resolvers + + +def _check_dns_health(host: str) -> Dict: + """Perform a synchronous DNS health check and return timing info.""" + start = time.monotonic() + error: Optional[str] = None + resolved: Optional[str] = None + try: + resolved = socket.gethostbyname(host) + except socket.gaierror as exc: + error = str(exc) + elapsed_ms = (time.monotonic() - start) * 1000.0 + return { + "host": host, + "resolved": resolved, + "elapsed_ms": round(elapsed_ms, 2), + "error": error, + "healthy": error is None, + } + + +class DNSPlugin(BasePlugin): + """ + Monitors DNS resolver configuration and performs periodic health checks. + Publishes resolver info and query health to the event bus. + """ + + name = "dns" + version = "1.0.0" + description = "Monitors DNS resolver configuration and query health" + tags = ["network", "dns"] + + def __init__( + self, + bus=None, + state=None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + health_host: str = HEALTH_CHECK_HOST, + ) -> None: + super().__init__(bus=bus, state=state) + self._poll_interval = poll_interval + self._health_host = health_host + self._task: asyncio.Task | None = None + self._running = False + + def on_load(self) -> None: + super().on_load() + self.subscribe("dns.query") # allow other plugins to request queries + self._running = True + try: + loop = asyncio.get_running_loop() + self._task = loop.create_task(self._poll_loop()) + except RuntimeError: + self._log.debug("no running event loop at load time; task deferred") + + def on_unload(self) -> None: + self._running = False + if self._task and not self._task.done(): + self._task.cancel() + super().on_unload() + + async def _poll_loop(self) -> None: + self._log.debug("dns poll loop started (interval=%.1fs)", self._poll_interval) + while self._running: + try: + resolvers = await asyncio.to_thread(_read_resolvers) + self.state_set("dns.resolvers", resolvers) + if self._bus: + await self._bus.publish("dns.resolvers", {"nameservers": resolvers}) + + health = await asyncio.to_thread(_check_dns_health, self._health_host) + self.state_set("dns.health", health) + if self._bus: + await self._bus.publish("dns.health", health) + except asyncio.CancelledError: + break + except Exception as exc: + self._log.error("dns poll error: %s", exc) + await asyncio.sleep(self._poll_interval) + self._log.debug("dns poll loop stopped") + + def on_event(self, topic: str, data: Any) -> None: + if topic == "dns.query" and isinstance(data, dict): + host = data.get("host", "") + if host: + asyncio.ensure_future(self._resolve_and_publish(host)) + + async def _resolve_and_publish(self, host: str) -> None: + result = await asyncio.to_thread(_check_dns_health, host) + if self._bus: + await self._bus.publish("dns.query.result", result) diff --git a/plugins/firewall/__init__.py b/plugins/firewall/__init__.py new file mode 100644 index 0000000..0063fc8 --- /dev/null +++ b/plugins/firewall/__init__.py @@ -0,0 +1,5 @@ +"""Firewall monitoring plugin package.""" + +from .plugin import FirewallPlugin + +__all__ = ["FirewallPlugin"] diff --git a/plugins/firewall/plugin.py b/plugins/firewall/plugin.py new file mode 100644 index 0000000..08ecc24 --- /dev/null +++ b/plugins/firewall/plugin.py @@ -0,0 +1,200 @@ +""" +Firewall Plugin — reads firewall rules from iptables or nftables. + +Publishes to: + firewall.backend — detected backend ("iptables", "nftables", "none") + firewall.rules — parsed rule list + firewall.chains — dict of chains with policy and rule count +""" + +from __future__ import annotations + +import asyncio +import logging +import re +import shutil +import subprocess +from typing import Any, Dict, List, Optional + +from plugins.base import BasePlugin + +log = logging.getLogger(__name__) + +DEFAULT_POLL_INTERVAL = 60.0 + + +def _detect_backend() -> str: + """Detect which firewall backend is available.""" + if shutil.which("nft"): + return "nftables" + if shutil.which("iptables"): + return "iptables" + return "none" + + +def _run(args: List[str]) -> str: + """Run a subprocess and return stdout. Returns '' on error.""" + try: + result = subprocess.run( + args, + capture_output=True, + text=True, + timeout=10, + ) + return result.stdout + except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError) as exc: + log.debug("command %r failed: %s", args, exc) + return "" + + +def _parse_iptables() -> Dict: + """Parse iptables -L -n -v output into structured data.""" + output = _run(["iptables", "-L", "-n", "-v", "--line-numbers"]) + chains: Dict[str, Dict] = {} + rules: List[Dict] = {} + + current_chain: Optional[str] = None + policy_re = re.compile(r"^Chain (\S+) \(policy (\S+)") + rule_re = re.compile( + r"^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)" + ) + + for line in output.splitlines(): + m = policy_re.match(line) + if m: + current_chain = m.group(1) + chains[current_chain] = { + "policy": m.group(2), + "rule_count": 0, + } + continue + if current_chain and (m := rule_re.match(line)): + rule = { + "chain": current_chain, + "num": int(m.group(1)), + "pkts": m.group(2), + "bytes": m.group(3), + "target": m.group(4), + "prot": m.group(5), + "in": m.group(7), + "out": m.group(8), + "source": m.group(9), + "destination": m.group(10).strip(), + } + rules.append(rule) + chains[current_chain]["rule_count"] += 1 + + return {"chains": chains, "rules": rules, "backend": "iptables"} + + +def _parse_nftables() -> Dict: + """Parse nft list ruleset output into structured data.""" + output = _run(["nft", "-j", "list", "ruleset"]) + chains: Dict[str, Dict] = {} + rules: List[Dict] = [] + + try: + import json + data = json.loads(output) + for item in data.get("nftables", []): + if "chain" in item: + c = item["chain"] + chains[c["name"]] = { + "table": c.get("table", ""), + "policy": c.get("policy", ""), + "rule_count": 0, + } + elif "rule" in item: + r = item["rule"] + chain_name = r.get("chain", "") + rule_entry = { + "chain": chain_name, + "table": r.get("table", ""), + "handle": r.get("handle", ""), + "expr": str(r.get("expr", "")), + } + rules.append(rule_entry) + if chain_name in chains: + chains[chain_name]["rule_count"] += 1 + except Exception as exc: + log.debug("nftables JSON parse failed, falling back: %s", exc) + # Plain-text fallback + for line in output.splitlines(): + line = line.strip() + if line: + rules.append({"chain": "unknown", "expr": line}) + + return {"chains": chains, "rules": rules, "backend": "nftables"} + + +class FirewallPlugin(BasePlugin): + """ + Reads and monitors firewall rules from iptables or nftables. + Automatically detects the available backend. + """ + + name = "firewall" + version = "1.0.0" + description = "Reads firewall rules from iptables or nftables" + tags = ["security", "network", "firewall"] + + def __init__( + self, + bus=None, + state=None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + ) -> None: + super().__init__(bus=bus, state=state) + self._poll_interval = poll_interval + self._backend: str = "none" + self._task: asyncio.Task | None = None + self._running = False + + def on_load(self) -> None: + super().on_load() + self._backend = _detect_backend() + self._log.info("firewall backend detected: %s", self._backend) + self.state_set("firewall.backend", self._backend) + self.subscribe("firewall.refresh") + self._running = True + try: + loop = asyncio.get_running_loop() + self._task = loop.create_task(self._poll_loop()) + except RuntimeError: + self._log.debug("no running event loop at load time; task deferred") + + def on_unload(self) -> None: + self._running = False + if self._task and not self._task.done(): + self._task.cancel() + super().on_unload() + + async def _poll_loop(self) -> None: + self._log.debug("firewall poll loop started (interval=%.1fs)", self._poll_interval) + while self._running: + try: + await self._collect_and_publish() + except asyncio.CancelledError: + break + except Exception as exc: + self._log.error("firewall poll error: %s", exc) + await asyncio.sleep(self._poll_interval) + self._log.debug("firewall poll loop stopped") + + async def _collect_and_publish(self) -> None: + if self._backend == "iptables": + data = await asyncio.to_thread(_parse_iptables) + elif self._backend == "nftables": + data = await asyncio.to_thread(_parse_nftables) + else: + data = {"chains": {}, "rules": [], "backend": "none"} + + self.state_set("firewall.rules", data.get("rules", [])) + self.state_set("firewall.chains", data.get("chains", {})) + if self._bus: + await self._bus.publish("firewall.rules", data) + await self._bus.publish("firewall.chains", data.get("chains", {})) + + def on_event(self, topic: str, data: Any) -> None: + if topic == "firewall.refresh": + asyncio.ensure_future(self._collect_and_publish()) diff --git a/plugins/network/__init__.py b/plugins/network/__init__.py new file mode 100644 index 0000000..38c2261 --- /dev/null +++ b/plugins/network/__init__.py @@ -0,0 +1,5 @@ +"""Network monitoring plugin package.""" + +from .plugin import NetworkPlugin + +__all__ = ["NetworkPlugin"] diff --git a/plugins/network/plugin.py b/plugins/network/plugin.py new file mode 100644 index 0000000..78fd30b --- /dev/null +++ b/plugins/network/plugin.py @@ -0,0 +1,129 @@ +""" +Network Plugin — monitors network interfaces and traffic using psutil. + +Publishes to: + network.interfaces — dict of {iface: {status, ip4, ip6, rx_bytes, tx_bytes, ...}} + network.stats — aggregate stats snapshot +""" + +from __future__ import annotations + +import asyncio +import logging +import socket +from typing import Any, Dict + +try: + import psutil + _PSUTIL_AVAILABLE = True +except ImportError: + _PSUTIL_AVAILABLE = False + +from plugins.base import BasePlugin + +log = logging.getLogger(__name__) + +# Polling interval in seconds +DEFAULT_POLL_INTERVAL = 5.0 + + +def _get_interfaces() -> Dict[str, Dict]: + """Collect interface statistics from psutil.""" + if not _PSUTIL_AVAILABLE: + return {} + + stats: Dict[str, Dict] = {} + io_counters = psutil.net_io_counters(pernic=True) + if_stats = psutil.net_if_stats() + if_addrs = psutil.net_if_addrs() + + for iface, io in io_counters.items(): + nic_stat = if_stats.get(iface) + addrs = if_addrs.get(iface, []) + + ip4 = "" + ip6 = "" + mac = "" + for addr in addrs: + if addr.family == socket.AF_INET: + ip4 = addr.address + elif addr.family == socket.AF_INET6: + ip6 = addr.address + elif addr.family == psutil.AF_LINK: + mac = addr.address + + stats[iface] = { + "status": "UP" if (nic_stat and nic_stat.isup) else "DOWN", + "speed": nic_stat.speed if nic_stat else 0, + "mtu": nic_stat.mtu if nic_stat else 0, + "ip4": ip4, + "ip6": ip6, + "mac": mac, + "rx_bytes": io.bytes_recv, + "tx_bytes": io.bytes_sent, + "rx_packets": io.packets_recv, + "tx_packets": io.packets_sent, + "rx_errors": io.errin, + "tx_errors": io.errout, + "rx_drop": io.dropin, + "tx_drop": io.dropout, + } + + return stats + + +class NetworkPlugin(BasePlugin): + """Monitors network interfaces and publishes stats to the event bus.""" + + name = "network" + version = "1.0.0" + description = "Monitors network interfaces and traffic statistics via psutil" + tags = ["network", "monitoring"] + + def __init__(self, bus=None, state=None, poll_interval: float = DEFAULT_POLL_INTERVAL) -> None: + super().__init__(bus=bus, state=state) + self._poll_interval = poll_interval + self._task: asyncio.Task | None = None + self._running = False + + def on_load(self) -> None: + super().on_load() + if not _PSUTIL_AVAILABLE: + self._log.warning("psutil not available — network monitoring degraded") + self._running = True + # Schedule the polling loop + try: + loop = asyncio.get_running_loop() + self._task = loop.create_task(self._poll_loop()) + except RuntimeError: + self._log.debug("no running event loop at load time; task deferred") + + def on_unload(self) -> None: + self._running = False + if self._task and not self._task.done(): + self._task.cancel() + super().on_unload() + + async def _poll_loop(self) -> None: + """Periodically collect interface data and publish to bus.""" + self._log.debug("network poll loop started (interval=%.1fs)", self._poll_interval) + while self._running: + try: + data = await asyncio.to_thread(_get_interfaces) + self.state_set("network.interfaces", data) + await self._bus.publish("network.interfaces", data) if self._bus else None + await self._bus.publish("network.stats", { + "interface_count": len(data), + "active_count": sum(1 for v in data.values() if v["status"] == "UP"), + }) if self._bus else None + except asyncio.CancelledError: + break + except Exception as exc: + self._log.error("poll error: %s", exc) + await asyncio.sleep(self._poll_interval) + self._log.debug("network poll loop stopped") + + def on_event(self, topic: str, data: Any) -> None: + """Handle requests to refresh immediately.""" + if topic == "network.refresh": + self._log.debug("manual refresh requested") diff --git a/plugins/system/__init__.py b/plugins/system/__init__.py new file mode 100644 index 0000000..fa6df7c --- /dev/null +++ b/plugins/system/__init__.py @@ -0,0 +1,5 @@ +"""System monitoring plugin package.""" + +from .plugin import SystemPlugin + +__all__ = ["SystemPlugin"] diff --git a/plugins/system/plugin.py b/plugins/system/plugin.py new file mode 100644 index 0000000..c2668ef --- /dev/null +++ b/plugins/system/plugin.py @@ -0,0 +1,164 @@ +""" +System Plugin — monitors CPU, memory, disk, and load via psutil. + +Publishes to: + system.cpu — per-core and overall CPU usage % + system.memory — RAM and swap usage + system.disk — disk partition usage + system.load — 1/5/15 minute load averages + system.snapshot — combined full snapshot +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Any, Dict, List + +try: + import psutil + _PSUTIL_AVAILABLE = True +except ImportError: + _PSUTIL_AVAILABLE = False + +from plugins.base import BasePlugin + +log = logging.getLogger(__name__) + +DEFAULT_POLL_INTERVAL = 3.0 + + +def _collect_cpu() -> Dict: + if not _PSUTIL_AVAILABLE: + return {} + per_core = psutil.cpu_percent(interval=None, percpu=True) + freq = psutil.cpu_freq() + return { + "percent": psutil.cpu_percent(interval=None), + "per_core": per_core, + "core_count": psutil.cpu_count(logical=False), + "logical_count": psutil.cpu_count(logical=True), + "freq_mhz": round(freq.current, 1) if freq else 0, + "freq_max_mhz": round(freq.max, 1) if freq else 0, + } + + +def _collect_memory() -> Dict: + if not _PSUTIL_AVAILABLE: + return {} + vm = psutil.virtual_memory() + sw = psutil.swap_memory() + return { + "total": vm.total, + "available": vm.available, + "used": vm.used, + "percent": vm.percent, + "swap_total": sw.total, + "swap_used": sw.used, + "swap_percent": sw.percent, + } + + +def _collect_disk() -> List[Dict]: + if not _PSUTIL_AVAILABLE: + return [] + partitions = [] + for part in psutil.disk_partitions(all=False): + try: + usage = psutil.disk_usage(part.mountpoint) + partitions.append({ + "device": part.device, + "mountpoint": part.mountpoint, + "fstype": part.fstype, + "total": usage.total, + "used": usage.used, + "free": usage.free, + "percent": usage.percent, + }) + except PermissionError: + continue + return partitions + + +def _collect_load() -> Dict: + try: + avg = os.getloadavg() + return {"load1": avg[0], "load5": avg[1], "load15": avg[2]} + except AttributeError: + return {"load1": 0.0, "load5": 0.0, "load15": 0.0} + + +def _collect_snapshot() -> Dict: + return { + "cpu": _collect_cpu(), + "memory": _collect_memory(), + "disk": _collect_disk(), + "load": _collect_load(), + } + + +class SystemPlugin(BasePlugin): + """Monitors CPU, memory, disk, and load averages via psutil.""" + + name = "system" + version = "1.0.0" + description = "Monitors system resources: CPU, memory, disk, and load averages" + tags = ["system", "monitoring"] + + def __init__( + self, + bus=None, + state=None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + ) -> None: + super().__init__(bus=bus, state=state) + self._poll_interval = poll_interval + self._task: asyncio.Task | None = None + self._running = False + + def on_load(self) -> None: + super().on_load() + if not _PSUTIL_AVAILABLE: + self._log.warning("psutil not available — system monitoring degraded") + self._running = True + try: + loop = asyncio.get_running_loop() + self._task = loop.create_task(self._poll_loop()) + except RuntimeError: + self._log.debug("no running event loop at load time; task deferred") + + def on_unload(self) -> None: + self._running = False + if self._task and not self._task.done(): + self._task.cancel() + super().on_unload() + + async def _poll_loop(self) -> None: + self._log.debug("system poll loop started (interval=%.1fs)", self._poll_interval) + while self._running: + try: + snapshot = await asyncio.to_thread(_collect_snapshot) + + self.state_set("system.cpu", snapshot["cpu"]) + self.state_set("system.memory", snapshot["memory"]) + self.state_set("system.disk", snapshot["disk"]) + self.state_set("system.load", snapshot["load"]) + + if self._bus: + await self._bus.publish("system.cpu", snapshot["cpu"]) + await self._bus.publish("system.memory", snapshot["memory"]) + await self._bus.publish("system.disk", snapshot["disk"]) + await self._bus.publish("system.load", snapshot["load"]) + await self._bus.publish("system.snapshot", snapshot) + + except asyncio.CancelledError: + break + except Exception as exc: + self._log.error("system poll error: %s", exc) + await asyncio.sleep(self._poll_interval) + self._log.debug("system poll loop stopped") + + def on_event(self, topic: str, data: Any) -> None: + if topic == "system.refresh": + asyncio.ensure_future(self._poll_loop()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b28fa25 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,73 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "metro-warden" +version = "0.1.0" +description = "Industrial dark metro map Network Operations Centre TUI" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } + +keywords = ["network", "tui", "noc", "monitoring", "textual"] + +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: System :: Networking :: Monitoring", + "Topic :: System :: Systems Administration", +] + +dependencies = [ + "textual>=0.47.0", + "psutil>=5.9.0", + "toml>=0.10.2", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.0.0", + "textual-dev>=0.47.0", +] + +[project.scripts] +metro-warden = "main:main" + +[project.urls] +Repository = "https://github.com/example/metro-warden" + +# ── Tool configs ───────────────────────────────────────────────────────── + +[tool.setuptools.packages.find] +where = ["."] +include = ["core*", "plugins*", "ui*"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +log_cli = true +log_cli_level = "DEBUG" +addopts = "-v --tb=short" + +[tool.coverage.run] +source = ["core", "plugins", "ui"] +omit = ["tests/*", "main.py"] + +[tool.coverage.report] +show_missing = true +skip_covered = false + +[tool.ruff] +line-length = 100 +target-version = "py311" +select = ["E", "F", "W", "I", "UP"] +ignore = ["E501"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..aaac6b5 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Metro Warden test suite.""" diff --git a/tests/test_bus.py b/tests/test_bus.py new file mode 100644 index 0000000..b38c66f --- /dev/null +++ b/tests/test_bus.py @@ -0,0 +1,273 @@ +""" +Tests for core.bus.EventBus + +Covers: + - Basic subscribe / publish / unsubscribe + - Wildcard topic matching (*, **) + - Multiple subscribers on the same topic + - Async handler dispatch + - Unsubscribe removes handler correctly + - History recording and retrieval + - publish_sync fire-and-forget + - Error in one handler does not prevent others +""" + +from __future__ import annotations + +import asyncio +from typing import Any, List + +import pytest + +from core.bus import EventBus + + +# ── Fixtures ────────────────────────────────────────────────────────────── + + +@pytest.fixture +def bus() -> EventBus: + return EventBus() + + +# ── Helper ─────────────────────────────────────────────────────────────── + + +def make_collector() -> tuple[list, Any]: + """Return (received_list, async_handler).""" + received: List[tuple[str, Any]] = [] + + async def handler(topic: str, data: Any) -> None: + received.append((topic, data)) + + return received, handler + + +# ── Basic subscribe / publish ───────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_basic_publish_subscribe(bus: EventBus) -> None: + received, handler = make_collector() + bus.subscribe("test.topic", handler) + count = await bus.publish("test.topic", {"key": "value"}) + assert count == 1 + assert len(received) == 1 + assert received[0] == ("test.topic", {"key": "value"}) + + +@pytest.mark.asyncio +async def test_no_subscribers_returns_zero(bus: EventBus) -> None: + count = await bus.publish("orphan.topic", "data") + assert count == 0 + + +@pytest.mark.asyncio +async def test_multiple_subscribers_same_topic(bus: EventBus) -> None: + received_a, handler_a = make_collector() + received_b, handler_b = make_collector() + bus.subscribe("multi.topic", handler_a) + bus.subscribe("multi.topic", handler_b) + count = await bus.publish("multi.topic", 42) + assert count == 2 + assert len(received_a) == 1 + assert len(received_b) == 1 + + +@pytest.mark.asyncio +async def test_publish_to_different_topic_not_received(bus: EventBus) -> None: + received, handler = make_collector() + bus.subscribe("topic.a", handler) + await bus.publish("topic.b", "ignored") + assert len(received) == 0 + + +# ── Unsubscribe ─────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_unsubscribe_stops_delivery(bus: EventBus) -> None: + received, handler = make_collector() + sub_id = bus.subscribe("unsub.test", handler) + await bus.publish("unsub.test", "first") + assert len(received) == 1 + bus.unsubscribe(sub_id) + await bus.publish("unsub.test", "second") + assert len(received) == 1 # no new message + + +def test_unsubscribe_unknown_id_returns_false(bus: EventBus) -> None: + assert bus.unsubscribe("not-a-real-id") is False + + +@pytest.mark.asyncio +async def test_unsubscribe_all_by_handler(bus: EventBus) -> None: + received, handler = make_collector() + bus.subscribe("topic.x", handler) + bus.subscribe("topic.y", handler) + removed = bus.unsubscribe_all(handler) + assert removed == 2 + await bus.publish("topic.x", None) + await bus.publish("topic.y", None) + assert len(received) == 0 + + +# ── Wildcard matching ───────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_wildcard_single_star(bus: EventBus) -> None: + received, handler = make_collector() + bus.subscribe("network.*", handler) + + await bus.publish("network.interfaces", {"eth0": "up"}) + await bus.publish("network.stats", {}) + await bus.publish("system.cpu", {}) # should NOT match + + assert len(received) == 2 + topics = [r[0] for r in received] + assert "network.interfaces" in topics + assert "network.stats" in topics + + +@pytest.mark.asyncio +async def test_wildcard_double_star(bus: EventBus) -> None: + received, handler = make_collector() + bus.subscribe("network.**", handler) + + await bus.publish("network.interfaces", {}) + await bus.publish("network.interfaces.eth0", {}) + await bus.publish("system.cpu", {}) # should NOT match + + assert len(received) == 2 + + +@pytest.mark.asyncio +async def test_wildcard_bare_star_matches_any_single_segment(bus: EventBus) -> None: + received, handler = make_collector() + bus.subscribe("*", handler) + + await bus.publish("anything", 1) + await bus.publish("other", 2) + await bus.publish("multi.segment", 3) # NOT matched by bare "*" + + assert len(received) == 2 + + +@pytest.mark.asyncio +async def test_exact_and_wildcard_subscriber_both_notified(bus: EventBus) -> None: + exact, handler_exact = make_collector() + wild, handler_wild = make_collector() + bus.subscribe("net.iface", handler_exact) + bus.subscribe("net.*", handler_wild) + + await bus.publish("net.iface", "data") + + assert len(exact) == 1 + assert len(wild) == 1 + + +# ── Async handler dispatch ──────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_async_handler_is_awaited(bus: EventBus) -> None: + result = [] + + async def slow_handler(topic: str, data: Any) -> None: + await asyncio.sleep(0.01) + result.append(data) + + bus.subscribe("async.test", slow_handler) + await bus.publish("async.test", "payload") + assert result == ["payload"] + + +@pytest.mark.asyncio +async def test_sync_handler_works(bus: EventBus) -> None: + result = [] + + def sync_handler(topic: str, data: Any) -> None: + result.append(data) + + bus.subscribe("sync.test", sync_handler) + await bus.publish("sync.test", "sync-payload") + assert result == ["sync-payload"] + + +# ── Error isolation ─────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_handler_error_does_not_block_others(bus: EventBus) -> None: + good_received = [] + + async def bad_handler(topic: str, data: Any) -> None: + raise RuntimeError("intentional test error") + + async def good_handler(topic: str, data: Any) -> None: + good_received.append(data) + + bus.subscribe("error.topic", bad_handler) + bus.subscribe("error.topic", good_handler) + + # Should not raise + count = await bus.publish("error.topic", "test") + assert count == 2 + assert good_received == ["test"] + + +# ── History ─────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_history_records_events(bus: EventBus) -> None: + await bus.publish("hist.a", 1) + await bus.publish("hist.b", 2) + await bus.publish("hist.a", 3) + + history = bus.get_history() + assert len(history) == 3 + + +@pytest.mark.asyncio +async def test_history_filter_by_topic(bus: EventBus) -> None: + await bus.publish("hist.a", 1) + await bus.publish("hist.b", 2) + await bus.publish("hist.a", 3) + + filtered = bus.get_history(topic_filter="hist.a") + assert len(filtered) == 2 + assert all(e.topic == "hist.a" for e in filtered) + + +@pytest.mark.asyncio +async def test_history_limit(bus: EventBus) -> None: + for i in range(20): + await bus.publish("flood.topic", i) + + history = bus.get_history(limit=5) + assert len(history) == 5 + # Most recent 5 + assert [e.data for e in history] == list(range(15, 20)) + + +# ── Introspection ───────────────────────────────────────────────────────── + + +def test_subscription_count(bus: EventBus) -> None: + _, h1 = make_collector() + _, h2 = make_collector() + assert bus.subscription_count == 0 + bus.subscribe("a", h1) + bus.subscribe("b", h2) + assert bus.subscription_count == 2 + + +def test_patterns(bus: EventBus) -> None: + _, h = make_collector() + bus.subscribe("network.*", h) + bus.subscribe("system.**", h) + patterns = bus.patterns + assert "network.*" in patterns + assert "system.**" in patterns diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..4c3c9f2 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,269 @@ +""" +Tests for core.state.StateStore + +Covers: + - get / set / delete basic operations + - default value on missing key + - watcher callbacks on change + - watcher not called when value unchanged + - watcher wildcard patterns + - unwatch removes callback + - update() for bulk set + - snapshot() returns deep copy + - bus integration (state publishes to bus) + - keys() and __contains__ +""" + +from __future__ import annotations + +from typing import Any, List +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from core.state import StateStore + + +# ── Fixtures ────────────────────────────────────────────────────────────── + + +@pytest.fixture +def store() -> StateStore: + return StateStore() + + +@pytest.fixture +def store_with_bus(): + bus = MagicMock() + bus.publish_sync = MagicMock() + s = StateStore(bus=bus) + return s, bus + + +# ── Basic get / set ────────────────────────────────────────────────────── + + +def test_set_and_get(store: StateStore) -> None: + store.set("key.a", 42) + assert store.get("key.a") == 42 + + +def test_get_missing_key_returns_default(store: StateStore) -> None: + assert store.get("does.not.exist") is None + assert store.get("does.not.exist", "fallback") == "fallback" + + +def test_set_overwrites_value(store: StateStore) -> None: + store.set("x", 1) + store.set("x", 2) + assert store.get("x") == 2 + + +def test_get_returns_deep_copy(store: StateStore) -> None: + store.set("obj", {"a": [1, 2, 3]}) + got = store.get("obj") + got["a"].append(99) + assert store.get("obj") == {"a": [1, 2, 3]} # original unchanged + + +# ── Delete ──────────────────────────────────────────────────────────────── + + +def test_delete_existing_key(store: StateStore) -> None: + store.set("del.me", "value") + assert store.delete("del.me") is True + assert store.get("del.me") is None + + +def test_delete_missing_key_returns_false(store: StateStore) -> None: + assert store.delete("ghost.key") is False + + +# ── Update ──────────────────────────────────────────────────────────────── + + +def test_update_sets_multiple_keys(store: StateStore) -> None: + store.update({"a": 1, "b": 2, "c": 3}) + assert store.get("a") == 1 + assert store.get("b") == 2 + assert store.get("c") == 3 + + +# ── Watchers ───────────────────────────────────────────────────────────── + + +def test_watcher_called_on_change(store: StateStore) -> None: + events: List[tuple] = [] + + def cb(key: str, old: Any, new: Any) -> None: + events.append((key, old, new)) + + store.watch("watched.key", cb) + store.set("watched.key", "hello") + assert len(events) == 1 + assert events[0] == ("watched.key", None, "hello") + + +def test_watcher_called_with_old_and_new(store: StateStore) -> None: + events: List[tuple] = [] + + store.set("my.key", "initial") + + def cb(key: str, old: Any, new: Any) -> None: + events.append((old, new)) + + store.watch("my.key", cb) + store.set("my.key", "updated") + + assert events == [("initial", "updated")] + + +def test_watcher_not_called_when_value_unchanged(store: StateStore) -> None: + events: List[tuple] = [] + + def cb(key, old, new): + events.append((old, new)) + + store.set("stable", 100) + store.watch("stable", cb) + store.set("stable", 100) # same value + assert len(events) == 0 + + +def test_multiple_watchers_on_same_key(store: StateStore) -> None: + results_a: list = [] + results_b: list = [] + + store.watch("key", lambda k, o, n: results_a.append(n)) + store.watch("key", lambda k, o, n: results_b.append(n)) + store.set("key", "fire") + + assert results_a == ["fire"] + assert results_b == ["fire"] + + +def test_watcher_on_delete(store: StateStore) -> None: + events: list = [] + store.set("dying.key", "alive") + store.watch("dying.key", lambda k, o, n: events.append((o, n))) + store.delete("dying.key") + assert events == [("alive", None)] + + +# ── Wildcard watchers ───────────────────────────────────────────────────── + + +def test_wildcard_watcher_fires_for_matching_keys(store: StateStore) -> None: + events: list = [] + store.watch("network.*", lambda k, o, n: events.append(k)) + store.set("network.interfaces", {}) + store.set("network.stats", {}) + store.set("system.cpu", {}) # should NOT trigger + assert "network.interfaces" in events + assert "network.stats" in events + assert "system.cpu" not in events + + +def test_exact_and_wildcard_both_fired(store: StateStore) -> None: + exact: list = [] + wild: list = [] + store.watch("net.iface", lambda k, o, n: exact.append(n)) + store.watch("net.*", lambda k, o, n: wild.append(n)) + store.set("net.iface", "up") + assert exact == ["up"] + assert wild == ["up"] + + +# ── Unwatch ─────────────────────────────────────────────────────────────── + + +def test_unwatch_stops_callback(store: StateStore) -> None: + events: list = [] + + def cb(k, o, n): + events.append(n) + + wid = store.watch("remove.me", cb) + store.set("remove.me", 1) + assert len(events) == 1 + + store.unwatch(wid) + store.set("remove.me", 2) + assert len(events) == 1 # no new call + + +def test_unwatch_unknown_id_returns_false(store: StateStore) -> None: + assert store.unwatch("not-a-real-id") is False + + +# ── Snapshot ────────────────────────────────────────────────────────────── + + +def test_snapshot_returns_full_state(store: StateStore) -> None: + store.set("a", 1) + store.set("b", {"nested": True}) + snap = store.snapshot() + assert snap["a"] == 1 + assert snap["b"] == {"nested": True} + + +def test_snapshot_is_deep_copy(store: StateStore) -> None: + store.set("obj", [1, 2, 3]) + snap = store.snapshot() + snap["obj"].append(99) + assert store.get("obj") == [1, 2, 3] + + +# ── Keys and contains ───────────────────────────────────────────────────── + + +def test_keys_returns_all_keys(store: StateStore) -> None: + store.set("x", 1) + store.set("y", 2) + assert set(store.keys()) == {"x", "y"} + + +def test_contains_operator(store: StateStore) -> None: + store.set("present", True) + assert "present" in store + assert "absent" not in store + + +# ── Bus integration ─────────────────────────────────────────────────────── + + +def test_bus_publish_called_on_set(store_with_bus) -> None: + store, bus = store_with_bus + store.set("some.key", "value") + bus.publish_sync.assert_called_once_with( + "state.some.key", {"key": "some.key", "value": "value"} + ) + + +def test_bus_not_called_when_value_unchanged(store_with_bus) -> None: + store, bus = store_with_bus + store.set("stable", 42) + bus.publish_sync.reset_mock() + store.set("stable", 42) # unchanged + bus.publish_sync.assert_not_called() + + +def test_bus_called_on_delete(store_with_bus) -> None: + store, bus = store_with_bus + store.set("temp", "here") + bus.publish_sync.reset_mock() + store.delete("temp") + bus.publish_sync.assert_called_once_with( + "state.temp", {"key": "temp", "value": None} + ) + + +# ── Repr ────────────────────────────────────────────────────────────────── + + +def test_repr(store: StateStore) -> None: + store.set("k", "v") + store.watch("k", lambda a, b, c: None) + r = repr(store) + assert "StateStore" in r + assert "keys=1" in r diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..d031997 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1 @@ +"""Metro Warden UI package.""" diff --git a/ui/tabs/__init__.py b/ui/tabs/__init__.py new file mode 100644 index 0000000..e0a63e7 --- /dev/null +++ b/ui/tabs/__init__.py @@ -0,0 +1,19 @@ +"""Metro Warden tab widgets.""" + +from .lines import LinesTab +from .routes import RoutesTab +from .signals import SignalsTab +from .chronicle import ChronicleTab +from .registry import RegistryTab +from .garrison import GarrisonTab +from .settings import SettingsTab + +__all__ = [ + "LinesTab", + "RoutesTab", + "SignalsTab", + "ChronicleTab", + "RegistryTab", + "GarrisonTab", + "SettingsTab", +] diff --git a/ui/tabs/chronicle.py b/ui/tabs/chronicle.py new file mode 100644 index 0000000..67098e0 --- /dev/null +++ b/ui/tabs/chronicle.py @@ -0,0 +1,164 @@ +""" +Chronicle Tab — persistent structured log viewer with filtering. + +Maintains a rolling buffer of all events and allows the user to filter +by topic prefix and search by keyword. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, List, Optional + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.widget import Widget +from textual.widgets import Input, Label, RichLog, Static + + +MAX_BUFFER = 2000 # maximum events retained + + +class ChronicleTab(Widget): + """ + Historical event log with topic filtering and keyword search. + + Events are buffered internally. Press 'f' to focus the filter input. + """ + + DEFAULT_CSS = """ + ChronicleTab { + layout: vertical; + height: 1fr; + padding: 1 2; + } + ChronicleTab .tab-title { + color: $primary; + text-style: bold; + height: 1; + margin-bottom: 1; + } + ChronicleTab .filter-row { + layout: horizontal; + height: 3; + margin-bottom: 1; + } + ChronicleTab .filter-label { + width: 10; + height: 3; + content-align: left middle; + color: $text-muted; + } + ChronicleTab Input { + width: 1fr; + margin-right: 1; + } + ChronicleTab RichLog { + height: 1fr; + border: round $primary; + background: $surface; + } + ChronicleTab .hint { + height: 1; + color: $text-muted; + margin-top: 1; + } + """ + + BINDINGS = [ + Binding("f", "focus_filter", "Filter"), + Binding("c", "clear_filter", "Clear filter"), + Binding("escape", "blur_filter", "Done"), + ] + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._buffer: List[dict] = [] + self._sub_id: Optional[str] = None + self._filter_text: str = "" + + def compose(self) -> ComposeResult: + yield Static("// CHRONICLE — Event History", classes="tab-title") + with Widget(classes="filter-row"): + yield Static("Filter:", classes="filter-label") + yield Input(placeholder="topic prefix or keyword…", id="chronicle-filter") + yield RichLog(id="chronicle-log", highlight=True, markup=True, max_lines=MAX_BUFFER) + yield Static( + "Press [bold]f[/bold] to filter | [bold]c[/bold] to clear filter", + classes="hint", + ) + + def on_mount(self) -> None: + app = self.app + if hasattr(app, "bus"): + self._sub_id = app.bus.subscribe("**", self._on_any_event) + + def on_unmount(self) -> None: + app = self.app + if hasattr(app, "bus") and self._sub_id: + app.bus.unsubscribe(self._sub_id) + + def _on_any_event(self, topic: str, data: Any) -> None: + now = datetime.now(timezone.utc) + entry = { + "ts": now.isoformat(), + "topic": topic, + "data": data, + } + self._buffer.append(entry) + if len(self._buffer) > MAX_BUFFER: + self._buffer = self._buffer[-MAX_BUFFER:] + + if self._passes_filter(entry): + self.call_from_thread(self._append_entry, entry) + + def _passes_filter(self, entry: dict) -> bool: + if not self._filter_text: + return True + text = self._filter_text.lower() + if text in entry["topic"].lower(): + return True + try: + if text in json.dumps(entry["data"], default=str).lower(): + return True + except Exception: + pass + return False + + def _append_entry(self, entry: dict) -> None: + log_widget = self.query_one("#chronicle-log", RichLog) + ts_short = entry["ts"][11:23] # HH:MM:SS.mmm + try: + data_str = json.dumps(entry["data"], default=str) + except Exception: + data_str = str(entry["data"]) + if len(data_str) > 100: + data_str = data_str[:100] + "…" + line = f"[dim]{ts_short}[/dim] [cyan]{entry['topic']:<35}[/cyan] [dim]{data_str}[/dim]" + log_widget.write(line) + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "chronicle-filter": + self._filter_text = event.value + self._rebuild_log() + + def _rebuild_log(self) -> None: + """Repopulate log from buffer according to current filter.""" + log_widget = self.query_one("#chronicle-log", RichLog) + log_widget.clear() + for entry in self._buffer: + if self._passes_filter(entry): + self._append_entry(entry) + + def action_focus_filter(self) -> None: + self.query_one("#chronicle-filter", Input).focus() + + def action_clear_filter(self) -> None: + inp = self.query_one("#chronicle-filter", Input) + inp.value = "" + self._filter_text = "" + self._rebuild_log() + + def action_blur_filter(self) -> None: + self.query_one("#chronicle-filter", Input).blur() diff --git a/ui/tabs/garrison.py b/ui/tabs/garrison.py new file mode 100644 index 0000000..0cadc01 --- /dev/null +++ b/ui/tabs/garrison.py @@ -0,0 +1,151 @@ +""" +Garrison Tab — firewall rules and chain overview. + +Subscribes to firewall.rules and firewall.chains events from the +FirewallPlugin and renders them in DataTable widgets. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.widget import Widget +from textual.widgets import DataTable, Label, Static, TabbedContent, TabPane + + +class GarrisonTab(Widget): + """ + Firewall / security tab. + + Two sub-sections: + - Chains: shows chain names, policies, and rule counts + - Rules: shows individual firewall rules + """ + + DEFAULT_CSS = """ + GarrisonTab { + layout: vertical; + height: 1fr; + padding: 1 2; + } + GarrisonTab .tab-title { + color: $primary; + text-style: bold; + height: 1; + margin-bottom: 1; + } + GarrisonTab .backend-label { + height: 1; + color: $text-muted; + margin-bottom: 1; + } + GarrisonTab DataTable { + height: 1fr; + border: round $primary; + } + GarrisonTab .section-label { + color: $accent; + text-style: bold; + height: 1; + margin-top: 1; + margin-bottom: 1; + } + GarrisonTab .hint { + height: 1; + color: $text-muted; + margin-top: 1; + } + """ + + BINDINGS = [Binding("r", "refresh_firewall", "Refresh")] + + CHAIN_COLUMNS = [("Chain", 16), ("Policy", 10), ("Rules", 8)] + RULE_COLUMNS = [("Chain", 12), ("Target", 10), ("Proto", 8), ("In", 8), ("Out", 8), ("Source", 18), ("Destination", 20)] + + def compose(self) -> ComposeResult: + yield Static("// GARRISON — Firewall", classes="tab-title") + yield Static("Backend: detecting…", id="garrison-backend", classes="backend-label") + yield Static("Chains", classes="section-label") + yield DataTable(id="garrison-chains", zebra_stripes=True, cursor_type="row") + yield Static("Rules", classes="section-label") + yield DataTable(id="garrison-rules", zebra_stripes=True, cursor_type="row") + yield Static("Press [bold]r[/bold] to refresh", classes="hint") + + def on_mount(self) -> None: + chains_table = self.query_one("#garrison-chains", DataTable) + for col, width in self.CHAIN_COLUMNS: + chains_table.add_column(col, width=width) + + rules_table = self.query_one("#garrison-rules", DataTable) + for col, width in self.RULE_COLUMNS: + rules_table.add_column(col, width=width) + + app = self.app + if hasattr(app, "bus"): + app.bus.subscribe("firewall.rules", self._on_rules) + app.bus.subscribe("firewall.chains", self._on_chains) + app.bus.subscribe("state.firewall.backend", self._on_backend) + + # Read initial state + if hasattr(app, "state"): + backend = app.state.get("firewall.backend", "unknown") + self._set_backend_label(backend) + rules_data = app.state.get("firewall.rules", []) + chains_data = app.state.get("firewall.chains", {}) + if rules_data: + self._populate_rules(rules_data) + if chains_data: + self._populate_chains(chains_data) + + def _on_backend(self, topic: str, data: Any) -> None: + backend = data.get("value", "unknown") if isinstance(data, dict) else str(data) + self.call_from_thread(self._set_backend_label, backend) + + def _set_backend_label(self, backend: str) -> None: + self.query_one("#garrison-backend", Static).update(f"Backend: [bold]{backend}[/bold]") + + def _on_rules(self, topic: str, data: Any) -> None: + if isinstance(data, dict): + rules = data.get("rules", []) + chains = data.get("chains", {}) + backend = data.get("backend", "") + self.call_from_thread(self._populate_rules, rules) + if chains: + self.call_from_thread(self._populate_chains, chains) + if backend: + self.call_from_thread(self._set_backend_label, backend) + + def _on_chains(self, topic: str, data: Any) -> None: + if isinstance(data, dict): + self.call_from_thread(self._populate_chains, data) + + def _populate_chains(self, chains: Dict) -> None: + table = self.query_one("#garrison-chains", DataTable) + table.clear() + for name, info in sorted(chains.items()): + table.add_row( + name, + info.get("policy", "—"), + str(info.get("rule_count", 0)), + ) + + def _populate_rules(self, rules: List[Dict]) -> None: + table = self.query_one("#garrison-rules", DataTable) + table.clear() + for rule in rules: + table.add_row( + rule.get("chain", "—"), + rule.get("target", rule.get("expr", "—"))[:20], + rule.get("prot", "—"), + rule.get("in", "—"), + rule.get("out", "—"), + rule.get("source", rule.get("table", "—")), + rule.get("destination", "—"), + ) + + def action_refresh_firewall(self) -> None: + app = self.app + if hasattr(app, "bus"): + app.bus.publish_sync("firewall.refresh", {}) diff --git a/ui/tabs/lines.py b/ui/tabs/lines.py new file mode 100644 index 0000000..a71726b --- /dev/null +++ b/ui/tabs/lines.py @@ -0,0 +1,107 @@ +""" +Lines Tab — network interfaces overview. + +Displays a DataTable of all network interfaces updated live via bus subscription +to the "network.interfaces" topic. +""" + +from __future__ import annotations + +from typing import Any, Dict + +from textual.app import ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import DataTable, Label, Static + + +def _fmt_bytes(n: int) -> str: + """Format a byte count as a human-readable string.""" + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024: + return f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} PB" + + +class LinesTab(Widget): + """ + Network Interfaces tab. + + Subscribes to ``network.interfaces`` bus events and refreshes + the DataTable in real time. + """ + + DEFAULT_CSS = """ + LinesTab { + layout: vertical; + height: 1fr; + padding: 1 2; + } + LinesTab .tab-title { + color: $primary; + text-style: bold; + height: 1; + margin-bottom: 1; + } + LinesTab DataTable { + height: 1fr; + border: round $primary; + } + """ + + COLUMNS = [ + ("Interface", 12), + ("Status", 8), + ("IPv4", 16), + ("IPv6", 24), + ("MAC", 18), + ("Speed", 9), + ("RX", 12), + ("TX", 12), + ("RX Err", 8), + ("TX Err", 8), + ] + + def compose(self) -> ComposeResult: + yield Static("// LINES — Network Interfaces", classes="tab-title") + yield DataTable(id="lines-table", zebra_stripes=True, cursor_type="row") + + def on_mount(self) -> None: + table = self.query_one("#lines-table", DataTable) + for col_label, width in self.COLUMNS: + table.add_column(col_label, width=width, key=col_label.lower().replace(" ", "_")) + + # Subscribe to the bus if app exposes one + app = self.app + if hasattr(app, "bus"): + app.bus.subscribe("network.interfaces", self._on_interfaces) + # Request immediate data + if hasattr(app, "state"): + interfaces = app.state.get("network.interfaces") + if interfaces: + self._refresh_table(interfaces) + + def _on_interfaces(self, topic: str, data: Any) -> None: + """Bus handler — schedule table refresh on the UI thread.""" + self.call_from_thread(self._refresh_table, data) + + def _refresh_table(self, interfaces: Dict[str, Dict]) -> None: + table = self.query_one("#lines-table", DataTable) + table.clear() + for iface, info in sorted(interfaces.items()): + status_text = info.get("status", "?") + speed = info.get("speed", 0) + speed_str = f"{speed} Mb" if speed else "—" + table.add_row( + iface, + status_text, + info.get("ip4", "") or "—", + info.get("ip6", "") or "—", + info.get("mac", "") or "—", + speed_str, + _fmt_bytes(info.get("rx_bytes", 0)), + _fmt_bytes(info.get("tx_bytes", 0)), + str(info.get("rx_errors", 0)), + str(info.get("tx_errors", 0)), + ) diff --git a/ui/tabs/registry.py b/ui/tabs/registry.py new file mode 100644 index 0000000..cd9400c --- /dev/null +++ b/ui/tabs/registry.py @@ -0,0 +1,128 @@ +""" +Registry Tab — displays loaded plugins and their status. + +Subscribes to registry.plugin.* events to show live plugin state. +Allows loading/unloading plugins interactively. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.widget import Widget +from textual.widgets import DataTable, Footer, Label, Static + + +class RegistryTab(Widget): + """ + Plugin registry viewer. + + Shows all discovered plugins with their version, status, module path, + and load timestamp. Pressing Enter on a row toggles load/unload. + """ + + DEFAULT_CSS = """ + RegistryTab { + layout: vertical; + height: 1fr; + padding: 1 2; + } + RegistryTab .tab-title { + color: $primary; + text-style: bold; + height: 1; + margin-bottom: 1; + } + RegistryTab DataTable { + height: 1fr; + border: round $primary; + } + RegistryTab .hint { + height: 1; + color: $text-muted; + margin-top: 1; + } + """ + + BINDINGS = [ + Binding("r", "refresh_registry", "Refresh"), + Binding("enter", "toggle_plugin", "Toggle"), + ] + + COLUMNS = [ + ("Name", 14), + ("Version", 9), + ("Status", 11), + ("Tags", 22), + ("Loaded At", 22), + ("Module", 32), + ("Error", 30), + ] + + def compose(self) -> ComposeResult: + yield Static("// REGISTRY — Plugin Manifest", classes="tab-title") + yield DataTable(id="registry-table", zebra_stripes=True, cursor_type="row") + yield Static( + "Press [bold]r[/bold] to refresh | [bold]Enter[/bold] to toggle load", + classes="hint", + ) + + def on_mount(self) -> None: + table = self.query_one("#registry-table", DataTable) + for col_label, width in self.COLUMNS: + table.add_column(col_label, width=width) + + app = self.app + if hasattr(app, "bus"): + app.bus.subscribe("registry.plugin.*", self._on_registry_event) + self._refresh_table() + + def _on_registry_event(self, topic: str, data: Any) -> None: + self.call_from_thread(self._refresh_table) + + def _refresh_table(self) -> None: + table = self.query_one("#registry-table", DataTable) + table.clear() + app = self.app + if not hasattr(app, "registry"): + return + for record in sorted(app.registry.all_records(), key=lambda r: r.name): + loaded_at = record.loaded_at.strftime("%Y-%m-%d %H:%M:%S") if record.loaded_at else "—" + table.add_row( + record.name, + record.version, + record.status.name, + ", ".join(record.tags) or "—", + loaded_at, + record.module_path, + record.error or "—", + ) + + def action_refresh_registry(self) -> None: + self._refresh_table() + + def action_toggle_plugin(self) -> None: + table = self.query_one("#registry-table", DataTable) + row_key = table.cursor_row + if row_key is None: + return + # Get plugin name from the focused row + try: + cell_value = table.get_cell_at((row_key, 0)) + except Exception: + return + plugin_name = str(cell_value) + app = self.app + if not hasattr(app, "registry"): + return + record = app.registry.get(plugin_name) + if record is None: + return + from core.registry import PluginStatus + if record.status == PluginStatus.ACTIVE: + app.registry.unload(plugin_name) + else: + app.registry.load(plugin_name) + self._refresh_table() diff --git a/ui/tabs/routes.py b/ui/tabs/routes.py new file mode 100644 index 0000000..65b529b --- /dev/null +++ b/ui/tabs/routes.py @@ -0,0 +1,185 @@ +""" +Routes Tab — displays the system routing table. + +Reads routes from the OS on mount and on manual refresh. +Uses ``ip route show`` on Linux or ``netstat -rn`` as a fallback. +""" + +from __future__ import annotations + +import asyncio +import shutil +import subprocess +import re +from typing import List, Dict + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.widget import Widget +from textual.widgets import DataTable, Footer, Label, Static + + +def _parse_ip_route() -> List[Dict]: + """Parse ``ip route show`` output into a list of route dicts.""" + routes: List[Dict] = [] + try: + result = subprocess.run( + ["ip", "route", "show"], + capture_output=True, + text=True, + timeout=5, + ) + for line in result.stdout.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split() + dest = parts[0] if parts else "?" + gateway = "" + iface = "" + metric = "" + proto = "" + scope = "" + src = "" + i = 1 + while i < len(parts): + token = parts[i] + if token == "via" and i + 1 < len(parts): + gateway = parts[i + 1]; i += 2 + elif token == "dev" and i + 1 < len(parts): + iface = parts[i + 1]; i += 2 + elif token == "metric" and i + 1 < len(parts): + metric = parts[i + 1]; i += 2 + elif token == "proto" and i + 1 < len(parts): + proto = parts[i + 1]; i += 2 + elif token == "scope" and i + 1 < len(parts): + scope = parts[i + 1]; i += 2 + elif token == "src" and i + 1 < len(parts): + src = parts[i + 1]; i += 2 + else: + i += 1 + routes.append({ + "destination": dest, + "gateway": gateway or "—", + "interface": iface or "—", + "metric": metric or "—", + "proto": proto or "—", + "scope": scope or "—", + "src": src or "—", + }) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + return routes + + +def _parse_netstat_route() -> List[Dict]: + """Fallback: parse ``netstat -rn`` output.""" + routes: List[Dict] = [] + try: + result = subprocess.run( + ["netstat", "-rn"], + capture_output=True, + text=True, + timeout=5, + ) + lines = result.stdout.splitlines() + # Skip header lines + data_start = False + for line in lines: + if re.match(r"^(Destination|0\.0\.0\.0|default)", line): + data_start = True + if not data_start: + continue + parts = line.split() + if len(parts) >= 4: + routes.append({ + "destination": parts[0], + "gateway": parts[1] if len(parts) > 1 else "—", + "interface": parts[-1], + "metric": "—", + "proto": "—", + "scope": "—", + "src": "—", + }) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + return routes + + +def _get_routes() -> List[Dict]: + if shutil.which("ip"): + routes = _parse_ip_route() + if routes: + return routes + return _parse_netstat_route() + + +class RoutesTab(Widget): + """Routing table tab — refreshes on mount and on 'r' keypress.""" + + DEFAULT_CSS = """ + RoutesTab { + layout: vertical; + height: 1fr; + padding: 1 2; + } + RoutesTab .tab-title { + color: $primary; + text-style: bold; + height: 1; + margin-bottom: 1; + } + RoutesTab DataTable { + height: 1fr; + border: round $primary; + } + RoutesTab .hint { + height: 1; + color: $text-muted; + margin-top: 1; + } + """ + + BINDINGS = [Binding("r", "refresh_routes", "Refresh")] + + COLUMNS = [ + ("Destination", 20), + ("Gateway", 16), + ("Interface", 12), + ("Proto", 8), + ("Scope", 8), + ("Metric", 8), + ("Src", 16), + ] + + def compose(self) -> ComposeResult: + yield Static("// ROUTES — Routing Table", classes="tab-title") + yield DataTable(id="routes-table", zebra_stripes=True, cursor_type="row") + yield Static("Press [bold]r[/bold] to refresh", classes="hint") + + def on_mount(self) -> None: + table = self.query_one("#routes-table", DataTable) + for col_label, width in self.COLUMNS: + table.add_column(col_label, width=width) + self.run_worker(self._load_routes(), exclusive=True) + + async def _load_routes(self) -> None: + routes = await asyncio.to_thread(_get_routes) + self._populate_table(routes) + + def _populate_table(self, routes: List[Dict]) -> None: + table = self.query_one("#routes-table", DataTable) + table.clear() + for r in routes: + table.add_row( + r["destination"], + r["gateway"], + r["interface"], + r["proto"], + r["scope"], + r["metric"], + r["src"], + ) + + def action_refresh_routes(self) -> None: + self.run_worker(self._load_routes(), exclusive=True) diff --git a/ui/tabs/settings.py b/ui/tabs/settings.py new file mode 100644 index 0000000..74cd346 --- /dev/null +++ b/ui/tabs/settings.py @@ -0,0 +1,260 @@ +""" +Settings Tab — application configuration form. + +Reads from the StateStore and writes back on change. +Supports editing poll intervals, theme options, and plugin enable/disable. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.widget import Widget +from textual.widgets import Button, Input, Label, Select, Static, Switch + + +CONFIG_PATH = Path(__file__).parent.parent.parent / "config" / "defaults.toml" + + +class SettingRow(Widget): + """A single label + control row in the settings form.""" + + DEFAULT_CSS = """ + SettingRow { + layout: horizontal; + height: 3; + align: left middle; + margin-bottom: 1; + } + SettingRow .setting-label { + width: 28; + color: $text; + content-align: left middle; + } + SettingRow .setting-control { + width: 24; + } + SettingRow .setting-desc { + width: 1fr; + color: $text-muted; + content-align: left middle; + padding-left: 2; + } + """ + + def __init__(self, label: str, control: Widget, description: str = "", **kwargs) -> None: + super().__init__(**kwargs) + self._label = label + self._control = control + self._description = description + + def compose(self) -> ComposeResult: + yield Static(self._label, classes="setting-label") + yield self._control + if self._description: + yield Static(self._description, classes="setting-desc") + + +class SettingsTab(Widget): + """ + Application settings form. + + Changes are applied immediately to the state store and persisted + to config/defaults.toml on save. + """ + + DEFAULT_CSS = """ + SettingsTab { + layout: vertical; + height: 1fr; + padding: 1 2; + overflow-y: auto; + } + SettingsTab .tab-title { + color: $primary; + text-style: bold; + height: 1; + margin-bottom: 1; + } + SettingsTab .section-header { + color: $accent; + text-style: bold; + height: 1; + margin-top: 2; + margin-bottom: 1; + } + SettingsTab .button-row { + layout: horizontal; + height: 3; + margin-top: 2; + } + SettingsTab Button { + margin-right: 2; + } + SettingsTab .status-label { + height: 1; + color: $success; + margin-top: 1; + } + """ + + BINDINGS = [Binding("s", "save_settings", "Save")] + + def compose(self) -> ComposeResult: + yield Static("// SETTINGS — Configuration", classes="tab-title") + + yield Static("Polling Intervals", classes="section-header") + yield SettingRow( + "Network poll interval (s):", + Input(value="5", id="cfg-network-interval", classes="setting-control"), + "How often to refresh interface stats", + ) + yield SettingRow( + "DNS poll interval (s):", + Input(value="30", id="cfg-dns-interval", classes="setting-control"), + "How often to check DNS resolver health", + ) + yield SettingRow( + "System poll interval (s):", + Input(value="3", id="cfg-system-interval", classes="setting-control"), + "How often to sample CPU / memory", + ) + yield SettingRow( + "Firewall poll interval (s):", + Input(value="60", id="cfg-firewall-interval", classes="setting-control"), + "How often to re-read firewall rules", + ) + + yield Static("Plugins", classes="section-header") + yield SettingRow( + "Network plugin:", + Switch(value=True, id="cfg-plugin-network", classes="setting-control"), + "Monitor network interfaces", + ) + yield SettingRow( + "DNS plugin:", + Switch(value=True, id="cfg-plugin-dns", classes="setting-control"), + "Monitor DNS resolver health", + ) + yield SettingRow( + "System plugin:", + Switch(value=True, id="cfg-plugin-system", classes="setting-control"), + "Monitor CPU / memory / disk", + ) + yield SettingRow( + "Firewall plugin:", + Switch(value=True, id="cfg-plugin-firewall", classes="setting-control"), + "Read firewall rules", + ) + + yield Static("Display", classes="section-header") + yield SettingRow( + "Signal history limit:", + Input(value="500", id="cfg-signal-limit", classes="setting-control"), + "Max events shown in Signals tab", + ) + yield SettingRow( + "Chronicle buffer:", + Input(value="2000", id="cfg-chronicle-buffer", classes="setting-control"), + "Max events retained in Chronicle", + ) + + with Widget(classes="button-row"): + yield Button("Save", id="btn-save", variant="primary") + yield Button("Reset to defaults", id="btn-reset", variant="default") + + yield Static("", id="settings-status", classes="status-label") + + def on_mount(self) -> None: + self._load_from_state() + + def _load_from_state(self) -> None: + app = self.app + if not hasattr(app, "state"): + return + mappings = { + "settings.network_interval": "cfg-network-interval", + "settings.dns_interval": "cfg-dns-interval", + "settings.system_interval": "cfg-system-interval", + "settings.firewall_interval": "cfg-firewall-interval", + "settings.signal_limit": "cfg-signal-limit", + "settings.chronicle_buffer": "cfg-chronicle-buffer", + } + for state_key, widget_id in mappings.items(): + val = app.state.get(state_key) + if val is not None: + try: + widget = self.query_one(f"#{widget_id}", Input) + widget.value = str(val) + except Exception: + pass + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-save": + self.action_save_settings() + elif event.button.id == "btn-reset": + self._reset_to_defaults() + + def action_save_settings(self) -> None: + app = self.app + status = self.query_one("#settings-status", Static) + if not hasattr(app, "state"): + status.update("[red]No state store available[/red]") + return + + try: + mappings = { + "settings.network_interval": ("cfg-network-interval", float), + "settings.dns_interval": ("cfg-dns-interval", float), + "settings.system_interval": ("cfg-system-interval", float), + "settings.firewall_interval": ("cfg-firewall-interval", float), + "settings.signal_limit": ("cfg-signal-limit", int), + "settings.chronicle_buffer": ("cfg-chronicle-buffer", int), + } + for state_key, (widget_id, cast) in mappings.items(): + widget = self.query_one(f"#{widget_id}", Input) + app.state.set(state_key, cast(widget.value)) + + # Persist to TOML + self._persist_toml() + status.update("[green]Settings saved.[/green]") + except Exception as exc: + status.update(f"[red]Error: {exc}[/red]") + + def _reset_to_defaults(self) -> None: + defaults = { + "cfg-network-interval": "5", + "cfg-dns-interval": "30", + "cfg-system-interval": "3", + "cfg-firewall-interval": "60", + "cfg-signal-limit": "500", + "cfg-chronicle-buffer": "2000", + } + for widget_id, value in defaults.items(): + try: + self.query_one(f"#{widget_id}", Input).value = value + except Exception: + pass + self.query_one("#settings-status", Static).update("[yellow]Defaults restored. Press Save to apply.[/yellow]") + + def _persist_toml(self) -> None: + try: + import toml + data = { + "polling": { + "network_interval": float(self.query_one("#cfg-network-interval", Input).value), + "dns_interval": float(self.query_one("#cfg-dns-interval", Input).value), + "system_interval": float(self.query_one("#cfg-system-interval", Input).value), + "firewall_interval": float(self.query_one("#cfg-firewall-interval", Input).value), + }, + "display": { + "signal_limit": int(self.query_one("#cfg-signal-limit", Input).value), + "chronicle_buffer": int(self.query_one("#cfg-chronicle-buffer", Input).value), + }, + } + CONFIG_PATH.write_text(toml.dumps(data)) + except Exception as exc: + pass # non-fatal diff --git a/ui/tabs/signals.py b/ui/tabs/signals.py new file mode 100644 index 0000000..9ab5f02 --- /dev/null +++ b/ui/tabs/signals.py @@ -0,0 +1,140 @@ +""" +Signals Tab — live scrolling monitor of all bus events. + +Subscribes to every topic ("**") and appends new events to a scrollable +RichLog widget, styled to resemble an oscilloscope or signal analyser. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.widget import Widget +from textual.widgets import Label, RichLog, Static + + +def _preview(data: Any, max_len: int = 80) -> str: + """Produce a compact single-line preview of event data.""" + if data is None: + return "null" + try: + raw = json.dumps(data, default=str) + except Exception: + raw = str(data) + if len(raw) > max_len: + raw = raw[:max_len] + "…" + return raw + + +class SignalsTab(Widget): + """ + Live event signal monitor. + + Every event published on the bus appears here with: + - Timestamp (HH:MM:SS.mmm) + - Topic (colour-coded by namespace) + - Data preview (truncated JSON) + """ + + DEFAULT_CSS = """ + SignalsTab { + layout: vertical; + height: 1fr; + padding: 1 2; + } + SignalsTab .tab-title { + color: $primary; + text-style: bold; + height: 1; + margin-bottom: 1; + } + SignalsTab RichLog { + height: 1fr; + border: round $primary; + background: $surface; + scrollbar-gutter: stable; + } + SignalsTab .hint { + height: 1; + color: $text-muted; + margin-top: 1; + } + """ + + BINDINGS = [ + Binding("c", "clear_log", "Clear"), + Binding("p", "toggle_pause", "Pause"), + ] + + # Colour map per topic namespace + TOPIC_COLOURS = { + "network": "cyan", + "dns": "blue", + "firewall": "red", + "system": "green", + "state": "yellow", + "registry": "magenta", + } + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._paused = False + self._event_count = 0 + self._sub_id: str | None = None + + def compose(self) -> ComposeResult: + yield Static("// SIGNALS — Live Event Monitor", classes="tab-title") + yield RichLog(id="signal-log", highlight=True, markup=True, max_lines=500) + yield Static( + "Press [bold]c[/bold] to clear | [bold]p[/bold] to pause", + classes="hint", + ) + + def on_mount(self) -> None: + app = self.app + if hasattr(app, "bus"): + self._sub_id = app.bus.subscribe("**", self._on_any_event) + + def on_unmount(self) -> None: + app = self.app + if hasattr(app, "bus") and self._sub_id: + app.bus.unsubscribe(self._sub_id) + + def _on_any_event(self, topic: str, data: Any) -> None: + if self._paused: + return + self.call_from_thread(self._append_event, topic, data) + + def _append_event(self, topic: str, data: Any) -> None: + self._event_count += 1 + log_widget = self.query_one("#signal-log", RichLog) + + now = datetime.now(timezone.utc) + ts = now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}" + + namespace = topic.split(".")[0] + colour = self.TOPIC_COLOURS.get(namespace, "white") + preview = _preview(data) + + # Format: [dim]HH:MM:SS.mmm[/] [colour]topic[/] data + line = ( + f"[dim]{ts}[/dim] " + f"[{colour} bold]{topic:<35}[/{colour} bold] " + f"[dim]{preview}[/dim]" + ) + log_widget.write(line) + + def action_clear_log(self) -> None: + log_widget = self.query_one("#signal-log", RichLog) + log_widget.clear() + self._event_count = 0 + + def action_toggle_pause(self) -> None: + self._paused = not self._paused + log_widget = self.query_one("#signal-log", RichLog) + status = "[yellow]PAUSED[/yellow]" if self._paused else "[green]LIVE[/green]" + log_widget.write(f" ── {status} ──") diff --git a/ui/widgets/__init__.py b/ui/widgets/__init__.py new file mode 100644 index 0000000..47e27b0 --- /dev/null +++ b/ui/widgets/__init__.py @@ -0,0 +1,5 @@ +"""Metro Warden custom widgets.""" + +from .header import MetroHeader + +__all__ = ["MetroHeader"] diff --git a/ui/widgets/header.py b/ui/widgets/header.py new file mode 100644 index 0000000..3d67003 --- /dev/null +++ b/ui/widgets/header.py @@ -0,0 +1,122 @@ +""" +Metro Warden header widget — industrial metro-map style banner. + +Displays the application name, a live clock, and a pulsing status indicator +that reflects overall system health sourced from the state store. +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Optional + +from textual.app import ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Label, Static + + +class PulseIndicator(Static): + """A small pulsing dot that animates to show the system is alive.""" + + DEFAULT_CSS = """ + PulseIndicator { + width: 3; + height: 1; + color: $success; + } + PulseIndicator.pulse-off { + color: $panel; + } + """ + + _frames = ["●", "○"] + _frame_idx: reactive[int] = reactive(0) + + def on_mount(self) -> None: + self.set_interval(1.0, self._tick) + + def _tick(self) -> None: + self._frame_idx = (self._frame_idx + 1) % len(self._frames) + self.update(self._frames[self._frame_idx]) + if self._frame_idx == 0: + self.add_class("pulse-off") + else: + self.remove_class("pulse-off") + + def render(self): + return self._frames[self._frame_idx] + + +class MetroHeader(Widget): + """ + Industrial metro-map style header bar. + + Shows: + - Application name and tagline (left) + - Live UTC clock (centre) + - Pulse indicator + status label (right) + """ + + DEFAULT_CSS = """ + MetroHeader { + height: 3; + background: $surface; + border-bottom: heavy $primary; + layout: horizontal; + align: left middle; + padding: 0 1; + } + + MetroHeader .header-brand { + width: 1fr; + color: $primary; + text-style: bold; + } + + MetroHeader .header-clock { + width: auto; + color: $text-muted; + text-align: center; + min-width: 24; + } + + MetroHeader .header-status { + width: 1fr; + text-align: right; + color: $success; + } + + MetroHeader .header-sep { + color: $primary; + width: 1; + } + """ + + _clock: reactive[str] = reactive("--:--:-- UTC") + _status: reactive[str] = reactive("NOMINAL") + + def compose(self) -> ComposeResult: + yield Static("METRO WARDEN // NOC", classes="header-brand") + yield Static(self._clock, id="header-clock", classes="header-clock") + yield Static("▶ " + self._status, id="header-status", classes="header-status") + + def on_mount(self) -> None: + self.set_interval(1.0, self._update_clock) + + def _update_clock(self) -> None: + now = datetime.now(timezone.utc) + self._clock = now.strftime("%Y-%m-%d %H:%M:%S UTC") + clock_widget = self.query_one("#header-clock", Static) + clock_widget.update(self._clock) + + def set_status(self, status: str, healthy: bool = True) -> None: + """Update the status label text and colour.""" + self._status = status + status_widget = self.query_one("#header-status", Static) + status_widget.update(("▶ " if healthy else "▲ ") + status) + if healthy: + status_widget.remove_class("status-warn") + else: + status_widget.add_class("status-warn")