diff --git a/CHANGELOG.md b/CHANGELOG.md index a775671..66d5581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md). +## [1.5.0] — 2026-04-03 + +IRM-ядро: инциденты, задачи, эскалации, миграция БД, документация. + +### Добавлено + +- **Документация:** [docs/IRM.md](docs/IRM.md) — матрица функций IRM и что настраивать в Grafana. +- **Alembic `002_irm_core`:** таблицы `incidents`, `tasks`, `escalation_policies`. +- **Модули:** `incidents` (API + UI, авто-создание из `alert.received` при наличии БД), `tasks`, `escalations`. +- **`register_module_events(bus, pool)`** — подписки получают пул PostgreSQL. +- **Тесты:** `tests/test_irm_modules.py`, обновлены тесты навигации. + ## [1.4.1] — 2026-04-03 ### Исправлено diff --git a/README.md b/README.md index 22b885d..a00d2ea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # onGuard24 -**Версия: 1.4.1** · Модульный монолит на **Python (FastAPI)**: ядро, приём алертов из Grafana, заготовки модулей (дежурства, контакты, светофор), PostgreSQL, проверки Vault / Grafana / Forgejo. +**Версия: 1.5.0** · Модульный монолит на **Python (FastAPI)**: ядро, приём алертов из Grafana, заготовки модулей (дежурства, контакты, светофор), PostgreSQL, проверки Vault / Grafana / Forgejo. | Документ | Назначение | |----------|------------| @@ -10,6 +10,7 @@ | [docs/AI_CONTEXT.md](docs/AI_CONTEXT.md) | Краткий контекст для доработок | | [docs/DOMAIN.md](docs/DOMAIN.md) | Сущности (инцидент, алерт, эскалация), шина событий | | [docs/MODULES.md](docs/MODULES.md) | Как добавлять модули и подписки на события | +| [docs/IRM.md](docs/IRM.md) | Функционал IRM: что делаем, что в Grafana | **Репозиторий:** [forgejo.pvenode.ru/admin/onGuard24](https://forgejo.pvenode.ru/admin/onGuard24) @@ -21,7 +22,7 @@ - **PostgreSQL:** пул asyncpg, таблица `ingress_events` для сырых тел webhook Grafana; схема через **Alembic** (отдельные ревизии в `alembic/versions/`). - **POST `/api/v1/ingress/grafana`** — приём JSON алерта (опционально защита `X-OnGuard-Secret`). - **GET `/`**, **GET `/api/v1/status`** — проверки: БД, Vault, Grafana (service account), Forgejo (PAT). -- **Модули (API + веб-UI):** JSON под `/api/v1/modules/...`, полные HTML-страницы под `/ui/modules//`, превью на главной `/` (см. [docs/MODULES.md](docs/MODULES.md)). +- **Модули (API + веб-UI):** IRM — **инциденты**, **задачи**, **эскалации**, дежурства, контакты, светофор; JSON под `/api/v1/modules/...`, UI под `/ui/modules//` (см. [docs/MODULES.md](docs/MODULES.md), [docs/IRM.md](docs/IRM.md)). - **Фронт (опционально):** `web/` — Vite + React, прокси на API. Чего **ещё нет** (следующие версии): авторизация публичных API (кроме секрета webhook), полноценная бизнес-логика IRM в коде (эскалации, дежурства, светофор), фоновые задачи. Доменные сущности и задел под модули описаны в [docs/DOMAIN.md](docs/DOMAIN.md). @@ -35,7 +36,7 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -e . ``` -Скопируй `.env.example` в `.env` и заполни секреты (см. ниже). Перед первым запуском с БД примените миграции: +Скопируй `.env.example` в `.env` и заполни секреты (см. ниже). Перед первым запуском с БД примените миграции (в т.ч. таблицы IRM: `incidents`, `tasks`, `escalation_policies`): ```bash alembic upgrade head diff --git a/alembic/versions/002_irm_core_tables.py b/alembic/versions/002_irm_core_tables.py new file mode 100644 index 0000000..d5ab0cd --- /dev/null +++ b/alembic/versions/002_irm_core_tables.py @@ -0,0 +1,72 @@ +"""irm core: incidents, tasks, escalation_policies + +Revision ID: 002_irm_core +Revises: 001_initial +Create Date: 2026-04-03 + +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "002_irm_core" +down_revision: Union[str, None] = "001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE IF NOT EXISTS incidents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + title text NOT NULL, + status text NOT NULL DEFAULT 'open', + severity text NOT NULL DEFAULT 'warning', + source text NOT NULL DEFAULT 'grafana', + ingress_event_id uuid REFERENCES ingress_events (id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ); + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS incidents_created_at_idx + ON incidents (created_at DESC); + """ + ) + op.execute( + """ + CREATE TABLE IF NOT EXISTS tasks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + incident_id uuid REFERENCES incidents (id) ON DELETE CASCADE, + title text NOT NULL, + status text NOT NULL DEFAULT 'open', + created_at timestamptz NOT NULL DEFAULT now() + ); + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS tasks_incident_id_idx ON tasks (incident_id); + """ + ) + op.execute( + """ + CREATE TABLE IF NOT EXISTS escalation_policies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + enabled boolean NOT NULL DEFAULT true, + steps jsonb NOT NULL DEFAULT '[]'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() + ); + """ + ) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS tasks;") + op.execute("DROP TABLE IF EXISTS incidents;") + op.execute("DROP TABLE IF EXISTS escalation_policies;") diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e0bab06..07bef1c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,7 +20,7 @@ onGuard24/ │ ├── integrations/ │ │ ├── grafana_api.py # Grafana HTTP API (Bearer SA) │ │ └── forgejo_api.py # Forgejo/Gitea API (token + probe/fallback) -│ └── modules/ # API + ui_router + registry + ui_support (фрагменты главной) +│ └── modules/ # IRM: incidents, tasks, escalations, … + registry + ui_support ├── web/ # Vite + React (опционально) ├── pyproject.toml ├── pytest.ini diff --git a/docs/IRM.md b/docs/IRM.md new file mode 100644 index 0000000..2db0d13 --- /dev/null +++ b/docs/IRM.md @@ -0,0 +1,32 @@ +# IRM: функционал, назначение и реализация в onGuard24 + +Краткий ориентир для разработки (аналог облачного IRM: инциденты, задачи, эскалации, дежурства). + +## Матрица: что это, зачем, как у нас, что в Grafana + +| Область | Назначение | onGuard24 | Grafana / внешнее | +|---------|------------|-----------|-------------------| +| **Инциденты** | Учёт сбоев, статусы (open → resolved), связь с алертом | Модуль `incidents`: таблица `incidents`, API, UI, авто-создание из `alert.received` | Contact point **Webhook** → `POST /api/v1/ingress/grafana`; правила алертинга в Grafana | +| **Задачи** | Подзадачи по инциденту (разбор, фикс) | Модуль `tasks`: таблица `tasks`, привязка к `incident_id` | Опционально: ссылки из алерта; основная работа в onGuard24 | +| **Цепочки эскалаций** | Кого звать и в каком порядке при таймаутах | Модуль `escalations`: таблица `escalation_policies` (JSON `steps`), API/UI заготовка | Маршрутизация уведомлений может дублироваться в Grafana contact points; целевая логика — в onGuard24 | +| **Календарь дежурств** | Кто в смене, расписание | Модуль `schedules` (развитие) | Календари/команды — данные в onGuard24; уведомления — через интеграции | +| **Контакты** | Люди, каналы | Модуль `contacts` | Получатели в **Contact points** (email, Slack, webhook) | +| **Светофор / статус сервисов** | Агрегат здоровья | Модуль `statusboard` | Источник метрик — Prometheus/Loki; правила — Grafana | +| **Группы алертов** | Группировка шумных алертов | *План:* отдельная сущность / правила | **Alertmanager** / группировка в Grafana Alerting | +| **Интеграции** | Внешние системы | `integrations/`, статус в `/api/v1/status` | API-ключи Grafana, Vault, Forgejo в `.env` | +| **Пользователи / права** | RBAC | *Пока нет* | SSO Grafana, сеть за reverse proxy | +| **SLO** | Цели по доступности | *Вне скоупа v1* | Grafana SLO / Mimir | + +## Поток данных (алерт → инцидент) + +1. Grafana срабатывает правило → шлёт JSON на **webhook** onGuard24. +2. Сервис пишет строку в `ingress_events`, публикует **`alert.received`**. +3. Модуль **incidents** подписан на событие и создаёт запись в **`incidents`** с ссылкой на `ingress_event_id`. + +## Что настроить в Grafana (обязательно для приёма алертов) + +1. **Alerting → Contact points → New** — тип **Webhook**, URL: `https://<ваш-хост>/api/v1/ingress/grafana`, метод POST, Optional HTTP headers если задан `GRAFANA_WEBHOOK_SECRET`: `X-OnGuard-Secret: <секрет>`. +2. **Notification policies** — направить нужные правила на этот contact point (или default policy). +3. Убедиться, что сеть до onGuard24 доступна (firewall, TLS). + +Подробнее: [MODULES.md](MODULES.md), [DOMAIN.md](DOMAIN.md). diff --git a/docs/MODULES.md b/docs/MODULES.md index af3d667..9bc70cc 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -33,7 +33,7 @@ - `router` — JSON API под `/api/v1/modules/<имя>/`. - Опционально **`ui_router`** — `APIRouter(include_in_schema=False)`, маршруты полных HTML-страниц (корень `/` → `/ui/modules//`). - Опционально **`async def render_home_fragment(request) -> str`** — HTML-фрагмент (без ``) для карточки на главной. - - **`register_events(_bus)`** — подписки на шину. + - **`register_events(bus, pool)`** — подписки на шину; при необходимости используйте **`pool`** для записи в БД из обработчика события. 2. **Регистрация** в **`onguard24/modules/registry.py`** — объект **`ModuleMount`**: - `router`, `url_prefix`, `register_events`, **`slug`**, **`title`**, опционально **`ui_router`**, **`render_home_fragment`**. diff --git a/onguard24/__init__.py b/onguard24/__init__.py index 054d478..e077da5 100644 --- a/onguard24/__init__.py +++ b/onguard24/__init__.py @@ -1,3 +1,3 @@ """onGuard24 — модульный монолит (ядро + модули).""" -__version__ = "1.4.1" +__version__ = "1.5.0" diff --git a/onguard24/deps.py b/onguard24/deps.py new file mode 100644 index 0000000..1be5581 --- /dev/null +++ b/onguard24/deps.py @@ -0,0 +1,9 @@ +"""Общие зависимости FastAPI.""" + +from __future__ import annotations + +from fastapi import Request + + +def get_pool(request: Request): + return getattr(request.app.state, "pool", None) diff --git a/onguard24/main.py b/onguard24/main.py index ee33c62..eb2e502 100644 --- a/onguard24/main.py +++ b/onguard24/main.py @@ -34,7 +34,7 @@ async def lifespan(app: FastAPI): settings = get_settings() pool = await create_pool(settings) bus = InMemoryEventBus() - register_module_events(bus) + register_module_events(bus, pool) app.state.pool = pool app.state.settings = settings app.state.event_bus = bus diff --git a/onguard24/modules/contacts.py b/onguard24/modules/contacts.py index 62d0f1e..feea2ba 100644 --- a/onguard24/modules/contacts.py +++ b/onguard24/modules/contacts.py @@ -9,7 +9,7 @@ router = APIRouter(tags=["module-contacts"]) ui_router = APIRouter(tags=["web-contacts"], include_in_schema=False) -def register_events(_bus: EventBus) -> None: +def register_events(_bus: EventBus, _pool=None) -> None: pass diff --git a/onguard24/modules/escalations.py b/onguard24/modules/escalations.py new file mode 100644 index 0000000..0d9e126 --- /dev/null +++ b/onguard24/modules/escalations.py @@ -0,0 +1,153 @@ +"""IRM: цепочки эскалаций (политики в JSON, дальше — исполнение по шагам).""" + +from __future__ import annotations + +import html +import json +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 + +router = APIRouter(tags=["module-escalations"]) +ui_router = APIRouter(tags=["web-escalations"], include_in_schema=False) + + +class PolicyCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + enabled: bool = True + steps: list[dict] = Field(default_factory=list) + + +def register_events(_bus: EventBus, _pool: asyncpg.Pool | None = None) -> None: + pass + + +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 escalation_policies WHERE enabled = true") + except Exception: + return '

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

