v1.5.0: IRM — инциденты, задачи, эскалации

- docs/IRM.md; Alembic 002: incidents, tasks, escalation_policies
- Модули incidents/tasks/escalations: API, UI, register_events(bus, pool)
- Авто-инцидент из alert.received; тесты test_irm_modules.py

Made-with: Cursor
This commit is contained in:
Alexandr
2026-04-03 09:03:16 +03:00
parent 0787745098
commit 89b5983526
20 changed files with 772 additions and 16 deletions

View File

@ -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
### Исправлено

View File

@ -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/<slug>/`, превью на главной `/` (см. [docs/MODULES.md](docs/MODULES.md)).
- **Модули (API + веб-UI):** IRM — **инциденты**, **задачи**, **эскалации**, дежурства, контакты, светофор; JSON под `/api/v1/modules/...`, UI под `/ui/modules/<slug>/` (см. [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

View File

@ -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;")

View File

@ -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

32
docs/IRM.md Normal file
View File

@ -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).

View File

@ -33,7 +33,7 @@
- `router` — JSON API под `/api/v1/modules/<имя>/`.
- Опционально **`ui_router`** — `APIRouter(include_in_schema=False)`, маршруты полных HTML-страниц (корень `/``/ui/modules/<slug>/`).
- Опционально **`async def render_home_fragment(request) -> str`** — HTML-фрагмент (без `<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`**.

View File

@ -1,3 +1,3 @@
"""onGuard24 — модульный монолит (ядро + модули)."""
__version__ = "1.4.1"
__version__ = "1.5.0"

9
onguard24/deps.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 '<p class="module-note">Нужна БД для политик эскалации.</p>'
try:
async with pool.acquire() as conn:
n = await conn.fetchval("SELECT count(*)::int FROM escalation_policies WHERE enabled = true")
except Exception:
return '<p class="module-note">Таблица политик недоступна (миграции?).</p>'
return f'<div class="module-fragment"><p>Активных политик: <strong>{int(n)}</strong></p></div>'
@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 = "<p>База данных не настроена.</p>"
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 += (
"<tr>"
f"<td>{html.escape(str(r['id']))[:8]}…</td>"
f"<td>{html.escape(r['name'])}</td>"
f"<td>{'да' if r['enabled'] else 'нет'}</td>"
f"<td><code>{steps_preview}</code></td>"
"</tr>"
)
except Exception as e:
err = f"<p class=\"module-err\">{html.escape(str(e))}</p>"
inner = f"""<h1>Цепочки эскалаций</h1>
<p>Заготовка: шаги хранятся в JSON; исполнение по таймерам — следующие версии.</p>
{err}
<table class="irm-table">
<thead><tr><th>ID</th><th>Имя</th><th>Вкл.</th><th>Шаги (фрагмент)</th></tr></thead>
<tbody>{rows_html or '<tr><td colspan="4">Нет политик — создайте через API POST</td></tr>'}</tbody>
</table>"""
return HTMLResponse(
wrap_module_html_page(
document_title="Эскалации — onGuard24",
current_slug="escalations",
main_inner_html=inner,
)
)

View File

@ -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 '<p class="module-note">Нужна БД для списка инцидентов.</p>'
try:
async with pool.acquire() as conn:
n = await conn.fetchval("SELECT count(*)::int FROM incidents")
except Exception:
return '<p class="module-note">Таблица инцидентов недоступна (миграции?).</p>'
return f'<div class="module-fragment"><p>Инцидентов в учёте: <strong>{int(n)}</strong></p></div>'
@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 = "<p>База данных не настроена.</p>"
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 += (
"<tr>"
f"<td>{html.escape(str(r['id']))[:8]}…</td>"
f"<td>{html.escape(r['title'])}</td>"
f"<td>{html.escape(r['status'])}</td>"
f"<td>{html.escape(r['severity'])}</td>"
f"<td>{html.escape(r['source'])}</td>"
"</tr>"
)
except Exception as e:
err = f"<p class=\"module-err\">{html.escape(str(e))}</p>"
inner = f"""<h1>Инциденты</h1>
{err}
<table class="irm-table">
<thead><tr><th>ID</th><th>Заголовок</th><th>Статус</th><th>Важность</th><th>Источник</th></tr></thead>
<tbody>{rows_html or '<tr><td colspan="5">Пока нет записей</td></tr>'}</tbody>
</table>
<p><small>Создание из Grafana: webhook → запись в <code>ingress_events</code> → событие → строка здесь.</small></p>"""
return HTMLResponse(
wrap_module_html_page(
document_title="Инциденты — onGuard24",
current_slug="incidents",
main_inner_html=inner,
)
)

View File

@ -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)

View File

@ -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)

View File

@ -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

159
onguard24/modules/tasks.py Normal file
View File

@ -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 '<p class="module-note">Нужна БД для задач.</p>'
try:
async with pool.acquire() as conn:
n = await conn.fetchval("SELECT count(*)::int FROM tasks")
except Exception:
return '<p class="module-note">Таблица задач недоступна (миграции?).</p>'
return f'<div class="module-fragment"><p>Задач: <strong>{int(n)}</strong></p></div>'
@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 = "<p>База данных не настроена.</p>"
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 += (
"<tr>"
f"<td>{html.escape(str(r['id']))[:8]}…</td>"
f"<td>{html.escape(r['title'])}</td>"
f"<td>{html.escape(r['status'])}</td>"
f"<td>{html.escape(iid)}</td>"
"</tr>"
)
except Exception as e:
err = f"<p class=\"module-err\">{html.escape(str(e))}</p>"
inner = f"""<h1>Задачи</h1>
{err}
<table class="irm-table">
<thead><tr><th>ID</th><th>Заголовок</th><th>Статус</th><th>Инцидент</th></tr></thead>
<tbody>{rows_html or '<tr><td colspan="4">Пока нет задач</td></tr>'}</tbody>
</table>"""
return HTMLResponse(
wrap_module_html_page(
document_title="Задачи — onGuard24",
current_slug="tasks",
main_inner_html=inner,
)
)

View File

@ -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; }
"""

View File

@ -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"

69
tests/test_irm_modules.py Normal file
View File

@ -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

View File

@ -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