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
+19
View File
@@ -0,0 +1,19 @@
"""Metro Warden tab widgets."""
from .lines import LinesTab
from .routes import RoutesTab
from .signals import SignalsTab
from .chronicle import ChronicleTab
from .registry import RegistryTab
from .garrison import GarrisonTab
from .settings import SettingsTab
__all__ = [
"LinesTab",
"RoutesTab",
"SignalsTab",
"ChronicleTab",
"RegistryTab",
"GarrisonTab",
"SettingsTab",
]
+164
View File
@@ -0,0 +1,164 @@
"""
Chronicle Tab — persistent structured log viewer with filtering.
Maintains a rolling buffer of all events and allows the user to filter
by topic prefix and search by keyword.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, List, Optional
from textual.app import ComposeResult
from textual.binding import Binding
from textual.widget import Widget
from textual.widgets import Input, Label, RichLog, Static
MAX_BUFFER = 2000 # maximum events retained
class ChronicleTab(Widget):
"""
Historical event log with topic filtering and keyword search.
Events are buffered internally. Press 'f' to focus the filter input.
"""
DEFAULT_CSS = """
ChronicleTab {
layout: vertical;
height: 1fr;
padding: 1 2;
}
ChronicleTab .tab-title {
color: $primary;
text-style: bold;
height: 1;
margin-bottom: 1;
}
ChronicleTab .filter-row {
layout: horizontal;
height: 3;
margin-bottom: 1;
}
ChronicleTab .filter-label {
width: 10;
height: 3;
content-align: left middle;
color: $text-muted;
}
ChronicleTab Input {
width: 1fr;
margin-right: 1;
}
ChronicleTab RichLog {
height: 1fr;
border: round $primary;
background: $surface;
}
ChronicleTab .hint {
height: 1;
color: $text-muted;
margin-top: 1;
}
"""
BINDINGS = [
Binding("f", "focus_filter", "Filter"),
Binding("c", "clear_filter", "Clear filter"),
Binding("escape", "blur_filter", "Done"),
]
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._buffer: List[dict] = []
self._sub_id: Optional[str] = None
self._filter_text: str = ""
def compose(self) -> ComposeResult:
yield Static("// CHRONICLE — Event History", classes="tab-title")
with Widget(classes="filter-row"):
yield Static("Filter:", classes="filter-label")
yield Input(placeholder="topic prefix or keyword…", id="chronicle-filter")
yield RichLog(id="chronicle-log", highlight=True, markup=True, max_lines=MAX_BUFFER)
yield Static(
"Press [bold]f[/bold] to filter | [bold]c[/bold] to clear filter",
classes="hint",
)
def on_mount(self) -> None:
app = self.app
if hasattr(app, "bus"):
self._sub_id = app.bus.subscribe("**", self._on_any_event)
def on_unmount(self) -> None:
app = self.app
if hasattr(app, "bus") and self._sub_id:
app.bus.unsubscribe(self._sub_id)
def _on_any_event(self, topic: str, data: Any) -> None:
now = datetime.now(timezone.utc)
entry = {
"ts": now.isoformat(),
"topic": topic,
"data": data,
}
self._buffer.append(entry)
if len(self._buffer) > MAX_BUFFER:
self._buffer = self._buffer[-MAX_BUFFER:]
if self._passes_filter(entry):
self.call_from_thread(self._append_entry, entry)
def _passes_filter(self, entry: dict) -> bool:
if not self._filter_text:
return True
text = self._filter_text.lower()
if text in entry["topic"].lower():
return True
try:
if text in json.dumps(entry["data"], default=str).lower():
return True
except Exception:
pass
return False
def _append_entry(self, entry: dict) -> None:
log_widget = self.query_one("#chronicle-log", RichLog)
ts_short = entry["ts"][11:23] # HH:MM:SS.mmm
try:
data_str = json.dumps(entry["data"], default=str)
except Exception:
data_str = str(entry["data"])
if len(data_str) > 100:
data_str = data_str[:100] + ""
line = f"[dim]{ts_short}[/dim] [cyan]{entry['topic']:<35}[/cyan] [dim]{data_str}[/dim]"
log_widget.write(line)
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "chronicle-filter":
self._filter_text = event.value
self._rebuild_log()
def _rebuild_log(self) -> None:
"""Repopulate log from buffer according to current filter."""
log_widget = self.query_one("#chronicle-log", RichLog)
log_widget.clear()
for entry in self._buffer:
if self._passes_filter(entry):
self._append_entry(entry)
def action_focus_filter(self) -> None:
self.query_one("#chronicle-filter", Input).focus()
def action_clear_filter(self) -> None:
inp = self.query_one("#chronicle-filter", Input)
inp.value = ""
self._filter_text = ""
self._rebuild_log()
def action_blur_filter(self) -> None:
self.query_one("#chronicle-filter", Input).blur()
+151
View File
@@ -0,0 +1,151 @@
"""
Garrison Tab — firewall rules and chain overview.
Subscribes to firewall.rules and firewall.chains events from the
FirewallPlugin and renders them in DataTable widgets.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from textual.app import ComposeResult
from textual.binding import Binding
from textual.widget import Widget
from textual.widgets import DataTable, Label, Static, TabbedContent, TabPane
class GarrisonTab(Widget):
"""
Firewall / security tab.
Two sub-sections:
- Chains: shows chain names, policies, and rule counts
- Rules: shows individual firewall rules
"""
DEFAULT_CSS = """
GarrisonTab {
layout: vertical;
height: 1fr;
padding: 1 2;
}
GarrisonTab .tab-title {
color: $primary;
text-style: bold;
height: 1;
margin-bottom: 1;
}
GarrisonTab .backend-label {
height: 1;
color: $text-muted;
margin-bottom: 1;
}
GarrisonTab DataTable {
height: 1fr;
border: round $primary;
}
GarrisonTab .section-label {
color: $accent;
text-style: bold;
height: 1;
margin-top: 1;
margin-bottom: 1;
}
GarrisonTab .hint {
height: 1;
color: $text-muted;
margin-top: 1;
}
"""
BINDINGS = [Binding("r", "refresh_firewall", "Refresh")]
CHAIN_COLUMNS = [("Chain", 16), ("Policy", 10), ("Rules", 8)]
RULE_COLUMNS = [("Chain", 12), ("Target", 10), ("Proto", 8), ("In", 8), ("Out", 8), ("Source", 18), ("Destination", 20)]
def compose(self) -> ComposeResult:
yield Static("// GARRISON — Firewall", classes="tab-title")
yield Static("Backend: detecting…", id="garrison-backend", classes="backend-label")
yield Static("Chains", classes="section-label")
yield DataTable(id="garrison-chains", zebra_stripes=True, cursor_type="row")
yield Static("Rules", classes="section-label")
yield DataTable(id="garrison-rules", zebra_stripes=True, cursor_type="row")
yield Static("Press [bold]r[/bold] to refresh", classes="hint")
def on_mount(self) -> None:
chains_table = self.query_one("#garrison-chains", DataTable)
for col, width in self.CHAIN_COLUMNS:
chains_table.add_column(col, width=width)
rules_table = self.query_one("#garrison-rules", DataTable)
for col, width in self.RULE_COLUMNS:
rules_table.add_column(col, width=width)
app = self.app
if hasattr(app, "bus"):
app.bus.subscribe("firewall.rules", self._on_rules)
app.bus.subscribe("firewall.chains", self._on_chains)
app.bus.subscribe("state.firewall.backend", self._on_backend)
# Read initial state
if hasattr(app, "state"):
backend = app.state.get("firewall.backend", "unknown")
self._set_backend_label(backend)
rules_data = app.state.get("firewall.rules", [])
chains_data = app.state.get("firewall.chains", {})
if rules_data:
self._populate_rules(rules_data)
if chains_data:
self._populate_chains(chains_data)
def _on_backend(self, topic: str, data: Any) -> None:
backend = data.get("value", "unknown") if isinstance(data, dict) else str(data)
self.call_from_thread(self._set_backend_label, backend)
def _set_backend_label(self, backend: str) -> None:
self.query_one("#garrison-backend", Static).update(f"Backend: [bold]{backend}[/bold]")
def _on_rules(self, topic: str, data: Any) -> None:
if isinstance(data, dict):
rules = data.get("rules", [])
chains = data.get("chains", {})
backend = data.get("backend", "")
self.call_from_thread(self._populate_rules, rules)
if chains:
self.call_from_thread(self._populate_chains, chains)
if backend:
self.call_from_thread(self._set_backend_label, backend)
def _on_chains(self, topic: str, data: Any) -> None:
if isinstance(data, dict):
self.call_from_thread(self._populate_chains, data)
def _populate_chains(self, chains: Dict) -> None:
table = self.query_one("#garrison-chains", DataTable)
table.clear()
for name, info in sorted(chains.items()):
table.add_row(
name,
info.get("policy", ""),
str(info.get("rule_count", 0)),
)
def _populate_rules(self, rules: List[Dict]) -> None:
table = self.query_one("#garrison-rules", DataTable)
table.clear()
for rule in rules:
table.add_row(
rule.get("chain", ""),
rule.get("target", rule.get("expr", ""))[:20],
rule.get("prot", ""),
rule.get("in", ""),
rule.get("out", ""),
rule.get("source", rule.get("table", "")),
rule.get("destination", ""),
)
def action_refresh_firewall(self) -> None:
app = self.app
if hasattr(app, "bus"):
app.bus.publish_sync("firewall.refresh", {})
+107
View File
@@ -0,0 +1,107 @@
"""
Lines Tab — network interfaces overview.
Displays a DataTable of all network interfaces updated live via bus subscription
to the "network.interfaces" topic.
"""
from __future__ import annotations
from typing import Any, Dict
from textual.app import ComposeResult
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import DataTable, Label, Static
def _fmt_bytes(n: int) -> str:
"""Format a byte count as a human-readable string."""
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024:
return f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} PB"
class LinesTab(Widget):
"""
Network Interfaces tab.
Subscribes to ``network.interfaces`` bus events and refreshes
the DataTable in real time.
"""
DEFAULT_CSS = """
LinesTab {
layout: vertical;
height: 1fr;
padding: 1 2;
}
LinesTab .tab-title {
color: $primary;
text-style: bold;
height: 1;
margin-bottom: 1;
}
LinesTab DataTable {
height: 1fr;
border: round $primary;
}
"""
COLUMNS = [
("Interface", 12),
("Status", 8),
("IPv4", 16),
("IPv6", 24),
("MAC", 18),
("Speed", 9),
("RX", 12),
("TX", 12),
("RX Err", 8),
("TX Err", 8),
]
def compose(self) -> ComposeResult:
yield Static("// LINES — Network Interfaces", classes="tab-title")
yield DataTable(id="lines-table", zebra_stripes=True, cursor_type="row")
def on_mount(self) -> None:
table = self.query_one("#lines-table", DataTable)
for col_label, width in self.COLUMNS:
table.add_column(col_label, width=width, key=col_label.lower().replace(" ", "_"))
# Subscribe to the bus if app exposes one
app = self.app
if hasattr(app, "bus"):
app.bus.subscribe("network.interfaces", self._on_interfaces)
# Request immediate data
if hasattr(app, "state"):
interfaces = app.state.get("network.interfaces")
if interfaces:
self._refresh_table(interfaces)
def _on_interfaces(self, topic: str, data: Any) -> None:
"""Bus handler — schedule table refresh on the UI thread."""
self.call_from_thread(self._refresh_table, data)
def _refresh_table(self, interfaces: Dict[str, Dict]) -> None:
table = self.query_one("#lines-table", DataTable)
table.clear()
for iface, info in sorted(interfaces.items()):
status_text = info.get("status", "?")
speed = info.get("speed", 0)
speed_str = f"{speed} Mb" if speed else ""
table.add_row(
iface,
status_text,
info.get("ip4", "") or "",
info.get("ip6", "") or "",
info.get("mac", "") or "",
speed_str,
_fmt_bytes(info.get("rx_bytes", 0)),
_fmt_bytes(info.get("tx_bytes", 0)),
str(info.get("rx_errors", 0)),
str(info.get("tx_errors", 0)),
)
+128
View File
@@ -0,0 +1,128 @@
"""
Registry Tab — displays loaded plugins and their status.
Subscribes to registry.plugin.* events to show live plugin state.
Allows loading/unloading plugins interactively.
"""
from __future__ import annotations
from typing import Any, Optional
from textual.app import ComposeResult
from textual.binding import Binding
from textual.widget import Widget
from textual.widgets import DataTable, Footer, Label, Static
class RegistryTab(Widget):
"""
Plugin registry viewer.
Shows all discovered plugins with their version, status, module path,
and load timestamp. Pressing Enter on a row toggles load/unload.
"""
DEFAULT_CSS = """
RegistryTab {
layout: vertical;
height: 1fr;
padding: 1 2;
}
RegistryTab .tab-title {
color: $primary;
text-style: bold;
height: 1;
margin-bottom: 1;
}
RegistryTab DataTable {
height: 1fr;
border: round $primary;
}
RegistryTab .hint {
height: 1;
color: $text-muted;
margin-top: 1;
}
"""
BINDINGS = [
Binding("r", "refresh_registry", "Refresh"),
Binding("enter", "toggle_plugin", "Toggle"),
]
COLUMNS = [
("Name", 14),
("Version", 9),
("Status", 11),
("Tags", 22),
("Loaded At", 22),
("Module", 32),
("Error", 30),
]
def compose(self) -> ComposeResult:
yield Static("// REGISTRY — Plugin Manifest", classes="tab-title")
yield DataTable(id="registry-table", zebra_stripes=True, cursor_type="row")
yield Static(
"Press [bold]r[/bold] to refresh | [bold]Enter[/bold] to toggle load",
classes="hint",
)
def on_mount(self) -> None:
table = self.query_one("#registry-table", DataTable)
for col_label, width in self.COLUMNS:
table.add_column(col_label, width=width)
app = self.app
if hasattr(app, "bus"):
app.bus.subscribe("registry.plugin.*", self._on_registry_event)
self._refresh_table()
def _on_registry_event(self, topic: str, data: Any) -> None:
self.call_from_thread(self._refresh_table)
def _refresh_table(self) -> None:
table = self.query_one("#registry-table", DataTable)
table.clear()
app = self.app
if not hasattr(app, "registry"):
return
for record in sorted(app.registry.all_records(), key=lambda r: r.name):
loaded_at = record.loaded_at.strftime("%Y-%m-%d %H:%M:%S") if record.loaded_at else ""
table.add_row(
record.name,
record.version,
record.status.name,
", ".join(record.tags) or "",
loaded_at,
record.module_path,
record.error or "",
)
def action_refresh_registry(self) -> None:
self._refresh_table()
def action_toggle_plugin(self) -> None:
table = self.query_one("#registry-table", DataTable)
row_key = table.cursor_row
if row_key is None:
return
# Get plugin name from the focused row
try:
cell_value = table.get_cell_at((row_key, 0))
except Exception:
return
plugin_name = str(cell_value)
app = self.app
if not hasattr(app, "registry"):
return
record = app.registry.get(plugin_name)
if record is None:
return
from core.registry import PluginStatus
if record.status == PluginStatus.ACTIVE:
app.registry.unload(plugin_name)
else:
app.registry.load(plugin_name)
self._refresh_table()
+185
View File
@@ -0,0 +1,185 @@
"""
Routes Tab — displays the system routing table.
Reads routes from the OS on mount and on manual refresh.
Uses ``ip route show`` on Linux or ``netstat -rn`` as a fallback.
"""
from __future__ import annotations
import asyncio
import shutil
import subprocess
import re
from typing import List, Dict
from textual.app import ComposeResult
from textual.binding import Binding
from textual.widget import Widget
from textual.widgets import DataTable, Footer, Label, Static
def _parse_ip_route() -> List[Dict]:
"""Parse ``ip route show`` output into a list of route dicts."""
routes: List[Dict] = []
try:
result = subprocess.run(
["ip", "route", "show"],
capture_output=True,
text=True,
timeout=5,
)
for line in result.stdout.splitlines():
line = line.strip()
if not line:
continue
parts = line.split()
dest = parts[0] if parts else "?"
gateway = ""
iface = ""
metric = ""
proto = ""
scope = ""
src = ""
i = 1
while i < len(parts):
token = parts[i]
if token == "via" and i + 1 < len(parts):
gateway = parts[i + 1]; i += 2
elif token == "dev" and i + 1 < len(parts):
iface = parts[i + 1]; i += 2
elif token == "metric" and i + 1 < len(parts):
metric = parts[i + 1]; i += 2
elif token == "proto" and i + 1 < len(parts):
proto = parts[i + 1]; i += 2
elif token == "scope" and i + 1 < len(parts):
scope = parts[i + 1]; i += 2
elif token == "src" and i + 1 < len(parts):
src = parts[i + 1]; i += 2
else:
i += 1
routes.append({
"destination": dest,
"gateway": gateway or "",
"interface": iface or "",
"metric": metric or "",
"proto": proto or "",
"scope": scope or "",
"src": src or "",
})
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return routes
def _parse_netstat_route() -> List[Dict]:
"""Fallback: parse ``netstat -rn`` output."""
routes: List[Dict] = []
try:
result = subprocess.run(
["netstat", "-rn"],
capture_output=True,
text=True,
timeout=5,
)
lines = result.stdout.splitlines()
# Skip header lines
data_start = False
for line in lines:
if re.match(r"^(Destination|0\.0\.0\.0|default)", line):
data_start = True
if not data_start:
continue
parts = line.split()
if len(parts) >= 4:
routes.append({
"destination": parts[0],
"gateway": parts[1] if len(parts) > 1 else "",
"interface": parts[-1],
"metric": "",
"proto": "",
"scope": "",
"src": "",
})
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return routes
def _get_routes() -> List[Dict]:
if shutil.which("ip"):
routes = _parse_ip_route()
if routes:
return routes
return _parse_netstat_route()
class RoutesTab(Widget):
"""Routing table tab — refreshes on mount and on 'r' keypress."""
DEFAULT_CSS = """
RoutesTab {
layout: vertical;
height: 1fr;
padding: 1 2;
}
RoutesTab .tab-title {
color: $primary;
text-style: bold;
height: 1;
margin-bottom: 1;
}
RoutesTab DataTable {
height: 1fr;
border: round $primary;
}
RoutesTab .hint {
height: 1;
color: $text-muted;
margin-top: 1;
}
"""
BINDINGS = [Binding("r", "refresh_routes", "Refresh")]
COLUMNS = [
("Destination", 20),
("Gateway", 16),
("Interface", 12),
("Proto", 8),
("Scope", 8),
("Metric", 8),
("Src", 16),
]
def compose(self) -> ComposeResult:
yield Static("// ROUTES — Routing Table", classes="tab-title")
yield DataTable(id="routes-table", zebra_stripes=True, cursor_type="row")
yield Static("Press [bold]r[/bold] to refresh", classes="hint")
def on_mount(self) -> None:
table = self.query_one("#routes-table", DataTable)
for col_label, width in self.COLUMNS:
table.add_column(col_label, width=width)
self.run_worker(self._load_routes(), exclusive=True)
async def _load_routes(self) -> None:
routes = await asyncio.to_thread(_get_routes)
self._populate_table(routes)
def _populate_table(self, routes: List[Dict]) -> None:
table = self.query_one("#routes-table", DataTable)
table.clear()
for r in routes:
table.add_row(
r["destination"],
r["gateway"],
r["interface"],
r["proto"],
r["scope"],
r["metric"],
r["src"],
)
def action_refresh_routes(self) -> None:
self.run_worker(self._load_routes(), exclusive=True)
+260
View File
@@ -0,0 +1,260 @@
"""
Settings Tab — application configuration form.
Reads from the StateStore and writes back on change.
Supports editing poll intervals, theme options, and plugin enable/disable.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict
from textual.app import ComposeResult
from textual.binding import Binding
from textual.widget import Widget
from textual.widgets import Button, Input, Label, Select, Static, Switch
CONFIG_PATH = Path(__file__).parent.parent.parent / "config" / "defaults.toml"
class SettingRow(Widget):
"""A single label + control row in the settings form."""
DEFAULT_CSS = """
SettingRow {
layout: horizontal;
height: 3;
align: left middle;
margin-bottom: 1;
}
SettingRow .setting-label {
width: 28;
color: $text;
content-align: left middle;
}
SettingRow .setting-control {
width: 24;
}
SettingRow .setting-desc {
width: 1fr;
color: $text-muted;
content-align: left middle;
padding-left: 2;
}
"""
def __init__(self, label: str, control: Widget, description: str = "", **kwargs) -> None:
super().__init__(**kwargs)
self._label = label
self._control = control
self._description = description
def compose(self) -> ComposeResult:
yield Static(self._label, classes="setting-label")
yield self._control
if self._description:
yield Static(self._description, classes="setting-desc")
class SettingsTab(Widget):
"""
Application settings form.
Changes are applied immediately to the state store and persisted
to config/defaults.toml on save.
"""
DEFAULT_CSS = """
SettingsTab {
layout: vertical;
height: 1fr;
padding: 1 2;
overflow-y: auto;
}
SettingsTab .tab-title {
color: $primary;
text-style: bold;
height: 1;
margin-bottom: 1;
}
SettingsTab .section-header {
color: $accent;
text-style: bold;
height: 1;
margin-top: 2;
margin-bottom: 1;
}
SettingsTab .button-row {
layout: horizontal;
height: 3;
margin-top: 2;
}
SettingsTab Button {
margin-right: 2;
}
SettingsTab .status-label {
height: 1;
color: $success;
margin-top: 1;
}
"""
BINDINGS = [Binding("s", "save_settings", "Save")]
def compose(self) -> ComposeResult:
yield Static("// SETTINGS — Configuration", classes="tab-title")
yield Static("Polling Intervals", classes="section-header")
yield SettingRow(
"Network poll interval (s):",
Input(value="5", id="cfg-network-interval", classes="setting-control"),
"How often to refresh interface stats",
)
yield SettingRow(
"DNS poll interval (s):",
Input(value="30", id="cfg-dns-interval", classes="setting-control"),
"How often to check DNS resolver health",
)
yield SettingRow(
"System poll interval (s):",
Input(value="3", id="cfg-system-interval", classes="setting-control"),
"How often to sample CPU / memory",
)
yield SettingRow(
"Firewall poll interval (s):",
Input(value="60", id="cfg-firewall-interval", classes="setting-control"),
"How often to re-read firewall rules",
)
yield Static("Plugins", classes="section-header")
yield SettingRow(
"Network plugin:",
Switch(value=True, id="cfg-plugin-network", classes="setting-control"),
"Monitor network interfaces",
)
yield SettingRow(
"DNS plugin:",
Switch(value=True, id="cfg-plugin-dns", classes="setting-control"),
"Monitor DNS resolver health",
)
yield SettingRow(
"System plugin:",
Switch(value=True, id="cfg-plugin-system", classes="setting-control"),
"Monitor CPU / memory / disk",
)
yield SettingRow(
"Firewall plugin:",
Switch(value=True, id="cfg-plugin-firewall", classes="setting-control"),
"Read firewall rules",
)
yield Static("Display", classes="section-header")
yield SettingRow(
"Signal history limit:",
Input(value="500", id="cfg-signal-limit", classes="setting-control"),
"Max events shown in Signals tab",
)
yield SettingRow(
"Chronicle buffer:",
Input(value="2000", id="cfg-chronicle-buffer", classes="setting-control"),
"Max events retained in Chronicle",
)
with Widget(classes="button-row"):
yield Button("Save", id="btn-save", variant="primary")
yield Button("Reset to defaults", id="btn-reset", variant="default")
yield Static("", id="settings-status", classes="status-label")
def on_mount(self) -> None:
self._load_from_state()
def _load_from_state(self) -> None:
app = self.app
if not hasattr(app, "state"):
return
mappings = {
"settings.network_interval": "cfg-network-interval",
"settings.dns_interval": "cfg-dns-interval",
"settings.system_interval": "cfg-system-interval",
"settings.firewall_interval": "cfg-firewall-interval",
"settings.signal_limit": "cfg-signal-limit",
"settings.chronicle_buffer": "cfg-chronicle-buffer",
}
for state_key, widget_id in mappings.items():
val = app.state.get(state_key)
if val is not None:
try:
widget = self.query_one(f"#{widget_id}", Input)
widget.value = str(val)
except Exception:
pass
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-save":
self.action_save_settings()
elif event.button.id == "btn-reset":
self._reset_to_defaults()
def action_save_settings(self) -> None:
app = self.app
status = self.query_one("#settings-status", Static)
if not hasattr(app, "state"):
status.update("[red]No state store available[/red]")
return
try:
mappings = {
"settings.network_interval": ("cfg-network-interval", float),
"settings.dns_interval": ("cfg-dns-interval", float),
"settings.system_interval": ("cfg-system-interval", float),
"settings.firewall_interval": ("cfg-firewall-interval", float),
"settings.signal_limit": ("cfg-signal-limit", int),
"settings.chronicle_buffer": ("cfg-chronicle-buffer", int),
}
for state_key, (widget_id, cast) in mappings.items():
widget = self.query_one(f"#{widget_id}", Input)
app.state.set(state_key, cast(widget.value))
# Persist to TOML
self._persist_toml()
status.update("[green]Settings saved.[/green]")
except Exception as exc:
status.update(f"[red]Error: {exc}[/red]")
def _reset_to_defaults(self) -> None:
defaults = {
"cfg-network-interval": "5",
"cfg-dns-interval": "30",
"cfg-system-interval": "3",
"cfg-firewall-interval": "60",
"cfg-signal-limit": "500",
"cfg-chronicle-buffer": "2000",
}
for widget_id, value in defaults.items():
try:
self.query_one(f"#{widget_id}", Input).value = value
except Exception:
pass
self.query_one("#settings-status", Static).update("[yellow]Defaults restored. Press Save to apply.[/yellow]")
def _persist_toml(self) -> None:
try:
import toml
data = {
"polling": {
"network_interval": float(self.query_one("#cfg-network-interval", Input).value),
"dns_interval": float(self.query_one("#cfg-dns-interval", Input).value),
"system_interval": float(self.query_one("#cfg-system-interval", Input).value),
"firewall_interval": float(self.query_one("#cfg-firewall-interval", Input).value),
},
"display": {
"signal_limit": int(self.query_one("#cfg-signal-limit", Input).value),
"chronicle_buffer": int(self.query_one("#cfg-chronicle-buffer", Input).value),
},
}
CONFIG_PATH.write_text(toml.dumps(data))
except Exception as exc:
pass # non-fatal
+140
View File
@@ -0,0 +1,140 @@
"""
Signals Tab — live scrolling monitor of all bus events.
Subscribes to every topic ("**") and appends new events to a scrollable
RichLog widget, styled to resemble an oscilloscope or signal analyser.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from textual.app import ComposeResult
from textual.binding import Binding
from textual.widget import Widget
from textual.widgets import Label, RichLog, Static
def _preview(data: Any, max_len: int = 80) -> str:
"""Produce a compact single-line preview of event data."""
if data is None:
return "null"
try:
raw = json.dumps(data, default=str)
except Exception:
raw = str(data)
if len(raw) > max_len:
raw = raw[:max_len] + ""
return raw
class SignalsTab(Widget):
"""
Live event signal monitor.
Every event published on the bus appears here with:
- Timestamp (HH:MM:SS.mmm)
- Topic (colour-coded by namespace)
- Data preview (truncated JSON)
"""
DEFAULT_CSS = """
SignalsTab {
layout: vertical;
height: 1fr;
padding: 1 2;
}
SignalsTab .tab-title {
color: $primary;
text-style: bold;
height: 1;
margin-bottom: 1;
}
SignalsTab RichLog {
height: 1fr;
border: round $primary;
background: $surface;
scrollbar-gutter: stable;
}
SignalsTab .hint {
height: 1;
color: $text-muted;
margin-top: 1;
}
"""
BINDINGS = [
Binding("c", "clear_log", "Clear"),
Binding("p", "toggle_pause", "Pause"),
]
# Colour map per topic namespace
TOPIC_COLOURS = {
"network": "cyan",
"dns": "blue",
"firewall": "red",
"system": "green",
"state": "yellow",
"registry": "magenta",
}
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._paused = False
self._event_count = 0
self._sub_id: str | None = None
def compose(self) -> ComposeResult:
yield Static("// SIGNALS — Live Event Monitor", classes="tab-title")
yield RichLog(id="signal-log", highlight=True, markup=True, max_lines=500)
yield Static(
"Press [bold]c[/bold] to clear | [bold]p[/bold] to pause",
classes="hint",
)
def on_mount(self) -> None:
app = self.app
if hasattr(app, "bus"):
self._sub_id = app.bus.subscribe("**", self._on_any_event)
def on_unmount(self) -> None:
app = self.app
if hasattr(app, "bus") and self._sub_id:
app.bus.unsubscribe(self._sub_id)
def _on_any_event(self, topic: str, data: Any) -> None:
if self._paused:
return
self.call_from_thread(self._append_event, topic, data)
def _append_event(self, topic: str, data: Any) -> None:
self._event_count += 1
log_widget = self.query_one("#signal-log", RichLog)
now = datetime.now(timezone.utc)
ts = now.strftime("%H:%M:%S") + f".{now.microsecond // 1000:03d}"
namespace = topic.split(".")[0]
colour = self.TOPIC_COLOURS.get(namespace, "white")
preview = _preview(data)
# Format: [dim]HH:MM:SS.mmm[/] [colour]topic[/] data
line = (
f"[dim]{ts}[/dim] "
f"[{colour} bold]{topic:<35}[/{colour} bold] "
f"[dim]{preview}[/dim]"
)
log_widget.write(line)
def action_clear_log(self) -> None:
log_widget = self.query_one("#signal-log", RichLog)
log_widget.clear()
self._event_count = 0
def action_toggle_pause(self) -> None:
self._paused = not self._paused
log_widget = self.query_one("#signal-log", RichLog)
status = "[yellow]PAUSED[/yellow]" if self._paused else "[green]LIVE[/green]"
log_widget.write(f" ── {status} ──")