' + return f'

Активных политик: {int(n)}

' + + +@router.get("/") +async def list_policies_api(pool: asyncpg.Pool | None = Depends(get_pool)): + if pool is None: + return {"items": [], "database": "disabled"} + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, name, enabled, steps, created_at + FROM escalation_policies + ORDER BY name + """ + ) + items = [] + for r in rows: + steps = r["steps"] + if isinstance(steps, str): + steps = json.loads(steps) + items.append( + { + "id": str(r["id"]), + "name": r["name"], + "enabled": r["enabled"], + "steps": steps if isinstance(steps, list) else [], + "created_at": r["created_at"].isoformat() if r["created_at"] else None, + } + ) + return {"items": items} + + +@router.post("/", status_code=201) +async def create_policy_api(body: PolicyCreate, 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 escalation_policies (name, enabled, steps) + VALUES ($1, $2, $3::jsonb) + RETURNING id, name, enabled, steps, created_at + """, + body.name.strip(), + body.enabled, + json.dumps(body.steps), + ) + steps = row["steps"] + return { + "id": str(row["id"]), + "name": row["name"], + "enabled": row["enabled"], + "steps": list(steps) if steps else [], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + } + + +@router.delete("/{policy_id}", status_code=204) +async def delete_policy_api(policy_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( + "DELETE FROM escalation_policies WHERE id = $1::uuid RETURNING id", + policy_id, + ) + if row is None: + raise HTTPException(status_code=404, detail="not found") + + +@ui_router.get("/", response_class=HTMLResponse) +async def escalations_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, name, enabled, steps FROM escalation_policies ORDER BY name" + ) + for r in rows: + steps = r["steps"] + if hasattr(steps, "__iter__") and not isinstance(steps, (str, bytes)): + steps_preview = html.escape(json.dumps(steps, ensure_ascii=False)[:120]) + else: + steps_preview = "—" + rows_html += ( + "" + f"{html.escape(str(r['id']))[:8]}…" + f"{html.escape(r['name'])}" + f"{'да' if r['enabled'] else 'нет'}" + f"{steps_preview}" + "" + ) + except Exception as e: + err = f"

