"""Страница просмотра логов в реальном времени (SSE). Маршруты: GET /ui/logs — HTML-страница с историей + EventSource GET /ui/logs/stream — Server-Sent Events (text/event-stream) """ from __future__ import annotations import asyncio import html as _html import json from collections.abc import AsyncGenerator from fastapi import APIRouter from starlette.requests import Request from starlette.responses import HTMLResponse, StreamingResponse from onguard24 import log_buffer from onguard24.modules.ui_support import APP_SHELL_CSS, nav_rail_html router = APIRouter(include_in_schema=False, tags=["web-logs"]) _LEVEL_COLOR: dict[str, str] = { "DEBUG": "#71717a", "INFO": "#a3e635", "WARNING": "#fbbf24", "ERROR": "#f87171", "CRITICAL": "#ef4444", } _LOG_CSS = """ .log-wrap { background:#0f0f10; border-radius:10px; padding:0.75rem; min-height:20rem; max-height:75vh; overflow-y:auto; font-family:monospace; font-size:0.8rem; line-height:1.55; color:#e4e4e7; } .log-line { display:flex; gap:0.5rem; border-bottom:1px solid #1e1e21; padding:0.12rem 0; } .log-line:last-child { border-bottom: none; } .log-ts { color:#52525b; flex-shrink:0; } .log-lv { flex-shrink:0; width:5.5rem; font-weight:600; } .log-name { flex-shrink:0; width:16rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#a1a1aa; } .log-msg { flex:1; word-break:break-all; white-space:pre-wrap; color:#e4e4e7; } .lv-DEBUG { color:#71717a; } .lv-INFO { color:#a3e635; } .lv-WARNING { color:#fbbf24; } .lv-ERROR { color:#f87171; } .lv-CRITICAL { color:#ef4444; background:#3f0000; border-radius:3px; padding:0 2px; } .log-controls { display:flex; gap:0.75rem; align-items:center; margin-bottom:0.75rem; } .log-controls label { font-size:0.85rem; color:#52525b; display:flex; align-items:center; gap:0.3rem; } .badge-live { display:inline-block; width:8px; height:8px; border-radius:50%; background:#a3e635; box-shadow:0 0 6px #a3e635; animation: pulse 1.6s infinite; } .badge-live.disconnected { background:#f87171; box-shadow:0 0 6px #f87171; animation:none; } @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} } #status-bar { font-size:0.8rem; color:#52525b; margin-bottom:0.5rem; } """ _LOG_JS = """ """ def _line_html(entry: dict) -> str: ts = _html.escape(entry.get("ts", "")) lvl = entry.get("level", "INFO") name = _html.escape((entry.get("name") or "")[:36]) msg = _html.escape(entry.get("msg") or "") return ( f'
' f'{ts}' f'{_html.escape(lvl)}' f'{name}' f'{msg}' f"
" ) @router.get("/ui/logs", response_class=HTMLResponse) async def logs_page(request: Request) -> HTMLResponse: history = log_buffer.get_history() lines_html = "\n".join(_line_html(e) for e in history) count = len(history) rail = nav_rail_html("__logs__") page = f""" Логи — onGuard24

Логи приложения

real-time Записей: {count} Обновить
Подключаемся к потоку…
{lines_html}
{rail}
{_LOG_JS} """ return HTMLResponse(page) @router.get("/ui/logs/stream") async def logs_stream(request: Request) -> StreamingResponse: q = log_buffer.subscribe() async def generator() -> AsyncGenerator[bytes, None]: try: while True: if await request.is_disconnected(): break try: entry = await asyncio.wait_for(q.get(), timeout=20.0) payload = json.dumps(entry, ensure_ascii=False) yield f"data: {payload}\n\n".encode() except asyncio.TimeoutError: yield b": keepalive\n\n" finally: log_buffer.unsubscribe(q) return StreamingResponse( generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive", }, )