2 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
85eb61b576 v1.1.0: Alembic, pytest, домен и документация
- Миграции PostgreSQL через Alembic; DDL убран из lifespan приложения.
- Тесты: health, status, ingress Grafana; моки Vault/Grafana/Forgejo.
- Пакет onguard24/domain/ (сущности, шина событий), docs/DOMAIN.md.
- Обновлены README, CHANGELOG, ARCHITECTURE.

Made-with: Cursor
2026-04-03 08:36:35 +03:00
30 changed files with 1173 additions and 47 deletions

View File

@ -2,6 +2,53 @@
Формат: семантическое версионирование `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
Инфраструктура разработки и задел под домен IRM.
### Добавлено
- **Миграции:** Alembic (`alembic.ini`, `alembic/env.py`, ревизии в `alembic/versions/`). Начальная схема: таблица `ingress_events` (как раньше в коде). Команда: `alembic upgrade head`. DDL при старте приложения убран — только пул asyncpg.
- **Тесты:** `pytest`, `pytest-asyncio`, моки интеграций; тесты API: `/health`, `/api/v1/status`, `POST /api/v1/ingress/grafana` (в т.ч. секрет webhook). Установка: `pip install -e ".[dev]"`.
- **Домен (задел):** пакет `onguard24/domain/` — сущности `Alert`, `Incident`, эскалация; `EventBus` / `InMemoryEventBus`, протокол `Module` для подписки на события. Описание: [docs/DOMAIN.md](docs/DOMAIN.md).
### Зависимости
- Прод: `sqlalchemy`, `alembic`, `psycopg[binary]` (для CLI миграций).
- Dev (optional): `pytest`, `pytest-asyncio`, `respx`.
Тег в репозитории (после публикации): `v1.1.0`.
## [1.0.0] — 2026-04-03 ## [1.0.0] — 2026-04-03
Первый зафиксированный релиз **каркаса** (scaffold). Первый зафиксированный релиз **каркаса** (scaffold).

View File

@ -1,6 +1,6 @@
# onGuard24 # onGuard24
**Версия: 1.0.0** · Модульный монолит на **Python (FastAPI)**: ядро, приём алертов из Grafana, заготовки модулей (дежурства, контакты, светофор), PostgreSQL, проверки Vault / Grafana / Forgejo. **Версия: 1.4.0** · Модульный монолит на **Python (FastAPI)**: ядро, приём алертов из Grafana, заготовки модулей (дежурства, контакты, светофор), PostgreSQL, проверки Vault / Grafana / Forgejo.
| Документ | Назначение | | Документ | Назначение |
|----------|------------| |----------|------------|
@ -8,6 +8,8 @@
| [docs/VERSIONING.md](docs/VERSIONING.md) | Теги, откат к предыдущей версии | | [docs/VERSIONING.md](docs/VERSIONING.md) | Теги, откат к предыдущей версии |
| [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/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)
@ -16,13 +18,13 @@
## Что уже есть (функционал v1) ## Что уже есть (функционал v1)
- Запуск HTTP API (`uvicorn`), конфиг из `.env`. - Запуск HTTP API (`uvicorn`), конфиг из `.env`.
- **PostgreSQL:** пул asyncpg, таблица `ingress_events` для сырых тел webhook Grafana. - **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.
Чего **ещё нет** (следующие версии): Alembic, авторизация API, доменная модель инцидентов, эскалации, фоновые задачи. Чего **ещё нет** (следующие версии): авторизация публичных API (кроме секрета webhook), полноценная бизнес-логика IRM в коде (эскалации, дежурства, светофор), фоновые задачи. Доменные сущности и задел под модули описаны в [docs/DOMAIN.md](docs/DOMAIN.md).
## Быстрый старт ## Быстрый старт
@ -33,7 +35,11 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e . pip install -e .
``` ```
Скопируй `.env.example` в `.env` и заполни секреты (см. ниже). Скопируй `.env.example` в `.env` и заполни секреты (см. ниже). Перед первым запуском с БД примените миграции:
```bash
alembic upgrade head
```
```bash ```bash
python -m uvicorn onguard24.main:app --reload --host 0.0.0.0 --port 8080 python -m uvicorn onguard24.main:app --reload --host 0.0.0.0 --port 8080
@ -73,3 +79,20 @@ cd web && npm install && npm run dev
``` ```
Vite проксирует `/api` на `http://127.0.0.1:8080` (см. `web/vite.config.ts`). Vite проксирует `/api` на `http://127.0.0.1:8080` (см. `web/vite.config.ts`).
## Миграции БД (Alembic)
- URL БД: переменная **`DATABASE_URL`** (как у приложения; в `alembic/env.py` используется синхронный драйвер `postgresql+psycopg`).
- Применить схему: `alembic upgrade head`.
- Новая ревизия: `alembic revision -m "описание"` и правка файла в `alembic/versions/`.
Приложение **не** выполняет DDL при старте — только пул соединений.
## Тесты
```bash
pip install -e ".[dev]"
pytest
```
Покрытие: `/health`, `/api/v1/status`, webhook Grafana; внешние вызовы (Vault, Grafana, Forgejo) в тестах статуса подменяются моками.

