1 Commits

Author SHA1 Message Date
18ba48e8d0 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
2026-04-03 15:34:46 +03:00
15 changed files with 735 additions and 37 deletions

View File

@ -2,6 +2,21 @@
Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md). Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md).
## [1.10.0] — 2026-04-03
Команды (teams) по лейблам, как ориентир на Grafana IRM **Team**.
### Добавлено
- **Alembic `006_teams`:** таблицы `teams`, `team_label_rules`, колонка **`irm_alerts.team_id`**.
- **Модуль «Команды»:** CRUD команд, правила лейблов (`priority`), UI список и карточка.
- **Вебхук Grafana:** подстановка `team_id` по первому совпадению правила.
- **Алерты:** в API и UI колонка команды, фильтр `team_id`, `GET /alerts/?team_id=…`.
### Изменено
- Документация [IRM_GRAFANA_PARITY.md](docs/IRM_GRAFANA_PARITY.md), [IRM.md](docs/IRM.md).
## [1.9.0] — 2026-04-03 ## [1.9.0] — 2026-04-03
Алерты отдельно от инцидентов (модель ближе к Grafana IRM). Алерты отдельно от инцидентов (модель ближе к Grafana IRM).

View File

@ -0,0 +1,78 @@
"""IRM: команды (teams) и правила сопоставления по лейблам алерта
Revision ID: 006_teams
Revises: 005_irm_alerts
Create Date: 2026-04-03
"""
from typing import Sequence, Union
from alembic import op
revision: str = "006_teams"
down_revision: Union[str, None] = "005_irm_alerts"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS teams (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL,
description text,
created_at timestamptz NOT NULL DEFAULT now()
);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS teams_slug_idx ON teams (slug);
"""
)
op.execute(
"""
CREATE TABLE IF NOT EXISTS team_label_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
team_id uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
label_key text NOT NULL,
label_value text NOT NULL,
priority integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT team_label_rules_key_nonempty CHECK (
length(trim(label_key)) > 0
)
);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS team_label_rules_team_idx ON team_label_rules (team_id);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS team_label_rules_priority_idx
ON team_label_rules (priority DESC, id ASC);
"""
)
op.execute(
"""
ALTER TABLE irm_alerts
ADD COLUMN IF NOT EXISTS team_id uuid REFERENCES teams(id) ON DELETE SET NULL;
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS irm_alerts_team_id_idx ON irm_alerts (team_id);
"""
)
def downgrade() -> None:
op.execute("ALTER TABLE irm_alerts DROP COLUMN IF EXISTS team_id;")
op.execute("DROP TABLE IF EXISTS team_label_rules;")
op.execute("DROP TABLE IF EXISTS teams;")

View File

@ -10,6 +10,7 @@
| **Алерты (IRM)** | Приём, Ack/Resolve, не смешивать с инцидентом | Модуль `alerts`: `irm_alerts`, UI/API, вебхук пишет в одной транзакции с `ingress_events` | Grafana IRM Alert Groups; у нас без группировки/эскалации на уровне алерта | | **Алерты (IRM)** | Приём, Ack/Resolve, не смешивать с инцидентом | Модуль `alerts`: `irm_alerts`, UI/API, вебхук пишет в одной транзакции с `ingress_events` | Grafana IRM Alert Groups; у нас без группировки/эскалации на уровне алерта |
| **Задачи** | Подзадачи по инциденту (разбор, фикс) | Модуль `tasks`: таблица `tasks`, привязка к `incident_id` | Опционально: ссылки из алерта; основная работа в onGuard24 | | **Задачи** | Подзадачи по инциденту (разбор, фикс) | Модуль `tasks`: таблица `tasks`, привязка к `incident_id` | Опционально: ссылки из алерта; основная работа в onGuard24 |
| **Цепочки эскалаций** | Кого звать и в каком порядке при таймаутах | Модуль `escalations`: таблица `escalation_policies` (JSON `steps`), API/UI заготовка | Маршрутизация уведомлений может дублироваться в Grafana contact points; целевая логика — в onGuard24 | | **Цепочки эскалаций** | Кого звать и в каком порядке при таймаутах | Модуль `escalations`: таблица `escalation_policies` (JSON `steps`), API/UI заготовка | Маршрутизация уведомлений может дублироваться в Grafana contact points; целевая логика — в onGuard24 |
| **Команды (teams)** | Фильтр и маршрутизация как в IRM | Модуль `teams`: `teams`, `team_label_rules`, `irm_alerts.team_id`; правила по лейблам при вебхуке | Колонка **Team** в Grafana IRM; у нас сопоставление по `label_key` = `label_value` |
| **Календарь дежурств** | Кто в смене, расписание | Модуль `schedules` (развитие) | Календари/команды — данные в onGuard24; уведомления — через интеграции | | **Календарь дежурств** | Кто в смене, расписание | Модуль `schedules` (развитие) | Календари/команды — данные в onGuard24; уведомления — через интеграции |
| **Контакты** | Люди, каналы | Модуль `contacts` | Получатели в **Contact points** (email, Slack, webhook) | | **Контакты** | Люди, каналы | Модуль `contacts` | Получатели в **Contact points** (email, Slack, webhook) |
| **Светофор / статус сервисов** | Агрегат здоровья | Модуль `statusboard` | Источник метрик — Prometheus/Loki; правила — Grafana | | **Светофор / статус сервисов** | Агрегат здоровья | Модуль `statusboard` | Источник метрик — Prometheus/Loki; правила — Grafana |

View File

@ -13,12 +13,13 @@ Grafana Cloud / IRM даёт **группы алертов**, **Acknowledge / Re
| Эскалации (JSON-шаги) | Модуль **Эскалации** (`escalation_policies`) — без автодвижка по таймерам | | Эскалации (JSON-шаги) | Модуль **Эскалации** (`escalation_policies`) — без автодвижка по таймерам |
| Контакты / каналы | Модуль **Контакты** | | Контакты / каналы | Модуль **Контакты** |
| Расписания (заглушка) | **Календарь дежурств** — UI-задел | | Расписания (заглушка) | **Календарь дежурств** — UI-задел |
| **Teams** (команда по лейблам) | Таблицы **`teams`**, **`team_label_rules`**, поле **`irm_alerts.team_id`**; вебхук подбирает команду по первому совпадению правила (priority); UI/API **Команды**, фильтр по команде в **Алертах** |
## Пока нет (зрелые следующие этапы) ## Пока нет (зрелые следующие этапы)
| Функция Grafana IRM | Заметка | | Функция Grafana IRM | Заметка |
|---------------------|---------| |---------------------|---------|
| **Teams** с фильтрами и привязкой маршрутов | Нет сущности `team`; алерты не маршрутизируются по команде | | **Teams** как маршрутизация уведомлений (кому слать из коробки) | Команда назначена на алерт; **цепочки уведомлений по team** — впереди (связка с escalations / contact points) |
| **Alert groups** (несколько алертов в одной группе с общим ID) | Сейчас **одна строка `irm_alerts` на один webhook**; группировка fingerprint / rule_uid — отдельная задача | | **Alert groups** (несколько алертов в одной группе с общим ID) | Сейчас **одна строка `irm_alerts` на один webhook**; группировка fingerprint / rule_uid — отдельная задача |
| **Silence / Restart** из UI | Статус `silenced` в БД зарезервирован, логика не подключена | | **Silence / Restart** из UI | Статус `silenced` в БД зарезервирован, логика не подключена |
| **Эскалация по таймеру** (wait 15m → notify next) | Политики есть, **фонового исполнителя** нет | | **Эскалация по таймеру** (wait 15m → notify next) | Политики есть, **фонового исполнителя** нет |

View File

@ -1,3 +1,3 @@
"""onGuard24 — модульный монолит (ядро + модули).""" """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.domain.entities import Alert, Severity
from onguard24.grafana_sources import sources_by_slug, webhook_authorized 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.grafana_payload import extract_alert_row_from_grafana_body
from onguard24.ingress.team_match import resolve_team_id_for_labels
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["ingress"]) router = APIRouter(tags=["ingress"])
@ -136,13 +137,14 @@ async def _grafana_webhook_impl(
) )
raw_id = row["id"] if row else None raw_id = row["id"] if row else None
if raw_id is not None: if raw_id is not None:
team_id = await resolve_team_id_for_labels(conn, labels_row)
await conn.execute( await conn.execute(
""" """
INSERT INTO irm_alerts ( INSERT INTO irm_alerts (
ingress_event_id, status, title, severity, source, 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, raw_id,
title_row or "", title_row or "",
@ -151,6 +153,7 @@ async def _grafana_webhook_impl(
service_name, service_name,
json.dumps(labels_row), json.dumps(labels_row),
fp_row, fp_row,
team_id,
) )
bus = getattr(request.app.state, "event_bus", None) bus = getattr(request.app.state, "event_bus", None)
if bus and raw_id is not 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: def _row_to_item(r: asyncpg.Record) -> dict:
return { tid = r.get("team_id")
out = {
"id": str(r["id"]), "id": str(r["id"]),
"ingress_event_id": str(r["ingress_event_id"]), "ingress_event_id": str(r["ingress_event_id"]),
"status": r["status"], "status": r["status"],
@ -48,6 +49,9 @@ def _row_to_item(r: asyncpg.Record) -> dict:
"service_name": r["service_name"], "service_name": r["service_name"],
"labels": r["labels"] if isinstance(r["labels"], dict) else {}, "labels": r["labels"] if isinstance(r["labels"], dict) else {},
"fingerprint": r["fingerprint"], "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_at": r["acknowledged_at"].isoformat() if r["acknowledged_at"] else None,
"acknowledged_by": r["acknowledged_by"], "acknowledged_by": r["acknowledged_by"],
"resolved_at": r["resolved_at"].isoformat() if r["resolved_at"] else None, "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, "created_at": r["created_at"].isoformat() if r["created_at"] else None,
"updated_at": r["updated_at"].isoformat() if r["updated_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("/") @router.get("/")
async def list_alerts_api( async def list_alerts_api(
pool: asyncpg.Pool | None = Depends(get_pool), pool: asyncpg.Pool | None = Depends(get_pool),
status: str | None = None, status: str | None = None,
team_id: UUID | None = None,
limit: int = 100, limit: int = 100,
): ):
if pool is None: if pool is None:
@ -70,20 +90,42 @@ async def list_alerts_api(
if st and st not in _VALID_STATUS: if st and st not in _VALID_STATUS:
raise HTTPException(status_code=400, detail="invalid status filter") raise HTTPException(status_code=400, detail="invalid status filter")
async with pool.acquire() as conn: async with pool.acquire() as conn:
if st: if st and team_id is not None:
rows = await conn.fetch( rows = await conn.fetch(
""" f"""
SELECT * FROM irm_alerts WHERE status = $1 {_ALERT_SELECT}
ORDER BY created_at DESC LIMIT $2 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, st,
limit, 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: else:
rows = await conn.fetch( rows = await conn.fetch(
""" f"""
SELECT * FROM irm_alerts {_ALERT_SELECT}
ORDER BY created_at DESC LIMIT $1 ORDER BY a.created_at DESC LIMIT $1
""", """,
limit, limit,
) )
@ -95,7 +137,7 @@ async def get_alert_api(alert_id: UUID, pool: asyncpg.Pool | None = Depends(get_
if pool is None: if pool is None:
raise HTTPException(status_code=503, detail="database disabled") raise HTTPException(status_code=503, detail="database disabled")
async with pool.acquire() as conn: 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 raw = None
if row and row.get("ingress_event_id"): if row and row.get("ingress_event_id"):
raw = await conn.fetchrow( raw = await conn.fetchrow(
@ -125,7 +167,7 @@ async def acknowledge_alert_api(
raise HTTPException(status_code=503, detail="database disabled") raise HTTPException(status_code=503, detail="database disabled")
who = (body.by_user or "").strip() or None who = (body.by_user or "").strip() or None
async with pool.acquire() as conn: async with pool.acquire() as conn:
row = await conn.fetchrow( uid = await conn.fetchval(
""" """
UPDATE irm_alerts SET UPDATE irm_alerts SET
status = 'acknowledged', status = 'acknowledged',
@ -133,13 +175,15 @@ async def acknowledge_alert_api(
acknowledged_by = COALESCE($2, acknowledged_by), acknowledged_by = COALESCE($2, acknowledged_by),
updated_at = now() updated_at = now()
WHERE id = $1::uuid AND status = 'firing' WHERE id = $1::uuid AND status = 'firing'
RETURNING * RETURNING id
""", """,
alert_id, alert_id,
who, who,
) )
if not row: if not uid:
raise HTTPException(status_code=409, detail="alert not in firing state or not found") 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) return _row_to_item(row)
@ -153,7 +197,7 @@ async def resolve_alert_api(
raise HTTPException(status_code=503, detail="database disabled") raise HTTPException(status_code=503, detail="database disabled")
who = (body.by_user or "").strip() or None who = (body.by_user or "").strip() or None
async with pool.acquire() as conn: async with pool.acquire() as conn:
row = await conn.fetchrow( uid = await conn.fetchval(
""" """
UPDATE irm_alerts SET UPDATE irm_alerts SET
status = 'resolved', status = 'resolved',
@ -161,16 +205,18 @@ async def resolve_alert_api(
resolved_by = COALESCE($2, resolved_by), resolved_by = COALESCE($2, resolved_by),
updated_at = now() updated_at = now()
WHERE id = $1::uuid AND status IN ('firing', 'acknowledged') WHERE id = $1::uuid AND status IN ('firing', 'acknowledged')
RETURNING * RETURNING id
""", """,
alert_id, alert_id,
who, who,
) )
if not row: if not uid:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail="alert cannot be resolved from current state or not found", 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) return _row_to_item(row)
@ -187,25 +233,64 @@ function ogInc(aid,title){var t=prompt('Заголовок инцидента',t
async def alerts_ui_list(request: Request): async def alerts_ui_list(request: Request):
pool = get_pool(request) pool = get_pool(request)
body = "" 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: if pool is None:
body = "<p>База не настроена.</p>" body = "<p>База не настроена.</p>"
else: else:
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
rows = await conn.fetch( teams_opts = await conn.fetch(
""" "SELECT id, slug, name FROM teams ORDER BY name"
SELECT id, status, title, severity, grafana_org_slug, service_name, created_at, fingerprint
FROM irm_alerts
ORDER BY created_at DESC
LIMIT 150
"""
) )
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: if not rows:
body = "<p>Пока нет алертов. События появляются после вебхука Grafana.</p>" body = "<p>Пока нет алертов. События появляются после вебхука Grafana.</p>"
else: else:
trs = [] trs = []
for r in rows: for r in rows:
aid = str(r["id"]) 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( trs.append(
"<tr>" "<tr>"
f"<td>{html.escape(r['status'])}</td>" 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"{html.escape(aid[:8])}…</a></td>"
f"<td>{html.escape((r['title'] or '')[:200])}</td>" f"<td>{html.escape((r['title'] or '')[:200])}</td>"
f"<td>{html.escape(r['severity'])}</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['grafana_org_slug'] or ''))}</td>"
f"<td>{html.escape(str(r['service_name'] 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>" f"<td>{html.escape(r['created_at'].isoformat() if r['created_at'] else '')}</td>"
"</tr>" "</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 = ( body = (
"<p class='gc-muted'>Алерт — запись о входящем уведомлении. " "<p class='gc-muted'>Алерт — запись о входящем уведомлении. "
"<strong>Инцидент</strong> создаётся вручную (из карточки алерта или раздела «Инциденты») " "<strong>Инцидент</strong> создаётся вручную (из карточки алерта или раздела «Инциденты») "
"и может ссылаться на один или несколько алертов.</p>" "и может ссылаться на один или несколько алертов. Команда назначается по "
"<table class='irm-table'><thead><tr><th>Статус</th><th>ID</th><th>Заголовок</th>" "<a href=\"/ui/modules/teams/\">правилам лейблов</a>.</p>"
"<th>Важность</th><th>Grafana slug</th><th>Сервис</th><th>Создан</th></tr></thead><tbody>" + 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) + "".join(trs)
+ "</tbody></table>" + "</tbody></table>"
) )
@ -252,7 +354,7 @@ async def alerts_ui_detail(request: Request, alert_id: UUID):
) )
try: try:
async with pool.acquire() as conn: 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 raw = None
if row and row.get("ingress_event_id"): if row and row.get("ingress_event_id"):
raw = await conn.fetchrow( 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"background:#18181b;color:#e4e4e7;padding:0.75rem;border-radius:8px'>"
f"{html.escape(pretty)}</pre>" 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 = ( inner = (
f"<p><a href=\"/ui/modules/alerts/\">← К списку алертов</a></p>" f"<p><a href=\"/ui/modules/alerts/\">← К списку алертов</a></p>"
f"<h1>Алерт</h1><div class='og-sync-bar'>{''.join(btns)}</div>" 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>Важность</dt><dd>{html.escape(row['severity'])}</dd>"
f"<dt>Grafana slug</dt><dd>{html.escape(str(row['grafana_org_slug'] or ''))}</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>Сервис</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"<dt>Labels</dt><dd><pre style='margin:0;font-size:0.8rem'>{html.escape(lab_s)}</pre></dd>"
f"</dl>{raw_pre}" f"</dl>{raw_pre}"
) )

View File

@ -22,6 +22,7 @@ from onguard24.modules import (
schedules, schedules,
statusboard, statusboard,
tasks, tasks,
teams,
) )
# async (Request) -> str — фрагмент HTML для главной страницы (опционально) # async (Request) -> str — фрагмент HTML для главной страницы (опционально)
@ -62,6 +63,15 @@ def _mounts() -> list[ModuleMount]:
ui_router=alerts.ui_router, ui_router=alerts.ui_router,
render_home_fragment=alerts.render_home_fragment, 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( ModuleMount(
router=incidents.router, router=incidents.router,
url_prefix="/api/v1/modules/incidents", 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>'
)

View File

@ -1,6 +1,6 @@
[project] [project]
name = "onguard24" name = "onguard24"
version = "1.9.0" version = "1.10.0"
description = "onGuard24 — модульный сервис (аналог IRM)" description = "onGuard24 — модульный сервис (аналог IRM)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@ -9,6 +9,7 @@ def _webhook_mock_pool(mock_conn: AsyncMock) -> MagicMock:
tx.__aenter__ = AsyncMock(return_value=None) tx.__aenter__ = AsyncMock(return_value=None)
tx.__aexit__ = AsyncMock(return_value=None) tx.__aexit__ = AsyncMock(return_value=None)
mock_conn.transaction = MagicMock(return_value=tx) mock_conn.transaction = MagicMock(return_value=tx)
mock_conn.fetch = AsyncMock(return_value=[])
mock_conn.execute = AsyncMock() mock_conn.execute = AsyncMock()
mock_cm = AsyncMock() mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn) mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)

View File

@ -34,6 +34,7 @@ def test_rail_lists_all_registered_ui_modules(client: TestClient) -> None:
expected = ( expected = (
("grafana-catalog", "Каталог Grafana"), ("grafana-catalog", "Каталог Grafana"),
("alerts", "Алерты"), ("alerts", "Алерты"),
("teams", "Команды"),
("incidents", "Инциденты"), ("incidents", "Инциденты"),
("tasks", "Задачи"), ("tasks", "Задачи"),
("escalations", "Эскалации"), ("escalations", "Эскалации"),
@ -51,6 +52,7 @@ def test_each_module_page_single_active_nav_item(client: TestClient) -> None:
for slug in ( for slug in (
"grafana-catalog", "grafana-catalog",
"alerts", "alerts",
"teams",
"incidents", "incidents",
"tasks", "tasks",
"escalations", "escalations",

38
tests/test_team_match.py Normal file
View File

@ -0,0 +1,38 @@
"""Сопоставление лейблов с командой (без БД)."""
from uuid import UUID, uuid4
from onguard24.ingress.team_match import match_team_for_labels
def test_match_returns_none_when_empty() -> None:
assert match_team_for_labels({}, []) is None
assert match_team_for_labels({"a": "b"}, []) is None
def test_match_first_rule_wins_in_order() -> None:
u_infra = uuid4()
u_other = uuid4()
labels = {"team": "infra", "env": "prod"}
rules: list[tuple[UUID, str, str]] = [
(u_infra, "team", "infra"),
(u_other, "env", "prod"),
]
assert match_team_for_labels(labels, rules) == u_infra
def test_match_skips_until_value_matches() -> None:
u = uuid4()
labels = {"x": "1"}
rules: list[tuple[UUID, str, str]] = [
(uuid4(), "x", "2"),
(u, "x", "1"),
]
assert match_team_for_labels(labels, rules) == u
def test_match_coerces_label_values_to_str() -> None:
u = uuid4()
labels = {"port": 8080}
rules: list[tuple[UUID, str, str]] = [(u, "port", "8080")]
assert match_team_for_labels(labels, rules) == u

9
tests/test_teams_api.py Normal file
View File

@ -0,0 +1,9 @@
"""API модуля команд без БД."""
from fastapi.testclient import TestClient
def test_teams_list_no_db(client: TestClient) -> None:
r = client.get("/api/v1/modules/teams/")
assert r.status_code == 200
assert r.json() == {"items": [], "database": "disabled"}