{html.escape(str(e))}

" + inner = f"""

Цепочки эскалаций

+

Заготовка: шаги хранятся в JSON; исполнение по таймерам — следующие версии.

+{err} + + +{rows_html or ''} +
IDИмяВкл.Шаги (фрагмент)
Нет политик — создайте через API POST
""" + return HTMLResponse( + wrap_module_html_page( + document_title="Эскалации — onGuard24", + current_slug="escalations", + main_inner_html=inner, + ) + ) diff --git a/onguard24/modules/incidents.py b/onguard24/modules/incidents.py new file mode 100644 index 0000000..b54f801 --- /dev/null +++ b/onguard24/modules/incidents.py @@ -0,0 +1,200 @@ +"""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, + ) + ) diff --git a/onguard24/modules/registry.py b/onguard24/modules/registry.py index 316dd90..c2b9bb0 100644 --- a/onguard24/modules/registry.py +++ b/onguard24/modules/registry.py @@ -5,6 +5,7 @@ from __future__ import annotations +import asyncpg from collections.abc import Awaitable, Callable from dataclasses import dataclass @@ -12,10 +13,18 @@ from fastapi import APIRouter from starlette.requests import Request from onguard24.domain.events import EventBus -from onguard24.modules import contacts, schedules, statusboard +from onguard24.modules import ( + contacts, + escalations, + incidents, + schedules, + statusboard, + tasks, +) # async (Request) -> str — фрагмент HTML для главной страницы (опционально) HomeFragment = Callable[[Request], Awaitable[str]] +RegisterEvents = Callable[[EventBus, asyncpg.Pool | None], None] @dataclass(frozen=True) @@ -24,7 +33,7 @@ class ModuleMount: router: APIRouter url_prefix: str - register_events: Callable[[EventBus], None] + register_events: RegisterEvents slug: str title: str ui_router: APIRouter | None = None @@ -33,6 +42,33 @@ class ModuleMount: def _mounts() -> list[ModuleMount]: return [ + ModuleMount( + router=incidents.router, + url_prefix="/api/v1/modules/incidents", + register_events=incidents.register_events, + slug="incidents", + title="Инциденты", + ui_router=incidents.ui_router, + render_home_fragment=incidents.render_home_fragment, + ), + ModuleMount( + router=tasks.router, + url_prefix="/api/v1/modules/tasks", + register_events=tasks.register_events, + slug="tasks", + title="Задачи", + ui_router=tasks.ui_router, + render_home_fragment=tasks.render_home_fragment, + ), + ModuleMount( + router=escalations.router, + url_prefix="/api/v1/modules/escalations", + register_events=escalations.register_events, + slug="escalations", + title="Эскалации", + ui_router=escalations.ui_router, + render_home_fragment=escalations.render_home_fragment, + ), ModuleMount( router=schedules.router, url_prefix="/api/v1/modules/schedules", @@ -66,6 +102,6 @@ def _mounts() -> list[ModuleMount]: MODULE_MOUNTS: list[ModuleMount] = _mounts() -def register_module_events(bus: EventBus) -> None: +def register_module_events(bus: EventBus, pool: asyncpg.Pool | None = None) -> None: for m in MODULE_MOUNTS: - m.register_events(bus) + m.register_events(bus, pool) diff --git a/onguard24/modules/schedules.py b/onguard24/modules/schedules.py index d9749db..7378789 100644 --- a/onguard24/modules/schedules.py +++ b/onguard24/modules/schedules.py @@ -9,7 +9,7 @@ router = APIRouter(tags=["module-schedules"]) ui_router = APIRouter(tags=["web-schedules"], include_in_schema=False) -def register_events(_bus: EventBus) -> None: +def register_events(_bus: EventBus, _pool=None) -> None: """Подписка на доменные события (например alert.received).""" # _bus.subscribe("alert.received", handler) diff --git a/onguard24/modules/statusboard.py b/onguard24/modules/statusboard.py index a379fc9..aabfb9f 100644 --- a/onguard24/modules/statusboard.py +++ b/onguard24/modules/statusboard.py @@ -9,7 +9,7 @@ router = APIRouter(tags=["module-statusboard"]) ui_router = APIRouter(tags=["web-statusboard"], include_in_schema=False) -def register_events(_bus: EventBus) -> None: +def register_events(_bus: EventBus, _pool=None) -> None: pass diff --git a/onguard24/modules/tasks.py b/onguard24/modules/tasks.py new file mode 100644 index 0000000..e1e74e0 --- /dev/null +++ b/onguard24/modules/tasks.py @@ -0,0 +1,159 @@ +"""IRM: задачи по инцидентам (или вне привязки).""" + +from __future__ import annotations + +import html +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 + +router = APIRouter(tags=["module-tasks"]) +ui_router = APIRouter(tags=["web-tasks"], include_in_schema=False) + + +class TaskCreate(BaseModel): + title: str = Field(..., min_length=1, max_length=500) + incident_id: UUID | None = None + + +def register_events(_bus: EventBus, _pool: asyncpg.Pool | None = None) -> None: + pass + + +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 tasks") + except Exception: + return '

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

