"""Учёт входящих алертов (отдельно от инцидентов): firing → acknowledged → resolved.""" from __future__ import annotations import html import json import logging from uuid import UUID import asyncpg from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field from onguard24.deps import get_pool from onguard24.domain.events import EventBus from onguard24.modules.ui_support import wrap_module_html_page log = logging.getLogger(__name__) router = APIRouter(tags=["module-alerts"]) ui_router = APIRouter(tags=["web-alerts"], include_in_schema=False) _VALID_STATUS = frozenset({"firing", "acknowledged", "resolved", "silenced"}) def register_events(_bus: EventBus, _pool: asyncpg.Pool | None = None) -> None: pass class AckBody(BaseModel): by_user: str | None = Field(default=None, max_length=200, description="Кто подтвердил") class ResolveBody(BaseModel): by_user: str | None = Field(default=None, max_length=200) def _row_to_item(r: asyncpg.Record) -> dict: return { "id": str(r["id"]), "ingress_event_id": str(r["ingress_event_id"]), "status": r["status"], "title": r["title"], "severity": r["severity"], "source": r["source"], "grafana_org_slug": r["grafana_org_slug"], "service_name": r["service_name"], "labels": r["labels"] if isinstance(r["labels"], dict) else {}, "fingerprint": r["fingerprint"], "acknowledged_at": r["acknowledged_at"].isoformat() if r["acknowledged_at"] else None, "acknowledged_by": r["acknowledged_by"], "resolved_at": r["resolved_at"].isoformat() if r["resolved_at"] else None, "resolved_by": r["resolved_by"], "created_at": r["created_at"].isoformat() if r["created_at"] else None, "updated_at": r["updated_at"].isoformat() if r["updated_at"] else None, } @router.get("/") async def list_alerts_api( pool: asyncpg.Pool | None = Depends(get_pool), status: str | None = None, limit: int = 100, ): if pool is None: return {"items": [], "database": "disabled"} limit = min(max(limit, 1), 200) st = (status or "").strip().lower() if st and st not in _VALID_STATUS: raise HTTPException(status_code=400, detail="invalid status filter") async with pool.acquire() as conn: if st: rows = await conn.fetch( """ SELECT * FROM irm_alerts WHERE status = $1 ORDER BY created_at DESC LIMIT $2 """, st, limit, ) else: rows = await conn.fetch( """ SELECT * FROM irm_alerts ORDER BY created_at DESC LIMIT $1 """, limit, ) return {"items": [_row_to_item(r) for r in rows]} @router.get("/{alert_id}") async def get_alert_api(alert_id: UUID, pool: asyncpg.Pool | None = Depends(get_pool)): if pool is None: raise HTTPException(status_code=503, detail="database disabled") async with pool.acquire() as conn: row = await conn.fetchrow("SELECT * FROM irm_alerts WHERE id = $1::uuid", alert_id) raw = None if row and row.get("ingress_event_id"): raw = await conn.fetchrow( "SELECT id, body, received_at FROM ingress_events WHERE id = $1::uuid", row["ingress_event_id"], ) if not row: raise HTTPException(status_code=404, detail="not found") out = _row_to_item(row) if raw: out["raw_received_at"] = raw["received_at"].isoformat() if raw["received_at"] else None body = raw["body"] out["raw_body"] = dict(body) if hasattr(body, "keys") else body else: out["raw_received_at"] = None out["raw_body"] = None return out @router.patch("/{alert_id}/acknowledge", status_code=200) async def acknowledge_alert_api( alert_id: UUID, body: AckBody, pool: asyncpg.Pool | None = Depends(get_pool), ): if pool is None: raise HTTPException(status_code=503, detail="database disabled") who = (body.by_user or "").strip() or None async with pool.acquire() as conn: row = await conn.fetchrow( """ UPDATE irm_alerts SET status = 'acknowledged', acknowledged_at = now(), acknowledged_by = COALESCE($2, acknowledged_by), updated_at = now() WHERE id = $1::uuid AND status = 'firing' RETURNING * """, alert_id, who, ) if not row: raise HTTPException(status_code=409, detail="alert not in firing state or not found") return _row_to_item(row) @router.patch("/{alert_id}/resolve", status_code=200) async def resolve_alert_api( alert_id: UUID, body: ResolveBody, pool: asyncpg.Pool | None = Depends(get_pool), ): if pool is None: raise HTTPException(status_code=503, detail="database disabled") who = (body.by_user or "").strip() or None async with pool.acquire() as conn: row = await conn.fetchrow( """ UPDATE irm_alerts SET status = 'resolved', resolved_at = now(), resolved_by = COALESCE($2, resolved_by), updated_at = now() WHERE id = $1::uuid AND status IN ('firing', 'acknowledged') RETURNING * """, alert_id, who, ) if not row: raise HTTPException( status_code=409, detail="alert cannot be resolved from current state or not found", ) return _row_to_item(row) _SYNC_BTN_STYLE = """ """ @ui_router.get("/", response_class=HTMLResponse) async def alerts_ui_list(request: Request): pool = get_pool(request) body = "" if pool is None: body = "

