mirror of
https://github.com/samjage/metro-warden.git
synced 2026-06-06 01:00:41 +00:00
173 lines
6.4 KiB
Python
173 lines
6.4 KiB
Python
"""
|
|
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)
|