' + return f'

Задач: {int(n)}

' + + +@router.get("/") +async def list_tasks_api( + pool: asyncpg.Pool | None = Depends(get_pool), + incident_id: UUID | None = None, + limit: int = 100, +): + if pool is None: + return {"items": [], "database": "disabled"} + limit = min(max(limit, 1), 200) + async with pool.acquire() as conn: + if incident_id: + rows = await conn.fetch( + """ + SELECT id, incident_id, title, status, created_at + FROM tasks WHERE incident_id = $1::uuid + ORDER BY created_at DESC LIMIT $2 + """, + incident_id, + limit, + ) + else: + rows = await conn.fetch( + """ + SELECT id, incident_id, title, status, created_at + FROM tasks + ORDER BY created_at DESC + LIMIT $1 + """, + limit, + ) + items = [] + for r in rows: + items.append( + { + "id": str(r["id"]), + "incident_id": str(r["incident_id"]) if r["incident_id"] else None, + "title": r["title"], + "status": r["status"], + "created_at": r["created_at"].isoformat() if r["created_at"] else None, + } + ) + return {"items": items} + + +@router.post("/", status_code=201) +async def create_task_api(body: TaskCreate, pool: asyncpg.Pool | None = Depends(get_pool)): + if pool is None: + raise HTTPException(status_code=503, detail="database disabled") + if body.incident_id: + async with pool.acquire() as conn: + ok = await conn.fetchval( + "SELECT 1 FROM incidents WHERE id = $1::uuid", + body.incident_id, + ) + if not ok: + raise HTTPException(status_code=400, detail="incident not found") + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO tasks (title, incident_id, status) + VALUES ($1, $2::uuid, 'open') + RETURNING id, incident_id, title, status, created_at + """, + body.title.strip(), + body.incident_id, + ) + return { + "id": str(row["id"]), + "incident_id": str(row["incident_id"]) if row["incident_id"] else None, + "title": row["title"], + "status": row["status"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + } + + +@ui_router.get("/", response_class=HTMLResponse) +async def tasks_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 t.id, t.title, t.status, t.incident_id, t.created_at + FROM tasks t + ORDER BY t.created_at DESC + LIMIT 100 + """ + ) + for r in rows: + iid = str(r["incident_id"])[:8] + "…" if r["incident_id"] else "—" + rows_html += ( + "" + f"{html.escape(str(r['id']))[:8]}…" + f"{html.escape(r['title'])}" + f"{html.escape(r['status'])}" + f"{html.escape(iid)}" + "" + ) + except Exception as e: + err = f"

