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
This commit is contained in:
234
onguard24/ui_logs.py
Normal file
234
onguard24/ui_logs.py
Normal file
@ -0,0 +1,234 @@
|
||||
"""Страница просмотра логов в реальном времени (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",
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user