2026-04-03 08:45:19 +03:00
|
|
|
|
"""Безопасная сборка HTML с модулей и общий каркас UI (правая колонка меню из реестра)."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import html
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
|
|
|
|
|
|
|
|
from starlette.requests import Request
|
|
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger("onguard24.modules.ui")
|
|
|
|
|
|
|
|
|
|
|
|
# Общие стили: сетка «контент слева + меню справа», навигация по модулям
|
|
|
|
|
|
APP_SHELL_CSS = """
|
|
|
|
|
|
body { font-family: system-ui, sans-serif; margin: 0; background: #fafafa; color: #18181b; }
|
|
|
|
|
|
a { color: #2563eb; text-decoration: none; }
|
|
|
|
|
|
a:hover { text-decoration: underline; }
|
|
|
|
|
|
.app-shell { display: flex; flex-direction: row; align-items: flex-start; gap: 1.5rem;
|
|
|
|
|
|
max-width: 72rem; margin: 0 auto; padding: 1.5rem 1.25rem 2rem; box-sizing: border-box; }
|
|
|
|
|
|
.app-main { flex: 1; min-width: 0; }
|
|
|
|
|
|
.app-rail { width: 13.5rem; flex-shrink: 0; position: sticky; top: 1rem;
|
|
|
|
|
|
background: #fff; border-radius: 8px; box-shadow: 0 1px 3px #0001; padding: 0.75rem 0; }
|
|
|
|
|
|
.rail-title { margin: 0 0 0.5rem; padding: 0 0.75rem; font-size: 0.75rem; font-weight: 600;
|
|
|
|
|
|
text-transform: uppercase; letter-spacing: 0.04em; color: #71717a; }
|
|
|
|
|
|
.rail-nav ul { list-style: none; margin: 0; padding: 0; }
|
|
|
|
|
|
.rail-item a { display: block; padding: 0.45rem 0.75rem; font-size: 0.9rem; color: #3f3f46; border-radius: 4px; }
|
|
|
|
|
|
.rail-item a:hover { background: #f4f4f5; }
|
|
|
|
|
|
.rail-item.is-active a { background: #eff6ff; color: #1d4ed8; font-weight: 600; }
|
|
|
|
|
|
.module-page-main h1 { margin-top: 0; font-size: 1.35rem; }
|
2026-04-03 09:03:16 +03:00
|
|
|
|
.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; }
|
2026-04-03 15:04:22 +03:00
|
|
|
|
.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; }
|
2026-04-03 15:56:58 +03:00
|
|
|
|
.rail-item--util { border-top: 1px solid #e4e4e7; margin-top: 0.4rem; padding-top: 0.4rem; }
|
2026-04-03 08:45:19 +03:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def nav_rail_html(current_slug: str | None = None) -> str:
|
|
|
|
|
|
"""Правая колонка: пункты из реестра модулей с `ui_router` + «Главная».
|
|
|
|
|
|
|
|
|
|
|
|
Новый модуль в `MODULE_MOUNTS` с UI — пункт появляется автоматически.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from onguard24.modules.registry import MODULE_MOUNTS
|
|
|
|
|
|
|
|
|
|
|
|
home_li = (
|
|
|
|
|
|
'<li class="rail-item'
|
|
|
|
|
|
+ (" is-active" if current_slug is None else "")
|
|
|
|
|
|
+ '"><a href="/">Главная</a></li>'
|
|
|
|
|
|
)
|
|
|
|
|
|
items: list[str] = [home_li]
|
|
|
|
|
|
for m in MODULE_MOUNTS:
|
|
|
|
|
|
if m.ui_router is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
href = f"/ui/modules/{m.slug}/"
|
|
|
|
|
|
active = m.slug == current_slug
|
|
|
|
|
|
licls = "rail-item" + (" is-active" if active else "")
|
|
|
|
|
|
cur = ' aria-current="page"' if active else ""
|
|
|
|
|
|
items.append(
|
|
|
|
|
|
f'<li class="{licls}"><a href="{html.escape(href)}"{cur}>{html.escape(m.title)}</a></li>'
|
|
|
|
|
|
)
|
2026-04-03 15:56:58 +03:00
|
|
|
|
logs_active = current_slug == "__logs__"
|
|
|
|
|
|
items.append(
|
|
|
|
|
|
'<li class="rail-item rail-item--util' + (" is-active" if logs_active else "") + '">'
|
|
|
|
|
|
'<a href="/ui/logs"' + (' aria-current="page"' if logs_active else "") + ">📋 Логи</a></li>"
|
|
|
|
|
|
)
|
2026-04-03 08:45:19 +03:00
|
|
|
|
lis = "".join(items)
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<aside class="app-rail" role="navigation" aria-label="Разделы приложения">'
|
|
|
|
|
|
'<nav class="rail-nav">'
|
|
|
|
|
|
'<h2 class="rail-title">Разделы</h2>'
|
|
|
|
|
|
f"<ul>{lis}</ul>"
|
|
|
|
|
|
"</nav>"
|
|
|
|
|
|
"</aside>"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def wrap_module_html_page(
|
|
|
|
|
|
*,
|
|
|
|
|
|
document_title: str,
|
|
|
|
|
|
current_slug: str,
|
|
|
|
|
|
main_inner_html: str,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""Полная HTML-страница модуля: основной контент + то же правое меню, что и на главной."""
|
|
|
|
|
|
rail = nav_rail_html(current_slug)
|
|
|
|
|
|
return f"""<!DOCTYPE html>
|
|
|
|
|
|
<html lang="ru">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8"/>
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
|
|
|
|
<title>{html.escape(document_title)}</title>
|
|
|
|
|
|
<style>{APP_SHELL_CSS}</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="app-shell">
|
|
|
|
|
|
<main class="app-main module-page-main">
|
|
|
|
|
|
{main_inner_html}
|
|
|
|
|
|
</main>
|
|
|
|
|
|
{rail}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def safe_fragment(
|
|
|
|
|
|
module_slug: str,
|
|
|
|
|
|
fn: Callable[[Request], Awaitable[str]],
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await fn(request)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
log.exception("module %s: ошибка фрагмента главной страницы", module_slug)
|
|
|
|
|
|
return (
|
|
|
|
|
|
'<aside class="module-err" role="alert">'
|
|
|
|
|
|
f"Модуль «{html.escape(module_slug)}»: блок временно недоступен."
|
|
|
|
|
|
"</aside>"
|
|
|
|
|
|
)
|