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

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