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:
@ -85,7 +85,7 @@ jobs:
|
|||||||
echo "=== docker compose version ==="
|
echo "=== docker compose version ==="
|
||||||
docker compose version
|
docker compose version
|
||||||
echo "=== docker compose build ==="
|
echo "=== docker compose build ==="
|
||||||
docker compose build --progress=plain
|
docker compose --progress plain build
|
||||||
echo "=== docker compose up ==="
|
echo "=== docker compose up ==="
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|||||||
@ -238,15 +238,9 @@ async def list_meta_api(pool: asyncpg.Pool | None = Depends(get_pool)):
|
|||||||
return {"items": items}
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tree")
|
async def _build_catalog_tree_dict(conn: asyncpg.Connection, instance_slug: str) -> dict | None:
|
||||||
async def tree_api(
|
"""Сборка дерева из БД (общая логика для API и UI)."""
|
||||||
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()
|
slug = instance_slug.strip().lower()
|
||||||
async with pool.acquire() as conn:
|
|
||||||
meta = await conn.fetchrow(
|
meta = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM grafana_catalog_meta
|
SELECT * FROM grafana_catalog_meta
|
||||||
@ -256,7 +250,7 @@ async def tree_api(
|
|||||||
slug,
|
slug,
|
||||||
)
|
)
|
||||||
if not meta:
|
if not meta:
|
||||||
raise HTTPException(status_code=404, detail="no catalog for this slug; run POST /sync first")
|
return None
|
||||||
oid = meta["grafana_org_id"]
|
oid = meta["grafana_org_id"]
|
||||||
folders = await conn.fetch(
|
folders = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
@ -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 = []
|
folder_nodes = []
|
||||||
for f in folders:
|
for f in folders:
|
||||||
uid = f["folder_uid"]
|
uid = f["folder_uid"]
|
||||||
@ -311,14 +309,118 @@ async def tree_api(
|
|||||||
"org_name": meta["org_name"],
|
"org_name": meta["org_name"],
|
||||||
"synced_at": meta["synced_at"].isoformat() if meta["synced_at"] else None,
|
"synced_at": meta["synced_at"].isoformat() if meta["synced_at"] else None,
|
||||||
"folders": folder_nodes,
|
"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)
|
@ui_router.get("/", response_class=HTMLResponse)
|
||||||
async def grafana_catalog_ui(request: Request):
|
async def grafana_catalog_ui(request: Request):
|
||||||
pool = get_pool(request)
|
pool = get_pool(request)
|
||||||
inner = ""
|
inner = ""
|
||||||
|
tree_html = ""
|
||||||
if pool is None:
|
if pool is None:
|
||||||
inner = "<p>База не настроена.</p>"
|
inner = "<p>База не настроена.</p>"
|
||||||
else:
|
else:
|
||||||
@ -331,12 +433,26 @@ async def grafana_catalog_ui(request: Request):
|
|||||||
ORDER BY instance_slug
|
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:
|
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:
|
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:
|
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 "—"
|
st = r["synced_at"].isoformat() if r["synced_at"] else "—"
|
||||||
inner += (
|
inner += (
|
||||||
f"<tr><td>{html.escape(r['instance_slug'])}</td>"
|
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>{r['folder_count']}</td><td>{r['rule_count']}</td>"
|
||||||
f"<td>{err}</td></tr>"
|
f"<td>{err}</td></tr>"
|
||||||
)
|
)
|
||||||
|
slug = str(r["instance_slug"])
|
||||||
|
if slug not in seen_slugs:
|
||||||
|
seen_slugs.add(slug)
|
||||||
inner += "</tbody></table>"
|
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:
|
except Exception as e:
|
||||||
inner = f"<p class='module-err'>{html.escape(str(e))}</p>"
|
inner = f"<p class='module-err'>{html.escape(str(e))}</p>"
|
||||||
page = f"""<h1>Каталог Grafana</h1>
|
page = f"""<h1>Каталог Grafana</h1>
|
||||||
<p>Иерархия: инстанс (slug) → организация → папки → правила. Синхронизация по HTTP API.</p>
|
<p>Иерархия: инстанс (slug) → организация → папки (имена и UID) → правила (название, UID, группа, интервал, labels).</p>
|
||||||
{inner}
|
{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(
|
return HTMLResponse(
|
||||||
wrap_module_html_page(
|
wrap_module_html_page(
|
||||||
document_title="Каталог Grafana — onGuard24",
|
document_title="Каталог Grafana — onGuard24",
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import html
|
import html
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@ -261,6 +262,11 @@ async def patch_incident_api(
|
|||||||
return _incident_row_dict(row)
|
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)
|
@ui_router.get("/", response_class=HTMLResponse)
|
||||||
async def incidents_ui_home(request: Request):
|
async def incidents_ui_home(request: Request):
|
||||||
pool = get_pool(request)
|
pool = get_pool(request)
|
||||||
@ -280,17 +286,22 @@ async def incidents_ui_home(request: Request):
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
iid = r["id"]
|
||||||
|
iid_s = str(iid)
|
||||||
org = html.escape(str(r["grafana_org_slug"] or "—"))
|
org = html.escape(str(r["grafana_org_slug"] or "—"))
|
||||||
svc = html.escape(str(r["service_name"] or "—"))
|
svc = html.escape(str(r["service_name"] or "—"))
|
||||||
|
ca = r["created_at"].isoformat() if r["created_at"] else "—"
|
||||||
rows_html += (
|
rows_html += (
|
||||||
"<tr>"
|
"<tr>"
|
||||||
f"<td>{html.escape(str(r['id']))[:8]}…</td>"
|
f"<td><a href=\"/ui/modules/incidents/{html.escape(iid_s, quote=True)}\">"
|
||||||
f"<td>{html.escape(r['title'])}</td>"
|
f"{html.escape(iid_s[:8])}…</a></td>"
|
||||||
|
f"<td>{_title_cell(r['title'])}</td>"
|
||||||
f"<td>{html.escape(r['status'])}</td>"
|
f"<td>{html.escape(r['status'])}</td>"
|
||||||
f"<td>{html.escape(r['severity'])}</td>"
|
f"<td>{html.escape(r['severity'])}</td>"
|
||||||
f"<td>{html.escape(r['source'])}</td>"
|
f"<td>{html.escape(r['source'])}</td>"
|
||||||
f"<td>{org}</td>"
|
f"<td>{org}</td>"
|
||||||
f"<td>{svc}</td>"
|
f"<td>{svc}</td>"
|
||||||
|
f"<td>{html.escape(ca)}</td>"
|
||||||
"</tr>"
|
"</tr>"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -298,10 +309,10 @@ async def incidents_ui_home(request: Request):
|
|||||||
inner = f"""<h1>Инциденты</h1>
|
inner = f"""<h1>Инциденты</h1>
|
||||||
{err}
|
{err}
|
||||||
<table class="irm-table">
|
<table class="irm-table">
|
||||||
<thead><tr><th>ID</th><th>Заголовок</th><th>Статус</th><th>Важность</th><th>Источник</th><th>Grafana slug</th><th>Сервис</th></tr></thead>
|
<thead><tr><th>ID</th><th>Заголовок</th><th>Статус</th><th>Важность</th><th>Источник</th><th>Grafana slug</th><th>Сервис</th><th>Создан</th></tr></thead>
|
||||||
<tbody>{rows_html or '<tr><td colspan="7">Пока нет записей</td></tr>'}</tbody>
|
<tbody>{rows_html or '<tr><td colspan="8">Пока нет записей</td></tr>'}</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p><small>Создание из Grafana: webhook → запись в <code>ingress_events</code> → событие → строка здесь.</small></p>"""
|
<p><small>Создание из Grafana: webhook → <code>ingress_events</code> → событие → строка здесь. Пустой заголовок бывает при тестовом JSON без полей алерта.</small></p>"""
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
wrap_module_html_page(
|
wrap_module_html_page(
|
||||||
document_title="Инциденты — onGuard24",
|
document_title="Инциденты — onGuard24",
|
||||||
@ -309,3 +320,90 @@ async def incidents_ui_home(request: Request):
|
|||||||
main_inner_html=inner,
|
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 = "<p>База данных не настроена.</p>"
|
||||||
|
return HTMLResponse(
|
||||||
|
wrap_module_html_page(
|
||||||
|
document_title="Инцидент — onGuard24",
|
||||||
|
current_slug="incidents",
|
||||||
|
main_inner_html=f"<h1>Инцидент</h1>{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"<h1>Инцидент</h1><p class='module-err'>{html.escape(str(e))}</p>",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
body = "<p>Запись не найдена.</p>"
|
||||||
|
else:
|
||||||
|
title = _title_cell(row["title"])
|
||||||
|
ing = row["ingress_event_id"]
|
||||||
|
ing_l = html.escape(str(ing)) if ing else "—"
|
||||||
|
meta = f"""<dl style="display:grid;grid-template-columns:10rem 1fr;gap:0.35rem 1rem;font-size:0.9rem">
|
||||||
|
<dt>ID</dt><dd><code>{html.escape(str(row['id']))}</code></dd>
|
||||||
|
<dt>Заголовок</dt><dd>{title}</dd>
|
||||||
|
<dt>Статус</dt><dd>{html.escape(row['status'])}</dd>
|
||||||
|
<dt>Важность</dt><dd>{html.escape(row['severity'])}</dd>
|
||||||
|
<dt>Источник</dt><dd>{html.escape(row['source'])}</dd>
|
||||||
|
<dt>Grafana slug</dt><dd>{html.escape(str(row['grafana_org_slug'] or '—'))}</dd>
|
||||||
|
<dt>Сервис</dt><dd>{html.escape(str(row['service_name'] or '—'))}</dd>
|
||||||
|
<dt>Создан</dt><dd>{html.escape(row['created_at'].isoformat() if row['created_at'] else '—')}</dd>
|
||||||
|
<dt>Обновлён</dt><dd>{html.escape(row['updated_at'].isoformat() if row.get('updated_at') else '—')}</dd>
|
||||||
|
<dt>ingress_event_id</dt><dd><code>{ing_l}</code></dd>
|
||||||
|
</dl>"""
|
||||||
|
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 = (
|
||||||
|
"<h2 style='font-size:1.05rem;margin-top:1.25rem'>Сырой JSON вебхука</h2>"
|
||||||
|
f"<p class='gc-muted'>ingress_events · {html.escape(str(raw_row['received_at']))}</p>"
|
||||||
|
f"<pre style='overflow:auto;max-height:28rem;font-size:0.78rem;background:#18181b;color:#e4e4e7;"
|
||||||
|
f"padding:0.75rem;border-radius:8px'>{html.escape(pretty)}</pre>"
|
||||||
|
)
|
||||||
|
except Exception as ex:
|
||||||
|
raw_block = f"<p class='module-err'>Не удалось показать JSON: {html.escape(str(ex))}</p>"
|
||||||
|
body = (
|
||||||
|
f"<p><a href=\"/ui/modules/incidents/\">← К списку</a></p><h1>Инцидент</h1>{meta}{raw_block}"
|
||||||
|
)
|
||||||
|
return HTMLResponse(
|
||||||
|
wrap_module_html_page(
|
||||||
|
document_title="Инцидент — onGuard24",
|
||||||
|
current_slug="incidents",
|
||||||
|
main_inner_html=body,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@ -30,6 +30,22 @@ APP_SHELL_CSS = """
|
|||||||
.irm-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
.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 th, .irm-table td { border: 1px solid #e4e4e7; padding: 0.45rem 0.65rem; text-align: left; }
|
||||||
.irm-table thead th { background: #f4f4f5; }
|
.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; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user