From 18ba48e8d034982296104b54dd108a2d3d7e40da Mon Sep 17 00:00:00 2001 From: Alexandr Date: Fri, 3 Apr 2026 15:34:46 +0300 Subject: [PATCH] =?UTF-8?q?release:=20v1.10.0=20=E2=80=94=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=D0=BB=D1=8C=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=20(teams),=20team=5Fid=20=D0=BD=D0=B0=20=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D1=80=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Alembic 006: teams, team_label_rules, irm_alerts.team_id - Вебхук: сопоставление команды по правилам лейблов (priority) - API/UI Команды; алерты: JOIN team, фильтр team_id - Тесты test_team_match, test_teams_api; обновлён test_root_ui Made-with: Cursor --- CHANGELOG.md | 15 ++ alembic/versions/006_teams.py | 78 +++++++ docs/IRM.md | 1 + docs/IRM_GRAFANA_PARITY.md | 3 +- onguard24/__init__.py | 2 +- onguard24/ingress/grafana.py | 7 +- onguard24/ingress/team_match.py | 51 +++++ onguard24/modules/alerts.py | 175 ++++++++++++--- onguard24/modules/registry.py | 10 + onguard24/modules/teams.py | 378 ++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_ingress.py | 1 + tests/test_root_ui.py | 2 + tests/test_team_match.py | 38 ++++ tests/test_teams_api.py | 9 + 15 files changed, 735 insertions(+), 37 deletions(-) create mode 100644 alembic/versions/006_teams.py create mode 100644 onguard24/ingress/team_match.py create mode 100644 onguard24/modules/teams.py create mode 100644 tests/test_team_match.py create mode 100644 tests/test_teams_api.py 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( "" f"{html.escape(r['status'])}" @@ -213,17 +298,34 @@ async def alerts_ui_list(request: Request): f"{html.escape(aid[:8])}…" f"{html.escape((r['title'] or '—')[:200])}" f"{html.escape(r['severity'])}" + f"{team_cell}" 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 '—')}" "" ) + opts = [""] + for t in teams_opts: + tid = str(t["id"]) + sel = " selected" if filter_tid and str(filter_tid) == tid else "" + opts.append( + f"" + ) + filter_form = ( + "
" + "
" + ) body = ( "

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

" - "" - "" + "и может ссылаться на один или несколько алертов. Команда назначается по " + "правилам лейблов.

" + + filter_form + + "
СтатусIDЗаголовокВажностьGrafana slugСервисСоздан
" + "" + "".join(trs) + "
СтатусIDЗаголовокВажностьКомандаGrafana slugСервисСоздан
" ) @@ -252,7 +354,7 @@ async def alerts_ui_detail(request: Request, alert_id: UUID): ) try: 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( @@ -303,6 +405,14 @@ async def alerts_ui_detail(request: Request, alert_id: UUID): f"background:#18181b;color:#e4e4e7;padding:0.75rem;border-radius:8px'>" f"{html.escape(pretty)}" ) + team_dd = "" + if row.get("team_id") and row.get("team_slug"): + team_dd = ( + f"
Команда
" + f"{html.escape(row['team_slug'])} ({html.escape(row.get('team_name') or '')})
" + ) + elif row.get("team_id"): + team_dd = f"
Команда
{html.escape(str(row['team_id']))}
" inner = ( f"

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

" f"

Алерт

