diff --git a/CHANGELOG.md b/CHANGELOG.md index 882f45b..905afb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md). +## [1.4.0] — 2026-04-03 + +Правое меню «Разделы» на главной и на страницах модулей, пункты из `MODULE_MOUNTS` (`title` + `ui_router`). + +- **`nav_rail_html`**, **`wrap_module_html_page`**, общие стили **`APP_SHELL_CSS`** в `modules/ui_support.py`. +- Модуль **schedules** в реестре переименован для примера: **`title` = «Календарь дежурств»**. + +## [1.3.0] — 2026-04-03 + +Веб-UI модулей с главной страницы и изоляция ошибок превью. + +### Добавлено + +- **`ModuleMount`**: поля `slug`, `title`, опционально `ui_router`, `render_home_fragment`. +- **`/ui/modules//`** — монтирование `ui_router` каждого модуля (полные HTML-страницы, не в OpenAPI). +- **Главная `/`**: секция «Модули» с карточками; фрагменты через **`ui_support.safe_fragment`** (падение одного модуля не ломает страницу). +- Примеры в `schedules`, `contacts`, `statusboard`; тесты `tests/test_root_ui.py`. + +## [1.2.0] — 2026-04-03 + +Модульная разработка без правок `main.py` на каждый новый роутер. + +### Добавлено + +- **`onguard24/modules/registry.py`** — единый список `MODULE_MOUNTS` (роутер, префикс URL, `register_events`). Подключение роутеров в `create_app()` циклом. +- У каждого модуля (`schedules`, `contacts`, `statusboard`) функция **`register_events(EventBus)`** — заготовка подписки на `alert.received`. +- **`app.state.event_bus`**: при старте создаётся `InMemoryEventBus`, вызывается `register_module_events`. +- **Ingress Grafana:** `INSERT … RETURNING id`, затем **`publish_alert_received`** с ссылкой на строку `ingress_events`. +- Документация: [docs/MODULES.md](docs/MODULES.md). + ## [1.1.0] — 2026-04-03 Инфраструктура разработки и задел под домен IRM. diff --git a/README.md b/README.md index f92ebc7..6c94532 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # onGuard24 -**Версия: 1.1.0** · Модульный монолит на **Python (FastAPI)**: ядро, приём алертов из Grafana, заготовки модулей (дежурства, контакты, светофор), PostgreSQL, проверки Vault / Grafana / Forgejo. +**Версия: 1.4.0** · Модульный монолит на **Python (FastAPI)**: ядро, приём алертов из Grafana, заготовки модулей (дежурства, контакты, светофор), PostgreSQL, проверки Vault / Grafana / Forgejo. | Документ | Назначение | |----------|------------| @@ -9,6 +9,7 @@ | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Структура кода, куда что класть | | [docs/AI_CONTEXT.md](docs/AI_CONTEXT.md) | Краткий контекст для доработок | | [docs/DOMAIN.md](docs/DOMAIN.md) | Сущности (инцидент, алерт, эскалация), шина событий | +| [docs/MODULES.md](docs/MODULES.md) | Как добавлять модули и подписки на события | **Репозиторий:** [forgejo.pvenode.ru/admin/onGuard24](https://forgejo.pvenode.ru/admin/onGuard24) @@ -20,7 +21,7 @@ - **PostgreSQL:** пул asyncpg, таблица `ingress_events` для сырых тел webhook Grafana; схема через **Alembic** (отдельные ревизии в `alembic/versions/`). - **POST `/api/v1/ingress/grafana`** — приём JSON алерта (опционально защита `X-OnGuard-Secret`). - **GET `/`**, **GET `/api/v1/status`** — проверки: БД, Vault, Grafana (service account), Forgejo (PAT). -- **Модули-заглушки:** `/api/v1/modules/schedules|contacts|statusboard/`. +- **Модули (API + веб-UI):** JSON под `/api/v1/modules/...`, полные HTML-страницы под `/ui/modules//`, превью на главной `/` (см. [docs/MODULES.md](docs/MODULES.md)). - **Фронт (опционально):** `web/` — Vite + React, прокси на API. Чего **ещё нет** (следующие версии): авторизация публичных API (кроме секрета webhook), полноценная бизнес-логика IRM в коде (эскалации, дежурства, светофор), фоновые задачи. Доменные сущности и задел под модули описаны в [docs/DOMAIN.md](docs/DOMAIN.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dd87d93..e0bab06 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Архитектура onGuard24 (для разработки и доработок) -Цель продукта: **модульный монолит** в духе IRM — ядро + подключаемые области (дежурства, контакты, «светофор» по сервисам). Текущая версия — **каркас v1.1**: HTTP, БД, ingress Grafana, проверки интеграций, Alembic, задел домена в `onguard24/domain/`. +Цель продукта: **модульный монолит** в духе IRM — ядро + подключаемые области (дежурства, контакты, «светофор» по сервисам). Текущая версия — **каркас v1.2**: HTTP, БД, ingress Grafana, проверки интеграций, Alembic, задел домена в `onguard24/domain/`. ## Дерево пакетов @@ -20,7 +20,7 @@ onGuard24/ │ ├── integrations/ │ │ ├── grafana_api.py # Grafana HTTP API (Bearer SA) │ │ └── forgejo_api.py # Forgejo/Gitea API (token + probe/fallback) -│ └── modules/ # Заглушки: schedules, contacts, statusboard +│ └── modules/ # API + ui_router + registry + ui_support (фрагменты главной) ├── web/ # Vite + React (опционально) ├── pyproject.toml ├── pytest.ini @@ -31,7 +31,7 @@ onGuard24/ ## Поток данных (сейчас) -1. **Grafana** (отдельно настроенный contact point) шлёт **POST** на `/api/v1/ingress/grafana` → тело JSON пишется в **`ingress_events`**. +1. **Grafana** (отдельно настроенный contact point) шлёт **POST** на `/api/v1/ingress/grafana` → тело JSON пишется в **`ingress_events`**, затем **`event_bus`** публикует **`alert.received`** (см. [MODULES.md](MODULES.md)). 2. **Параллельно** Grafana может слать в Mattermost — это вне этого репозитория (конфиг Grafana). 3. **Статус страницы** не ходит в Grafana за алертами — только **проверка доступности API** (токен SA). @@ -39,7 +39,7 @@ onGuard24/ | Задача | Место | |--------|--------| -| Новый HTTP-роут модуля | `onguard24/modules/.py` + `include_router` в `main.py` | +| Новый HTTP-роут модуля | `onguard24/modules/.py` + запись в `modules/registry.py` (см. [MODULES.md](MODULES.md)) | | Общая логика инцидентов / событий | задел: `onguard24/domain/` + [DOMAIN.md](DOMAIN.md); позже сервисный слой и БД | | Новая таблица БД | Alembic: `alembic revision`, правка `alembic/versions/`, `alembic upgrade head` | | Новая внешняя интеграция | `onguard24/integrations/.py`, вызов из `status_snapshot` при необходимости | @@ -51,6 +51,7 @@ onGuard24/ ## Зависимости между компонентами - `status_snapshot.build(request)` читает `request.app.state.pool` и `request.app.state.settings` (устанавливаются в `lifespan`). +- `request.app.state.event_bus` — доменная шина; модули подписываются в `register_events` из `modules/registry.py`. - Модули **не** зависят друг от друга; контракт заделан через **доменные события** (`domain/events.py`, `EventBus`) и описан в [DOMAIN.md](DOMAIN.md); проводка в HTTP пока не подключена. ## Известные ограничения diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md index 481b699..dc55bfe 100644 --- a/docs/DOMAIN.md +++ b/docs/DOMAIN.md @@ -1,6 +1,6 @@ # Доменная модель onGuard24 -Версия **1.1.0** вводит явные сущности и задел под **события** между модулями. Таблицы БД для инцидентов пока не добавлены — см. [Alembic](../alembic/versions/). +Версия **1.1+** вводит явные сущности и задел под **события** между модулями. Таблицы БД для инцидентов пока не добавлены — см. [Alembic](../alembic/versions/). ## Сущности (код: `onguard24/domain/entities.py`) @@ -24,9 +24,9 @@ 1. Модуль реализует **`Module`**: свойство `name`, метод `on_event(event)`. 2. При старте приложения модуль регистрируется: `bus.subscribe("alert.received", handler)`. -3. После успешного INSERT в `ingress_events` (или нормализации) ядро вызывает `await bus.publish(AlertReceived(...))`. +3. После успешного INSERT в `ingress_events` ядро вызывает `await bus.publish_alert_received(Alert, raw_payload_ref=id_строки)`. -Сейчас **ingress** ещё не публикует в шину — подключение в следующих версиях. +Подключение к шине и регистрация модулей: **`app.state.event_bus`**, список модулей — **`modules/registry.py`** (см. [MODULES.md](MODULES.md)). ## Связь с БД diff --git a/docs/MODULES.md b/docs/MODULES.md new file mode 100644 index 0000000..af3d667 --- /dev/null +++ b/docs/MODULES.md @@ -0,0 +1,57 @@ +# Разработка функционала через модули + +Цель: **новые возможности добавляются в `onguard24/modules/`**, без правок «размазанных» по `main.py`, с явной подпиской на события и **своим веб-UI** (HTML и API в одном файле пакета). + +## Что уже есть в ядре + +| Механизм | Назначение | +|----------|------------| +| **`modules/registry.py`** | Список `MODULE_MOUNTS`: API, метаданные UI, `register_events`. | +| **`modules/ui_support.py`** | `safe_fragment` — безопасный вызов фрагмента главной: ошибка **одного** модуля не роняет `/`. | +| **`app.state.event_bus`** | `InMemoryEventBus` — публикация после сохранения ingress (`alert.received`). | +| **`domain/events.py`** | Имена событий, `AlertReceived`. | +| **Ingress** | `INSERT … RETURNING id` → `publish_alert_received`. | + +## Веб-UI: главная и полные страницы + +- **Главная `/`** автоматически подтягивает карточки из `MODULE_MOUNTS`: заголовок, превью (`render_home_fragment`), ссылка на полный UI. +- **Правое меню («Разделы»)** строится из того же реестра: пункт **«Главная»** и по одному пункту на каждый модуль с **`ui_router`** (текст пункта = поле **`title`** в `ModuleMount`). Новый модуль с UI появляется в меню без правок шаблона — только запись в реестре. +- **Полный интерфейс модуля** — **`/ui/modules//`**, страница собирается через **`wrap_module_html_page`** (`ui_support.py`): тот же каркас и правое меню, активный пункт подсвечивается (`current_slug`). +- Всё, что относится к модулю (JSON API, HTML, события), живёт **в одном файле модуля** + строка в реестре. + +### Изоляция сбоев + +- Ошибка в **`render_home_fragment`** перехватывается в **`safe_fragment`**: на главной показывается блок с классом `module-err`, остальные модули и таблица статусов отображаются. +- Ошибка в обработчике **полной страницы** `/ui/modules/...` даёт 500 **только для этого запроса**; процесс и остальные маршруты продолжают работать. +- Рекомендуется не полагаться на глобальное состояние между модулями; общение — через БД и `event_bus`. + +Фронт в **`web/`** (Vite) остаётся опциональным; серверный HTML — основной путь для встроенного UI. + +## Добавить новый модуль (чеклист) + +1. **Файл** `onguard24/modules/<имя>.py`: + - `router` — JSON API под `/api/v1/modules/<имя>/`. + - Опционально **`ui_router`** — `APIRouter(include_in_schema=False)`, маршруты полных HTML-страниц (корень `/` → `/ui/modules//`). + - Опционально **`async def render_home_fragment(request) -> str`** — HTML-фрагмент (без ``) для карточки на главной. + - **`register_events(_bus)`** — подписки на шину. + +2. **Регистрация** в **`onguard24/modules/registry.py`** — объект **`ModuleMount`**: + - `router`, `url_prefix`, `register_events`, **`slug`**, **`title`**, опционально **`ui_router`**, **`render_home_fragment`**. + +3. **Миграции** — если нужны таблицы: `alembic revision`, `alembic upgrade head`. + +4. **Тесты** — API, при необходимости GET `/` и `/ui/modules//`. + +`main.py` **не** меняется — только реестр. + +## События + +- Из ingress публикуется **`alert.received`** (`AlertReceived`). +- Обработчик: `async def h(event: DomainEvent) -> None`; удобно `isinstance(event, AlertReceived)`. + +## Ограничения + +- Шина **in-process**; несколько воркеров — позже общая очередь. +- Auth на модули пока нет — сеть / reverse proxy. + +См. [DOMAIN.md](DOMAIN.md), [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/onguard24/__init__.py b/onguard24/__init__.py index b483487..37bf145 100644 --- a/onguard24/__init__.py +++ b/onguard24/__init__.py @@ -1,3 +1,3 @@ """onGuard24 — модульный монолит (ядро + модули).""" -__version__ = "1.1.0" +__version__ = "1.4.0" diff --git a/onguard24/ingress/grafana.py b/onguard24/ingress/grafana.py index 35cc5e1..de0e3d8 100644 --- a/onguard24/ingress/grafana.py +++ b/onguard24/ingress/grafana.py @@ -1,9 +1,12 @@ import json import logging +from datetime import datetime, timezone from fastapi import APIRouter, Depends, Header, HTTPException, Request from starlette.responses import Response +from onguard24.domain.entities import Alert, Severity + logger = logging.getLogger(__name__) router = APIRouter(tags=["ingress"]) @@ -35,9 +38,21 @@ async def grafana_webhook( return Response(status_code=202) async with pool.acquire() as conn: - await conn.execute( - "INSERT INTO ingress_events (source, body) VALUES ($1, $2::jsonb)", + row = await conn.fetchrow( + "INSERT INTO ingress_events (source, body) VALUES ($1, $2::jsonb) RETURNING id", "grafana", json.dumps(body), ) + raw_id = row["id"] if row else None + bus = getattr(request.app.state, "event_bus", None) + if bus and raw_id is not None: + title = str(body.get("title") or body.get("ruleName") or "")[:500] + alert = Alert( + source="grafana", + title=title, + severity=Severity.WARNING, + payload=body, + received_at=datetime.now(timezone.utc), + ) + await bus.publish_alert_received(alert, raw_payload_ref=raw_id) return Response(status_code=202) diff --git a/onguard24/main.py b/onguard24/main.py index 999b13d..ee33c62 100644 --- a/onguard24/main.py +++ b/onguard24/main.py @@ -7,8 +7,9 @@ from starlette.responses import HTMLResponse, Response from onguard24.config import get_settings from onguard24.db import create_pool +from onguard24.domain.events import InMemoryEventBus from onguard24.ingress import grafana as grafana_ingress -from onguard24.modules import contacts, schedules, statusboard +from onguard24.modules.registry import MODULE_MOUNTS, register_module_events from onguard24.root_html import render_root_page from onguard24.status_snapshot import build as build_status from onguard24 import __version__ as app_version @@ -32,8 +33,11 @@ def parse_addr(http_addr: str) -> tuple[str, int]: async def lifespan(app: FastAPI): settings = get_settings() pool = await create_pool(settings) + bus = InMemoryEventBus() + register_module_events(bus) app.state.pool = pool app.state.settings = settings + app.state.event_bus = bus log.info("onGuard24 started, db=%s", "ok" if pool else "disabled") yield if pool: @@ -78,9 +82,10 @@ def create_app() -> FastAPI: return await build_status(request) app.include_router(grafana_ingress.router, prefix="/api/v1") - app.include_router(schedules.router, prefix="/api/v1/modules/schedules") - app.include_router(contacts.router, prefix="/api/v1/modules/contacts") - app.include_router(statusboard.router, prefix="/api/v1/modules/statusboard") + for mount in MODULE_MOUNTS: + app.include_router(mount.router, prefix=mount.url_prefix) + if mount.ui_router is not None: + app.include_router(mount.ui_router, prefix=f"/ui/modules/{mount.slug}") return app diff --git a/onguard24/modules/contacts.py b/onguard24/modules/contacts.py index a6c29b7..62d0f1e 100644 --- a/onguard24/modules/contacts.py +++ b/onguard24/modules/contacts.py @@ -1,7 +1,22 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from onguard24.domain.events import EventBus +from onguard24.modules.ui_support import wrap_module_html_page router = APIRouter(tags=["module-contacts"]) +ui_router = APIRouter(tags=["web-contacts"], include_in_schema=False) + + +def register_events(_bus: EventBus) -> None: + pass + + +async def render_home_fragment(request: Request) -> str: + del request + return '

