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 ( '