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
This commit is contained in:
378
onguard24/modules/teams.py
Normal file
378
onguard24/modules/teams.py
Normal file
@ -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 = "<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>'
|
||||
)
|
||||
Reference in New Issue
Block a user