1 Commits

Author SHA1 Message Date
349cea85a3 v1.4.0: модули с веб-UI, правое меню, расширенные тесты
Реестр MODULE_MOUNTS: API, ui_router, фрагменты главной, EventBus.
Главная и страницы модулей с правой навигацией из реестра; wrap_module_html_page.
Ingress: публикация alert.received после сохранения в БД.
Документация MODULES.md; pytest покрывает API, UI и навигацию.

Made-with: Cursor
2026-04-03 08:45:19 +03:00
17 changed files with 571 additions and 24 deletions

View File

@ -2,6 +2,36 @@
Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md). Формат: семантическое версионирование `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/<slug>/`** — монтирование `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 ## [1.1.0] — 2026-04-03
Инфраструктура разработки и задел под домен IRM. Инфраструктура разработки и задел под домен IRM.

View File

@ -1,6 +1,6 @@
# onGuard24 # 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/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Структура кода, куда что класть |
| [docs/AI_CONTEXT.md](docs/AI_CONTEXT.md) | Краткий контекст для доработок | | [docs/AI_CONTEXT.md](docs/AI_CONTEXT.md) | Краткий контекст для доработок |
| [docs/DOMAIN.md](docs/DOMAIN.md) | Сущности (инцидент, алерт, эскалация), шина событий | | [docs/DOMAIN.md](docs/DOMAIN.md) | Сущности (инцидент, алерт, эскалация), шина событий |
| [docs/MODULES.md](docs/MODULES.md) | Как добавлять модули и подписки на события |
**Репозиторий:** [forgejo.pvenode.ru/admin/onGuard24](https://forgejo.pvenode.ru/admin/onGuard24) **Репозиторий:** [forgejo.pvenode.ru/admin/onGuard24](https://forgejo.pvenode.ru/admin/onGuard24)
@ -20,7 +21,7 @@
- **PostgreSQL:** пул asyncpg, таблица `ingress_events` для сырых тел webhook Grafana; схема через **Alembic** (отдельные ревизии в `alembic/versions/`). - **PostgreSQL:** пул asyncpg, таблица `ingress_events` для сырых тел webhook Grafana; схема через **Alembic** (отдельные ревизии в `alembic/versions/`).
- **POST `/api/v1/ingress/grafana`** — приём JSON алерта (опционально защита `X-OnGuard-Secret`). - **POST `/api/v1/ingress/grafana`** — приём JSON алерта (опционально защита `X-OnGuard-Secret`).
- **GET `/`**, **GET `/api/v1/status`** — проверки: БД, Vault, Grafana (service account), Forgejo (PAT). - **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/<slug>/`, превью на главной `/` (см. [docs/MODULES.md](docs/MODULES.md)).
- **Фронт (опционально):** `web/` — Vite + React, прокси на API. - **Фронт (опционально):** `web/` — Vite + React, прокси на API.
Чего **ещё нет** (следующие версии): авторизация публичных API (кроме секрета webhook), полноценная бизнес-логика IRM в коде (эскалации, дежурства, светофор), фоновые задачи. Доменные сущности и задел под модули описаны в [docs/DOMAIN.md](docs/DOMAIN.md). Чего **ещё нет** (следующие версии): авторизация публичных API (кроме секрета webhook), полноценная бизнес-логика IRM в коде (эскалации, дежурства, светофор), фоновые задачи. Доменные сущности и задел под модули описаны в [docs/DOMAIN.md](docs/DOMAIN.md).

View File

