mirror of
https://github.com/samjage/metro-warden.git
synced 2026-06-05 23:40:42 +00:00
Initial commit: Metro Warden TUI network operations center
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+24
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
+24
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+46
@@ -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
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Metro Warden core package."""
|
||||
|
||||
from .bus import EventBus
|
||||
from .state import StateStore
|
||||
from .registry import PluginRegistry
|
||||
|
||||
__all__ = ["EventBus", "StateStore", "PluginRegistry"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+172
@@ -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)
|
||||
+223
@@ -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
|
||||
@@ -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)
|
||||
+164
@@ -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.<key>`` 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)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Metro Warden plugins package."""
|
||||
|
||||
from .base import BasePlugin
|
||||
|
||||
__all__ = ["BasePlugin"]
|
||||
Binary file not shown.
Binary file not shown.
+105
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Metro Warden Base Plugin — abstract base class for all plugins.
|
||||
|
||||
Every plugin must subclass :class:`BasePlugin` and define the class-level
|
||||
attributes ``name``, ``version``, and ``description``. Lifecycle hooks
|
||||
``on_load``, ``on_unload``, and ``on_event`` can be overridden as needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BasePlugin(abc.ABC):
|
||||
"""
|
||||
Abstract base class for Metro Warden plugins.
|
||||
|
||||
Subclasses must declare:
|
||||
name = "my-plugin" # unique identifier
|
||||
version = "1.0.0"
|
||||
description = "Does something useful"
|
||||
tags = ["category"] # optional
|
||||
|
||||
Lifecycle::
|
||||
|
||||
on_load() — called once after instantiation
|
||||
on_unload() — called before the plugin is removed
|
||||
on_event(topic, data) — called for bus events the plugin subscribes to
|
||||
"""
|
||||
|
||||
# Class-level attributes — subclasses MUST override these
|
||||
name: str = ""
|
||||
version: str = "0.0.0"
|
||||
description: str = ""
|
||||
tags: list = []
|
||||
|
||||
def __init__(self, bus=None, state=None) -> 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}>"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""DNS monitoring plugin package."""
|
||||
|
||||
from .plugin import DNSPlugin
|
||||
|
||||
__all__ = ["DNSPlugin"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Firewall monitoring plugin package."""
|
||||
|
||||
from .plugin import FirewallPlugin
|
||||
|
||||
__all__ = ["FirewallPlugin"]
|
||||
@@ -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())
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Network monitoring plugin package."""
|
||||
|
||||
from .plugin import NetworkPlugin
|
||||
|
||||
__all__ = ["NetworkPlugin"]
|
||||
@@ -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")
|
||||
@@ -0,0 +1,5 @@
|
||||
"""System monitoring plugin package."""
|
||||
|
||||
from .plugin import SystemPlugin
|
||||
|
||||
__all__ = ["SystemPlugin"]
|
||||
@@ -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())
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Metro Warden test suite."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Metro Warden UI package."""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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", {})
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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} ──")
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Metro Warden custom widgets."""
|
||||
|
||||
from .header import MetroHeader
|
||||
|
||||
__all__ = ["MetroHeader"]
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user