From 420821f3a08739bb41941ff9c3e4204fe7f17edf Mon Sep 17 00:00:00 2001 From: Alexandr Date: Fri, 3 Apr 2026 15:04:22 +0300 Subject: [PATCH] =?UTF-8?q?ui:=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=20Grafana=20=D0=B2=20=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80=D0=B5,=20=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D0=BE=20=D0=BF=D0=B0=D0=BF=D0=BE=D0=BA/?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB;=20=D0=B8=D0=BD=D1=86?= =?UTF-8?q?=D0=B8=D0=B4=D0=B5=D0=BD=D1=82=D1=8B=20=E2=80=94=20=D1=81=D1=81?= =?UTF-8?q?=D1=8B=D0=BB=D0=BA=D0=B0,=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D0=BA=D0=B0,=20=D1=81=D1=8B=D1=80=D0=BE=D0=B9=20JSON;=20ci:=20?= =?UTF-8?q?docker=20compose=20--progress=20plain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .gitea/workflows/deploy.yml | 2 +- onguard24/modules/grafana_catalog.py | 222 +++++++++++++++++++++------ onguard24/modules/incidents.py | 108 ++++++++++++- onguard24/modules/ui_support.py | 16 ++ 4 files changed, 296 insertions(+), 52 deletions(-) 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"{title}{uid}" + f"{grp}{interval}{lab_s}" + ) + head = "Название правилаrule_uidГруппаИнтервалLabels (фрагмент)" + return f"{head}{''.join(rows)}
" + + +def _catalog_tree_html(tree: dict) -> str: + """HTML: папки и правила под ними + «осиротевшие» namespace из Ruler.""" + parts: list[str] = [ + "

Папки и правила

" + "

Папки — из 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"
{summ}{_rules_subtable_html(rules)}
" + ) + for block in tree.get("orphan_rule_groups") or []: + ns = block.get("namespace_uid") + rules = block.get("rules") or [] + ns_e = html.escape(str(ns)) + parts.append( + f"
Namespace Ruler без папки в API " + f"({ns_e}) — часто системная группа. Правил: {len(rules)}." + f"{_rules_subtable_html(rules)}
" + ) + parts.append("
") + return "".join(parts) + + +_SYNC_SCRIPT = """ + +""" + + @ui_router.get("/", response_class=HTMLResponse) async def grafana_catalog_ui(request: Request): pool = get_pool(request) inner = "" + tree_html = "" if pool is None: inner = "

База не настроена.

" 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.

" + inner = ( + sync_bar + + "

Каталог пуст — нажмите кнопку выше (нужны GRAFANA_URL и токен в .env).

" + ) else: - inner = "" + inner = ( + sync_bar + + "

Строка ниже — сводка по последней синхронизации. " + "Число Правил — все правила алертинга (Grafana-managed) из Ruler для этой org.

" + + "
SlugOrgСинхр.ПапокПравилОшибка
" + "" + ) + seen_slugs: set[str] = set() for r in rows: - err = html.escape(str(r["error_text"] or "—"))[:120] + err = html.escape(str(r["error_text"] or "—"))[:200] st = r["synced_at"].isoformat() if r["synced_at"] else "—" inner += ( f"" @@ -345,13 +461,27 @@ async def grafana_catalog_ui(request: Request): f"" f"" ) + slug = str(r["instance_slug"]) + if slug not in seen_slugs: + seen_slugs.add(slug) inner += "
SlugOrgСинхр.Папок (API)Правил (Ruler)Ошибка
{html.escape(r['instance_slug'])}{r['folder_count']}{r['rule_count']}{err}
" + async with pool.acquire() as conn: + for slug in sorted(seen_slugs): + tree = await _build_catalog_tree_dict(conn, slug) + if tree: + tree_html += ( + f"

Инстанс " + f"{html.escape(slug)} · {html.escape(str(tree['org_name']))}

" + + _catalog_tree_html(tree) + ) except Exception as e: inner = f"

{html.escape(str(e))}

" page = f"""

Каталог Grafana

-

Иерархия: инстанс (slug) → организация → папки → правила. Синхронизация по HTTP API.

+

Иерархия: инстанс (slug) → организация → папки (имена и UID) → правила (название, UID, группа, интервал, labels).

{inner} -

API: POST …/grafana-catalog/sync, GET …/grafana-catalog/tree?instance_slug=…

