Files
onGuard24/onguard24/ui_logs.py

235 lines
8.0 KiB
Python
Raw Permalink Normal View History

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