40
alembic.ini Normal file
View File

@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

57
alembic/env.py Normal file
View File

@ -0,0 +1,57 @@
"""Alembic: синхронный движок SQLAlchemy + psycopg3 (отдельно от asyncpg в рантайме)."""
from __future__ import annotations
import os
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from dotenv import load_dotenv
from sqlalchemy import create_engine, pool
ROOT = Path(__file__).resolve().parent.parent
load_dotenv(ROOT / ".env")
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = None
def get_sync_url() -> str:
url = os.environ.get("DATABASE_URL", "").strip()
if not url:
raise RuntimeError("Задай DATABASE_URL для alembic upgrade")
if url.startswith("postgres://"):
url = url.replace("postgres://", "postgresql://", 1)
if url.startswith("postgresql://") and "+psycopg" not in url and "+asyncpg" not in url:
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
if "+asyncpg" in url:
url = url.replace("+asyncpg", "+psycopg")
return url
def run_migrations_offline() -> None:
context.configure(
url=get_sync_url(),
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = create_engine(get_sync_url(), poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

26
alembic/script.py.mako Normal file
View File

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,40 @@
"""initial ingress_events
Revision ID: 001_initial
Revises:
Create Date: 2026-04-03
"""
from typing import Sequence, Union
from alembic import op
revision: str = "001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS ingress_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
body jsonb NOT NULL
);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS ingress_events_received_at_idx
ON ingress_events (received_at DESC);
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ingress_events_received_at_idx;")
op.execute("DROP TABLE IF EXISTS ingress_events;")

View File

@ -1,15 +1,18 @@
# Архитектура onGuard24 (для разработки и доработок) # Архитектура onGuard24 (для разработки и доработок)
Цель продукта: **модульный монолит** в духе IRM — ядро + подключаемые области (дежурства, контакты, «светофор» по сервисам). Текущая версия — **каркас v1**: HTTP, БД, ingress Grafana, проверки интеграций. Цель продукта: **модульный монолит** в духе IRM — ядро + подключаемые области (дежурства, контакты, «светофор» по сервисам). Текущая версия — **каркас v1.2**: HTTP, БД, ingress Grafana, проверки интеграций, Alembic, задел домена в `onguard24/domain/`.
## Дерево пакетов ## Дерево пакетов
``` ```
onGuard24/ onGuard24/
├── alembic/ # Ревизии миграций PostgreSQL (Alembic)
├── alembic.ini
├── onguard24/ ├── onguard24/
│ ├── main.py # FastAPI app, lifespan, маршруты верхнего уровня │ ├── main.py # FastAPI app, lifespan, маршруты верхнего уровня
│ ├── config.py # Settings: .env из корня репозитория (не от cwd) │ ├── config.py # Settings: .env из корня репозитория (не от cwd)
│ ├── db.py # asyncpg pool, миграция ingress_events │ ├── db.py # asyncpg pool (без DDL)
│ ├── domain/ # Сущности и шина событий (задел под модули)
│ ├── status_snapshot.py # Единый сборщик JSON для /api/v1/status │ ├── status_snapshot.py # Единый сборщик JSON для /api/v1/status
│ ├── root_html.py # HTML главной страницы со статусами │ ├── root_html.py # HTML главной страницы со статусами
│ ├── vaultcheck.py # Vault /v1/sys/health │ ├── vaultcheck.py # Vault /v1/sys/health
@ -17,16 +20,18 @@ 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
├── tests/ # pytest: health, status, ingress
├── CHANGELOG.md ├── CHANGELOG.md
└── docs/ └── docs/
``` ```
## Поток данных (сейчас) ## Поток данных (сейчас)
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).
@ -34,9 +39,9 @@ onGuard24/
| Задача | Место | | Задача | Место |
|--------|--------| |--------|--------|
| Новый HTTP-роут модуля | `onguard24/modules/<name>.py` + `include_router` в `main.py` | | Новый HTTP-роут модуля | `onguard24/modules/<name>.py` + запись в `modules/registry.py` (см. [MODULES.md](MODULES.md)) |
| Общая логика инцидентов / событий | позже: `onguard24/core/` или сервисный слой + события из БД | | Общая логика инцидентов / событий | задел: `onguard24/domain/` + [DOMAIN.md](DOMAIN.md); позже сервисный слой и БД |
| Новая таблица БД | пока: SQL в `db.py` (MIGRATION_00N); позже: Alembic | | Новая таблица БД | Alembic: `alembic revision`, правка `alembic/versions/`, `alembic upgrade head` |
| Новая внешняя интеграция | `onguard24/integrations/<name>.py`, вызов из `status_snapshot` при необходимости | | Новая внешняя интеграция | `onguard24/integrations/<name>.py`, вызов из `status_snapshot` при необходимости |
## Конфигурация ## Конфигурация
@ -46,10 +51,11 @@ 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 пока не подключена.
## Известные ограничения v1 ## Известные ограничения
- Нет единой модели «инцидент» в БД — только сырой ingest в `ingress_events`. - Нет единой модели «инцидент» в БД — только сырой ingest в `ingress_events` (в коде есть Pydantic-модели в `domain/entities.py` как задел).
- Нет очереди/воркеров для эскалаций. - Нет очереди/воркеров для эскалаций.
- Нет auth на GET `/api/v1/status` (только для внутренней сети / за reverse proxy с ограничением). - Нет auth на GET `/api/v1/status` (только для внутренней сети / за reverse proxy с ограничением).