{''.join(btns)}
" @@ -313,7 +423,8 @@ async def alerts_ui_detail(request: Request, alert_id: UUID): 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 '—'))}
" + + team_dd + + f"
Fingerprint
{html.escape(str(row['fingerprint'] or '—'))}
" f"
Labels
{html.escape(lab_s)}
" f"{raw_pre}" ) diff --git a/onguard24/modules/registry.py b/onguard24/modules/registry.py index d564965..eceebd1 100644 --- a/onguard24/modules/registry.py +++ b/onguard24/modules/registry.py @@ -22,6 +22,7 @@ from onguard24.modules import ( schedules, statusboard, tasks, + teams, ) # async (Request) -> str — фрагмент HTML для главной страницы (опционально) @@ -62,6 +63,15 @@ def _mounts() -> list[ModuleMount]: ui_router=alerts.ui_router, render_home_fragment=alerts.render_home_fragment, ), + ModuleMount( + router=teams.router, + url_prefix="/api/v1/modules/teams", + register_events=teams.register_events, + slug="teams", + title="Команды", + ui_router=teams.ui_router, + render_home_fragment=teams.render_home_fragment, + ), ModuleMount( router=incidents.router, url_prefix="/api/v1/modules/incidents", diff --git a/onguard24/modules/teams.py b/onguard24/modules/teams.py new file mode 100644 index 0000000..10d3f76 --- /dev/null +++ b/onguard24/modules/teams.py @@ -0,0 +1,378 @@ +"""IRM: команды (teams) — как Team в Grafana IRM; правила сопоставления по лейблам алерта.""" + +from __future__ import annotations + +import html +import re +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-teams"]) +ui_router = APIRouter(tags=["web-teams"], include_in_schema=False) + +_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$") + + +def register_events(_bus: EventBus, _pool: asyncpg.Pool | None = None) -> None: + pass + + +def _normalize_slug(raw: str) -> str: + s = raw.strip().lower() + if not _SLUG_RE.match(s): + raise HTTPException( + status_code=400, + detail="slug: 1–63 символа, a-z 0-9 _ -, начинается с буквы или цифры", + ) + return s + + +class TeamCreate(BaseModel): + slug: str = Field(..., min_length=1, max_length=63) + name: str = Field(..., min_length=1, max_length=200) + description: str | None = Field(default=None, max_length=2000) + + +class TeamPatch(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=200) + description: str | None = Field(default=None, max_length=2000) + + +class RuleCreate(BaseModel): + label_key: str = Field(..., min_length=1, max_length=500) + label_value: str = Field(..., min_length=1, max_length=2000) + priority: int = Field(default=0, ge=-1000, le=100000) + + +def _team_row(r: asyncpg.Record) -> dict: + return { + "id": str(r["id"]), + "slug": r["slug"], + "name": r["name"], + "description": r["description"], + "created_at": r["created_at"].isoformat() if r["created_at"] else None, + } + + +def _rule_row(r: asyncpg.Record) -> dict: + return { + "id": str(r["id"]), + "team_id": str(r["team_id"]), + "label_key": r["label_key"], + "label_value": r["label_value"], + "priority": int(r["priority"]), + "created_at": r["created_at"].isoformat() if r["created_at"] else None, + } + + +@router.get("/") +async def list_teams_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, slug, name, description, created_at + FROM teams + ORDER BY name + """ + ) + return {"items": [_team_row(r) for r in rows]} + + +@router.post("/", status_code=201) +async def create_team_api(body: TeamCreate, pool: asyncpg.Pool | None = Depends(get_pool)): + if pool is None: + raise HTTPException(status_code=503, detail="database disabled") + slug = _normalize_slug(body.slug) + async with pool.acquire() as conn: + try: + row = await conn.fetchrow( + """ + INSERT INTO teams (slug, name, description) + VALUES ($1, $2, $3) + RETURNING id, slug, name, description, created_at + """, + slug, + body.name.strip(), + (body.description or "").strip() or None, + ) + except asyncpg.UniqueViolationError: + raise HTTPException(status_code=409, detail="team slug already exists") from None + return _team_row(row) + + +@router.get("/{team_id}") +async def get_team_api(team_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, slug, name, description, created_at + FROM teams WHERE id = $1::uuid + """, + team_id, + ) + if not row: + raise HTTPException(status_code=404, detail="not found") + return _team_row(row) + + +@router.patch("/{team_id}", status_code=200) +async def patch_team_api( + team_id: UUID, + body: TeamPatch, + pool: asyncpg.Pool | None = Depends(get_pool), +): + if pool is None: + raise HTTPException(status_code=503, detail="database disabled") + name = body.name.strip() if body.name is not None else None + desc = body.description + if desc is not None: + desc = desc.strip() or None + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + UPDATE teams SET + name = COALESCE($2, name), + description = COALESCE($3, description) + WHERE id = $1::uuid + RETURNING id, slug, name, description, created_at + """, + team_id, + name, + desc, + ) + if not row: + raise HTTPException(status_code=404, detail="not found") + return _team_row(row) + + +@router.delete("/{team_id}", status_code=204) +async def delete_team_api(team_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: + r = await conn.execute("DELETE FROM teams WHERE id = $1::uuid", team_id) + if r == "DELETE 0": + raise HTTPException(status_code=404, detail="not found") + + +@router.get("/{team_id}/rules") +async def list_rules_api(team_id: UUID, pool: asyncpg.Pool | None = Depends(get_pool)): + if pool is None: + return {"items": [], "database": "disabled"} + async with pool.acquire() as conn: + ok = await conn.fetchval("SELECT 1 FROM teams WHERE id = $1::uuid", team_id) + if not ok: + raise HTTPException(status_code=404, detail="team not found") + rows = await conn.fetch( + """ + SELECT id, team_id, label_key, label_value, priority, created_at + FROM team_label_rules + WHERE team_id = $1::uuid + ORDER BY priority DESC, id ASC + """, + team_id, + ) + return {"items": [_rule_row(r) for r in rows]} + + +@router.post("/{team_id}/rules", status_code=201) +async def create_rule_api( + team_id: UUID, + body: RuleCreate, + 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: + ok = await conn.fetchval("SELECT 1 FROM teams WHERE id = $1::uuid", team_id) + if not ok: + raise HTTPException(status_code=404, detail="team not found") + row = await conn.fetchrow( + """ + INSERT INTO team_label_rules (team_id, label_key, label_value, priority) + VALUES ($1::uuid, $2, $3, $4) + RETURNING id, team_id, label_key, label_value, priority, created_at + """, + team_id, + body.label_key.strip(), + body.label_value.strip(), + body.priority, + ) + return _rule_row(row) + + +@router.delete("/{team_id}/rules/{rule_id}", status_code=204) +async def delete_rule_api( + team_id: UUID, + rule_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: + r = await conn.execute( + """ + DELETE FROM team_label_rules + WHERE id = $1::uuid AND team_id = $2::uuid + """, + rule_id, + team_id, + ) + if r == "DELETE 0": + raise HTTPException(status_code=404, detail="not found") + + +@ui_router.get("/", response_class=HTMLResponse) +async def teams_ui_list(request: Request): + pool = get_pool(request) + inner = "" + if pool is None: + inner = "

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

" + 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 при совпадении.

" + ) + else: + trs = [] + for r in rows: + tid = str(r["id"]) + trs.append( + "" + f"" + f"{html.escape(r['slug'])}" + f"{html.escape(r['name'])}" + f"{int(r['n_rules'])}" + f"{int(r['n_alerts'])}" + "" + ) + inner = ( + "

Команда соответствует колонке Team в Grafana IRM. " + "Сопоставление: первое правило по приоритету, у которого совпали ключ и значение лейбла.

" + "" + "" + + "".join(trs) + + "
SlugНазваниеПравилАлертов
" + ) + except Exception as e: + inner = f"

{html.escape(str(e))}

" + page = f"

Команды

{inner}" + return HTMLResponse( + wrap_module_html_page( + document_title="Команды — onGuard24", + current_slug="teams", + main_inner_html=page, + ) + ) + + +@ui_router.get("/{team_id:uuid}", response_class=HTMLResponse) +async def teams_ui_detail(request: Request, team_id: UUID): + pool = get_pool(request) + if pool is None: + return HTMLResponse( + wrap_module_html_page( + document_title="Команда — onGuard24", + current_slug="teams", + main_inner_html="

Команда

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

", + ) + ) + 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( + "" + f"{html.escape(ru['label_key'])}" + f"{html.escape(ru['label_value'])}" + f"{int(ru['priority'])}" + f"{html.escape(str(ru['id']))}" + "" + ) + desc = html.escape(team["description"] or "—") + inner = ( + f"

← К списку команд

" + f"

{html.escape(team['name'])}

" + f"

slug: {html.escape(team['slug'])}

" + f"

Описание: {desc}

" + "

Правила лейблов

" + "

Пример: team = infra — как в ваших алертах Grafana.

" + "" + + ("".join(rows_html) or "") + + "
КлючЗначениеPriorityID правила
Правил нет — добавьте через API.
" + f"

API: " + f"POST /api/v1/modules/teams/{tid}/rules с JSON " + "{\"label_key\":\"team\",\"label_value\":\"infra\",\"priority\":10}

" + ) + return HTMLResponse( + wrap_module_html_page( + document_title="Команда — onGuard24", + current_slug="teams", + main_inner_html=inner, + ) + ) + + +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 teams") + except Exception: + return '

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

' + return ( + f'

Команд: {int(n)}. ' + f'Открыть

' + ) diff --git a/pyproject.toml b/pyproject.toml index 865598f..6347b5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "onguard24" -version = "1.9.0" +version = "1.10.0" description = "onGuard24 — модульный сервис (аналог IRM)" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_ingress.py b/tests/test_ingress.py index c5b9cba..29c67de 100644 --- a/tests/test_ingress.py +++ b/tests/test_ingress.py @@ -9,6 +9,7 @@ def _webhook_mock_pool(mock_conn: AsyncMock) -> MagicMock: tx.__aenter__ = AsyncMock(return_value=None) tx.__aexit__ = AsyncMock(return_value=None) mock_conn.transaction = MagicMock(return_value=tx) + mock_conn.fetch = AsyncMock(return_value=[]) mock_conn.execute = AsyncMock() mock_cm = AsyncMock() mock_cm.__aenter__ = AsyncMock(return_value=mock_conn) diff --git a/tests/test_root_ui.py b/tests/test_root_ui.py index ed2887c..0e54761 100644 --- a/tests/test_root_ui.py +++ b/tests/test_root_ui.py @@ -34,6 +34,7 @@ def test_rail_lists_all_registered_ui_modules(client: TestClient) -> None: expected = ( ("grafana-catalog", "Каталог Grafana"), ("alerts", "Алерты"), + ("teams", "Команды"), ("incidents", "Инциденты"), ("tasks", "Задачи"), ("escalations", "Эскалации"), @@ -51,6 +52,7 @@ def test_each_module_page_single_active_nav_item(client: TestClient) -> None: for slug in ( "grafana-catalog", "alerts", + "teams", "incidents", "tasks", "escalations", diff --git a/tests/test_team_match.py b/tests/test_team_match.py new file mode 100644 index 0000000..00de285 --- /dev/null +++ b/tests/test_team_match.py @@ -0,0 +1,38 @@ +"""Сопоставление лейблов с командой (без БД).""" + +from uuid import UUID, uuid4 + +from onguard24.ingress.team_match import match_team_for_labels + + +def test_match_returns_none_when_empty() -> None: + assert match_team_for_labels({}, []) is None + assert match_team_for_labels({"a": "b"}, []) is None + + +def test_match_first_rule_wins_in_order() -> None: + u_infra = uuid4() + u_other = uuid4() + labels = {"team": "infra", "env": "prod"} + rules: list[tuple[UUID, str, str]] = [ + (u_infra, "team", "infra"), + (u_other, "env", "prod"), + ] + assert match_team_for_labels(labels, rules) == u_infra + + +def test_match_skips_until_value_matches() -> None: + u = uuid4() + labels = {"x": "1"} + rules: list[tuple[UUID, str, str]] = [ + (uuid4(), "x", "2"), + (u, "x", "1"), + ] + assert match_team_for_labels(labels, rules) == u + + +def test_match_coerces_label_values_to_str() -> None: + u = uuid4() + labels = {"port": 8080} + rules: list[tuple[UUID, str, str]] = [(u, "port", "8080")] + assert match_team_for_labels(labels, rules) == u diff --git a/tests/test_teams_api.py b/tests/test_teams_api.py new file mode 100644 index 0000000..6bea99c --- /dev/null +++ b/tests/test_teams_api.py @@ -0,0 +1,9 @@ +"""API модуля команд без БД.""" + +from fastapi.testclient import TestClient + + +def test_teams_list_no_db(client: TestClient) -> None: + r = client.get("/api/v1/modules/teams/") + assert r.status_code == 200 + assert r.json() == {"items": [], "database": "disabled"}