Контакты, группы, каналы доставки.

' + @router.get("/") async def contacts_root(): @@ -10,3 +25,17 @@ async def contacts_root(): "status": "stub", "note": "люди, группы, каналы доставки", } + + +@ui_router.get("/", response_class=HTMLResponse) +async def contacts_ui_home(request: Request): + del request + inner = """

Контакты

+

Люди, группы, каналы уведомлений.

""" + return HTMLResponse( + wrap_module_html_page( + document_title="Контакты — onGuard24", + current_slug="contacts", + main_inner_html=inner, + ) + ) diff --git a/onguard24/modules/registry.py b/onguard24/modules/registry.py new file mode 100644 index 0000000..316dd90 --- /dev/null +++ b/onguard24/modules/registry.py @@ -0,0 +1,71 @@ +"""Единая точка регистрации модулей: API, веб-UI и подписки на события. + +Новый модуль: файл в `onguard24/modules/`, запись в `MODULE_MOUNTS` — см. docs/MODULES.md. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from fastapi import APIRouter +from starlette.requests import Request + +from onguard24.domain.events import EventBus +from onguard24.modules import contacts, schedules, statusboard + +# async (Request) -> str — фрагмент HTML для главной страницы (опционально) +HomeFragment = Callable[[Request], Awaitable[str]] + + +@dataclass(frozen=True) +class ModuleMount: + """Один модуль: API под url_prefix, UI под /ui/modules/{slug}.""" + + router: APIRouter + url_prefix: str + register_events: Callable[[EventBus], None] + slug: str + title: str + ui_router: APIRouter | None = None + render_home_fragment: HomeFragment | None = None + + +def _mounts() -> list[ModuleMount]: + return [ + ModuleMount( + router=schedules.router, + url_prefix="/api/v1/modules/schedules", + register_events=schedules.register_events, + slug="schedules", + title="Календарь дежурств", + ui_router=schedules.ui_router, + render_home_fragment=schedules.render_home_fragment, + ), + ModuleMount( + router=contacts.router, + url_prefix="/api/v1/modules/contacts", + register_events=contacts.register_events, + slug="contacts", + title="Контакты", + ui_router=contacts.ui_router, + render_home_fragment=contacts.render_home_fragment, + ), + ModuleMount( + router=statusboard.router, + url_prefix="/api/v1/modules/statusboard", + register_events=statusboard.register_events, + slug="statusboard", + title="Светофор", + ui_router=statusboard.ui_router, + render_home_fragment=statusboard.render_home_fragment, + ), + ] + + +MODULE_MOUNTS: list[ModuleMount] = _mounts() + + +def register_module_events(bus: EventBus) -> None: + for m in MODULE_MOUNTS: + m.register_events(bus) diff --git a/onguard24/modules/schedules.py b/onguard24/modules/schedules.py index 9c768c2..d9749db 100644 --- a/onguard24/modules/schedules.py +++ b/onguard24/modules/schedules.py @@ -1,7 +1,28 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from onguard24.domain.events import EventBus +from onguard24.modules.ui_support import wrap_module_html_page router = APIRouter(tags=["module-schedules"]) +ui_router = APIRouter(tags=["web-schedules"], include_in_schema=False) + + +def register_events(_bus: EventBus) -> None: + """Подписка на доменные события (например alert.received).""" + # _bus.subscribe("alert.received", handler) + + +async def render_home_fragment(request: Request) -> str: + """Фрагмент для главной (в root_html вызывается через safe_fragment — падение не ломает главную).""" + del request + return ( + '
' + "

