From 80645713a0b5eabe6f9dee11160741bedfc7e404 Mon Sep 17 00:00:00 2001
From: Alexandr
Date: Fri, 3 Apr 2026 15:56:58 +0300
Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?=
=?UTF-8?q?=D1=86=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2=20/ui/logs=20?=
=?UTF-8?q?=D1=81=20SSE=20real-time=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=BE?=
=?UTF-8?q?=D0=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
CHANGELOG.md | 11 ++
onguard24/__init__.py | 2 +-
onguard24/ingress/grafana.py | 24 ++-
onguard24/ingress/json_sanitize.py | 26 ++++
onguard24/log_buffer.py | 91 +++++++++++
onguard24/main.py | 9 +-
onguard24/modules/ui_support.py | 6 +
onguard24/root_html.py | 1 +
onguard24/ui_logs.py | 234 +++++++++++++++++++++++++++++
pyproject.toml | 2 +-
tests/test_json_sanitize.py | 26 ++++
tests/test_log_buffer.py | 45 ++++++
12 files changed, 465 insertions(+), 12 deletions(-)
create mode 100644 onguard24/ingress/json_sanitize.py
create mode 100644 onguard24/log_buffer.py
create mode 100644 onguard24/ui_logs.py
create mode 100644 tests/test_json_sanitize.py
create mode 100644 tests/test_log_buffer.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a01cf9..27813d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,17 @@
Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md).
+## [1.10.1] — 2026-04-03
+
+### Добавлено
+
+- **Страница логов** `/ui/logs` — кольцевой буфер (600 записей) + **SSE real-time** поток; фильтр по уровню, авто-прокрутка; ссылка на главной и в nav rail (раздел «📋 Логи»).
+
+### Исправлено
+
+- **Вебхук Grafana:** санитизация тела перед записью в `jsonb` — `NaN` / `±Inf` → `None`, удаление `\x00` в строках (иначе PostgreSQL и строгий JSON часто давали **500** на реальных алертах с метриками, тогда как «Test contact point» оставался рабочим).
+- Ошибки подписчиков **`alert.received`** после успешного коммита в БД больше не рвут ответ вебхука (логируются).
+
## [1.10.0] — 2026-04-03
Команды (teams) по лейблам, как ориентир на Grafana IRM **Team**.
diff --git a/onguard24/__init__.py b/onguard24/__init__.py
index 0b0acbc..3187e84 100644
--- a/onguard24/__init__.py
+++ b/onguard24/__init__.py
@@ -1,3 +1,3 @@
"""onGuard24 — модульный монолит (ядро + модули)."""
-__version__ = "1.10.0"
+__version__ = "1.10.1"
diff --git a/onguard24/ingress/grafana.py b/onguard24/ingress/grafana.py
index 99e3937..0b285c6 100644
--- a/onguard24/ingress/grafana.py
+++ b/onguard24/ingress/grafana.py
@@ -10,6 +10,7 @@ from starlette.responses import Response
from onguard24.domain.entities import Alert, Severity
from onguard24.grafana_sources import sources_by_slug, webhook_authorized
from onguard24.ingress.grafana_payload import extract_alert_row_from_grafana_body
+from onguard24.ingress.json_sanitize import sanitize_for_jsonb
from onguard24.ingress.team_match import resolve_team_id_for_labels
logger = logging.getLogger(__name__)
@@ -99,6 +100,8 @@ async def _grafana_webhook_impl(
body = {}
if not isinstance(body, dict):
body = {}
+ else:
+ body = sanitize_for_jsonb(body)
derived = extract_grafana_source_key(body)
path_key: str | None = None
@@ -131,7 +134,7 @@ async def _grafana_webhook_impl(
RETURNING id
""",
"grafana",
- json.dumps(body),
+ json.dumps(body, ensure_ascii=False, allow_nan=False),
stored_org_slug,
service_name,
)
@@ -151,7 +154,7 @@ async def _grafana_webhook_impl(
sev_row,
stored_org_slug,
service_name,
- json.dumps(labels_row),
+ json.dumps(labels_row, ensure_ascii=False, allow_nan=False),
fp_row,
team_id,
)
@@ -165,12 +168,17 @@ async def _grafana_webhook_impl(
payload=body,
received_at=datetime.now(timezone.utc),
)
- await bus.publish_alert_received(
- alert,
- raw_payload_ref=raw_id,
- grafana_org_slug=stored_org_slug,
- service_name=service_name,
- )
+ try:
+ await bus.publish_alert_received(
+ alert,
+ raw_payload_ref=raw_id,
+ grafana_org_slug=stored_org_slug,
+ service_name=service_name,
+ )
+ except Exception:
+ logger.exception(
+ "ingress: событие alert.received не доставлено подписчикам (БД уже сохранена)"
+ )
return Response(status_code=202)
diff --git a/onguard24/ingress/json_sanitize.py b/onguard24/ingress/json_sanitize.py
new file mode 100644
index 0000000..4932418
--- /dev/null
+++ b/onguard24/ingress/json_sanitize.py
@@ -0,0 +1,26 @@
+"""Подготовка структур из JSON к записи в PostgreSQL jsonb."""
+
+from __future__ import annotations
+
+import math
+from typing import Any
+
+
+def sanitize_for_jsonb(obj: Any) -> Any:
+ """
+ - float NaN / ±Inf → None (иначе json.dumps даёт невалидный JSON для PG / сюрпризы при записи).
+ - Символ NUL в строках убрать (PostgreSQL text/jsonb NUL в строке не принимает).
+ """
+ if isinstance(obj, float):
+ if math.isnan(obj) or math.isinf(obj):
+ return None
+ return obj
+ if isinstance(obj, str):
+ if "\x00" not in obj:
+ return obj
+ return obj.replace("\x00", "")
+ if isinstance(obj, dict):
+ return {k: sanitize_for_jsonb(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [sanitize_for_jsonb(x) for x in obj]
+ return obj
diff --git a/onguard24/log_buffer.py b/onguard24/log_buffer.py
new file mode 100644
index 0000000..e0654eb
--- /dev/null
+++ b/onguard24/log_buffer.py
@@ -0,0 +1,91 @@
+"""Кольцевой буфер логов + fan-out в SSE-очереди подписчиков.
+
+Подключается через RingBufferHandler в main.py (install_log_handler()).
+Потокобезопасен: emit() вызывается в любом потоке, asyncio-очереди
+обновляются через loop.call_soon_threadsafe.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import collections
+import logging
+import threading
+from datetime import datetime, timezone
+from typing import Any
+
+MAX_HISTORY = 600
+_lock = threading.Lock()
+_ring: collections.deque[dict[str, Any]] = collections.deque(maxlen=MAX_HISTORY)
+_subscribers: list[asyncio.Queue[dict[str, Any]]] = []
+_loop: asyncio.AbstractEventLoop | None = None
+
+
+def set_event_loop(loop: asyncio.AbstractEventLoop) -> None:
+ global _loop
+ _loop = loop
+
+
+def get_history() -> list[dict[str, Any]]:
+ with _lock:
+ return list(_ring)
+
+
+def subscribe() -> asyncio.Queue[dict[str, Any]]:
+ q: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=300)
+ with _lock:
+ _subscribers.append(q)
+ return q
+
+
+def unsubscribe(q: asyncio.Queue[dict[str, Any]]) -> None:
+ with _lock:
+ try:
+ _subscribers.remove(q)
+ except ValueError:
+ pass
+
+
+def _push_to_subscriber(q: asyncio.Queue[dict[str, Any]], entry: dict[str, Any]) -> None:
+ try:
+ q.put_nowait(entry)
+ except asyncio.QueueFull:
+ pass
+
+
+class RingBufferHandler(logging.Handler):
+ """Logging handler — пишет в кольцевой буфер и раздаёт SSE-подписчикам."""
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ msg = self.format(record)
+ ts = datetime.fromtimestamp(record.created, tz=timezone.utc).strftime(
+ "%Y-%m-%d %H:%M:%S"
+ )
+ entry: dict[str, Any] = {
+ "ts": ts,
+ "level": record.levelname,
+ "name": record.name,
+ "msg": msg,
+ }
+ with _lock:
+ _ring.append(entry)
+ subs = list(_subscribers)
+ if subs and _loop is not None and _loop.is_running():
+ for q in subs:
+ _loop.call_soon_threadsafe(_push_to_subscriber, q, entry)
+ except Exception:
+ self.handleError(record)
+
+
+def install_log_handler(loop: asyncio.AbstractEventLoop) -> None:
+ """Вызывается один раз при старте: регистрирует handler на корневом логгере."""
+ set_event_loop(loop)
+ handler = RingBufferHandler()
+ handler.setFormatter(
+ logging.Formatter("%(name)s %(message)s")
+ )
+ handler.setLevel(logging.DEBUG)
+ root = logging.getLogger()
+ if not any(isinstance(h, RingBufferHandler) for h in root.handlers):
+ root.addHandler(handler)
diff --git a/onguard24/main.py b/onguard24/main.py
index eb2e502..b962bff 100644
--- a/onguard24/main.py
+++ b/onguard24/main.py
@@ -1,3 +1,4 @@
+import asyncio
import logging
from contextlib import asynccontextmanager
@@ -5,14 +6,16 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import HTMLResponse, Response
+from onguard24 import __version__ as app_version
from onguard24.config import get_settings
from onguard24.db import create_pool
from onguard24.domain.events import InMemoryEventBus
from onguard24.ingress import grafana as grafana_ingress
+from onguard24.log_buffer import install_log_handler
from onguard24.modules.registry import MODULE_MOUNTS, register_module_events
from onguard24.root_html import render_root_page
from onguard24.status_snapshot import build as build_status
-from onguard24 import __version__ as app_version
+from onguard24.ui_logs import router as logs_router
logging.basicConfig(level=logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
@@ -31,6 +34,7 @@ def parse_addr(http_addr: str) -> tuple[str, int]:
@asynccontextmanager
async def lifespan(app: FastAPI):
+ install_log_handler(asyncio.get_event_loop())
settings = get_settings()
pool = await create_pool(settings)
bus = InMemoryEventBus()
@@ -38,7 +42,7 @@ async def lifespan(app: FastAPI):
app.state.pool = pool
app.state.settings = settings
app.state.event_bus = bus
- log.info("onGuard24 started, db=%s", "ok" if pool else "disabled")
+ log.info("onGuard24 started v%s, db=%s", app_version, "ok" if pool else "disabled")
yield
if pool:
await pool.close()
@@ -82,6 +86,7 @@ def create_app() -> FastAPI:
return await build_status(request)
app.include_router(grafana_ingress.router, prefix="/api/v1")
+ app.include_router(logs_router)
for mount in MODULE_MOUNTS:
app.include_router(mount.router, prefix=mount.url_prefix)
if mount.ui_router is not None:
diff --git a/onguard24/modules/ui_support.py b/onguard24/modules/ui_support.py
index d350687..0db965d 100644
--- a/onguard24/modules/ui_support.py
+++ b/onguard24/modules/ui_support.py
@@ -46,6 +46,7 @@ APP_SHELL_CSS = """
.gc-subtable th, .gc-subtable td { border: 1px solid #e4e4e7; padding: 0.3rem 0.45rem; }
.gc-subtable th { background: #fafafa; }
.gc-orphan { margin-top: 1rem; padding: 0.75rem; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 8px; font-size: 0.88rem; }
+ .rail-item--util { border-top: 1px solid #e4e4e7; margin-top: 0.4rem; padding-top: 0.4rem; }
"""
@@ -72,6 +73,11 @@ def nav_rail_html(current_slug: str | None = None) -> str:
items.append(
f'{html.escape(m.title)}'
)
+ logs_active = current_slug == "__logs__"
+ items.append(
+ ''
+ '📋 Логи"
+ )
lis = "".join(items)
return (
'
Проверки доступа
diff --git a/onguard24/ui_logs.py b/onguard24/ui_logs.py
new file mode 100644
index 0000000..5c80cc3
--- /dev/null
+++ b/onguard24/ui_logs.py
@@ -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 = """
+
+"""
+
+
+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''
+ f'{ts}'
+ f'{_html.escape(lvl)}'
+ f'{name}'
+ f'{msg}'
+ f"
"
+ )
+
+
+@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"""
+
+
+
+
+ Логи — onGuard24
+
+
+
+
+
+ Логи приложения
+
+
real-time
+
Записей: {count}
+
+
+
Обновить
+
+ Подключаемся к потоку…
+
+{lines_html}
+
+
+{rail}
+
+{_LOG_JS}
+
+"""
+ 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",
+ },
+ )
diff --git a/pyproject.toml b/pyproject.toml
index 6347b5e..206a185 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "onguard24"
-version = "1.10.0"
+version = "1.10.1"
description = "onGuard24 — модульный сервис (аналог IRM)"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/tests/test_json_sanitize.py b/tests/test_json_sanitize.py
new file mode 100644
index 0000000..895f33c
--- /dev/null
+++ b/tests/test_json_sanitize.py
@@ -0,0 +1,26 @@
+import json
+import math
+
+from onguard24.ingress.json_sanitize import sanitize_for_jsonb
+
+
+def test_sanitize_nan_inf_to_none() -> None:
+ raw = json.loads('{"a": NaN, "b": Infinity, "c": -Infinity, "d": 1.5}')
+ out = sanitize_for_jsonb(raw)
+ assert math.isnan(raw["a"])
+ assert out["a"] is None
+ assert out["b"] is None
+ assert out["c"] is None
+ assert out["d"] == 1.5
+
+
+def test_sanitize_strips_nul_in_strings() -> None:
+ assert sanitize_for_jsonb({"x": "a\x00b"}) == {"x": "ab"}
+
+
+def test_dumps_after_sanitize_is_valid_json() -> None:
+ raw = json.loads('{"v": NaN}')
+ clean = sanitize_for_jsonb(raw)
+ s = json.dumps(clean, allow_nan=False)
+ assert "NaN" not in s
+ assert json.loads(s)["v"] is None
diff --git a/tests/test_log_buffer.py b/tests/test_log_buffer.py
new file mode 100644
index 0000000..d101bb8
--- /dev/null
+++ b/tests/test_log_buffer.py
@@ -0,0 +1,45 @@
+"""Кольцевой буфер логов и SSE-страница."""
+
+import logging
+
+import pytest
+from fastapi.testclient import TestClient
+
+from onguard24 import log_buffer
+
+
+def test_ring_buffer_captures_log_records() -> None:
+ log_buffer._ring.clear()
+ handler = log_buffer.RingBufferHandler()
+ handler.setFormatter(logging.Formatter("%(name)s %(message)s"))
+ logger = logging.getLogger("test.ring")
+ logger.addHandler(handler)
+ logger.setLevel(logging.DEBUG)
+ try:
+ logger.info("hello ring")
+ history = log_buffer.get_history()
+ assert any("hello ring" in e["msg"] for e in history)
+ finally:
+ logger.removeHandler(handler)
+ log_buffer._ring.clear()
+
+
+def test_logs_page_returns_html(client: TestClient) -> None:
+ r = client.get("/ui/logs")
+ assert r.status_code == 200
+ assert "text/html" in r.headers.get("content-type", "")
+ assert "Логи" in r.text
+ assert "log-wrap" in r.text
+ assert "EventSource" in r.text or "event-stream" in r.text or "ui/logs/stream" in r.text
+
+
+def test_logs_page_in_nav_rail(client: TestClient) -> None:
+ r = client.get("/ui/logs")
+ assert r.status_code == 200
+ assert "/ui/logs" in r.text
+
+
+def test_root_has_logs_link(client: TestClient) -> None:
+ r = client.get("/")
+ assert r.status_code == 200
+ assert "/ui/logs" in r.text