- FastAPI + htmx + Jinja2 web frontend, started with --web flag - JWT HS256 auth (WEB_SECRET_KEY) with httpOnly cookies; access (15 min) + refresh (7 day) tokens; refresh rotation + JTI revocation in data/web.db - RBAC: superadmin > admin > reader enforced per route - Live SSE dashboard fed by tui/events broadcast queue - Config editor: keyword groups and channel list saved to data/runtime_config.json and hot-reloaded in-process (scorer.reload_from_config, signal_channel_changed) - config.py migrated to load groups/channels from runtime_config.json; falls back to hardcoded defaults when file absent - tui/events.py: subscribe/unsubscribe broadcast, set_bot_context/signal_channel_changed - utils/scorer.py: import config as _config (fixes local binding); reload_from_config() - utils/database.py: count_by_severity, recent_for_domains, count_by_severity_for_domains - 53 new tests (events bus, JWT lifecycle, web DB CRUD, RBAC enforcement, config round-trip); total 141 passing
3.6 KiB
tui/events.py
Thread-safe event bus between the bot backend thread and the Textual TUI.
The bot thread calls post(). The TUI drains the queue every 100ms via _drain_bus().
Web SSE consumers call subscribe() / unsubscribe() to get their own broadcast queue.
Public API
from tui import events as bus # from core/ and tui/app.py
from tui.events import post, init_bus, get_bus, tui_active
from tui.events import subscribe, unsubscribe
from tui.events import set_bot_context, signal_channel_changed
init_bus() -> queue.Queue
Creates the queue.Queue. Called inside MonitorApp.on_mount() — must run on Textual's event loop, not before App.run().
post(event: Any) -> None
Fire-and-forget from any thread. Delivers to the TUI queue and all subscriber queues.
Uses queue.Queue.put_nowait() — never blocks.
get_bus() -> queue.Queue | None
Returns the TUI queue for _drain_bus() to consume.
tui_active: bool
Set to True by init_bus(). Checked by core/tdl_downloader.py to decide whether to pipe tdl output or inherit the terminal.
subscribe() -> queue.Queue
Register a new subscriber. Returns a private queue.Queue that receives every future post(). Thread-safe. Call once per SSE connection.
unsubscribe(q: queue.Queue) -> None
Remove a subscriber queue. Safe to call if already removed. Call on SSE disconnect.
set_bot_context(loop, event) -> None
Called by _bot_main() once the bot asyncio loop and _ch_changed event exist. Enables signal_channel_changed().
signal_channel_changed() -> None
Wake the bot's _watch_channels() coroutine from any thread. Used by web config routes after channel list is updated.
Event types
| Class | Fields | Posted by | Consumed by |
|---|---|---|---|
EvDownloadQueued |
batch_id, filename, size_mb, source, password |
tdl_downloader, scraper |
DownloadPanel.queued() |
EvDownloadStarted |
batch_id, filename |
tdl_downloader, scraper |
DownloadPanel.status("downloading") |
EvDownloadDone |
batch_id, filename, via |
tdl_downloader, scraper |
DownloadPanel.status("done_tdl"|"done_tel") |
EvDownloadFailed |
batch_id, filename, reason |
tdl_downloader, scraper |
DownloadPanel.status("failed") |
EvTdlOutput |
line |
tdl_downloader._relay() |
DownloadPanel.tdl_line() |
EvHit |
severity, raw, source, filename, reasons |
notifier.notify() |
HitsPanel.add_hit() + StatsPanel.refresh_stats() |
EvChannelAdded |
channel |
ChannelPanel.on_button_pressed() |
_drain_bus → _signal_channel_changed() |
EvChannelRemoved |
channel |
ChannelPanel.on_button_pressed() |
_drain_bus → _signal_channel_changed() |
EvStatus |
text, level |
everywhere | MonitorApp.notify() toast |
level on EvStatus: "info" (default) · "warning" · "error"
Threading model
Bot thread (own asyncio loop)
└─ bus.post(event) ← queue.Queue.put_nowait() [thread-safe]
↓
_queue (TUI) + _subscribers[0..n] (web SSE)
↓ ↓
Textual thread Web thread (uvicorn)
_drain_bus() SSE generator: q.get_nowait()
Channel changes from TUI:
_drain_bus sees EvChannelAdded/Removed
→ _signal_channel_changed()
→ loop.call_soon_threadsafe(asyncio.Event.set)
→ bot thread's _watch_channels() wakes
Channel changes from web:
PUT /config/channels
→ config.save_runtime_config()
→ bus.signal_channel_changed() ← uses stored loop + event
→ bot thread's _watch_channels() wakes