{html.escape(str(e))}

" + inner = f"""

Задачи

+{err} + + +{rows_html or ''} +
IDЗаголовокСтатусИнцидент
Пока нет задач
""" + return HTMLResponse( + wrap_module_html_page( + document_title="Задачи — onGuard24", + current_slug="tasks", + main_inner_html=inner, + ) + ) diff --git a/onguard24/modules/ui_support.py b/onguard24/modules/ui_support.py index c33fe35..4d7d826 100644 --- a/onguard24/modules/ui_support.py +++ b/onguard24/modules/ui_support.py @@ -27,6 +27,9 @@ APP_SHELL_CSS = """ .rail-item a:hover { background: #f4f4f5; } .rail-item.is-active a { background: #eff6ff; color: #1d4ed8; font-weight: 600; } .module-page-main h1 { margin-top: 0; font-size: 1.35rem; } + .irm-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } + .irm-table th, .irm-table td { border: 1px solid #e4e4e7; padding: 0.45rem 0.65rem; text-align: left; } + .irm-table thead th { background: #f4f4f5; } """ diff --git a/pyproject.toml b/pyproject.toml index da18f87..46012bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "onguard24" -version = "1.4.1" +version = "1.5.0" description = "onGuard24 — модульный сервис (аналог IRM)" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_irm_modules.py b/tests/test_irm_modules.py new file mode 100644 index 0000000..61f64b2 --- /dev/null +++ b/tests/test_irm_modules.py @@ -0,0 +1,69 @@ +"""IRM-модули: API без БД и обработчик инцидента по событию.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from onguard24.domain.entities import Alert, Severity +from onguard24.domain.events import AlertReceived + + +def test_incidents_api_list_no_db(client: TestClient) -> None: + r = client.get("/api/v1/modules/incidents/") + assert r.status_code == 200 + assert r.json() == {"items": [], "database": "disabled"} + + +def test_tasks_api_list_no_db(client: TestClient) -> None: + r = client.get("/api/v1/modules/tasks/") + assert r.status_code == 200 + assert r.json()["database"] == "disabled" + + +def test_escalations_api_list_no_db(client: TestClient) -> None: + r = client.get("/api/v1/modules/escalations/") + assert r.status_code == 200 + assert r.json()["database"] == "disabled" + + +@pytest.mark.asyncio +async def test_incident_inserted_on_alert_received() -> None: + """При пуле БД подписка создаёт инцидент (INSERT).""" + inserted: dict = {} + + async def fake_execute(_query, *args): + inserted["args"] = args + return "INSERT 0 1" + + mock_conn = AsyncMock() + mock_conn.execute = fake_execute + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_conn) + mock_cm.__aexit__ = AsyncMock(return_value=None) + mock_pool = MagicMock() + mock_pool.acquire = MagicMock(return_value=mock_cm) + + from onguard24.domain.events import InMemoryEventBus + from onguard24.modules import incidents as inc_mod + + bus = InMemoryEventBus() + inc_mod.register_events(bus, mock_pool) + + uid = uuid4() + ev = AlertReceived( + alert=Alert(source="grafana", title="CPU high", severity=Severity.WARNING), + raw_payload_ref=uid, + ) + await bus.publish(ev) + + assert inserted.get("args") is not None + assert inserted["args"][0] == "CPU high" + assert inserted["args"][1] == "warning" + assert inserted["args"][2] == uid + + +def test_incidents_post_requires_db(client: TestClient) -> None: + r = client.post("/api/v1/modules/incidents/", json={"title": "x"}) + assert r.status_code == 503 diff --git a/tests/test_root_ui.py b/tests/test_root_ui.py index 09495b2..c3d4467 100644 --- a/tests/test_root_ui.py +++ b/tests/test_root_ui.py @@ -32,6 +32,9 @@ def test_rail_lists_all_registered_ui_modules(client: TestClient) -> None: assert r.status_code == 200 t = r.text expected = ( + ("incidents", "Инциденты"), + ("tasks", "Задачи"), + ("escalations", "Эскалации"), ("schedules", "Календарь дежурств"), ("contacts", "Контакты"), ("statusboard", "Светофор"), @@ -43,7 +46,14 @@ def test_rail_lists_all_registered_ui_modules(client: TestClient) -> None: def test_each_module_page_single_active_nav_item(client: TestClient) -> None: """На странице модуля ровно один пункт с aria-current (текущий раздел).""" - for slug in ("schedules", "contacts", "statusboard"): + for slug in ( + "incidents", + "tasks", + "escalations", + "schedules", + "contacts", + "statusboard", + ): r = client.get(f"/ui/modules/{slug}/") assert r.status_code == 200 assert r.text.count('aria-current="page"') == 1