diff --git a/CHANGELOG.md b/CHANGELOG.md index 54398f4..9a01cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md). +## [1.10.0] — 2026-04-03 + +Команды (teams) по лейблам, как ориентир на Grafana IRM **Team**. + +### Добавлено + +- **Alembic `006_teams`:** таблицы `teams`, `team_label_rules`, колонка **`irm_alerts.team_id`**. +- **Модуль «Команды»:** CRUD команд, правила лейблов (`priority`), UI список и карточка. +- **Вебхук Grafana:** подстановка `team_id` по первому совпадению правила. +- **Алерты:** в API и UI колонка команды, фильтр `team_id`, `GET /alerts/?team_id=…`. + +### Изменено + +- Документация [IRM_GRAFANA_PARITY.md](docs/IRM_GRAFANA_PARITY.md), [IRM.md](docs/IRM.md). + ## [1.9.0] — 2026-04-03 Алерты отдельно от инцидентов (модель ближе к Grafana IRM). diff --git a/alembic/versions/006_teams.py b/alembic/versions/006_teams.py new file mode 100644 index 0000000..af1caea --- /dev/null +++ b/alembic/versions/006_teams.py @@ -0,0 +1,78 @@ +"""IRM: команды (teams) и правила сопоставления по лейблам алерта + +Revision ID: 006_teams +Revises: 005_irm_alerts +Create Date: 2026-04-03 + +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "006_teams" +down_revision: Union[str, None] = "005_irm_alerts" +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 teams ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug text NOT NULL UNIQUE, + name text NOT NULL, + description text, + created_at timestamptz NOT NULL DEFAULT now() + ); + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS teams_slug_idx ON teams (slug); + """ + ) + op.execute( + """ + CREATE TABLE IF NOT EXISTS team_label_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + team_id uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + label_key text NOT NULL, + label_value text NOT NULL, + priority integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT team_label_rules_key_nonempty CHECK ( + length(trim(label_key)) > 0 + ) + ); + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS team_label_rules_team_idx ON team_label_rules (team_id); + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS team_label_rules_priority_idx + ON team_label_rules (priority DESC, id ASC); + """ + ) + op.execute( + """ + ALTER TABLE irm_alerts + ADD COLUMN IF NOT EXISTS team_id uuid REFERENCES teams(id) ON DELETE SET NULL; + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS irm_alerts_team_id_idx ON irm_alerts (team_id); + """ + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE irm_alerts DROP COLUMN IF EXISTS team_id;") + op.execute("DROP TABLE IF EXISTS team_label_rules;") + op.execute("DROP TABLE IF EXISTS teams;") diff --git a/docs/IRM.md b/docs/IRM.md index 35d2db3..63b2db0 100644 --- a/docs/IRM.md +++ b/docs/IRM.md @@ -10,6 +10,7 @@ | **Алерты (IRM)** | Приём, Ack/Resolve, не смешивать с инцидентом | Модуль `alerts`: `irm_alerts`, UI/API, вебхук пишет в одной транзакции с `ingress_events` | Grafana IRM Alert Groups; у нас без группировки/эскалации на уровне алерта | | **Задачи** | Подзадачи по инциденту (разбор, фикс) | Модуль `tasks`: таблица `tasks`, привязка к `incident_id` | Опционально: ссылки из алерта; основная работа в onGuard24 | | **Цепочки эскалаций** | Кого звать и в каком порядке при таймаутах | Модуль `escalations`: таблица `escalation_policies` (JSON `steps`), API/UI заготовка | Маршрутизация уведомлений может дублироваться в Grafana contact points; целевая логика — в onGuard24 | +| **Команды (teams)** | Фильтр и маршрутизация как в IRM | Модуль `teams`: `teams`, `team_label_rules`, `irm_alerts.team_id`; правила по лейблам при вебхуке | Колонка **Team** в Grafana IRM; у нас сопоставление по `label_key` = `label_value` | | **Календарь дежурств** | Кто в смене, расписание | Модуль `schedules` (развитие) | Календари/команды — данные в onGuard24; уведомления — через интеграции | | **Контакты** | Люди, каналы | Модуль `contacts` | Получатели в **Contact points** (email, Slack, webhook) | | **Светофор / статус сервисов** | Агрегат здоровья | Модуль `statusboard` | Источник метрик — Prometheus/Loki; правила — Grafana | diff --git a/docs/IRM_GRAFANA_PARITY.md b/docs/IRM_GRAFANA_PARITY.md index 4c631d1..464a543 100644 --- a/docs/IRM_GRAFANA_PARITY.md +++ b/docs/IRM_GRAFANA_PARITY.md @@ -13,12 +13,13 @@ Grafana Cloud / IRM даёт **группы алертов**, **Acknowledge / Re | Эскалации (JSON-шаги) | Модуль **Эскалации** (`escalation_policies`) — без автодвижка по таймерам | | Контакты / каналы | Модуль **Контакты** | | Расписания (заглушка) | **Календарь дежурств** — UI-задел | +| **Teams** (команда по лейблам) | Таблицы **`teams`**, **`team_label_rules`**, поле **`irm_alerts.team_id`**; вебхук подбирает команду по первому совпадению правила (priority); UI/API **Команды**, фильтр по команде в **Алертах** | ## Пока нет (зрелые следующие этапы) | Функция Grafana IRM | Заметка | |---------------------|---------| -| **Teams** с фильтрами и привязкой маршрутов | Нет сущности `team`; алерты не маршрутизируются по команде | +| **Teams** как маршрутизация уведомлений (кому слать из коробки) | Команда назначена на алерт; **цепочки уведомлений по team** — впереди (связка с escalations / contact points) | | **Alert groups** (несколько алертов в одной группе с общим ID) | Сейчас **одна строка `irm_alerts` на один webhook**; группировка fingerprint / rule_uid — отдельная задача | | **Silence / Restart** из UI | Статус `silenced` в БД зарезервирован, логика не подключена | | **Эскалация по таймеру** (wait 15m → notify next) | Политики есть, **фонового исполнителя** нет | diff --git a/onguard24/__init__.py b/onguard24/__init__.py index 0c9e4d3..0b0acbc 100644 --- a/onguard24/__init__.py +++ b/onguard24/__init__.py @@ -1,3 +1,3 @@ """onGuard24 — модульный монолит (ядро + модули).""" -__version__ = "1.9.0" +__version__ = "1.10.0" diff --git a/onguard24/ingress/grafana.py b/onguard24/ingress/grafana.py index 5c69b86..99e3937 100644 --- a/onguard24/ingress/grafana.py +++ b/onguard24/ingress/grafana.py @@ -10,6 +10,7 @@ from starlette.responses import Response from onguard24.domain.entities import Alert, Severity 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.team_match import resolve_team_id_for_labels logger = logging.getLogger(__name__) router = APIRouter(tags=["ingress"]) @@ -136,13 +137,14 @@ async def _grafana_webhook_impl( ) raw_id = row["id"] if row else None if raw_id is not None: + team_id = await resolve_team_id_for_labels(conn, labels_row) await conn.execute( """ INSERT INTO irm_alerts ( ingress_event_id, status, title, severity, source, - grafana_org_slug, service_name, labels, fingerprint + grafana_org_slug, service_name, labels, fingerprint, team_id ) - VALUES ($1, 'firing', $2, $3, 'grafana', $4, $5, $6::jsonb, $7) + VALUES ($1, 'firing', $2, $3, 'grafana', $4, $5, $6::jsonb, $7, $8) """, raw_id, title_row or "—", @@ -151,6 +153,7 @@ async def _grafana_webhook_impl( service_name, json.dumps(labels_row), fp_row, + team_id, ) bus = getattr(request.app.state, "event_bus", None) if bus and raw_id is not None: diff --git a/onguard24/ingress/team_match.py b/onguard24/ingress/team_match.py new file mode 100644 index 0000000..6b9e9a8 --- /dev/null +++ b/onguard24/ingress/team_match.py @@ -0,0 +1,51 @@ +"""Сопоставление входящего алерта с командой по правилам лейблов (как Team в Grafana IRM).""" + +from __future__ import annotations + +from typing import Any, Sequence +from uuid import UUID + +import asyncpg + + +def match_team_for_labels( + labels: dict[str, Any], + rules: Sequence[asyncpg.Record | tuple[UUID, str, str]], +) -> UUID | None: + """ + rules — упорядочены по приоритету (выше priority — раньше проверка). + Первое совпадение label_key == label_value возвращает team_id. + """ + if not labels or not rules: + return None + flat: dict[str, str] = { + str(k): "" if v is None else str(v) for k, v in labels.items() + } + for row in rules: + if isinstance(row, tuple): + tid, key, val = row[0], row[1], row[2] + else: + tid = row["team_id"] + key = row["label_key"] + val = row["label_value"] + if flat.get(str(key)) == str(val): + return tid if isinstance(tid, UUID) else UUID(str(tid)) + return None + + +async def fetch_team_rules(conn: asyncpg.Connection) -> list[asyncpg.Record]: + return await conn.fetch( + """ + SELECT team_id, label_key, label_value + FROM team_label_rules + ORDER BY priority DESC, id ASC + """ + ) + + +async def resolve_team_id_for_labels( + conn: asyncpg.Connection, + labels: dict[str, Any], +) -> UUID | None: + rules = await fetch_team_rules(conn) + return match_team_for_labels(labels, list(rules)) diff --git a/onguard24/modules/alerts.py b/onguard24/modules/alerts.py index bf17afe..3d297f6 100644 --- a/onguard24/modules/alerts.py +++ b/onguard24/modules/alerts.py @@ -37,7 +37,8 @@ class ResolveBody(BaseModel): def _row_to_item(r: asyncpg.Record) -> dict: - return { + tid = r.get("team_id") + out = { "id": str(r["id"]), "ingress_event_id": str(r["ingress_event_id"]), "status": r["status"], @@ -48,6 +49,9 @@ def _row_to_item(r: asyncpg.Record) -> dict: "service_name": r["service_name"], "labels": r["labels"] if isinstance(r["labels"], dict) else {}, "fingerprint": r["fingerprint"], + "team_id": str(tid) if tid is not None else None, + "team_slug": r.get("team_slug"), + "team_name": r.get("team_name"), "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, @@ -55,12 +59,28 @@ def _row_to_item(r: asyncpg.Record) -> dict: "created_at": r["created_at"].isoformat() if r["created_at"] else None, "updated_at": r["updated_at"].isoformat() if r["updated_at"] else None, } + return out + + +_ALERT_SELECT = """ + SELECT a.*, t.slug AS team_slug, t.name AS team_name + FROM irm_alerts a + LEFT JOIN teams t ON t.id = a.team_id +""" + + +async def _fetch_alert_with_team(conn: asyncpg.Connection, alert_id: UUID) -> asyncpg.Record | None: + return await conn.fetchrow( + f"{_ALERT_SELECT} WHERE a.id = $1::uuid", + alert_id, + ) @router.get("/") async def list_alerts_api( pool: asyncpg.Pool | None = Depends(get_pool), status: str | None = None, + team_id: UUID | None = None, limit: int = 100, ): if pool is None: @@ -70,20 +90,42 @@ async def list_alerts_api( 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: + if st and team_id is not None: rows = await conn.fetch( - """ - SELECT * FROM irm_alerts WHERE status = $1 - ORDER BY created_at DESC LIMIT $2 + f""" + {_ALERT_SELECT} + WHERE a.status = $1 AND a.team_id = $2::uuid + ORDER BY a.created_at DESC LIMIT $3 + """, + st, + team_id, + limit, + ) + elif st: + rows = await conn.fetch( + f""" + {_ALERT_SELECT} + WHERE a.status = $1 + ORDER BY a.created_at DESC LIMIT $2 """, st, limit, ) + elif team_id is not None: + rows = await conn.fetch( + f""" + {_ALERT_SELECT} + WHERE a.team_id = $1::uuid + ORDER BY a.created_at DESC LIMIT $2 + """, + team_id, + limit, + ) else: rows = await conn.fetch( - """ - SELECT * FROM irm_alerts - ORDER BY created_at DESC LIMIT $1 + f""" + {_ALERT_SELECT} + ORDER BY a.created_at DESC LIMIT $1 """, limit, ) @@ -95,7 +137,7 @@ async def get_alert_api(alert_id: UUID, pool: asyncpg.Pool | None = Depends(get_ 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) + row = await _fetch_alert_with_team(conn, alert_id) raw = None if row and row.get("ingress_event_id"): raw = await conn.fetchrow( @@ -125,7 +167,7 @@ async def acknowledge_alert_api( 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( + uid = await conn.fetchval( """ UPDATE irm_alerts SET status = 'acknowledged', @@ -133,13 +175,15 @@ async def acknowledge_alert_api( acknowledged_by = COALESCE($2, acknowledged_by), updated_at = now() WHERE id = $1::uuid AND status = 'firing' - RETURNING * + RETURNING id """, alert_id, who, ) - if not row: - raise HTTPException(status_code=409, detail="alert not in firing state or not found") + if not uid: + raise HTTPException(status_code=409, detail="alert not in firing state or not found") + row = await _fetch_alert_with_team(conn, alert_id) + assert row is not None return _row_to_item(row) @@ -153,7 +197,7 @@ async def resolve_alert_api( 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( + uid = await conn.fetchval( """ UPDATE irm_alerts SET status = 'resolved', @@ -161,16 +205,18 @@ async def resolve_alert_api( resolved_by = COALESCE($2, resolved_by), updated_at = now() WHERE id = $1::uuid AND status IN ('firing', 'acknowledged') - RETURNING * + RETURNING id """, alert_id, who, ) - if not row: - raise HTTPException( - status_code=409, - detail="alert cannot be resolved from current state or not found", - ) + if not uid: + raise HTTPException( + status_code=409, + detail="alert cannot be resolved from current state or not found", + ) + row = await _fetch_alert_with_team(conn, alert_id) + assert row is not None return _row_to_item(row) @@ -187,25 +233,64 @@ function ogInc(aid,title){var t=prompt('Заголовок инцидента',t async def alerts_ui_list(request: Request): pool = get_pool(request) body = "" + filter_tid: UUID | None = None + raw_team = (request.query_params.get("team_id") or "").strip() + if raw_team: + try: + filter_tid = UUID(raw_team) + except ValueError: + filter_tid = None 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 - """ + teams_opts = await conn.fetch( + "SELECT id, slug, name FROM teams ORDER BY name" ) + if filter_tid is not None: + rows = await conn.fetch( + """ + SELECT a.id, a.status, a.title, a.severity, a.grafana_org_slug, + a.service_name, a.created_at, a.fingerprint, + t.slug AS team_slug, t.name AS team_name, a.team_id + FROM irm_alerts a + LEFT JOIN teams t ON t.id = a.team_id + WHERE a.team_id = $1::uuid + ORDER BY a.created_at DESC + LIMIT 150 + """, + filter_tid, + ) + else: + rows = await conn.fetch( + """ + SELECT a.id, a.status, a.title, a.severity, a.grafana_org_slug, + a.service_name, a.created_at, a.fingerprint, + t.slug AS team_slug, t.name AS team_name, a.team_id + FROM irm_alerts a + LEFT JOIN teams t ON t.id = a.team_id + ORDER BY a.created_at DESC + LIMIT 150 + """ + ) if not rows: body = "Пока нет алертов. События появляются после вебхука Grafana.
" else: trs = [] for r in rows: aid = str(r["id"]) + ts = r.get("team_slug") + tn = r.get("team_name") + tid = r.get("team_id") + team_cell = "—" + if tid and ts: + team_cell = ( + f"" + f"{html.escape(ts)}" + ) + elif tn: + team_cell = html.escape(str(tn)) trs.append( "Алерт — запись о входящем уведомлении. " "Инцидент создаётся вручную (из карточки алерта или раздела «Инциденты») " - "и может ссылаться на один или несколько алертов.
" - "| Статус | ID | Заголовок | " - "Важность | Grafana slug | Сервис | Создан | |
|---|---|---|---|---|---|---|---|
| Статус | ID | Заголовок | " + "Важность | Команда | Grafana slug | Сервис | Создан |
|---|
{html.escape(str(row['team_id']))}{html.escape(str(row['fingerprint'] or '—'))}{html.escape(str(row['fingerprint'] or '—'))}{html.escape(lab_s)}База не настроена.
" + else: + try: + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT t.id, t.slug, t.name, + (SELECT count(*)::int FROM team_label_rules r WHERE r.team_id = t.id) AS n_rules, + (SELECT count(*)::int FROM irm_alerts a WHERE a.team_id = t.id) AS n_alerts + FROM teams t + ORDER BY t.name + """ + ) + if not rows: + inner = ( + "Команд пока нет. Создайте команду через API "
+ "POST /api/v1/modules/teams/ и добавьте правила лейблов — "
+ "новые алерты из Grafana получат team_id при совпадении.
Команда соответствует колонке Team в Grafana IRM. " + "Сопоставление: первое правило по приоритету, у которого совпали ключ и значение лейбла.
" + "| Slug | Название | " + "Правил | Алертов |
|---|
{html.escape(str(e))}
" + page = f"База не настроена.
", + ) + ) + try: + async with pool.acquire() as conn: + team = await conn.fetchrow( + "SELECT id, slug, name, description FROM teams WHERE id = $1::uuid", + team_id, + ) + rules = await conn.fetch( + """ + SELECT label_key, label_value, priority, id + FROM team_label_rules + WHERE team_id = $1::uuid + ORDER BY priority DESC, id ASC + """, + team_id, + ) + except Exception as e: + return HTMLResponse( + wrap_module_html_page( + document_title="Команда — onGuard24", + current_slug="teams", + main_inner_html=f"{html.escape(str(e))}
", + ) + ) + if not team: + inner = "Не найдено.
" + else: + tid = str(team["id"]) + rows_html = [] + for ru in rules: + rows_html.append( + "{html.escape(ru['label_key'])}{html.escape(ru['label_value'])}{html.escape(str(ru['id']))}slug: {html.escape(team['slug'])}
Описание: {desc}
" + "Пример: team = infra — как в ваших алертах Grafana.
| Ключ | Значение | Priority | ID правила |
|---|---|---|---|
| Правил нет — добавьте через API. | |||
API: "
+ f"POST /api/v1/modules/teams/{tid}/rules с JSON "
+ "{\"label_key\":\"team\",\"label_value\":\"infra\",\"priority\":10}
Нужна БД для команд.
' + try: + async with pool.acquire() as conn: + n = await conn.fetchval("SELECT count(*)::int FROM teams") + except Exception: + return 'Таблица teams недоступна (миграция 006?).
' + return ( + f'Команд: {int(n)}. ' + f'Открыть