"""Кэш иерархии Grafana (инстанс → org → папки → правила) через API + БД.""" from __future__ import annotations import html import json import logging from dataclasses import dataclass import asyncpg from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field from onguard24.config import get_settings from onguard24.deps import get_pool from onguard24.domain.events import EventBus from onguard24.grafana_sources import iter_grafana_sources from onguard24.integrations import grafana_topology as gt from onguard24.modules.ui_support import wrap_module_html_page log = logging.getLogger(__name__) router = APIRouter(tags=["module-grafana-catalog"]) ui_router = APIRouter(tags=["web-grafana-catalog"], include_in_schema=False) def register_events(_bus: EventBus, _pool: asyncpg.Pool | None = None) -> None: pass class SyncBody(BaseModel): instance_slug: str | None = Field( default=None, description="Slug из GRAFANA_SOURCES_JSON; пусто — все источники с api_token", ) @dataclass class PullOutcome: org_id: int org_name: str folder_rows: list[tuple[str, str, str | None]] rules: list[gt.ParsedRuleRow] warnings: list[str] async def pull_topology(api_url: str, token: str) -> tuple[PullOutcome | None, str | None]: org, oerr = await gt.fetch_org(api_url, token) if oerr or not org: return None, oerr or "no org" oid = org.get("id") if oid is None: return None, "org response without id" oname = str(org.get("name") or "") warnings: list[str] = [] folders_raw, ferr = await gt.fetch_all_folders(api_url, token) if ferr: warnings.append(ferr) ruler_raw, rerr = await gt.fetch_ruler_rules_raw(api_url, token) if rerr: warnings.append(rerr) rules: list[gt.ParsedRuleRow] = [] namespaces: set[str] = set() else: rules = gt.parse_ruler_rules(ruler_raw or {}) namespaces = {r.namespace_uid for r in rules} merged = gt.merge_folder_rows(folders_raw, namespaces) return ( PullOutcome( org_id=int(oid), org_name=oname, folder_rows=merged, rules=rules, warnings=warnings, ), None, ) async def persist_topology( conn: asyncpg.Connection, instance_slug: str, outcome: PullOutcome, ) -> None: oid = outcome.org_id await conn.execute( """ DELETE FROM grafana_catalog_rules WHERE instance_slug = $1 AND grafana_org_id = $2 """, instance_slug, oid, ) await conn.execute( """ DELETE FROM grafana_catalog_folders WHERE instance_slug = $1 AND grafana_org_id = $2 """, instance_slug, oid, ) for uid, title, parent in outcome.folder_rows: await conn.execute( """ INSERT INTO grafana_catalog_folders (instance_slug, grafana_org_id, folder_uid, title, parent_uid) VALUES ($1, $2, $3, $4, $5) """, instance_slug, oid, uid, title, parent, ) for r in outcome.rules: await conn.execute( """ INSERT INTO grafana_catalog_rules ( instance_slug, grafana_org_id, namespace_uid, rule_group_name, rule_uid, title, rule_group_interval, labels ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) """, instance_slug, oid, r.namespace_uid, r.rule_group_name, r.rule_uid, r.title, r.rule_group_interval, json.dumps(r.labels), ) fc = len(outcome.folder_rows) rc = len(outcome.rules) warn_txt = "; ".join(outcome.warnings) if outcome.warnings else None if warn_txt and len(warn_txt) > 1900: warn_txt = warn_txt[:1900] + "…" await conn.execute( """ INSERT INTO grafana_catalog_meta ( instance_slug, grafana_org_id, org_name, synced_at, folder_count, rule_count, error_text ) VALUES ($1, $2, $3, now(), $4, $5, $6) ON CONFLICT (instance_slug, grafana_org_id) DO UPDATE SET org_name = EXCLUDED.org_name, synced_at = EXCLUDED.synced_at, folder_count = EXCLUDED.folder_count, rule_count = EXCLUDED.rule_count, error_text = EXCLUDED.error_text """, instance_slug, oid, outcome.org_name, fc, rc, warn_txt, ) @router.post("/sync", status_code=200) async def sync_catalog_api( body: SyncBody, pool: asyncpg.Pool | None = Depends(get_pool), ): if pool is None: raise HTTPException(status_code=503, detail="database disabled") sources = iter_grafana_sources(get_settings()) if body.instance_slug: sl = body.instance_slug.strip().lower() sources = [s for s in sources if s.slug == sl] if not sources: raise HTTPException(status_code=404, detail="unknown instance slug") to_run = [s for s in sources if s.api_token.strip()] if not to_run: raise HTTPException( status_code=400, detail="нет источников с api_token; задайте GRAFANA_SOURCES_JSON или GRAFANA_SERVICE_ACCOUNT_TOKEN", ) results: list[dict] = [] for src in to_run: outcome, err = await pull_topology(src.api_url, src.api_token) if err or outcome is None: results.append({"slug": src.slug, "ok": False, "error": err}) log.warning("grafana_catalog sync failed %s: %s", src.slug, err) continue async with pool.acquire() as conn: async with conn.transaction(): await persist_topology(conn, src.slug, outcome) results.append( { "slug": src.slug, "ok": True, "org_id": outcome.org_id, "org_name": outcome.org_name, "folders": len(outcome.folder_rows), "rules": len(outcome.rules), "warnings": outcome.warnings, } ) return {"results": results} @router.get("/meta") async def list_meta_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 instance_slug, grafana_org_id, org_name, synced_at, folder_count, rule_count, error_text FROM grafana_catalog_meta ORDER BY instance_slug, grafana_org_id """ ) items = [] for r in rows: items.append( { "instance_slug": r["instance_slug"], "grafana_org_id": r["grafana_org_id"], "org_name": r["org_name"], "synced_at": r["synced_at"].isoformat() if r["synced_at"] else None, "folder_count": r["folder_count"], "rule_count": r["rule_count"], "error_text": r["error_text"], } ) 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") 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, ) by_ns: dict[str, list[dict]] = {} for r in rules: ns = r["namespace_uid"] by_ns.setdefault(ns, []).append( { "rule_uid": r["rule_uid"], "title": r["title"], "rule_group": r["rule_group_name"], "interval": r["rule_group_interval"], "labels": r["labels"] if isinstance(r["labels"], dict) else {}, } ) folder_nodes = [] for f in folders: uid = f["folder_uid"] folder_nodes.append( { "folder_uid": uid, "title": f["title"], "parent_uid": f["parent_uid"], "rules": by_ns.get(uid, []), } ) return { "instance_slug": slug, "grafana_org_id": oid, "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}), } @ui_router.get("/", response_class=HTMLResponse) async def grafana_catalog_ui(request: Request): pool = get_pool(request) inner = "" if pool is None: inner = "
База не настроена.
" else: try: async with pool.acquire() as conn: rows = await conn.fetch( """ SELECT instance_slug, org_name, synced_at, folder_count, rule_count, error_text FROM grafana_catalog_meta ORDER BY instance_slug """ ) if not rows: inner = "Каталог пуст. Вызовите POST /api/v1/modules/grafana-catalog/sync.
| Slug | Org | Синхр. | Папок | Правил | Ошибка |
|---|---|---|---|---|---|
| {html.escape(r['instance_slug'])} | " f"{html.escape(str(r['org_name']))} | " f"{html.escape(st)} | " f"{r['folder_count']} | {r['rule_count']} | " f"{err} |
{html.escape(str(e))}
" page = f"""Иерархия: инстанс (slug) → организация → папки → правила. Синхронизация по HTTP API.
{inner}API: POST …/grafana-catalog/sync, GET …/grafana-catalog/tree?instance_slug=…
Нужна БД для каталога Grafana.
' try: async with pool.acquire() as conn: n = await conn.fetchval("SELECT count(*)::int FROM grafana_catalog_meta WHERE grafana_org_id >= 0") last = await conn.fetchrow( "SELECT max(synced_at) AS m FROM grafana_catalog_meta WHERE grafana_org_id >= 0" ) except Exception: return 'Таблицы каталога недоступны (миграции?).
' ts = last["m"].isoformat() if last and last["m"] else "никогда" return ( f'Источников с синхронизацией: {int(n)}. ' f"Последняя синхр.: {html.escape(ts)}