mirror of
https://github.com/samjage/metro-warden.git
synced 2026-06-06 01:20:42 +00:00
165 lines
4.9 KiB
Python
165 lines
4.9 KiB
Python
"""
|
|
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()
|