Initial commit: Metro Warden TUI network operations center

This commit is contained in:
2026-03-22 21:33:40 -04:00
commit 98a17d9b7e
45 changed files with 4215 additions and 0 deletions
+20
View File
@@ -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)"
]
}
}
+24
View File
@@ -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"
]
}
+24
View File
@@ -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
}
]
}
+46
View File
@@ -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
}
+136
View File
@@ -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 |
|-----|--------|
| `17` | 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`.
+331
View File
@@ -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;
}
+41
View File
@@ -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"
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+223
View File
@@ -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
View File
@@ -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)
Executable
+65
View File
@@ -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"
+88
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
"""Metro Warden plugins package."""
from .base import BasePlugin
__all__ = ["BasePlugin"]
Binary file not shown.
Binary file not shown.
+105
View File
@@ -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}>"
+5
View File
@@ -0,0 +1,5 @@
"""DNS monitoring plugin package."""
from .plugin import DNSPlugin
__all__ = ["DNSPlugin"]
+130
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
"""Firewall monitoring plugin package."""
from .plugin import FirewallPlugin
__all__ = ["FirewallPlugin"]
+200
View File
@@ -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())
+5
View File
@@ -0,0 +1,5 @@
"""Network monitoring plugin package."""
from .plugin import NetworkPlugin
__all__ = ["NetworkPlugin"]
+129
View File
@@ -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")
+5
View File
@@ -0,0 +1,5 @@
"""System monitoring plugin package."""
from .plugin import SystemPlugin
__all__ = ["SystemPlugin"]
+164
View File
@@ -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())
+73
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
"""Metro Warden test suite."""
+273
View File
@@ -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
+269
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Metro Warden UI package."""
+19
View File
@@ -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",
]
+164
View File
@@ -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()
+151
View File
@@ -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", {})
+107
View File
@@ -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)),
)
+128
View File
@@ -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()
+185
View File
@@ -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)
+260
View File
@@ -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
+140
View File
@@ -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} ──")
+5
View File
@@ -0,0 +1,5 @@
"""Metro Warden custom widgets."""
from .header import MetroHeader
__all__ = ["MetroHeader"]
+122
View File
@@ -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")