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