diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 87d6495..f84ede5 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -85,7 +85,7 @@ jobs: echo "=== docker compose version ===" docker compose version echo "=== docker compose build ===" - docker compose build --progress=plain + docker compose --progress plain build echo "=== docker compose up ===" docker compose up -d docker compose ps diff --git a/onguard24/modules/grafana_catalog.py b/onguard24/modules/grafana_catalog.py index 2df6b1c..7c94a9b 100644 --- a/onguard24/modules/grafana_catalog.py +++ b/onguard24/modules/grafana_catalog.py @@ -238,47 +238,41 @@ async def list_meta_api(pool: asyncpg.Pool | None = Depends(get_pool)): return {"items": items} -@router.get("/tree") -async def tree_api( - instance_slug: str, - pool: asyncpg.Pool | None = Depends(get_pool), -): - if pool is None: - raise HTTPException(status_code=503, detail="database disabled") +async def _build_catalog_tree_dict(conn: asyncpg.Connection, instance_slug: str) -> dict | None: + """Сборка дерева из БД (общая логика для API и UI).""" slug = instance_slug.strip().lower() - async with pool.acquire() as conn: - meta = await conn.fetchrow( - """ - SELECT * FROM grafana_catalog_meta - WHERE instance_slug = $1 AND grafana_org_id > 0 - ORDER BY synced_at DESC LIMIT 1 - """, - slug, - ) - if not meta: - raise HTTPException(status_code=404, detail="no catalog for this slug; run POST /sync first") - oid = meta["grafana_org_id"] - folders = await conn.fetch( - """ - SELECT folder_uid, title, parent_uid - FROM grafana_catalog_folders - WHERE instance_slug = $1 AND grafana_org_id = $2 - ORDER BY title - """, - slug, - oid, - ) - rules = await conn.fetch( - """ - SELECT namespace_uid, rule_group_name, rule_uid, title, - rule_group_interval, labels - FROM grafana_catalog_rules - WHERE instance_slug = $1 AND grafana_org_id = $2 - ORDER BY namespace_uid, rule_group_name, title - """, - slug, - oid, - ) + meta = await conn.fetchrow( + """ + SELECT * FROM grafana_catalog_meta + WHERE instance_slug = $1 AND grafana_org_id > 0 + ORDER BY synced_at DESC LIMIT 1 + """, + slug, + ) + if not meta: + return None + oid = meta["grafana_org_id"] + folders = await conn.fetch( + """ + SELECT folder_uid, title, parent_uid + FROM grafana_catalog_folders + WHERE instance_slug = $1 AND grafana_org_id = $2 + ORDER BY title + """, + slug, + oid, + ) + rules = await conn.fetch( + """ + SELECT namespace_uid, rule_group_name, rule_uid, title, + rule_group_interval, labels + FROM grafana_catalog_rules + WHERE instance_slug = $1 AND grafana_org_id = $2 + ORDER BY namespace_uid, rule_group_name, title + """, + slug, + oid, + ) by_ns: dict[str, list[dict]] = {} for r in rules: @@ -293,6 +287,10 @@ async def tree_api( } ) + folder_uids = {f["folder_uid"] for f in folders} + orphans = sorted(set(by_ns.keys()) - folder_uids) + orphan_blocks = [{"namespace_uid": ns, "rules": by_ns[ns]} for ns in orphans] + folder_nodes = [] for f in folders: uid = f["folder_uid"] @@ -311,14 +309,118 @@ async def tree_api( "org_name": meta["org_name"], "synced_at": meta["synced_at"].isoformat() if meta["synced_at"] else None, "folders": folder_nodes, - "orphan_rule_namespaces": sorted(set(by_ns.keys()) - {f["folder_uid"] for f in folders}), + "orphan_rule_namespaces": orphans, + "orphan_rule_groups": orphan_blocks, } +@router.get("/tree") +async def tree_api( + instance_slug: str, + 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: + data = await _build_catalog_tree_dict(conn, instance_slug) + if not data: + raise HTTPException(status_code=404, detail="no catalog for this slug; run POST /sync first") + return data + + +def _rules_subtable_html(rules: list[dict]) -> str: + if not rules: + return "
Нет правил в этой группе/папке (по данным Ruler).
" + rows = [] + for ru in rules: + title = html.escape(str(ru.get("title") or "—")) + uid = html.escape(str(ru.get("rule_uid") or "—")) + grp = html.escape(str(ru.get("rule_group") or "—")) + interval = html.escape(str(ru.get("interval") or "—")) + labels = ru.get("labels") or {} + lab_s = html.escape(json.dumps(labels, ensure_ascii=False)[:240]) + if len(json.dumps(labels, ensure_ascii=False)) > 240: + lab_s += "…" + rows.append( + f"{uid}{lab_s}Папки — из API Grafana; правила — из Ruler, привязка по UID папки (namespace). " + "Название колонки «Правил» наверху — число всех записей с типом grafana_alert.
" + ] + for f in tree.get("folders") or []: + uid = html.escape(str(f.get("folder_uid") or "")) + title = html.escape(str(f.get("title") or "—")) + parent = f.get("parent_uid") + parent_s = html.escape(str(parent)) if parent else "—" + rules: list = f.get("rules") or [] + n = len(rules) + summ = ( + f"{title} " + f"· UID папки{uid} · родитель {parent_s} · правил: {n}"
+ )
+ parts.append(
+ f"{ns_e}) — часто системная группа. Правил: {len(rules)}."
+ f"{_rules_subtable_html(rules)}База не настроена.
" else: @@ -331,12 +433,26 @@ async def grafana_catalog_ui(request: Request): ORDER BY instance_slug """ ) + sync_bar = """""" if not rows: - inner = "Каталог пуст. Вызовите POST /api/v1/modules/grafana-catalog/sync.
Каталог пуст — нажмите кнопку выше (нужны GRAFANA_URL и токен в .env).
| Slug | Org | Синхр. | Папок | Правил | Ошибка | ||||
|---|---|---|---|---|---|---|---|---|---|
| Slug | Org | Синхр. | " + "Папок (API) | Правил (Ruler) | Ошибка |
|---|---|---|---|---|---|
| {html.escape(r['instance_slug'])} | " @@ -345,13 +461,27 @@ async def grafana_catalog_ui(request: Request): f"{r['folder_count']} | {r['rule_count']} | " f"{err} |
{html.escape(slug)} · {html.escape(str(tree['org_name']))}{html.escape(str(e))}
" page = f"""Иерархия: инстанс (slug) → организация → папки → правила. Синхронизация по HTTP API.
+Иерархия: инстанс (slug) → организация → папки (имена и UID) → правила (название, UID, группа, интервал, labels).
{inner} -API: POST …/grafana-catalog/sync, GET …/grafana-catalog/tree?instance_slug=…
API: POST /api/v1/modules/grafana-catalog/sync, GET …/tree?instance_slug=…
| ID | Заголовок | Статус | Важность | Источник | Grafana slug | Сервис | |
|---|---|---|---|---|---|---|---|
| Пока нет записей | |||||||
| ID | Заголовок | Статус | Важность | Источник | Grafana slug | Сервис | Создан |
| Пока нет записей | |||||||
Создание из Grafana: webhook → запись в ingress_events → событие → строка здесь.
Создание из Grafana: webhook → ingress_events → событие → строка здесь. Пустой заголовок бывает при тестовом JSON без полей алерта.
База данных не настроена.
" + return HTMLResponse( + wrap_module_html_page( + document_title="Инцидент — onGuard24", + current_slug="incidents", + main_inner_html=f"{html.escape(str(e))}
", + ) + ) + if not row: + body = "Запись не найдена.
" + else: + title = _title_cell(row["title"]) + ing = row["ingress_event_id"] + ing_l = html.escape(str(ing)) if ing else "—" + meta = f"""{html.escape(str(row['id']))}{ing_l}ingress_events · {html.escape(str(raw_row['received_at']))}
" + f"{html.escape(pretty)}"
+ )
+ except Exception as ex:
+ raw_block = f"Не удалось показать JSON: {html.escape(str(ex))}
" + body = ( + f"