""" +{tree_html} +

API: POST /api/v1/modules/grafana-catalog/sync, GET …/tree?instance_slug=…

+{_SYNC_SCRIPT}""" return HTMLResponse( wrap_module_html_page( document_title="Каталог Grafana — onGuard24", diff --git a/onguard24/modules/incidents.py b/onguard24/modules/incidents.py index dc16e08..cd65b25 100644 --- a/onguard24/modules/incidents.py +++ b/onguard24/modules/incidents.py @@ -3,6 +3,7 @@ from __future__ import annotations import html +import json import logging from uuid import UUID @@ -261,6 +262,11 @@ async def patch_incident_api( return _incident_row_dict(row) +def _title_cell(raw: object) -> str: + t = (str(raw).strip() if raw is not None else "") or "—" + return html.escape(t) + + @ui_router.get("/", response_class=HTMLResponse) async def incidents_ui_home(request: Request): pool = get_pool(request) @@ -280,17 +286,22 @@ async def incidents_ui_home(request: Request): """ ) for r in rows: + iid = r["id"] + iid_s = str(iid) org = html.escape(str(r["grafana_org_slug"] or "—")) svc = html.escape(str(r["service_name"] or "—")) + ca = r["created_at"].isoformat() if r["created_at"] else "—" rows_html += ( "" - f"{html.escape(str(r['id']))[:8]}…" - f"{html.escape(r['title'])}" + f"" + f"{html.escape(iid_s[:8])}…" + f"{_title_cell(r['title'])}" f"{html.escape(r['status'])}" f"{html.escape(r['severity'])}" f"{html.escape(r['source'])}" f"{org}" f"{svc}" + f"{html.escape(ca)}" "" ) except Exception as e: @@ -298,10 +309,10 @@ async def incidents_ui_home(request: Request): inner = f"""

Инциденты

{err} - -{rows_html or ''} + +{rows_html or ''}
IDЗаголовокСтатусВажностьИсточникGrafana slugСервис
Пока нет записей
IDЗаголовокСтатусВажностьИсточникGrafana slugСервисСоздан
Пока нет записей
-

Создание из Grafana: webhook → запись в ingress_events → событие → строка здесь.

""" +

Создание из Grafana: webhook → ingress_events → событие → строка здесь. Пустой заголовок бывает при тестовом JSON без полей алерта.

""" return HTMLResponse( wrap_module_html_page( document_title="Инциденты — onGuard24", @@ -309,3 +320,90 @@ async def incidents_ui_home(request: Request): main_inner_html=inner, ) ) + + +@ui_router.get("/{incident_id:uuid}", response_class=HTMLResponse) +async def incident_detail_ui(request: Request, incident_id: UUID): + pool = get_pool(request) + if pool is None: + body = "

База данных не настроена.

" + return HTMLResponse( + wrap_module_html_page( + document_title="Инцидент — onGuard24", + current_slug="incidents", + main_inner_html=f"

Инцидент

{body}", + ) + ) + try: + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, title, status, severity, source, ingress_event_id, created_at, updated_at, + grafana_org_slug, service_name + FROM incidents WHERE id = $1::uuid + """, + incident_id, + ) + raw_row = None + if row and row.get("ingress_event_id"): + raw_row = await conn.fetchrow( + """ + SELECT id, source, received_at, body, org_slug, service_name + FROM ingress_events WHERE id = $1::uuid + """, + row["ingress_event_id"], + ) + except Exception as e: + 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"""
+
ID
{html.escape(str(row['id']))}
+
Заголовок
{title}
+
Статус
{html.escape(row['status'])}
+
Важность
{html.escape(row['severity'])}
+
Источник
{html.escape(row['source'])}
+
Grafana slug
{html.escape(str(row['grafana_org_slug'] or '—'))}
+
Сервис
{html.escape(str(row['service_name'] or '—'))}
+
Создан
{html.escape(row['created_at'].isoformat() if row['created_at'] else '—')}
+
Обновлён
{html.escape(row['updated_at'].isoformat() if row.get('updated_at') else '—')}
+
ingress_event_id
{ing_l}
+
""" + raw_block = "" + if raw_row: + try: + body_obj = raw_row["body"] + if hasattr(body_obj, "keys"): + pretty = json.dumps(dict(body_obj), ensure_ascii=False, indent=2) + else: + pretty = str(body_obj) + if len(pretty) > 12000: + pretty = pretty[:12000] + "\n…" + raw_block = ( + "

Сырой JSON вебхука

" + f"

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"

← К списку

Инцидент

{meta}{raw_block}" + ) + return HTMLResponse( + wrap_module_html_page( + document_title="Инцидент — onGuard24", + current_slug="incidents", + main_inner_html=body, + ) + ) diff --git a/onguard24/modules/ui_support.py b/onguard24/modules/ui_support.py index 4d7d826..d350687 100644 --- a/onguard24/modules/ui_support.py +++ b/onguard24/modules/ui_support.py @@ -30,6 +30,22 @@ APP_SHELL_CSS = """ .irm-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } .irm-table th, .irm-table td { border: 1px solid #e4e4e7; padding: 0.45rem 0.65rem; text-align: left; } .irm-table thead th { background: #f4f4f5; } + .og-btn { padding: 0.45rem 0.9rem; font-size: 0.875rem; border-radius: 6px; + border: 1px solid #d4d4d8; background: #fff; cursor: pointer; margin-right: 0.5rem; } + .og-btn:hover { background: #f4f4f5; } + .og-btn-primary { background: #2563eb; color: #fff; border-color: #1d4ed8; } + .og-btn-primary:hover { background: #1d4ed8; } + .og-sync-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 1rem 0; } + .og-sync-status { font-size: 0.85rem; color: #52525b; min-height: 1.25rem; } + .gc-tree { margin-top: 1.25rem; } + .gc-folder { margin: 0.4rem 0; border: 1px solid #e4e4e7; border-radius: 8px; padding: 0.35rem 0.6rem; background: #fff; } + .gc-folder summary { cursor: pointer; list-style: none; } + .gc-folder summary::-webkit-details-marker { display: none; } + .gc-muted { color: #71717a; font-size: 0.82rem; font-weight: normal; } + .gc-subtable { width: 100%; border-collapse: collapse; font-size: 0.82rem; margin: 0.5rem 0 0.25rem; } + .gc-subtable th, .gc-subtable td { border: 1px solid #e4e4e7; padding: 0.3rem 0.45rem; } + .gc-subtable th { background: #fafafa; } + .gc-orphan { margin-top: 1rem; padding: 0.75rem; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 8px; font-size: 0.88rem; } """