Files
onGuard24/onguard24/ui_logs.py
Alexandr 80645713a0
Some checks failed
CI / test (push) Successful in 39s
Deploy / deploy (push) Failing after 15s
feat: страница логов /ui/logs с SSE real-time потоком
- log_buffer: RingBufferHandler, кольцевой буфер 600 записей, fan-out SSE
- ui_logs: GET /ui/logs (HTML), GET /ui/logs/stream (EventSource)
- main: install_log_handler при старте, подключён router логов
- nav_rail: ссылка Логи, root_html: кнопка-ссылка Логи
- Исправлено: NaN/Inf/NUL в теле вебхука → 500 от PostgreSQL jsonb
- Тесты: test_log_buffer, test_json_sanitize; 51 passed

Made-with: Cursor
2026-04-03 15:56:58 +03:00

235 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Страница просмотра логов в реальном времени (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 = """
<script>
(function(){
const wrap = document.getElementById('log-wrap');
const cntEl = document.getElementById('log-count');
const statusEl = document.getElementById('status-bar');
const dot = document.getElementById('live-dot');
const autoCheck = document.getElementById('auto-scroll');
const levelFilter = document.getElementById('level-filter');
let count = parseInt(cntEl.textContent || '0', 10);
const LEVEL_COLOR = {
DEBUG:'#71717a', INFO:'#a3e635', WARNING:'#fbbf24', ERROR:'#f87171', CRITICAL:'#ef4444'
};
function makeRow(d) {
const row = document.createElement('div');
row.className = 'log-line';
row.dataset.level = d.level;
const lvl = d.level || 'INFO';
const col = LEVEL_COLOR[lvl] || '#e4e4e7';
row.innerHTML =
'<span class="log-ts">' + esc(d.ts || '') + '</span>' +
'<span class="log-lv lv-' + lvl + '">' + esc(lvl) + '</span>' +
'<span class="log-name">' + esc((d.name||'').slice(0,36)) + '</span>' +
'<span class="log-msg">' + esc(d.msg||'') + '</span>';
return row;
}
function esc(s){ const d=document.createElement('div');d.textContent=s;return d.innerHTML; }
function applyFilter() {
const lv = levelFilter.value;
const ORDER = ['DEBUG','INFO','WARNING','ERROR','CRITICAL'];
const minIdx = lv ? ORDER.indexOf(lv) : 0;
wrap.querySelectorAll('.log-line').forEach(function(el){
const idx = ORDER.indexOf(el.dataset.level);
el.style.display = (idx >= minIdx) ? '' : 'none';
});
}
levelFilter.addEventListener('change', applyFilter);
applyFilter();
function scrollBottom() {
if (autoCheck.checked) wrap.scrollTop = wrap.scrollHeight;
}
scrollBottom();
const src = new EventSource('/ui/logs/stream');
src.onopen = function(){
dot.className = 'badge-live';
statusEl.textContent = 'Live — соединение установлено';
};
src.onmessage = function(e){
try {
const d = JSON.parse(e.data);
const row = makeRow(d);
wrap.appendChild(row);
count++;
cntEl.textContent = count;
// keep DOM manageable: trim oldest
while (wrap.children.length > 1000) wrap.removeChild(wrap.firstChild);
const lv = levelFilter.value;
if (lv) {
const ORDER = ['DEBUG','INFO','WARNING','ERROR','CRITICAL'];
if (ORDER.indexOf(d.level) < ORDER.indexOf(lv)) row.style.display='none';
}
scrollBottom();
} catch(ex){}
};
src.onerror = function(){
dot.className = 'badge-live disconnected';
statusEl.textContent = 'Соединение потеряно — попытка переподключения…';
};
})();
</script>
"""
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'<div class="log-line" data-level="{_html.escape(lvl)}">'
f'<span class="log-ts">{ts}</span>'
f'<span class="log-lv lv-{_html.escape(lvl)}">{_html.escape(lvl)}</span>'
f'<span class="log-name">{name}</span>'
f'<span class="log-msg">{msg}</span>'
f"</div>"
)
@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"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Логи — onGuard24</title>
<style>
{APP_SHELL_CSS}
{_LOG_CSS}
</style>
</head>
<body>
<div class="app-shell">
<main class="app-main module-page-main">
<h1>Логи приложения</h1>
<div class="log-controls">
<span><span id="live-dot" class="badge-live"></span> real-time</span>
<span>Записей: <strong id="log-count">{count}</strong></span>
<label><input type="checkbox" id="auto-scroll" checked> авто-прокрутка</label>
<label>
Уровень:
<select id="level-filter">
<option value="">Все</option>
<option value="DEBUG">DEBUG+</option>
<option value="INFO">INFO+</option>
<option value="WARNING">WARNING+</option>
<option value="ERROR">ERROR+</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</label>
<a href="/ui/logs" class="og-btn" style="text-decoration:none;padding:0.3rem 0.7rem;font-size:0.8rem">Обновить</a>
</div>
<div id="status-bar">Подключаемся к потоку…</div>
<div class="log-wrap" id="log-wrap">
{lines_html}
</div>
</main>
{rail}
</div>
{_LOG_JS}
</body>
</html>"""
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",
},
)