feat: страница логов /ui/logs с SSE real-time потоком
- 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
This commit is contained in:
@ -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)
|
||||
|
||||
|
||||
|
||||
26
onguard24/ingress/json_sanitize.py
Normal file
26
onguard24/ingress/json_sanitize.py
Normal file
@ -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
|
||||
Reference in New Issue
Block a user