Планирование смен и календарь — следующий этап.

" + "
" + ) + @router.get("/") async def schedules_root(): @@ -10,3 +31,18 @@ async def schedules_root(): "status": "stub", "note": "календарь и смены — следующий этап", } + + +@ui_router.get("/", response_class=HTMLResponse) +async def schedules_ui_home(request: Request): + """Полная HTML-страница: /ui/modules/schedules/ — то же правое меню, что на главной.""" + del request + inner = """

Календарь дежурств

+

Здесь будет функционал модуля: смены, календарь, уведомления.

""" + return HTMLResponse( + wrap_module_html_page( + document_title="Календарь дежурств — onGuard24", + current_slug="schedules", + main_inner_html=inner, + ) + ) diff --git a/onguard24/modules/statusboard.py b/onguard24/modules/statusboard.py index 7495232..a379fc9 100644 --- a/onguard24/modules/statusboard.py +++ b/onguard24/modules/statusboard.py @@ -1,7 +1,26 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from onguard24.domain.events import EventBus +from onguard24.modules.ui_support import wrap_module_html_page router = APIRouter(tags=["module-statusboard"]) +ui_router = APIRouter(tags=["web-statusboard"], include_in_schema=False) + + +def register_events(_bus: EventBus) -> None: + pass + + +async def render_home_fragment(request: Request) -> str: + del request + return ( + '
' + "

Сводка по сервисам (светофор) — по данным алертов.

" + "
" + ) + @router.get("/") async def statusboard_root(): @@ -11,3 +30,17 @@ async def statusboard_root(): "note": "светофор по сервисам — агрегация по алертам", "demo": [], } + + +@ui_router.get("/", response_class=HTMLResponse) +async def statusboard_ui_home(request: Request): + del request + inner = """

