release: v1.10.0 — модуль команд (teams), team_id на алертах
Some checks failed
CI / test (push) Successful in 43s
Deploy / deploy (push) Failing after 17s

- 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:
Alexandr
2026-04-03 15:34:46 +03:00
parent a8ccf1d35c
commit 18ba48e8d0
15 changed files with 735 additions and 37 deletions

View File

@ -1,3 +1,3 @@
"""onGuard24 — модульный монолит (ядро + модули)."""
__version__ = "1.9.0"
__version__ = "1.10.0"

View File

@ -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:

View File

@ -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))

View File

@ -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 = "<p>База не настроена.</p>"
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 = "<p>Пока нет алертов. События появляются после вебхука Grafana.</p>"
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"<a href=\"/ui/modules/teams/{html.escape(str(tid), quote=True)}\">"
f"{html.escape(ts)}</a>"
)
elif tn:
team_cell = html.escape(str(tn))
trs.append(
"<tr>"
f"<td>{html.escape(r['status'])}</td>"
@ -213,17 +298,34 @@ async def alerts_ui_list(request: Request):
f"{html.escape(aid[:8])}…</a></td>"
f"<td>{html.escape((r['title'] or '')[:200])}</td>"
f"<td>{html.escape(r['severity'])}</td>"
f"<td>{team_cell}</td>"
f"<td>{html.escape(str(r['grafana_org_slug'] or ''))}</td>"
f"<td>{html.escape(str(r['service_name'] or ''))}</td>"
f"<td>{html.escape(r['created_at'].isoformat() if r['created_at'] else '')}</td>"
"</tr>"
)
opts = ["<option value=''>Все команды</option>"]
for t in teams_opts:
tid = str(t["id"])
sel = " selected" if filter_tid and str(filter_tid) == tid else ""
opts.append(
f"<option value='{html.escape(tid, quote=True)}'{sel}>"
f"{html.escape(t['slug'])}{html.escape(t['name'])}</option>"
)
filter_form = (
"<form method='get' class='og-filter-bar' style='margin-bottom:1rem'>"
"<label>Команда <select name='team_id' onchange='this.form.submit()'>"
+ "".join(opts)
+ "</select></label></form>"
)
body = (
"<p class='gc-muted'>Алерт — запись о входящем уведомлении. "
"<strong>Инцидент</strong> создаётся вручную (из карточки алерта или раздела «Инциденты») "
"и может ссылаться на один или несколько алертов.</p>"
"<table class='irm-table'><thead><tr><th>Статус</th><th>ID</th><th>Заголовок</th>"
"<th>Важность</th><th>Grafana slug</th><th>Сервис</th><th>Создан</th></tr></thead><tbody>"
"и может ссылаться на один или несколько алертов. Команда назначается по "
"<a href=\"/ui/modules/teams/\">правилам лейблов</a>.</p>"
+ filter_form
+ "<table class='irm-table'><thead><tr><th>Статус</th><th>ID</th><th>Заголовок</th>"
"<th>Важность</th><th>Команда</th><th>Grafana slug</th><th>Сервис</th><th>Создан</th></tr></thead><tbody>"
+ "".join(trs)
+ "</tbody></table>"
)
@ -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)}</pre>"
)
team_dd = ""
if row.get("team_id") and row.get("team_slug"):
team_dd = (
f"<dt>Команда</dt><dd><a href=\"/ui/modules/teams/{html.escape(str(row['team_id']), quote=True)}\">"
f"{html.escape(row['team_slug'])}</a> ({html.escape(row.get('team_name') or '')})</dd>"
)
elif row.get("team_id"):
team_dd = f"<dt>Команда</dt><dd><code>{html.escape(str(row['team_id']))}</code></dd>"
inner = (
f"<p><a href=\"/ui/modules/alerts/\">← К списку алертов</a></p>"
f"<h1>Алерт</h1><div class='og-sync-bar'>{''.join(btns)}</div>"
@ -313,7 +423,8 @@ async def alerts_ui_detail(request: Request, alert_id: UUID):
f"<dt>Важность</dt><dd>{html.escape(row['severity'])}</dd>"
f"<dt>Grafana slug</dt><dd>{html.escape(str(row['grafana_org_slug'] or ''))}</dd>"
f"<dt>Сервис</dt><dd>{html.escape(str(row['service_name'] or ''))}</dd>"
f"<dt>Fingerprint</dt><dd><code>{html.escape(str(row['fingerprint'] or ''))}</code></dd>"
+ team_dd
+ f"<dt>Fingerprint</dt><dd><code>{html.escape(str(row['fingerprint'] or ''))}</code></dd>"
f"<dt>Labels</dt><dd><pre style='margin:0;font-size:0.8rem'>{html.escape(lab_s)}</pre></dd>"
f"</dl>{raw_pre}"
)

View File

@ -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",

378
onguard24/modules/teams.py Normal file
View 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: 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>'
)