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