34
docs/DOMAIN.md Normal file
View File

@ -0,0 +1,34 @@
# Доменная модель onGuard24
Версия **1.1+** вводит явные сущности и задел под **события** между модулями. Таблицы БД для инцидентов пока не добавлены — см. [Alembic](../alembic/versions/).
## Сущности (код: `onguard24/domain/entities.py`)
| Сущность | Назначение |
|----------|------------|
| **Alert** | Нормализованный алерт после парсинга webhook (Grafana и др.): `severity`, `labels`, `payload`. |
| **Incident** | Жизненный цикл инцидента: статус, связь с алертами (`alert_ids`). |
| **EscalationPolicy** / **EscalationStep** | Цепочка эскалаций (уведомления, паузы) — задел под модуль schedules/IRM. |
**Severity** — перечисление: `info`, `warning`, `critical`.
## События (код: `onguard24/domain/events.py`)
| Событие | Когда |
|---------|--------|
| **AlertReceived** (`name=alert.received`) | Алерт принят и (в будущем) сохранён/сопоставлен. |
**EventBus** — протокол; **InMemoryEventBus** — простая реализация для тестов и прототипа.
### Как модули подписываются (план)
1. Модуль реализует **`Module`**: свойство `name`, метод `on_event(event)`.
2. При старте приложения модуль регистрируется: `bus.subscribe("alert.received", handler)`.
3. После успешного INSERT в `ingress_events` ядро вызывает `await bus.publish_alert_received(Alert, raw_payload_ref=id_строки)`.
Подключение к шине и регистрация модулей: **`app.state.event_bus`**, список модулей — **`modules/registry.py`** (см. [MODULES.md](MODULES.md)).
## Связь с БД
- **ingress_events** — сырой JSON от Grafana (`alembic` миграция `001_initial`).
- Сущности **Alert** / **Incident** — пока только в памяти; позже — таблицы и маппинг.

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.0.0" __version__ = "1.4.0"

