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:
15
CHANGELOG.md
15
CHANGELOG.md
@ -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).
|
||||||
|
|||||||
78
alembic/versions/006_teams.py
Normal file
78
alembic/versions/006_teams.py
Normal 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;")
|
||||||
@ -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 |
|
||||||
|
|||||||
@ -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) | Политики есть, **фонового исполнителя** нет |
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"""onGuard24 — модульный монолит (ядро + модули)."""
|
"""onGuard24 — модульный монолит (ядро + модули)."""
|
||||||
|
|
||||||
__version__ = "1.9.0"
|
__version__ = "1.10.0"
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
51
onguard24/ingress/team_match.py
Normal file
51
onguard24/ingress/team_match.py
Normal 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))
|
||||||
@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
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>'
|
||||||
|
)
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
38
tests/test_team_match.py
Normal 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
9
tests/test_teams_api.py
Normal 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"}
|
||||||
Reference in New Issue
Block a user