Files
stealergram/tui/events.md
anti 4c104cddd2 Add web frontend with JWT auth, RBAC, SSE dashboard, and config editor
- 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
2026-04-02 11:41:46 -03:00

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