Files

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()