@ -1,6 +1,6 @@
# Архитектура onGuard24 (для разработки и доработок) # Архитектура 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/ │ ├── integrations/
│ │ ├── grafana_api.py # Grafana HTTP API (Bearer SA) │ │ ├── grafana_api.py # Grafana HTTP API (Bearer SA)
│ │ └── forgejo_api.py # Forgejo/Gitea API (token + probe/fallback) │ │ └── forgejo_api.py # Forgejo/Gitea API (token + probe/fallback)
│ └── modules/ # Заглушки: schedules, contacts, statusboard │ └── modules/ # API + ui_router + registry + ui_support (фрагменты главной)
├── web/ # Vite + React (опционально) ├── web/ # Vite + React (опционально)
├── pyproject.toml ├── pyproject.toml
├── pytest.ini ├── 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). 2. **Параллельно** Grafana может слать в Mattermost — это вне этого репозитория (конфиг Grafana).
3. **Статус страницы** не ходит в Grafana за алертами — только **проверка доступности API** (токен SA). 3. **Статус страницы** не ходит в Grafana за алертами — только **проверка доступности API** (токен SA).
@ -39,7 +39,7 @@ onGuard24/
| Задача | Место | | Задача | Место |
|--------|--------| |--------|--------|
| Новый HTTP-роут модуля | `onguard24/modules/<name>.py` + `include_router` в `main.py` | | Новый HTTP-роут модуля | `onguard24/modules/<name>.py` + запись в `modules/registry.py` (см. [MODULES.md](MODULES.md)) |
| Общая логика инцидентов / событий | задел: `onguard24/domain/` + [DOMAIN.md](DOMAIN.md); позже сервисный слой и БД | | Общая логика инцидентов / событий | задел: `onguard24/domain/` + [DOMAIN.md](DOMAIN.md); позже сервисный слой и БД |
| Новая таблица БД | Alembic: `alembic revision`, правка `alembic/versions/`, `alembic upgrade head` | | Новая таблица БД | Alembic: `alembic revision`, правка `alembic/versions/`, `alembic upgrade head` |
| Новая внешняя интеграция | `onguard24/integrations/<name>.py`, вызов из `status_snapshot` при необходимости | | Новая внешняя интеграция | `onguard24/integrations/<name>.py`, вызов из `status_snapshot` при необходимости |
@ -51,6 +51,7 @@ onGuard24/
## Зависимости между компонентами ## Зависимости между компонентами
- `status_snapshot.build(request)` читает `request.app.state.pool` и `request.app.state.settings` (устанавливаются в `lifespan`). - `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 пока не подключена. - Модули **не** зависят друг от друга; контракт заделан через **доменные события** (`domain/events.py`, `EventBus`) и описан в [DOMAIN.md](DOMAIN.md); проводка в HTTP пока не подключена.
## Известные ограничения ## Известные ограничения

View File

@ -1,6 +1,6 @@
# Доменная модель onGuard24 # Доменная модель onGuard24
Версия **1.1.0** вводит явные сущности и задел под **события** между модулями. Таблицы БД для инцидентов пока не добавлены — см. [Alembic](../alembic/versions/). Версия **1.1+** вводит явные сущности и задел под **события** между модулями. Таблицы БД для инцидентов пока не добавлены — см. [Alembic](../alembic/versions/).
## Сущности (код: `onguard24/domain/entities.py`) ## Сущности (код: `onguard24/domain/entities.py`)
@ -24,9 +24,9 @@
1. Модуль реализует **`Module`**: свойство `name`, метод `on_event(event)`. 1. Модуль реализует **`Module`**: свойство `name`, метод `on_event(event)`.
2. При старте приложения модуль регистрируется: `bus.subscribe("alert.received", handler)`. 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)).
## Связь с БД ## Связь с БД

57
docs/MODULES.md Normal file
View File

@ -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/<slug>/`**, страница собирается через **`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/<slug>/`).
- Опционально **`async def render_home_fragment(request) -> str`** — HTML-фрагмент (без `<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/<slug>/`.
`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).

View File

@ -1,3 +1,3 @@
"""onGuard24 — модульный монолит (ядро + модули).""" """onGuard24 — модульный монолит (ядро + модули)."""
__version__ = "1.1.0" __version__ = "1.4.0"

View File

@ -1,9 +1,12 @@
import json import json
import logging import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Header, HTTPException, Request from fastapi import APIRouter, Depends, Header, HTTPException, Request
from starlette.responses import Response from starlette.responses import Response
from onguard24.domain.entities import Alert, Severity
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["ingress"]) router = APIRouter(tags=["ingress"])
@ -35,9 +38,21 @@ async def grafana_webhook(
return Response(status_code=202) return Response(status_code=202)
async with pool.acquire() as conn: async with pool.acquire() as conn:
await conn.execute( row = await conn.fetchrow(
"INSERT INTO ingress_events (source, body) VALUES ($1, $2::jsonb)", "INSERT INTO ingress_events (source, body) VALUES ($1, $2::jsonb) RETURNING id",
"grafana", "grafana",
json.dumps(body), 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) return Response(status_code=202)

View File

@ -7,8 +7,9 @@ from starlette.responses import HTMLResponse, Response
from onguard24.config import get_settings from onguard24.config import get_settings
from onguard24.db import create_pool from onguard24.db import create_pool
from onguard24.domain.events import InMemoryEventBus
from onguard24.ingress import grafana as grafana_ingress 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.root_html import render_root_page
from onguard24.status_snapshot import build as build_status from onguard24.status_snapshot import build as build_status
from onguard24 import __version__ as app_version 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): async def lifespan(app: FastAPI):
settings = get_settings() settings = get_settings()
pool = await create_pool(settings) pool = await create_pool(settings)
bus = InMemoryEventBus()
register_module_events(bus)
app.state.pool = pool app.state.pool = pool
app.state.settings = settings app.state.settings = settings
app.state.event_bus = bus
log.info("onGuard24 started, db=%s", "ok" if pool else "disabled") log.info("onGuard24 started, db=%s", "ok" if pool else "disabled")
yield yield
if pool: if pool:
@ -78,9 +82,10 @@ def create_app() -> FastAPI:
return await build_status(request) return await build_status(request)
app.include_router(grafana_ingress.router, prefix="/api/v1") app.include_router(grafana_ingress.router, prefix="/api/v1")
app.include_router(schedules.router, prefix="/api/v1/modules/schedules") for mount in MODULE_MOUNTS:
app.include_router(contacts.router, prefix="/api/v1/modules/contacts") app.include_router(mount.router, prefix=mount.url_prefix)
app.include_router(statusboard.router, prefix="/api/v1/modules/statusboard") if mount.ui_router is not None:
app.include_router(mount.ui_router, prefix=f"/ui/modules/{mount.slug}")
return app return app

View File

@ -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"]) 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 '<div class="module-fragment"><p>Контакты, группы, каналы доставки.</p></div>'
@router.get("/") @router.get("/")
async def contacts_root(): async def contacts_root():
@ -10,3 +25,17 @@ async def contacts_root():
"status": "stub", "status": "stub",
"note": "люди, группы, каналы доставки", "note": "люди, группы, каналы доставки",
} }
@ui_router.get("/", response_class=HTMLResponse)
async def contacts_ui_home(request: Request):
del request
inner = """<h1>Контакты</h1>
<p>Люди, группы, каналы уведомлений.</p>"""
return HTMLResponse(
wrap_module_html_page(
document_title="Контакты — onGuard24",
current_slug="contacts",
main_inner_html=inner,
)
)

View File

@ -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)

View File

@ -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"]) 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 (
'<div class="module-fragment">'
"<p>Планирование смен и календарь — следующий этап.</p>"
"</div>"
)
@router.get("/") @router.get("/")
async def schedules_root(): async def schedules_root():
@ -10,3 +31,18 @@ async def schedules_root():
"status": "stub", "status": "stub",
"note": "календарь и смены — следующий этап", "note": "календарь и смены — следующий этап",
} }
@ui_router.get("/", response_class=HTMLResponse)
async def schedules_ui_home(request: Request):
"""Полная HTML-страница: /ui/modules/schedules/ — то же правое меню, что на главной."""
del request
inner = """<h1>Календарь дежурств</h1>
<p>Здесь будет функционал модуля: смены, календарь, уведомления.</p>"""
return HTMLResponse(
wrap_module_html_page(
document_title="Календарь дежурств — onGuard24",
current_slug="schedules",
main_inner_html=inner,
)
)

View File

@ -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"]) 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 (
'<div class="module-fragment">'
"<p>Сводка по сервисам (светофор) — по данным алертов.</p>"
"</div>"
)
@router.get("/") @router.get("/")
async def statusboard_root(): async def statusboard_root():
@ -11,3 +30,17 @@ async def statusboard_root():
"note": "светофор по сервисам — агрегация по алертам", "note": "светофор по сервисам — агрегация по алертам",
"demo": [], "demo": [],
} }
@ui_router.get("/", response_class=HTMLResponse)
async def statusboard_ui_home(request: Request):
del request
inner = """<h1>Светофор</h1>
<p>Агрегация статусов сервисов по алертам.</p>"""
return HTMLResponse(
wrap_module_html_page(
document_title="Светофор — onGuard24",
current_slug="statusboard",
main_inner_html=inner,
)
)

View File

@ -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 = (
'<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>'
)
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>"
)

View File

@ -1,6 +1,10 @@
import html import html
import json 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 from onguard24.status_snapshot import build
@ -34,7 +38,42 @@ def _row(name: str, value: object) -> str:
return f"<tr><th>{label}</th><td>{badge}</td></tr>" return f"<tr><th>{label}</th><td>{badge}</td></tr>"
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 = '<p class="module-note">Превью не задано — откройте полный интерфейс.</p>'
foot = ""
if m.ui_router:
foot = (
f'<footer class="module-card-foot">'
f'<a class="module-open" href="{html.escape(full_url)}">'
"Полный интерфейс модуля"
f"</a></footer>"
)
blocks.append(
f'<article class="module-card" data-module="{slug_e}">'
f'<header class="module-card-head"><h3>{title}</h3></header>'
f'<div class="module-card-body">{inner}</div>'
f"{foot}"
f"</article>"
)
grid = "".join(blocks)
return (
'<section class="modules" id="modules">'
"<h2>Модули</h2>"
f'<div class="modules-grid">{grid}</div>'
"</section>"
)
async def render_root_page(request: Request) -> str:
data = await build(request) data = await build(request)
rows = "" rows = ""
for key in ("database", "vault", "grafana", "forgejo"): for key in ("database", "vault", "grafana", "forgejo"):
@ -42,6 +81,8 @@ async def render_root_page(request) -> str:
rows += _row(key, data[key]) rows += _row(key, data[key])
payload = html.escape(json.dumps(data, ensure_ascii=False, indent=2)) 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"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html lang="ru"> <html lang="ru">
@ -50,7 +91,7 @@ async def render_root_page(request) -> str:
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>onGuard24</title> <title>onGuard24</title>
<style> <style>
body {{ font-family: system-ui, sans-serif; margin: 2rem; background: #fafafa; color: #18181b; }} {APP_SHELL_CSS}
h1 {{ margin-top: 0; }} h1 {{ margin-top: 0; }}
.badge {{ display: inline-block; padding: 0.15rem 0.5rem; border-radius: 6px; font-size: 0.85rem; font-weight: 600; }} .badge {{ display: inline-block; padding: 0.15rem 0.5rem; border-radius: 6px; font-size: 0.85rem; font-weight: 600; }}
.ok {{ background: #dcfce7; color: #166534; }} .ok {{ background: #dcfce7; color: #166534; }}
@ -64,9 +105,23 @@ async def render_root_page(request) -> str:
.links a {{ margin-right: 1rem; }} .links a {{ margin-right: 1rem; }}
.json {{ margin-top: 2rem; max-width: 56rem; }} .json {{ margin-top: 2rem; max-width: 56rem; }}
.json pre {{ background: #18181b; color: #e4e4e7; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 0.8rem; }} .json pre {{ background: #18181b; color: #e4e4e7; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 0.8rem; }}
.modules {{ margin-top: 2.5rem; max-width: 56rem; }}
.modules h2 {{ font-size: 1.25rem; }}
.modules-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: 1rem; margin-top: 1rem; }}
.module-card {{ background: #fff; border-radius: 8px; box-shadow: 0 1px 3px #0001; overflow: hidden; display: flex; flex-direction: column; }}
.module-card-head {{ padding: 0.75rem 1rem; border-bottom: 1px solid #e4e4e7; }}
.module-card-head h3 {{ margin: 0; font-size: 1rem; }}
.module-card-body {{ padding: 0.75rem 1rem; flex: 1; font-size: 0.9rem; }}
.module-card-foot {{ padding: 0.5rem 1rem; border-top: 1px solid #f4f4f5; font-size: 0.85rem; }}
.module-open {{ font-weight: 600; }}
.module-fragment p {{ margin: 0.35rem 0 0; }}
.module-note {{ color: #71717a; margin: 0; }}
.module-err {{ color: #991b1b; background: #fef2f2; padding: 0.5rem 0.75rem; border-radius: 6px; font-size: 0.85rem; }}
</style> </style>
</head> </head>
<body> <body>
<div class="app-shell">
<main class="app-main">
<h1>onGuard24</h1> <h1>onGuard24</h1>
<p class="links"> <p class="links">
<a href="/docs">Swagger</a> <a href="/docs">Swagger</a>
@ -80,9 +135,13 @@ async def render_root_page(request) -> str:
{rows} {rows}
</tbody> </tbody>
</table> </table>
{modules_html}
<div class="json"> <div class="json">
<h3>Полный ответ <code>/api/v1/status</code></h3> <h3>Полный ответ <code>/api/v1/status</code></h3>
<pre>{payload}</pre> <pre>{payload}</pre>
</div> </div>
</main>
{rail}
</div>
</body> </body>
</html>""" </html>"""

View File

@ -1,6 +1,6 @@
[project] [project]
name = "onguard24" name = "onguard24"
version = "1.1.0" version = "1.4.0"
description = "onGuard24 — модульный сервис (аналог IRM)" description = "onGuard24 — модульный сервис (аналог IRM)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@ -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: def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None:
from uuid import uuid4
mock_conn = AsyncMock() mock_conn = AsyncMock()
mock_conn.execute = AsyncMock() uid = uuid4()
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
mock_cm = AsyncMock() mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn) mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
mock_cm.__aexit__ = AsyncMock(return_value=None) 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"}, headers={"Content-Type": "application/json"},
) )
assert r.status_code == 202 assert r.status_code == 202
mock_conn.execute.assert_called_once() mock_conn.fetchrow.assert_called_once()
finally: finally:
app.state.pool = real_pool 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

69
tests/test_root_ui.py Normal file
View File

@ -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