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