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