"""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'Открыть

' )