Светофор

+

Агрегация статусов сервисов по алертам.

""" + return HTMLResponse( + wrap_module_html_page( + document_title="Светофор — onGuard24", + current_slug="statusboard", + main_inner_html=inner, + ) + ) diff --git a/onguard24/modules/ui_support.py b/onguard24/modules/ui_support.py new file mode 100644 index 0000000..c33fe35 --- /dev/null +++ b/onguard24/modules/ui_support.py @@ -0,0 +1,107 @@ +"""Безопасная сборка 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; } +""" + + +def nav_rail_html(current_slug: str | None = None) -> str: + """Правая колонка: пункты из реестра модулей с `ui_router` + «Главная». + + Новый модуль в `MODULE_MOUNTS` с UI — пункт появляется автоматически. + """ + from onguard24.modules.registry import MODULE_MOUNTS + + home_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'
  • {html.escape(m.title)}
  • ' + ) + lis = "".join(items) + return ( + '" + ) + + +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""" + + + + + {html.escape(document_title)} + + + +
    +
    +{main_inner_html} +
    + {rail} +
    + +""" + + +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 ( + '" + ) diff --git a/onguard24/root_html.py b/onguard24/root_html.py index 9b57ec1..5b4ce9c 100644 --- a/onguard24/root_html.py +++ b/onguard24/root_html.py @@ -1,6 +1,10 @@ import html import json +from starlette.requests import Request + +from onguard24.modules.registry import MODULE_MOUNTS +from onguard24.modules.ui_support import APP_SHELL_CSS, nav_rail_html, safe_fragment from onguard24.status_snapshot import build @@ -34,7 +38,42 @@ def _row(name: str, value: object) -> str: return f"{label}{badge}" -async def render_root_page(request) -> str: +async def _module_sections_html(request: Request) -> str: + """Блоки модулей на главной: фрагменты изолированы (ошибка одного не роняет страницу).""" + blocks: list[str] = [] + for m in MODULE_MOUNTS: + title = html.escape(m.title) + slug_e = html.escape(m.slug) + full_url = f"/ui/modules/{m.slug}/" + if m.render_home_fragment: + inner = await safe_fragment(m.slug, m.render_home_fragment, request) + else: + inner = '

    Превью не задано — откройте полный интерфейс.

    ' + foot = "" + if m.ui_router: + foot = ( + f'" + ) + blocks.append( + f'
    ' + f'

    {title}

    ' + f'
    {inner}
    ' + f"{foot}" + f"
    " + ) + grid = "".join(blocks) + return ( + '
    ' + "

    Модули

    " + f'
    {grid}
    ' + "
    " + ) + + +async def render_root_page(request: Request) -> str: data = await build(request) rows = "" for key in ("database", "vault", "grafana", "forgejo"): @@ -42,6 +81,8 @@ async def render_root_page(request) -> str: rows += _row(key, data[key]) payload = html.escape(json.dumps(data, ensure_ascii=False, indent=2)) + modules_html = await _module_sections_html(request) + rail = nav_rail_html(None) return f""" @@ -50,7 +91,7 @@ async def render_root_page(request) -> str: onGuard24 +
    +

    onGuard24

    Полный ответ /api/v1/status

    {payload}
    +
    +{rail} +
    """ diff --git a/pyproject.toml b/pyproject.toml index 7343f5a..b7d063d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "onguard24" -version = "1.1.0" +version = "1.4.0" description = "onGuard24 — модульный сервис (аналог IRM)" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_ingress.py b/tests/test_ingress.py index 19cf484..da2ad8b 100644 --- a/tests/test_ingress.py +++ b/tests/test_ingress.py @@ -36,8 +36,11 @@ def test_grafana_webhook_unauthorized_when_secret_set(client: TestClient) -> Non def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None: + from uuid import uuid4 + mock_conn = AsyncMock() - mock_conn.execute = AsyncMock() + uid = uuid4() + mock_conn.fetchrow = AsyncMock(return_value={"id": uid}) mock_cm = AsyncMock() mock_cm.__aenter__ = AsyncMock(return_value=mock_conn) mock_cm.__aexit__ = AsyncMock(return_value=None) @@ -55,6 +58,37 @@ def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None: headers={"Content-Type": "application/json"}, ) assert r.status_code == 202 - mock_conn.execute.assert_called_once() + mock_conn.fetchrow.assert_called_once() finally: app.state.pool = real_pool + + +def test_grafana_webhook_publishes_alert_received(client: TestClient) -> None: + from unittest.mock import patch + from uuid import uuid4 + + mock_conn = AsyncMock() + uid = uuid4() + mock_conn.fetchrow = AsyncMock(return_value={"id": uid}) + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_conn) + mock_cm.__aexit__ = AsyncMock(return_value=None) + mock_pool = MagicMock() + mock_pool.acquire = MagicMock(return_value=mock_cm) + + app = client.app + bus = app.state.event_bus + with patch.object(bus, "publish_alert_received", new_callable=AsyncMock) as spy: + real_pool = app.state.pool + app.state.pool = mock_pool + try: + r = client.post( + "/api/v1/ingress/grafana", + content=json.dumps({"title": "x"}), + headers={"Content-Type": "application/json"}, + ) + assert r.status_code == 202 + spy.assert_awaited_once() + assert spy.await_args.kwargs.get("raw_payload_ref") == uid + finally: + app.state.pool = real_pool diff --git a/tests/test_root_ui.py b/tests/test_root_ui.py new file mode 100644 index 0000000..09495b2 --- /dev/null +++ b/tests/test_root_ui.py @@ -0,0 +1,69 @@ +"""Главная страница и изолированные UI модулей.""" + +from unittest.mock import patch + +from fastapi.testclient import TestClient + + +def test_root_html_includes_module_cards(client: TestClient) -> None: + r = client.get("/") + assert r.status_code == 200 + body = r.text + assert "Модули" in body + assert "module-card" in body + assert "/ui/modules/schedules/" in body + assert "Календарь дежурств" in body + assert "app-rail" in body + assert "rail-nav" in body + + +def test_module_ui_page_schedules(client: TestClient) -> None: + r = client.get("/ui/modules/schedules/") + assert r.status_code == 200 + assert "text/html" in r.headers.get("content-type", "") + assert "Календарь дежурств" in r.text + assert "app-rail" in r.text + assert 'aria-current="page"' in r.text + + +def test_rail_lists_all_registered_ui_modules(client: TestClient) -> None: + """Правое меню синхронизировано с реестром: все модули с ui_router.""" + r = client.get("/") + assert r.status_code == 200 + t = r.text + expected = ( + ("schedules", "Календарь дежурств"), + ("contacts", "Контакты"), + ("statusboard", "Светофор"), + ) + for slug, title in expected: + assert f"/ui/modules/{slug}/" in t + assert title in t + + +def test_each_module_page_single_active_nav_item(client: TestClient) -> None: + """На странице модуля ровно один пункт с aria-current (текущий раздел).""" + for slug in ("schedules", "contacts", "statusboard"): + r = client.get(f"/ui/modules/{slug}/") + assert r.status_code == 200 + assert r.text.count('aria-current="page"') == 1 + + +def test_root_survives_broken_module_fragment(client: TestClient) -> None: + """MODULE_MOUNTS держит ссылки на функции при импорте — ломаем фрагмент через обёртку safe_fragment.""" + + async def bad_fragment(_request): + raise RuntimeError("simulated module bug") + + async def patched_safe_fragment(slug, fn, request): + from onguard24.modules import ui_support as us + + if slug == "schedules": + return await us.safe_fragment(slug, bad_fragment, request) + return await us.safe_fragment(slug, fn, request) + + with patch("onguard24.root_html.safe_fragment", new=patched_safe_fragment): + r = client.get("/") + assert r.status_code == 200 + assert "module-err" in r.text + assert "schedules" in r.text