Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6c37435f0 | |||
| c9b97814a5 | |||
| 80645713a0 |
@ -3,6 +3,11 @@
|
|||||||
HTTP_ADDR=0.0.0.0:8080
|
HTTP_ADDR=0.0.0.0:8080
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Запись логов в файл с авто-ротацией (10 МБ × 5 файлов = ~50 МБ).
|
||||||
|
# Пусто = не писать в файл (логи только в stdout и страницу /ui/logs).
|
||||||
|
# Пример для docker-compose (volume /logs): LOG_FILE=/logs/onguard24.log
|
||||||
|
# LOG_FILE=
|
||||||
|
|
||||||
# Опционально: общий секрет для вебхуков (если у источника в JSON не задан свой webhook_secret)
|
# Опционально: общий секрет для вебхуков (если у источника в JSON не задан свой webhook_secret)
|
||||||
# GRAFANA_WEBHOOK_SECRET=
|
# GRAFANA_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
|||||||
11
CHANGELOG.md
11
CHANGELOG.md
@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md).
|
Формат: семантическое версионирование `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
|
## [1.10.0] — 2026-04-03
|
||||||
|
|
||||||
Команды (teams) по лейблам, как ориентир на Grafana IRM **Team**.
|
Команды (teams) по лейблам, как ориентир на Grafana IRM **Team**.
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"""onGuard24 — модульный монолит (ядро + модули)."""
|
"""onGuard24 — модульный монолит (ядро + модули)."""
|
||||||
|
|
||||||
__version__ = "1.10.0"
|
__version__ = "1.10.1"
|
||||||
|
|||||||
@ -34,6 +34,9 @@ class Settings(BaseSettings):
|
|||||||
forgejo_url: str = Field(default="", validation_alias="FORGEJO_URL")
|
forgejo_url: str = Field(default="", validation_alias="FORGEJO_URL")
|
||||||
forgejo_token: str = Field(default="", validation_alias="FORGEJO_TOKEN")
|
forgejo_token: str = Field(default="", validation_alias="FORGEJO_TOKEN")
|
||||||
log_level: str = Field(default="info", validation_alias="LOG_LEVEL")
|
log_level: str = Field(default="info", validation_alias="LOG_LEVEL")
|
||||||
|
# Путь к лог-файлу. Пусто = не писать в файл. Пример: /var/log/onguard24/app.log
|
||||||
|
# Файл ротируется: 10 МБ × 5 штук (~50 МБ суммарно).
|
||||||
|
log_file: str = Field(default="", validation_alias="LOG_FILE")
|
||||||
# Устаревшее: автосоздание инцидента на каждый вебхук (без учёта irm_alerts). По умолчанию выкл.
|
# Устаревшее: автосоздание инцидента на каждый вебхук (без учёта irm_alerts). По умолчанию выкл.
|
||||||
auto_incident_from_alert: bool = Field(
|
auto_incident_from_alert: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from starlette.responses import Response
|
|||||||
from onguard24.domain.entities import Alert, Severity
|
from onguard24.domain.entities import Alert, Severity
|
||||||
from onguard24.grafana_sources import sources_by_slug, webhook_authorized
|
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.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
|
from onguard24.ingress.team_match import resolve_team_id_for_labels
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -81,6 +82,42 @@ def service_hint_from_grafana_body(body: dict, header_service: str | None) -> st
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _log_incoming_webhook(body: object, raw: bytes, path_slug: str | None) -> None:
|
||||||
|
"""Логирует каждый входящий вебхук: краткое резюме INFO + полное тело DEBUG."""
|
||||||
|
slug_tag = f"[/{path_slug}]" if path_slug else "[/]"
|
||||||
|
raw_len = len(raw)
|
||||||
|
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
logger.info("grafana webhook %s raw=%dB (non-dict body)", slug_tag, raw_len)
|
||||||
|
logger.debug("grafana webhook %s raw body: %s", slug_tag, raw[:4000].decode(errors="replace"))
|
||||||
|
return
|
||||||
|
|
||||||
|
alerts = body.get("alerts") or []
|
||||||
|
n_alerts = len(alerts) if isinstance(alerts, list) else 0
|
||||||
|
status = body.get("status", "?")
|
||||||
|
title = str(body.get("title") or body.get("ruleName") or "")[:120]
|
||||||
|
first_labels: dict = {}
|
||||||
|
if isinstance(alerts, list) and alerts and isinstance(alerts[0], dict):
|
||||||
|
first_labels = alerts[0].get("labels") or {}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"grafana webhook %s status=%s alerts=%d title=%r labels=%s raw=%dB",
|
||||||
|
slug_tag,
|
||||||
|
status,
|
||||||
|
n_alerts,
|
||||||
|
title,
|
||||||
|
json.dumps(first_labels, ensure_ascii=False)[:300],
|
||||||
|
raw_len,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
pretty = json.dumps(body, ensure_ascii=False, indent=2)
|
||||||
|
if len(pretty) > 8000:
|
||||||
|
pretty = pretty[:8000] + "\n…(обрезано)"
|
||||||
|
except Exception:
|
||||||
|
pretty = raw[:8000].decode(errors="replace")
|
||||||
|
logger.debug("grafana webhook %s full body:\n%s", slug_tag, pretty)
|
||||||
|
|
||||||
|
|
||||||
async def _grafana_webhook_impl(
|
async def _grafana_webhook_impl(
|
||||||
request: Request,
|
request: Request,
|
||||||
pool,
|
pool,
|
||||||
@ -97,8 +134,14 @@ async def _grafana_webhook_impl(
|
|||||||
body = json.loads(raw.decode() or "{}")
|
body = json.loads(raw.decode() or "{}")
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
body = {}
|
body = {}
|
||||||
|
|
||||||
|
# Логируем входящий вебхук ДО любой обработки — чтобы видеть при любой ошибке
|
||||||
|
_log_incoming_webhook(body, raw, path_slug)
|
||||||
|
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
body = {}
|
body = {}
|
||||||
|
else:
|
||||||
|
body = sanitize_for_jsonb(body)
|
||||||
|
|
||||||
derived = extract_grafana_source_key(body)
|
derived = extract_grafana_source_key(body)
|
||||||
path_key: str | None = None
|
path_key: str | None = None
|
||||||
@ -131,7 +174,7 @@ async def _grafana_webhook_impl(
|
|||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
"grafana",
|
"grafana",
|
||||||
json.dumps(body),
|
json.dumps(body, ensure_ascii=False, allow_nan=False),
|
||||||
stored_org_slug,
|
stored_org_slug,
|
||||||
service_name,
|
service_name,
|
||||||
)
|
)
|
||||||
@ -151,10 +194,17 @@ async def _grafana_webhook_impl(
|
|||||||
sev_row,
|
sev_row,
|
||||||
stored_org_slug,
|
stored_org_slug,
|
||||||
service_name,
|
service_name,
|
||||||
json.dumps(labels_row),
|
json.dumps(labels_row, ensure_ascii=False, allow_nan=False),
|
||||||
fp_row,
|
fp_row,
|
||||||
team_id,
|
team_id,
|
||||||
)
|
)
|
||||||
|
logger.info(
|
||||||
|
"grafana webhook saved: alert_id=%s title=%r sev=%s team=%s",
|
||||||
|
raw_id,
|
||||||
|
(title_row or "—")[:80],
|
||||||
|
sev_row,
|
||||||
|
str(team_id) if team_id else "—",
|
||||||
|
)
|
||||||
bus = getattr(request.app.state, "event_bus", None)
|
bus = getattr(request.app.state, "event_bus", None)
|
||||||
if bus and raw_id is not None:
|
if bus and raw_id is not None:
|
||||||
title = str(body.get("title") or body.get("ruleName") or "")[:500]
|
title = str(body.get("title") or body.get("ruleName") or "")[:500]
|
||||||
@ -165,12 +215,17 @@ async def _grafana_webhook_impl(
|
|||||||
payload=body,
|
payload=body,
|
||||||
received_at=datetime.now(timezone.utc),
|
received_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
await bus.publish_alert_received(
|
try:
|
||||||
alert,
|
await bus.publish_alert_received(
|
||||||
raw_payload_ref=raw_id,
|
alert,
|
||||||
grafana_org_slug=stored_org_slug,
|
raw_payload_ref=raw_id,
|
||||||
service_name=service_name,
|
grafana_org_slug=stored_org_slug,
|
||||||
)
|
service_name=service_name,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"ingress: событие alert.received не доставлено подписчикам (БД уже сохранена)"
|
||||||
|
)
|
||||||
return Response(status_code=202)
|
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
|
||||||
119
onguard24/log_buffer.py
Normal file
119
onguard24/log_buffer.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"""Кольцевой буфер логов + 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,
|
||||||
|
log_file: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Вызывается один раз при старте: регистрирует handler на корневом логгере."""
|
||||||
|
set_event_loop(loop)
|
||||||
|
fmt = logging.Formatter(
|
||||||
|
"%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
root = logging.getLogger()
|
||||||
|
|
||||||
|
# Кольцевой буфер (SSE-страница логов)
|
||||||
|
if not any(isinstance(h, RingBufferHandler) for h in root.handlers):
|
||||||
|
ring_h = RingBufferHandler()
|
||||||
|
ring_h.setFormatter(logging.Formatter("%(name)s %(message)s"))
|
||||||
|
ring_h.setLevel(logging.DEBUG)
|
||||||
|
root.addHandler(ring_h)
|
||||||
|
|
||||||
|
# Файл с ротацией (если задан LOG_FILE)
|
||||||
|
if log_file.strip():
|
||||||
|
import os
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
log_path = log_file.strip()
|
||||||
|
os.makedirs(os.path.dirname(log_path) if os.path.dirname(log_path) else ".", exist_ok=True)
|
||||||
|
if not any(isinstance(h, RotatingFileHandler) for h in root.handlers):
|
||||||
|
file_h = RotatingFileHandler(
|
||||||
|
log_path,
|
||||||
|
maxBytes=10 * 1024 * 1024, # 10 МБ
|
||||||
|
backupCount=5,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
file_h.setFormatter(fmt)
|
||||||
|
file_h.setLevel(logging.DEBUG)
|
||||||
|
root.addHandler(file_h)
|
||||||
|
logging.getLogger("onguard24").info(
|
||||||
|
"file logging enabled: %s (rotate 10MB×5)", log_path
|
||||||
|
)
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@ -5,17 +6,27 @@ from fastapi import FastAPI, Request
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from starlette.responses import HTMLResponse, Response
|
from starlette.responses import HTMLResponse, Response
|
||||||
|
|
||||||
|
from onguard24 import __version__ as app_version
|
||||||
from onguard24.config import get_settings
|
from onguard24.config import get_settings
|
||||||
from onguard24.db import create_pool
|
from onguard24.db import create_pool
|
||||||
from onguard24.domain.events import InMemoryEventBus
|
from onguard24.domain.events import InMemoryEventBus
|
||||||
from onguard24.ingress import grafana as grafana_ingress
|
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.modules.registry import MODULE_MOUNTS, register_module_events
|
||||||
from onguard24.root_html import render_root_page
|
from onguard24.root_html import render_root_page
|
||||||
from onguard24.status_snapshot import build as build_status
|
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.basicConfig(level=logging.DEBUG)
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
# Приглушаем шумные низкоуровневые библиотеки
|
||||||
|
for _noisy in (
|
||||||
|
"httpx",
|
||||||
|
"httpcore",
|
||||||
|
"asyncio",
|
||||||
|
"uvicorn.access",
|
||||||
|
"uvicorn.error",
|
||||||
|
):
|
||||||
|
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||||
log = logging.getLogger("onguard24")
|
log = logging.getLogger("onguard24")
|
||||||
|
|
||||||
|
|
||||||
@ -32,13 +43,14 @@ def parse_addr(http_addr: str) -> tuple[str, int]:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
install_log_handler(asyncio.get_event_loop(), log_file=settings.log_file)
|
||||||
pool = await create_pool(settings)
|
pool = await create_pool(settings)
|
||||||
bus = InMemoryEventBus()
|
bus = InMemoryEventBus()
|
||||||
register_module_events(bus, pool)
|
register_module_events(bus, pool)
|
||||||
app.state.pool = pool
|
app.state.pool = pool
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.event_bus = bus
|
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
|
yield
|
||||||
if pool:
|
if pool:
|
||||||
await pool.close()
|
await pool.close()
|
||||||
@ -82,6 +94,7 @@ def create_app() -> FastAPI:
|
|||||||
return await build_status(request)
|
return await build_status(request)
|
||||||
|
|
||||||
app.include_router(grafana_ingress.router, prefix="/api/v1")
|
app.include_router(grafana_ingress.router, prefix="/api/v1")
|
||||||
|
app.include_router(logs_router)
|
||||||
for mount in MODULE_MOUNTS:
|
for mount in MODULE_MOUNTS:
|
||||||
app.include_router(mount.router, prefix=mount.url_prefix)
|
app.include_router(mount.router, prefix=mount.url_prefix)
|
||||||
if mount.ui_router is not None:
|
if mount.ui_router is not None:
|
||||||
|
|||||||
@ -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, .gc-subtable td { border: 1px solid #e4e4e7; padding: 0.3rem 0.45rem; }
|
||||||
.gc-subtable th { background: #fafafa; }
|
.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; }
|
.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(
|
items.append(
|
||||||
f'<li class="{licls}"><a href="{html.escape(href)}"{cur}>{html.escape(m.title)}</a></li>'
|
f'<li class="{licls}"><a href="{html.escape(href)}"{cur}>{html.escape(m.title)}</a></li>'
|
||||||
)
|
)
|
||||||
|
logs_active = current_slug == "__logs__"
|
||||||
|
items.append(
|
||||||
|
'<li class="rail-item rail-item--util' + (" is-active" if logs_active else "") + '">'
|
||||||
|
'<a href="/ui/logs"' + (' aria-current="page"' if logs_active else "") + ">📋 Логи</a></li>"
|
||||||
|
)
|
||||||
lis = "".join(items)
|
lis = "".join(items)
|
||||||
return (
|
return (
|
||||||
'<aside class="app-rail" role="navigation" aria-label="Разделы приложения">'
|
'<aside class="app-rail" role="navigation" aria-label="Разделы приложения">'
|
||||||
|
|||||||
@ -128,6 +128,7 @@ async def render_root_page(request: Request) -> str:
|
|||||||
<a href="/openapi.json">OpenAPI</a>
|
<a href="/openapi.json">OpenAPI</a>
|
||||||
<a href="/health">/health</a>
|
<a href="/health">/health</a>
|
||||||
<a href="/api/v1/status">JSON статус</a>
|
<a href="/api/v1/status">JSON статус</a>
|
||||||
|
<a href="/ui/logs" style="font-weight:600">📋 Логи</a>
|
||||||
</p>
|
</p>
|
||||||
<h2>Проверки доступа</h2>
|
<h2>Проверки доступа</h2>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
234
onguard24/ui_logs.py
Normal file
234
onguard24/ui_logs.py
Normal file
@ -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 = """
|
||||||
|
<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",
|
||||||
|
},
|
||||||
|
)
|
||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "onguard24"
|
name = "onguard24"
|
||||||
version = "1.10.0"
|
version = "1.10.1"
|
||||||
description = "onGuard24 — модульный сервис (аналог IRM)"
|
description = "onGuard24 — модульный сервис (аналог IRM)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
26
tests/test_json_sanitize.py
Normal file
26
tests/test_json_sanitize.py
Normal file
@ -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
|
||||||
45
tests/test_log_buffer.py
Normal file
45
tests/test_log_buffer.py
Normal file
@ -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
|
||||||
Reference in New Issue
Block a user