mirror of
https://github.com/samjage/metro-warden.git
synced 2026-06-06 04:10:41 +00:00
Initial commit: Metro Warden TUI network operations center
This commit is contained in:
+172
@@ -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)
|
||||
Reference in New Issue
Block a user