View File

@ -14,19 +14,3 @@ async def create_pool(settings: Settings) -> asyncpg.Pool | None:
return None return None
dsn = normalize_dsn(settings.database_url.strip()) dsn = normalize_dsn(settings.database_url.strip())
return await asyncpg.create_pool(dsn=dsn, min_size=1, max_size=10) return await asyncpg.create_pool(dsn=dsn, min_size=1, max_size=10)
MIGRATION_001 = """
CREATE TABLE IF NOT EXISTS ingress_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
body jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS ingress_events_received_at_idx ON ingress_events (received_at DESC);
"""
async def migrate(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn:
await conn.execute(MIGRATION_001)

View File

@ -0,0 +1,23 @@
"""Доменные сущности и шина событий (задел под модули)."""
from onguard24.domain.entities import Alert, EscalationPolicy, EscalationStep, Incident, Severity
from onguard24.domain.events import (
AlertReceived,
DomainEvent,
EventBus,
InMemoryEventBus,
Module,
)
__all__ = [
"Severity",
"Alert",
"Incident",
"EscalationPolicy",
"EscalationStep",
"DomainEvent",
"AlertReceived",
"Module",
"EventBus",
"InMemoryEventBus",
]

View File

@ -0,0 +1,59 @@
"""Сущности домена (пока без таблиц БД — контракт для следующих версий)."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
class Severity(str, Enum):
"""Грубая шкала для алертов и инцидентов."""
INFO = "info"
WARNING = "warning"
CRITICAL = "critical"
class Alert(BaseModel):
"""Нормализованный алерт после парсинга ingress (Grafana и др.)."""
id: UUID = Field(default_factory=uuid4)
source: str = Field(..., description="grafana, manual, …")
external_ref: str | None = Field(None, description="uid правила, fingerprint")
title: str = ""
severity: Severity = Severity.WARNING
labels: dict[str, str] = Field(default_factory=dict)
payload: dict[str, Any] = Field(default_factory=dict)
received_at: datetime | None = None
class Incident(BaseModel):
"""Инцидент в продукте (отдельно от сырого ingress_events)."""
id: UUID = Field(default_factory=uuid4)
title: str = ""
status: str = Field("open", description="open, acknowledged, resolved, …")
severity: Severity = Severity.WARNING
alert_ids: list[UUID] = Field(default_factory=list)
created_at: datetime | None = None
updated_at: datetime | None = None
class EscalationStep(BaseModel):
"""Один шаг цепочки (уведомление, пауза, повтор)."""
order: int = 0
kind: str = Field(..., description="notify, wait, repeat, …")
config: dict[str, Any] = Field(default_factory=dict)
class EscalationPolicy(BaseModel):
"""Политика эскалации, привязанная к команде/сервису."""
id: UUID = Field(default_factory=uuid4)
name: str = ""
steps: list[EscalationStep] = Field(default_factory=list)

View File

@ -0,0 +1,64 @@
"""События домена и подписка модулей (задел; пока in-memory)."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Protocol
from uuid import UUID
from onguard24.domain.entities import Alert
@dataclass
class DomainEvent:
"""Базовый тип события."""
name: str = "domain.generic"
occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class AlertReceived(DomainEvent):
"""Алерт принят в систему (после нормализации)."""
name: str = "alert.received"
alert: Alert | None = None
raw_payload_ref: UUID | None = None
Handler = Callable[[DomainEvent], Awaitable[None]]
class Module(Protocol):
"""Модуль (schedules, contacts, …) может подписаться на события."""
@property
def name(self) -> str: ...
async def on_event(self, event: DomainEvent) -> None: ...
class EventBus(Protocol):
async def publish(self, event: DomainEvent) -> None: ...
def subscribe(self, event_name: str, handler: Handler) -> None: ...
class InMemoryEventBus:
"""Простая шина для тестов и раннего прототипа."""
def __init__(self) -> None:
self._subs: dict[str, list[Handler]] = {}
def subscribe(self, event_name: str, handler: Handler) -> None:
self._subs.setdefault(event_name, []).append(handler)
async def publish(self, event: DomainEvent) -> None:
for h in self._subs.get(event.name, []):
await h(event)
async def publish_alert_received(self, alert: Alert, raw_payload_ref: UUID | None = None) -> None:
ev = AlertReceived(alert=alert, raw_payload_ref=raw_payload_ref)
await self.publish(ev)

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

@ -6,9 +6,10 @@ from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import HTMLResponse, Response from starlette.responses import HTMLResponse, Response
from onguard24.config import get_settings from onguard24.config import get_settings
from onguard24.db import create_pool, migrate 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,10 +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)
if pool: bus = InMemoryEventBus()
await migrate(pool) 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:
@ -80,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.0.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"
@ -11,6 +11,16 @@ dependencies = [
"python-dotenv>=1.0.1", "python-dotenv>=1.0.1",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"httpx>=0.28.0", "httpx>=0.28.0",
"sqlalchemy>=2.0.0",
"alembic>=1.14.0",
"psycopg[binary]>=3.2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"respx>=0.22.0",
] ]
[project.scripts] [project.scripts]

