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