База не настроена.

" else: try: async with pool.acquire() as conn: rows = await conn.fetch( """ SELECT id, status, title, severity, grafana_org_slug, service_name, created_at, fingerprint FROM irm_alerts ORDER BY created_at DESC LIMIT 150 """ ) if not rows: body = "

Пока нет алертов. События появляются после вебхука Grafana.

" else: trs = [] for r in rows: aid = str(r["id"]) trs.append( "" f"{html.escape(r['status'])}" f"" f"{html.escape(aid[:8])}…" f"{html.escape((r['title'] or '—')[:200])}" f"{html.escape(r['severity'])}" f"{html.escape(str(r['grafana_org_slug'] or '—'))}" f"{html.escape(str(r['service_name'] or '—'))}" f"{html.escape(r['created_at'].isoformat() if r['created_at'] else '—')}" "" ) body = ( "

Алерт — запись о входящем уведомлении. " "Инцидент создаётся вручную (из карточки алерта или раздела «Инциденты») " "и может ссылаться на один или несколько алертов.

" "" "" + "".join(trs) + "
СтатусIDЗаголовокВажностьGrafana slugСервисСоздан
" ) except Exception as e: body = f"

{html.escape(str(e))}

" page = f"

Алерты

{body}{_SYNC_BTN_STYLE}" return HTMLResponse( wrap_module_html_page( document_title="Алерты — onGuard24", current_slug="alerts", main_inner_html=page, ) ) @ui_router.get("/{alert_id:uuid}", response_class=HTMLResponse) async def alerts_ui_detail(request: Request, alert_id: UUID): pool = get_pool(request) if pool is None: return HTMLResponse( wrap_module_html_page( document_title="Алерт — onGuard24", current_slug="alerts", main_inner_html="

Алерт

База не настроена.

", ) ) try: async with pool.acquire() as conn: row = await conn.fetchrow("SELECT * FROM irm_alerts WHERE id = $1::uuid", alert_id) raw = None if row and row.get("ingress_event_id"): raw = await conn.fetchrow( "SELECT body, received_at FROM ingress_events WHERE id = $1::uuid", row["ingress_event_id"], ) except Exception as e: return HTMLResponse( wrap_module_html_page( document_title="Алерт — onGuard24", current_slug="alerts", main_inner_html=f"

Алерт

{html.escape(str(e))}

", ) ) if not row: inner = "

Не найдено.

" else: aid = str(row["id"]) st = row["status"] title_js = json.dumps(row["title"] or "") btns = [] if st == "firing": btns.append( f"" ) if st in ("firing", "acknowledged"): btns.append( f"" ) btns.append( f"" ) lab = row["labels"] lab_s = json.dumps(dict(lab), ensure_ascii=False, indent=2) if isinstance(lab, dict) else "{}" raw_pre = "" if raw: b = raw["body"] pretty = json.dumps(dict(b), ensure_ascii=False, indent=2) if hasattr(b, "keys") else str(b) if len(pretty) > 14000: pretty = pretty[:14000] + "\n…" raw_pre = ( "

Полное тело вебхука

" f"
"
                f"{html.escape(pretty)}
" ) inner = ( f"

← К списку алертов

" f"

Алерт

{''.join(btns)}
" f"
" f"
ID
{html.escape(aid)}
" f"
Статус
{html.escape(st)}
" f"
Заголовок
{html.escape(row['title'] or '—')}
" f"
Важность
{html.escape(row['severity'])}
" f"
Grafana slug
{html.escape(str(row['grafana_org_slug'] or '—'))}
" f"
Сервис
{html.escape(str(row['service_name'] or '—'))}
" f"
Fingerprint
{html.escape(str(row['fingerprint'] or '—'))}
" f"
Labels
{html.escape(lab_s)}
" f"
{raw_pre}" ) page = f"{inner}{_SYNC_BTN_STYLE}" return HTMLResponse( wrap_module_html_page( document_title="Алерт — onGuard24", current_slug="alerts", main_inner_html=page, ) ) async def render_home_fragment(request: Request) -> str: pool = get_pool(request) if pool is None: return '

Нужна БД для учёта алертов.

' try: async with pool.acquire() as conn: n = await conn.fetchval("SELECT count(*)::int FROM irm_alerts") nf = await conn.fetchval( "SELECT count(*)::int FROM irm_alerts WHERE status = 'firing'" ) except Exception: return '

Таблица алертов недоступна (миграция 005?).

' return ( f'

Алертов в учёте: {int(n)} ' f'({int(nf)} firing). ' f'Открыть

' )