"""WebSocket endpoint for real-time log streaming. Clients connect to /ws/logs and receive live log lines from all modules. """ from __future__ import annotations import asyncio import threading from collections import deque from fastapi import APIRouter, WebSocket, WebSocketDisconnect from Project.Sanad.core.logger import set_ws_push router = APIRouter() MAX_WATCHERS = 50 # Ring buffer of recent log lines (shared across connections). _recent: deque[str] = deque(maxlen=500) _watchers: set[asyncio.Queue] = set() _watchers_lock = threading.Lock() _seq = 0 # total lines ever pushed; _recent holds the tail of this sequence def recent_since(cursor: int, limit: int = 300) -> tuple[list[str], int]: """Lines newer than `cursor`, plus the new cursor. Backs the HTTP polling fallback: this deployment sits behind an Apache `[P]` rewrite that cannot upgrade WebSockets (verified — the same handshake returns 101 straight to uvicorn and 404 through the proxy), so the browser can never hold /ws/logs open and needs to poll instead. A cursor of -1, or one so old its lines have already been evicted from the ring, returns the most recent `limit` lines. """ lines = list(_recent) first_seq = _seq - len(lines) + 1 # sequence number of lines[0] if cursor < 0 or cursor < first_seq - 1: tail = lines[-limit:] return tail, _seq skip = cursor - (first_seq - 1) fresh = lines[skip:][:limit] return fresh, cursor + len(fresh) def push_log_line(line: str): """Called from the logging system to feed new lines. May be called from any thread (logging is multi-threaded), so we snapshot the watchers under a lock before iterating. """ global _seq _seq += 1 _recent.append(line) with _watchers_lock: snapshot = list(_watchers) for q in snapshot: try: q.put_nowait(line) except asyncio.QueueFull: # Drop on overflow rather than block — logs are not critical data pass # Register with the logger so all log records are pushed to WS clients. # Wrap so a logger registration failure doesn't break Dashboard import. try: set_ws_push(push_log_line) except Exception: pass @router.websocket("/ws/logs") async def log_ws(ws: WebSocket): await ws.accept() with _watchers_lock: if len(_watchers) >= MAX_WATCHERS: await ws.close(code=1013, reason="Too many log watchers") return queue: asyncio.Queue[str] = asyncio.Queue(maxsize=200) _watchers.add(queue) try: # Send recent history for line in list(_recent): await ws.send_text(line) while True: line = await queue.get() await ws.send_text(line) except WebSocketDisconnect: pass except Exception: # Any other error closes the connection cleanly try: await ws.close() except Exception: pass finally: with _watchers_lock: _watchers.discard(queue)