5
pytest.ini Normal file
View File

@ -0,0 +1,5 @@
[pytest]
asyncio_mode = auto
testpaths = tests
filterwarnings =
ignore::DeprecationWarning

39
tests/conftest.py Normal file
View File

@ -0,0 +1,39 @@
"""Изоляция тестов от локального .env: секреты сбрасываются до импорта приложения."""
from __future__ import annotations
import os
# Не ходим в реальные Vault/Grafana/Forgejo/Postgres при прогоне тестов
for key in (
"DATABASE_URL",
"VAULT_ADDR",
"VAULT_TOKEN",
"GRAFANA_URL",
"GRAFANA_SERVICE_ACCOUNT_TOKEN",
"FORGEJO_URL",
"FORGEJO_TOKEN",
"GRAFANA_WEBHOOK_SECRET",
):
os.environ.pop(key, None)
os.environ["DATABASE_URL"] = ""
os.environ["VAULT_ADDR"] = ""
os.environ["GRAFANA_URL"] = ""
os.environ["FORGEJO_URL"] = ""
import pytest
from fastapi.testclient import TestClient
from onguard24.main import app
def pytest_configure() -> None:
"""Дополнительно: гарантировать пустые интеграции."""
os.environ.setdefault("DATABASE_URL", "")
@pytest.fixture
def client() -> TestClient:
"""Контекстный менеджер — отрабатывает lifespan (pool, settings в state)."""
with TestClient(app) as c:
yield c

22
tests/test_domain.py Normal file
View File

@ -0,0 +1,22 @@
import pytest
from onguard24.domain import Alert, AlertReceived, InMemoryEventBus, Severity
def test_alert_model() -> None:
a = Alert(source="grafana", severity=Severity.CRITICAL, title="x")
assert a.source == "grafana"
@pytest.mark.asyncio
async def test_event_bus_alert_received() -> None:
seen: list[str] = []
async def h(ev: AlertReceived) -> None:
seen.append(ev.name)
bus = InMemoryEventBus()
bus.subscribe("alert.received", h) # type: ignore[arg-type]
a = Alert(source="grafana")
await bus.publish_alert_received(a)
assert seen == ["alert.received"]

