"""IRM: инциденты — учёт сбоев, связь с сырым ingress и событием alert.received.""" from __future__ import annotations import html 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 AlertReceived, DomainEvent, EventBus from onguard24.modules.ui_support import wrap_module_html_page log = logging.getLogger(__name__) router = APIRouter(tags=["module-incidents"]) ui_router = APIRouter(tags=["web-incidents"], include_in_schema=False) class IncidentCreate(BaseModel): title: str = Field(..., min_length=1, max_length=500) status: str = Field(default="open", max_length=64) severity: str = Field(default="warning", max_length=32) def register_events(bus: EventBus, pool: asyncpg.Pool | None = None) -> None: if pool is None: return async def on_alert(ev: DomainEvent) -> None: if not isinstance(ev, AlertReceived) or ev.raw_payload_ref is None: return a = ev.alert title = (a.title if a else "Алерт без названия")[:500] sev = (a.severity.value if a else "warning") try: async with pool.acquire() as conn: await conn.execute( """ INSERT INTO incidents (title, status, severity, source, ingress_event_id) VALUES ($1, 'open', $2, 'grafana', $3::uuid) """, title, sev, ev.raw_payload_ref, ) except Exception: log.exception("incidents: не удалось создать инцидент из alert.received") bus.subscribe("alert.received", on_alert) 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 incidents") except Exception: return '

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

' return f'

Инцидентов в учёте: {int(n)}

' @router.get("/") async def list_incidents_api( pool: asyncpg.Pool | None = Depends(get_pool), limit: int = 50, ): if pool is None: return {"items": [], "database": "disabled"} limit = min(max(limit, 1), 200) async with pool.acquire() as conn: rows = await conn.fetch( """ SELECT id, title, status, severity, source, ingress_event_id, created_at FROM incidents ORDER BY created_at DESC LIMIT $1 """, limit, ) items = [] for r in rows: items.append( { "id": str(r["id"]), "title": r["title"], "status": r["status"], "severity": r["severity"], "source": r["source"], "ingress_event_id": str(r["ingress_event_id"]) if r["ingress_event_id"] else None, "created_at": r["created_at"].isoformat() if r["created_at"] else None, } ) return {"items": items} @router.post("/", status_code=201) async def create_incident_api( body: IncidentCreate, 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( """ INSERT INTO incidents (title, status, severity, source) VALUES ($1, $2, $3, 'manual') RETURNING id, title, status, severity, source, ingress_event_id, created_at """, body.title.strip(), body.status, body.severity, ) return { "id": str(row["id"]), "title": row["title"], "status": row["status"], "severity": row["severity"], "source": row["source"], "ingress_event_id": None, "created_at": row["created_at"].isoformat() if row["created_at"] else None, } @router.get("/{incident_id}") async def get_incident_api(incident_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 id, title, status, severity, source, ingress_event_id, created_at FROM incidents WHERE id = $1::uuid """, incident_id, ) if not row: raise HTTPException(status_code=404, detail="not found") return { "id": str(row["id"]), "title": row["title"], "status": row["status"], "severity": row["severity"], "source": row["source"], "ingress_event_id": str(row["ingress_event_id"]) if row["ingress_event_id"] else None, "created_at": row["created_at"].isoformat() if row["created_at"] else None, } @ui_router.get("/", response_class=HTMLResponse) async def incidents_ui_home(request: Request): pool = get_pool(request) rows_html = "" err = "" if pool is None: err = "

База данных не настроена.

" else: try: async with pool.acquire() as conn: rows = await conn.fetch( """ SELECT id, title, status, severity, source, created_at FROM incidents ORDER BY created_at DESC LIMIT 100 """ ) for r in rows: rows_html += ( "" f"{html.escape(str(r['id']))[:8]}…" f"{html.escape(r['title'])}" f"{html.escape(r['status'])}" f"{html.escape(r['severity'])}" f"{html.escape(r['source'])}" "" ) except Exception as e: err = f"

{html.escape(str(e))}

" inner = f"""

Инциденты

{err} {rows_html or ''}
IDЗаголовокСтатусВажностьИсточник
Пока нет записей

Создание из Grafana: webhook → запись в ingress_events → событие → строка здесь.

""" return HTMLResponse( wrap_module_html_page( document_title="Инциденты — onGuard24", current_slug="incidents", main_inner_html=inner, ) )