- 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
235 lines
8.0 KiB
Python
235 lines
8.0 KiB
Python
"""Страница просмотра логов в реальном времени (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",
|
||
},
|
||
)
|