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
+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)