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",
|
|||
|
|
},
|
|||
|
|
)
|