13
tests/test_health.py Normal file
View File

@ -0,0 +1,13 @@
from fastapi.testclient import TestClient
def test_health(client: TestClient) -> None:
r = client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
assert r.json()["service"] == "onGuard24"
def test_health_api_v1(client: TestClient) -> None:
r = client.get("/api/v1/health")
assert r.status_code == 200

94
tests/test_ingress.py Normal file
View File

@ -0,0 +1,94 @@
import json
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi.testclient import TestClient
def test_grafana_webhook_no_db(client: TestClient) -> None:
"""Без пула БД — 202, запись не падает."""
r = client.post(
"/api/v1/ingress/grafana",
content=json.dumps({"title": "t"}),
headers={"Content-Type": "application/json"},
)
assert r.status_code == 202
def test_grafana_webhook_unauthorized_when_secret_set(client: TestClient) -> None:
app = client.app
real = app.state.settings.grafana_webhook_secret
app.state.settings.grafana_webhook_secret = "s3cr3t"
try:
r = client.post(
"/api/v1/ingress/grafana",
content=b"{}",
headers={"Content-Type": "application/json"},
)
assert r.status_code == 401
r2 = client.post(
"/api/v1/ingress/grafana",
content=b"{}",
headers={"Content-Type": "application/json", "X-OnGuard-Secret": "s3cr3t"},
)
assert r2.status_code == 202
finally:
app.state.settings.grafana_webhook_secret = real
def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None:
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
real_pool = app.state.pool
app.state.pool = mock_pool
try:
r = client.post(
"/api/v1/ingress/grafana",
content=json.dumps({"a": 1}),
headers={"Content-Type": "application/json"},
)
assert r.status_code == 202
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

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

61
tests/test_status.py Normal file
View File

@ -0,0 +1,61 @@
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
def test_status_without_integrations(client: TestClient) -> None:
"""Без БД и без URL внешних сервисов — всё disabled."""
r = client.get("/api/v1/status")
assert r.status_code == 200
data = r.json()
assert data["service"] == "onGuard24"
assert data["database"] == "disabled"
assert data["vault"] == "disabled"
assert data["grafana"] == "disabled"
assert data["forgejo"] == "disabled"
def test_status_with_mocks(client: TestClient) -> None:
"""Моки внешних вызовов — ok-ветки без сети."""
with (
patch("onguard24.status_snapshot.vault_ping", new_callable=AsyncMock) as vp,
patch("onguard24.status_snapshot.grafana_api.ping", new_callable=AsyncMock) as gp,
patch(
"onguard24.status_snapshot.grafana_api.get_signed_in_user",
new_callable=AsyncMock,
) as gu,
patch("onguard24.status_snapshot.forgejo_api.probe", new_callable=AsyncMock) as fp,
):
vp.return_value = (True, None)
gp.return_value = (True, None)
gu.return_value = ({"login": "tester", "email": "t@x"}, None)
fp.return_value = {"status": "ok", "url": "https://x", "api": "authenticated", "login": "u"}
# Подмена полей settings (pydantic-settings иначе тянет env поверх конструктора)
from types import SimpleNamespace
app = client.app
real = app.state.settings
app.state.settings = SimpleNamespace(
database_url="",
vault_addr="https://vault.example",
vault_token="t",
grafana_url="https://grafana.example",
grafana_service_account_token="g",
forgejo_url="https://git.example",
forgejo_token="f",
grafana_webhook_secret="",
http_addr="0.0.0.0:8080",
log_level="info",
)
try:
r = client.get("/api/v1/status")
finally:
app.state.settings = real
assert r.status_code == 200
d = r.json()
assert d["vault"]["status"] == "ok"
assert d["grafana"]["status"] == "ok"
assert d["grafana"].get("service_account_login") == "tester"
assert d["forgejo"]["status"] == "ok"