Files
onGuard24/onguard24/modules/teams.py
Alexandr 18ba48e8d0
Some checks failed
CI / test (push) Successful in 43s
Deploy / deploy (push) Failing after 17s
release: v1.10.0 — модуль команд (teams), team_id на алертах
- 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
2026-04-03 15:34:46 +03:00

379 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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: 163 символа, 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 = "<p>База не настроена.</p>"
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 = (
"<p>Команд пока нет. Создайте команду через API "
"<code>POST /api/v1/modules/teams/</code> и добавьте правила лейблов — "
"новые алерты из Grafana получат <code>team_id</code> при совпадении.</p>"
)
else:
trs = []
for r in rows:
tid = str(r["id"])
trs.append(
"<tr>"
f"<td><a href=\"/ui/modules/teams/{html.escape(tid, quote=True)}\">"
f"{html.escape(r['slug'])}</a></td>"
f"<td>{html.escape(r['name'])}</td>"
f"<td>{int(r['n_rules'])}</td>"
f"<td>{int(r['n_alerts'])}</td>"
"</tr>"
)
inner = (
"<p class='gc-muted'>Команда соответствует колонке <strong>Team</strong> в Grafana IRM. "
"Сопоставление: первое правило по приоритету, у которого совпали ключ и значение лейбла.</p>"
"<table class='irm-table'><thead><tr><th>Slug</th><th>Название</th>"
"<th>Правил</th><th>Алертов</th></tr></thead><tbody>"
+ "".join(trs)
+ "</tbody></table>"
)
except Exception as e:
inner = f"<p class='module-err'>{html.escape(str(e))}</p>"
page = f"<h1>Команды</h1>{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="<h1>Команда</h1><p>База не настроена.</p>",
)
)
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"<h1>Команда</h1><p class='module-err'>{html.escape(str(e))}</p>",
)
)
if not team:
inner = "<p>Не найдено.</p>"
else:
tid = str(team["id"])
rows_html = []
for ru in rules:
rows_html.append(
"<tr>"
f"<td><code>{html.escape(ru['label_key'])}</code></td>"
f"<td><code>{html.escape(ru['label_value'])}</code></td>"
f"<td>{int(ru['priority'])}</td>"
f"<td><code>{html.escape(str(ru['id']))}</code></td>"
"</tr>"
)
desc = html.escape(team["description"] or "")
inner = (
f"<p><a href=\"/ui/modules/teams/\">← К списку команд</a></p>"
f"<h1>{html.escape(team['name'])}</h1>"
f"<p><strong>slug:</strong> <code>{html.escape(team['slug'])}</code></p>"
f"<p><strong>Описание:</strong> {desc}</p>"
"<h2 style='font-size:1.05rem;margin-top:1rem'>Правила лейблов</h2>"
"<p class='gc-muted'>Пример: <code>team</code> = <code>infra</code> — как в ваших алертах Grafana.</p>"
"<table class='irm-table'><thead><tr><th>Ключ</th><th>Значение</th><th>Priority</th><th>ID правила</th></tr></thead><tbody>"
+ ("".join(rows_html) or "<tr><td colspan='4'>Правил нет — добавьте через API.</td></tr>")
+ "</tbody></table>"
f"<p style='margin-top:1rem;font-size:0.85rem'>API: "
f"<code>POST /api/v1/modules/teams/{tid}/rules</code> с JSON "
"<code>{\"label_key\":\"team\",\"label_value\":\"infra\",\"priority\":10}</code></p>"
)
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 '<p class="module-note">Нужна БД для команд.</p>'
try:
async with pool.acquire() as conn:
n = await conn.fetchval("SELECT count(*)::int FROM teams")
except Exception:
return '<p class="module-note">Таблица teams недоступна (миграция 006?).</p>'
return (
f'<div class="module-fragment"><p>Команд: <strong>{int(n)}</strong>. '
f'<a href="/ui/modules/teams/">Открыть</a></p></div>'
)