ui: синхронизация каталога Grafana в браузере, дерево папок/правил; инциденты — ссылка, деталка, сырой JSON; ci: docker compose --progress plain
All checks were successful
CI / test (push) Successful in 37s
All checks were successful
CI / test (push) Successful in 37s
Made-with: Cursor
This commit is contained in:
@ -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 "<p class='gc-muted'>Нет правил в этой группе/папке (по данным Ruler).</p>"
|
||||
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"<tr><td>{title}</td><td><code>{uid}</code></td>"
|
||||
f"<td>{grp}</td><td>{interval}</td><td><code>{lab_s}</code></td></tr>"
|
||||
)
|
||||
head = "<thead><tr><th>Название правила</th><th>rule_uid</th><th>Группа</th><th>Интервал</th><th>Labels (фрагмент)</th></tr></thead>"
|
||||
return f"<table class='gc-subtable irm-table'>{head}<tbody>{''.join(rows)}</tbody></table>"
|
||||
|
||||
|
||||
def _catalog_tree_html(tree: dict) -> str:
|
||||
"""HTML: папки и правила под ними + «осиротевшие» namespace из Ruler."""
|
||||
parts: list[str] = [
|
||||
"<div class='gc-tree'><h2 style='font-size:1.1rem;margin:0 0 0.5rem'>Папки и правила</h2>"
|
||||
"<p class='gc-muted' style='margin:0 0 0.75rem'>Папки — из API Grafana; правила — из Ruler, привязка по UID папки (namespace). "
|
||||
"Название колонки «Правил» наверху — число всех записей с типом grafana_alert.</p>"
|
||||
]
|
||||
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"<strong>{title}</strong> "
|
||||
f"<span class='gc-muted'>· UID папки <code>{uid}</code> · родитель <code>{parent_s}</code> · правил: {n}</span>"
|
||||
)
|
||||
parts.append(
|
||||
f"<details class='gc-folder' open><summary>{summ}</summary>{_rules_subtable_html(rules)}</details>"
|
||||
)
|
||||
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"<div class='gc-orphan'><strong>Namespace Ruler без папки в API</strong> "
|
||||
f"(<code>{ns_e}</code>) — часто системная группа. Правил: {len(rules)}."
|
||||
f"{_rules_subtable_html(rules)}</div>"
|
||||
)
|
||||
parts.append("</div>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
_SYNC_SCRIPT = """
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById('og-sync-btn');
|
||||
var st = document.getElementById('og-sync-status');
|
||||
if (!btn || !st) return;
|
||||
btn.addEventListener('click', function () {
|
||||
st.textContent = 'Синхронизация…';
|
||||
btn.disabled = true;
|
||||
fetch('/api/v1/modules/grafana-catalog/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}'
|
||||
}).then(function (r) {
|
||||
return r.text().then(function (t) {
|
||||
if (!r.ok) {
|
||||
st.textContent = 'Ошибка HTTP ' + r.status + ': ' + (t.slice(0, 400) || r.statusText);
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
st.textContent = 'Готово, обновляем страницу…';
|
||||
location.reload();
|
||||
});
|
||||
}).catch(function (e) {
|
||||
st.textContent = 'Сбой сети: ' + e;
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</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 = "<p>База не настроена.</p>"
|
||||
else:
|
||||
@ -331,12 +433,26 @@ async def grafana_catalog_ui(request: Request):
|
||||
ORDER BY instance_slug
|
||||
"""
|
||||
)
|
||||
sync_bar = """<div class="og-sync-bar">
|
||||
<button type="button" class="og-btn og-btn-primary" id="og-sync-btn">Синхронизировать с Grafana</button>
|
||||
<span class="og-sync-status" id="og-sync-status"></span>
|
||||
</div>"""
|
||||
if not rows:
|
||||
inner = "<p>Каталог пуст. Вызовите <code>POST /api/v1/modules/grafana-catalog/sync</code>.</p>"
|
||||
inner = (
|
||||
sync_bar
|
||||
+ "<p>Каталог пуст — нажмите кнопку выше (нужны <code>GRAFANA_URL</code> и токен в .env).</p>"
|
||||
)
|
||||
else:
|
||||
inner = "<table class='irm-table'><thead><tr><th>Slug</th><th>Org</th><th>Синхр.</th><th>Папок</th><th>Правил</th><th>Ошибка</th></tr></thead><tbody>"
|
||||
inner = (
|
||||
sync_bar
|
||||
+ "<p class='gc-muted'>Строка ниже — сводка по последней синхронизации. "
|
||||
"Число <strong>Правил</strong> — все правила алертинга (Grafana-managed) из Ruler для этой org.</p>"
|
||||
+ "<table class='irm-table'><thead><tr><th>Slug</th><th>Org</th><th>Синхр.</th>"
|
||||
"<th>Папок (API)</th><th>Правил (Ruler)</th><th>Ошибка</th></tr></thead><tbody>"
|
||||
)
|
||||
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"<tr><td>{html.escape(r['instance_slug'])}</td>"
|
||||
@ -345,13 +461,27 @@ async def grafana_catalog_ui(request: Request):
|
||||
f"<td>{r['folder_count']}</td><td>{r['rule_count']}</td>"
|
||||
f"<td>{err}</td></tr>"
|
||||
)
|
||||
slug = str(r["instance_slug"])
|
||||
if slug not in seen_slugs:
|
||||
seen_slugs.add(slug)
|
||||
inner += "</tbody></table>"
|
||||
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"<h2 style='font-size:1.15rem;margin:1.25rem 0 0.35rem'>Инстанс "
|
||||
f"<code>{html.escape(slug)}</code> · {html.escape(str(tree['org_name']))}</h2>"
|
||||
+ _catalog_tree_html(tree)
|
||||
)
|
||||
except Exception as e:
|
||||
inner = f"<p class='module-err'>{html.escape(str(e))}</p>"
|
||||
page = f"""<h1>Каталог Grafana</h1>
|
||||
<p>Иерархия: инстанс (slug) → организация → папки → правила. Синхронизация по HTTP API.</p>
|
||||
<p>Иерархия: инстанс (slug) → организация → папки (имена и UID) → правила (название, UID, группа, интервал, labels).</p>
|
||||
{inner}
|
||||
<p><small>API: <code>POST …/grafana-catalog/sync</code>, <code>GET …/grafana-catalog/tree?instance_slug=…</code></small></p>"""
|
||||
{tree_html}
|
||||
<p><small>API: <code>POST /api/v1/modules/grafana-catalog/sync</code>, <code>GET …/tree?instance_slug=…</code></small></p>
|
||||
{_SYNC_SCRIPT}"""
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Каталог Grafana — onGuard24",
|
||||
|
||||
Reference in New Issue
Block a user