4 Commits

Author SHA1 Message Date
f275260b0d v1.6.0: Docker, docker-compose, Forgejo CI/CD и откат по тегу
Some checks failed
CI / test (push) Successful in 6m10s
Deploy / deploy (push) Failing after 42s
- Dockerfile + entrypoint (alembic + uvicorn), compose с healthcheck
- .gitea/workflows: ci (pytest), deploy (SSH + compose по тегу v*)
- docs/CICD.md: секреты, pvestandt9, ручной откат через workflow_dispatch

Made-with: Cursor
2026-04-03 09:11:52 +03:00
89b5983526 v1.5.0: IRM — инциденты, задачи, эскалации
- docs/IRM.md; Alembic 002: incidents, tasks, escalation_policies
- Модули incidents/tasks/escalations: API, UI, register_events(bus, pool)
- Авто-инцидент из alert.received; тесты test_irm_modules.py

Made-with: Cursor
2026-04-03 09:03:16 +03:00
0787745098 v1.4.1: Alembic — client_encoding=utf8 для psycopg3
Устраняет TypeError при определении версии PostgreSQL (bytes vs str).

Made-with: Cursor
2026-04-03 08:50:11 +03:00
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
32 changed files with 1586 additions and 27 deletions

13
.dockerignore Normal file
View File

@ -0,0 +1,13 @@
.git
.github
.gitea
.venv
__pycache__
*.pyc
.pytest_cache
.env
.env.*
web/node_modules
web/dist
*.md
!README.md

24
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,24 @@
# Forgejo / Gitea Actions — проверка перед деплоем
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Pytest
run: |
pip install -e ".[dev]"
pytest -q

View File

@ -0,0 +1,48 @@
# Деплой на сервер по SSH после пуша тега v* или вручную (в т.ч. откат на старый тег).
name: Deploy
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
ref:
description: "Git ref (тег для релиза или отката, напр. v1.5.0 или v1.4.1)"
required: true
default: "main"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Определить ревизию
id: pick
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "ref=${{ inputs.ref }}" >> "$GITHUB_OUTPUT"
else
echo "ref=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
fi
- name: SSH — fetch, checkout, docker compose
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script_stop: true
command_timeout: 20m
script: |
set -euo pipefail
cd "${{ secrets.DEPLOY_PATH }}"
git fetch origin --tags --prune
git checkout "${{ steps.pick.outputs.ref }}"
if git show-ref --verify --quiet "refs/remotes/origin/${{ steps.pick.outputs.ref }}"; then
git reset --hard "origin/${{ steps.pick.outputs.ref }}"
else
git reset --hard "${{ steps.pick.outputs.ref }}"
fi
docker compose build
docker compose up -d
docker compose ps

View File

@ -2,6 +2,65 @@
Формат: семантическое версионирование `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.6.0] — 2026-04-03
Docker-образ, `docker-compose.yml`, CI/CD Forgejo/Gitea Actions.
### Добавлено
- **`Dockerfile`**, **`docker-compose.yml`**, **`deploy/entrypoint.sh`** — `alembic upgrade` + `uvicorn` (отключение: `SKIP_ALEMBIC=1`).
- **`.gitea/workflows/ci.yml`** — pytest на push в `main` и PR.
- **`.gitea/workflows/deploy.yml`** — деплой по пушу тега `v*` или вручную; **откат** = тот же workflow с `ref` = старый тег.
- **[docs/CICD.md](docs/CICD.md)** — секреты, подготовка `root@pvestandt9`, порядок релиза и отката.
## [1.5.0] — 2026-04-03
IRM-ядро: инциденты, задачи, эскалации, миграция БД, документация.
### Добавлено
- **Документация:** [docs/IRM.md](docs/IRM.md) — матрица функций IRM и что настраивать в Grafana.
- **Alembic `002_irm_core`:** таблицы `incidents`, `tasks`, `escalation_policies`.
- **Модули:** `incidents` (API + UI, авто-создание из `alert.received` при наличии БД), `tasks`, `escalations`.
- **`register_module_events(bus, pool)`** — подписки получают пул PostgreSQL.
- **Тесты:** `tests/test_irm_modules.py`, обновлены тесты навигации.
## [1.4.1] — 2026-04-03
### Исправлено
- **Alembic:** к URL с `postgresql+psycopg` добавляется `client_encoding=utf8`, чтобы `alembic upgrade head` не падал с `TypeError: ... bytes-like object` при некоторых настройках кодировки на стороне PostgreSQL/psycopg3.
## [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.

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM python:3.12-slim-bookworm
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml README.md alembic.ini ./
COPY alembic ./alembic
COPY onguard24 ./onguard24
COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh \
&& pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir .
ENV PYTHONUNBUFFERED=1
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -1,6 +1,6 @@
# onGuard24 # onGuard24
**Версия: 1.1.0** · Модульный монолит на **Python (FastAPI)**: ядро, приём алертов из Grafana, заготовки модулей (дежурства, контакты, светофор), PostgreSQL, проверки Vault / Grafana / Forgejo. **Версия: 1.6.0** · Модульный монолит на **Python (FastAPI)**: ядро, приём алертов из Grafana, заготовки модулей (дежурства, контакты, светофор), PostgreSQL, проверки Vault / Grafana / Forgejo.
| Документ | Назначение | | Документ | Назначение |
|----------|------------| |----------|------------|
@ -9,6 +9,9 @@
| [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) | Как добавлять модули и подписки на события |
| [docs/IRM.md](docs/IRM.md) | Функционал IRM: что делаем, что в Grafana |
| [docs/CICD.md](docs/CICD.md) | Forgejo Actions: деплой на сервер, откат по тегу |
**Репозиторий:** [forgejo.pvenode.ru/admin/onGuard24](https://forgejo.pvenode.ru/admin/onGuard24) **Репозиторий:** [forgejo.pvenode.ru/admin/onGuard24](https://forgejo.pvenode.ru/admin/onGuard24)
@ -20,7 +23,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):** IRM — **инциденты**, **задачи**, **эскалации**, дежурства, контакты, светофор; JSON под `/api/v1/modules/...`, UI под `/ui/modules/<slug>/` (см. [docs/MODULES.md](docs/MODULES.md), [docs/IRM.md](docs/IRM.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).
@ -34,7 +37,7 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e . pip install -e .
``` ```
Скопируй `.env.example` в `.env` и заполни секреты (см. ниже). Перед первым запуском с БД примените миграции: Скопируй `.env.example` в `.env` и заполни секреты (см. ниже). Перед первым запуском с БД примените миграции (в т.ч. таблицы IRM: `incidents`, `tasks`, `escalation_policies`):
```bash ```bash
alembic upgrade head alembic upgrade head
@ -81,7 +84,7 @@ Vite проксирует `/api` на `http://127.0.0.1:8080` (см. `web/vite.c
## Миграции БД (Alembic) ## Миграции БД (Alembic)
- URL БД: переменная **`DATABASE_URL`** (как у приложения; в `alembic/env.py` используется синхронный драйвер `postgresql+psycopg`). - URL БД: переменная **`DATABASE_URL`** (как у приложения; в `alembic/env.py` используется синхронный драйвер `postgresql+psycopg`). К URL автоматически добавляется **`client_encoding=utf8`**, чтобы миграции не падали с `TypeError: ... bytes-like object`, если на сервере PostgreSQL включён режим вроде `SQL_ASCII`.
- Применить схему: `alembic upgrade head`. - Применить схему: `alembic upgrade head`.
- Новая ревизия: `alembic revision -m "описание"` и правка файла в `alembic/versions/`. - Новая ревизия: `alembic revision -m "описание"` и правка файла в `alembic/versions/`.
@ -95,3 +98,11 @@ pytest
``` ```
Покрытие: `/health`, `/api/v1/status`, webhook Grafana; внешние вызовы (Vault, Grafana, Forgejo) в тестах статуса подменяются моками. Покрытие: `/health`, `/api/v1/status`, webhook Grafana; внешние вызовы (Vault, Grafana, Forgejo) в тестах статуса подменяются моками.
## Docker и CI/CD
```bash
docker compose build && docker compose up -d
```
Деплой через **Forgejo Actions** (тег `v*`, SSH на сервер): см. [docs/CICD.md](docs/CICD.md). **Откат:** вручную запустить workflow **Deploy** с полем `ref` = нужный старый тег.

View File

@ -20,6 +20,14 @@ if config.config_file_name is not None:
target_metadata = None target_metadata = None
def _ensure_psycopg_client_encoding(url: str) -> str:
"""Иначе psycopg3 при SQL_ASCII на сервере отдаёт version() как bytes → падает SQLAlchemy re.match."""
if "+psycopg" not in url or "client_encoding=" in url:
return url
join = "&" if "?" in url else "?"
return f"{url}{join}client_encoding=utf8"
def get_sync_url() -> str: def get_sync_url() -> str:
url = os.environ.get("DATABASE_URL", "").strip() url = os.environ.get("DATABASE_URL", "").strip()
if not url: if not url:
@ -30,7 +38,7 @@ def get_sync_url() -> str:
url = url.replace("postgresql://", "postgresql+psycopg://", 1) url = url.replace("postgresql://", "postgresql+psycopg://", 1)
if "+asyncpg" in url: if "+asyncpg" in url:
url = url.replace("+asyncpg", "+psycopg") url = url.replace("+asyncpg", "+psycopg")
return url return _ensure_psycopg_client_encoding(url)
def run_migrations_offline() -> None: def run_migrations_offline() -> None:

View File

@ -0,0 +1,72 @@
"""irm core: incidents, tasks, escalation_policies
Revision ID: 002_irm_core
Revises: 001_initial
Create Date: 2026-04-03
"""
from typing import Sequence, Union
from alembic import op
revision: str = "002_irm_core"
down_revision: Union[str, None] = "001_initial"
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 incidents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL,
status text NOT NULL DEFAULT 'open',
severity text NOT NULL DEFAULT 'warning',
source text NOT NULL DEFAULT 'grafana',
ingress_event_id uuid REFERENCES ingress_events (id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS incidents_created_at_idx
ON incidents (created_at DESC);
"""
)
op.execute(
"""
CREATE TABLE IF NOT EXISTS tasks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
incident_id uuid REFERENCES incidents (id) ON DELETE CASCADE,
title text NOT NULL,
status text NOT NULL DEFAULT 'open',
created_at timestamptz NOT NULL DEFAULT now()
);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS tasks_incident_id_idx ON tasks (incident_id);
"""
)
op.execute(
"""
CREATE TABLE IF NOT EXISTS escalation_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
"""
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS tasks;")
op.execute("DROP TABLE IF EXISTS incidents;")
op.execute("DROP TABLE IF EXISTS escalation_policies;")

7
deploy/entrypoint.sh Normal file
View File

@ -0,0 +1,7 @@
#!/bin/sh
set -e
cd /app
if [ -n "${DATABASE_URL:-}" ] && [ "${SKIP_ALEMBIC:-0}" != "1" ]; then
alembic upgrade head
fi
exec uvicorn onguard24.main:app --host 0.0.0.0 --port "${PORT:-8080}"

21
docker-compose.yml Normal file
View File

@ -0,0 +1,21 @@
# Прод: на сервере рядом с репозиторием лежит .env (не в git).
# Сборка: docker compose build && docker compose up -d
services:
onguard24:
build: .
image: onguard24:latest
container_name: onguard24
restart: unless-stopped
env_file:
- .env
ports:
- "${ONGUARD_HTTP_PORT:-8080}:8080"
environment:
SKIP_ALEMBIC: ${SKIP_ALEMBIC:-0}
PORT: "8080"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s

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,18 +20,22 @@ 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/ # IRM: incidents, tasks, escalations, … + registry + ui_support
├── web/ # Vite + React (опционально) ├── web/ # Vite + React (опционально)
├── Dockerfile
├── docker-compose.yml
├── deploy/entrypoint.sh
├── pyproject.toml ├── pyproject.toml
├── pytest.ini ├── pytest.ini
├── tests/ # pytest: health, status, ingress ├── tests/ # pytest: health, status, ingress
├── .gitea/workflows/ # CI + SSH deploy (Forgejo Actions)
├── 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).
@ -39,7 +43,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 +55,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 пока не подключена.
## Известные ограничения ## Известные ограничения

85
docs/CICD.md Normal file
View File

@ -0,0 +1,85 @@
# CI/CD (Forgejo / Gitea) и деплой на `pvestandt9`
Цель: **пуш тега `v*`** → автоматический деплой на сервер; **откат** — повторный запуск workflow с другим тегом.
## Что в репозитории
| Файл | Назначение |
|------|------------|
| `.gitea/workflows/ci.yml` | На `push` в `main` и PR: `pytest`. |
| `.gitea/workflows/deploy.yml` | На `push` тега `v*`: SSH на сервер → `git checkout``docker compose build/up`. |
| `Dockerfile` | Образ Python 3.12, `pip install .`, entrypoint: `alembic upgrade` + `uvicorn`. |
| `docker-compose.yml` | Сервис `onguard24`, порт `8080`, `env_file: .env`. |
| `deploy/entrypoint.sh` | Перед стартом: `alembic upgrade head` (отключить: `SKIP_ALEMBIC=1` в `.env`). |
## Включить Actions в Forgejo
1. Админка инстанса или репозитория: включить **Actions**.
2. Зарегистрировать **runner** с меткой `ubuntu-latest` (или изменить `runs-on` в YAML на вашу метку, например `self-hosted`).
3. Если образы `actions/checkout` недоступны, в настройках Actions задайте зеркало GitHub или используйте встроенные экшены Forgejo (см. документацию вашей версии).
## Секреты репозитория
**Настройки репозитория → Actions → Secrets:**
| Секрет | Пример | Описание |
|--------|--------|----------|
| `DEPLOY_HOST` | `pvestandt9` или IP | Хост SSH. |
| `DEPLOY_USER` | `root` | Пользователь SSH. |
| `DEPLOY_SSH_KEY` | содержимое `id_rsa` | Приватный ключ (весь PEM, многострочный). |
| `DEPLOY_PATH` | `/opt/onGuard24` | Каталог **клона git** на сервере (там же `docker-compose.yml`). |
Ключ лучше отдельный **deploy key** только на чтение репозитория или полный доступ, если runner делает только `git fetch`.
## Однократная подготовка сервера (`root@pvestandt9`)
```bash
ssh root@pvestandt9
apt-get update && apt-get install -y git docker.io docker-compose-v2
# или Docker CE по инструкции вашей ОС
mkdir -p /opt/onGuard24
cd /opt
git clone https://forgejo.pvenode.ru/admin/onGuard24.git onGuard24
cd onGuard24
```
Создайте **`/opt/onGuard24/.env`** (как в `.env.example`): `DATABASE_URL`, `HTTP_ADDR` (в контейнере порт задаёт compose; для приложения можно `0.0.0.0:8080`), секреты Grafana и т.д.
Настройте **`git remote`** и доступ (`~/.ssh` deploy key или `git credential`), чтобы **`git fetch` без пароля** работал от пользователя, под которым заходит CI (часто `root`).
Проверка вручную:
```bash
cd /opt/onGuard24
docker compose build && docker compose up -d
curl -s http://127.0.0.1:8080/health
```
При необходимости пробросьте порт наружу (nginx, firewall).
## Релиз (деплой новой версии)
1. Закоммитить код, запушить тег: `git tag v1.6.0 && git push origin v1.6.0`.
2. Workflow **Deploy** запустится сам, на сервере обновится код и пересоберётся контейнер.
## Откат версии (простой процесс)
1. В Forgejo: **Actions → Deploy → Run workflow**.
2. В поле **ref** указать **старый тег**, например `v1.4.1`.
3. Запустить. На сервере выполнится `checkout` и `reset` на этот ref, затем `docker compose build && up -d`.
Убедитесь, что старый тег есть в `origin` (`git push --tags` не удалялся).
## Миграции БД
По умолчанию при каждом старте контейнера выполняется **`alembic upgrade head`**. Если нужно отключить (и гонять миграции вручную), в `.env` на сервере: `SKIP_ALEMBIC=1`.
## Устранение неполадок
- **Runner не берёт job** — проверьте `runs-on` и метки runner.
- **SSH fails** — `ssh -i key root@DEPLOY_HOST` с машины runner; `known_hosts` при необходимости добавьте в экшен (расширение `appleboy/ssh-action` / `ssh-keyscan`).
- **`git checkout` fails** — выполните на сервере `git fetch --tags` вручную, проверьте remote URL и ключ.
- **База недоступна из контейнера** — в `DATABASE_URL` укажите хост, доступный **из Docker** (не `127.0.0.1` хоста, если БД на хосте — используйте IP хоста или `host.docker.internal` где поддерживается).
См. также [VERSIONING.md](VERSIONING.md) и [IRM.md](IRM.md).

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)).
## Связь с БД ## Связь с БД

32
docs/IRM.md Normal file
View File

@ -0,0 +1,32 @@
# IRM: функционал, назначение и реализация в onGuard24
Краткий ориентир для разработки (аналог облачного IRM: инциденты, задачи, эскалации, дежурства).
## Матрица: что это, зачем, как у нас, что в Grafana
| Область | Назначение | onGuard24 | Grafana / внешнее |
|---------|------------|-----------|-------------------|
| **Инциденты** | Учёт сбоев, статусы (open → resolved), связь с алертом | Модуль `incidents`: таблица `incidents`, API, UI, авто-создание из `alert.received` | Contact point **Webhook**`POST /api/v1/ingress/grafana`; правила алертинга в Grafana |
| **Задачи** | Подзадачи по инциденту (разбор, фикс) | Модуль `tasks`: таблица `tasks`, привязка к `incident_id` | Опционально: ссылки из алерта; основная работа в onGuard24 |
| **Цепочки эскалаций** | Кого звать и в каком порядке при таймаутах | Модуль `escalations`: таблица `escalation_policies` (JSON `steps`), API/UI заготовка | Маршрутизация уведомлений может дублироваться в Grafana contact points; целевая логика — в onGuard24 |
| **Календарь дежурств** | Кто в смене, расписание | Модуль `schedules` (развитие) | Календари/команды — данные в onGuard24; уведомления — через интеграции |
| **Контакты** | Люди, каналы | Модуль `contacts` | Получатели в **Contact points** (email, Slack, webhook) |
| **Светофор / статус сервисов** | Агрегат здоровья | Модуль `statusboard` | Источник метрик — Prometheus/Loki; правила — Grafana |
| **Группы алертов** | Группировка шумных алертов | *План:* отдельная сущность / правила | **Alertmanager** / группировка в Grafana Alerting |
| **Интеграции** | Внешние системы | `integrations/`, статус в `/api/v1/status` | API-ключи Grafana, Vault, Forgejo в `.env` |
| **Пользователи / права** | RBAC | *Пока нет* | SSO Grafana, сеть за reverse proxy |
| **SLO** | Цели по доступности | *Вне скоупа v1* | Grafana SLO / Mimir |
## Поток данных (алерт → инцидент)
1. Grafana срабатывает правило → шлёт JSON на **webhook** onGuard24.
2. Сервис пишет строку в `ingress_events`, публикует **`alert.received`**.
3. Модуль **incidents** подписан на событие и создаёт запись в **`incidents`** с ссылкой на `ingress_event_id`.
## Что настроить в Grafana (обязательно для приёма алертов)
1. **Alerting → Contact points → New** — тип **Webhook**, URL: `https://<ваш-хост>/api/v1/ingress/grafana`, метод POST, Optional HTTP headers если задан `GRAFANA_WEBHOOK_SECRET`: `X-OnGuard-Secret: <секрет>`.
2. **Notification policies** — направить нужные правила на этот contact point (или default policy).
3. Убедиться, что сеть до onGuard24 доступна (firewall, TLS).
Подробнее: [MODULES.md](MODULES.md), [DOMAIN.md](DOMAIN.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, pool)`** — подписки на шину; при необходимости используйте **`pool`** для записи в БД из обработчика события.
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.6.0"

9
onguard24/deps.py Normal file
View File

@ -0,0 +1,9 @@
"""Общие зависимости FastAPI."""
from __future__ import annotations
from fastapi import Request
def get_pool(request: Request):
return getattr(request.app.state, "pool", None)

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, pool)
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, _pool=None) -> 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,153 @@
"""IRM: цепочки эскалаций (политики в JSON, дальше — исполнение по шагам)."""
from __future__ import annotations
import html
import json
from uuid import UUID
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from onguard24.deps import get_pool
from onguard24.domain.events import EventBus
from onguard24.modules.ui_support import wrap_module_html_page
router = APIRouter(tags=["module-escalations"])
ui_router = APIRouter(tags=["web-escalations"], include_in_schema=False)
class PolicyCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
enabled: bool = True
steps: list[dict] = Field(default_factory=list)
def register_events(_bus: EventBus, _pool: asyncpg.Pool | None = None) -> None:
pass
async def render_home_fragment(request: Request) -> str:
pool = get_pool(request)
if pool is None:
return '<p class="module-note">Нужна БД для политик эскалации.</p>'
try:
async with pool.acquire() as conn:
n = await conn.fetchval("SELECT count(*)::int FROM escalation_policies WHERE enabled = true")
except Exception:
return '<p class="module-note">Таблица политик недоступна (миграции?).</p>'
return f'<div class="module-fragment"><p>Активных политик: <strong>{int(n)}</strong></p></div>'
@router.get("/")
async def list_policies_api(pool: asyncpg.Pool | None = Depends(get_pool)):
if pool is None:
return {"items": [], "database": "disabled"}
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, name, enabled, steps, created_at
FROM escalation_policies
ORDER BY name
"""
)
items = []
for r in rows:
steps = r["steps"]
if isinstance(steps, str):
steps = json.loads(steps)
items.append(
{
"id": str(r["id"]),
"name": r["name"],
"enabled": r["enabled"],
"steps": steps if isinstance(steps, list) else [],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
}
)
return {"items": items}
@router.post("/", status_code=201)
async def create_policy_api(body: PolicyCreate, pool: asyncpg.Pool | None = Depends(get_pool)):
if pool is None:
raise HTTPException(status_code=503, detail="database disabled")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO escalation_policies (name, enabled, steps)
VALUES ($1, $2, $3::jsonb)
RETURNING id, name, enabled, steps, created_at
""",
body.name.strip(),
body.enabled,
json.dumps(body.steps),
)
steps = row["steps"]
return {
"id": str(row["id"]),
"name": row["name"],
"enabled": row["enabled"],
"steps": list(steps) if steps else [],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
@router.delete("/{policy_id}", status_code=204)
async def delete_policy_api(policy_id: UUID, pool: asyncpg.Pool | None = Depends(get_pool)):
if pool is None:
raise HTTPException(status_code=503, detail="database disabled")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"DELETE FROM escalation_policies WHERE id = $1::uuid RETURNING id",
policy_id,
)
if row is None:
raise HTTPException(status_code=404, detail="not found")
@ui_router.get("/", response_class=HTMLResponse)
async def escalations_ui_home(request: Request):
pool = get_pool(request)
rows_html = ""
err = ""
if pool is None:
err = "<p>База данных не настроена.</p>"
else:
try:
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, name, enabled, steps FROM escalation_policies ORDER BY name"
)
for r in rows:
steps = r["steps"]
if hasattr(steps, "__iter__") and not isinstance(steps, (str, bytes)):
steps_preview = html.escape(json.dumps(steps, ensure_ascii=False)[:120])
else:
steps_preview = ""
rows_html += (
"<tr>"
f"<td>{html.escape(str(r['id']))[:8]}…</td>"
f"<td>{html.escape(r['name'])}</td>"
f"<td>{'да' if r['enabled'] else 'нет'}</td>"
f"<td><code>{steps_preview}</code></td>"
"</tr>"
)
except Exception as e:
err = f"<p class=\"module-err\">{html.escape(str(e))}</p>"
inner = f"""<h1>Цепочки эскалаций</h1>
<p>Заготовка: шаги хранятся в JSON; исполнение по таймерам — следующие версии.</p>
{err}
<table class="irm-table">
<thead><tr><th>ID</th><th>Имя</th><th>Вкл.</th><th>Шаги (фрагмент)</th></tr></thead>
<tbody>{rows_html or '<tr><td colspan="4">Нет политик — создайте через API POST</td></tr>'}</tbody>
</table>"""
return HTMLResponse(
wrap_module_html_page(
document_title="Эскалации — onGuard24",
current_slug="escalations",
main_inner_html=inner,
)
)

View File

@ -0,0 +1,200 @@
"""IRM: инциденты — учёт сбоев, связь с сырым ingress и событием alert.received."""
from __future__ import annotations
import html
import logging
from uuid import UUID
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from onguard24.deps import get_pool
from onguard24.domain.events import AlertReceived, DomainEvent, EventBus
from onguard24.modules.ui_support import wrap_module_html_page
log = logging.getLogger(__name__)
router = APIRouter(tags=["module-incidents"])
ui_router = APIRouter(tags=["web-incidents"], include_in_schema=False)
class IncidentCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=500)
status: str = Field(default="open", max_length=64)
severity: str = Field(default="warning", max_length=32)
def register_events(bus: EventBus, pool: asyncpg.Pool | None = None) -> None:
if pool is None:
return
async def on_alert(ev: DomainEvent) -> None:
if not isinstance(ev, AlertReceived) or ev.raw_payload_ref is None:
return
a = ev.alert
title = (a.title if a else "Алерт без названия")[:500]
sev = (a.severity.value if a else "warning")
try:
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO incidents (title, status, severity, source, ingress_event_id)
VALUES ($1, 'open', $2, 'grafana', $3::uuid)
""",
title,
sev,
ev.raw_payload_ref,
)
except Exception:
log.exception("incidents: не удалось создать инцидент из alert.received")
bus.subscribe("alert.received", on_alert)
async def render_home_fragment(request: Request) -> str:
pool = get_pool(request)
if pool is None:
return '<p class="module-note">Нужна БД для списка инцидентов.</p>'
try:
async with pool.acquire() as conn:
n = await conn.fetchval("SELECT count(*)::int FROM incidents")
except Exception:
return '<p class="module-note">Таблица инцидентов недоступна (миграции?).</p>'
return f'<div class="module-fragment"><p>Инцидентов в учёте: <strong>{int(n)}</strong></p></div>'
@router.get("/")
async def list_incidents_api(
pool: asyncpg.Pool | None = Depends(get_pool),
limit: int = 50,
):
if pool is None:
return {"items": [], "database": "disabled"}
limit = min(max(limit, 1), 200)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, title, status, severity, source, ingress_event_id, created_at
FROM incidents
ORDER BY created_at DESC
LIMIT $1
""",
limit,
)
items = []
for r in rows:
items.append(
{
"id": str(r["id"]),
"title": r["title"],
"status": r["status"],
"severity": r["severity"],
"source": r["source"],
"ingress_event_id": str(r["ingress_event_id"]) if r["ingress_event_id"] else None,
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
}
)
return {"items": items}
@router.post("/", status_code=201)
async def create_incident_api(
body: IncidentCreate,
pool: asyncpg.Pool | None = Depends(get_pool),
):
if pool is None:
raise HTTPException(status_code=503, detail="database disabled")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO incidents (title, status, severity, source)
VALUES ($1, $2, $3, 'manual')
RETURNING id, title, status, severity, source, ingress_event_id, created_at
""",
body.title.strip(),
body.status,
body.severity,
)
return {
"id": str(row["id"]),
"title": row["title"],
"status": row["status"],
"severity": row["severity"],
"source": row["source"],
"ingress_event_id": None,
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
@router.get("/{incident_id}")
async def get_incident_api(incident_id: UUID, pool: asyncpg.Pool | None = Depends(get_pool)):
if pool is None:
raise HTTPException(status_code=503, detail="database disabled")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT id, title, status, severity, source, ingress_event_id, created_at
FROM incidents WHERE id = $1::uuid
""",
incident_id,
)
if not row:
raise HTTPException(status_code=404, detail="not found")
return {
"id": str(row["id"]),
"title": row["title"],
"status": row["status"],
"severity": row["severity"],
"source": row["source"],
"ingress_event_id": str(row["ingress_event_id"]) if row["ingress_event_id"] else None,
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
@ui_router.get("/", response_class=HTMLResponse)
async def incidents_ui_home(request: Request):
pool = get_pool(request)
rows_html = ""
err = ""
if pool is None:
err = "<p>База данных не настроена.</p>"
else:
try:
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, title, status, severity, source, created_at
FROM incidents
ORDER BY created_at DESC
LIMIT 100
"""
)
for r in rows:
rows_html += (
"<tr>"
f"<td>{html.escape(str(r['id']))[:8]}…</td>"
f"<td>{html.escape(r['title'])}</td>"
f"<td>{html.escape(r['status'])}</td>"
f"<td>{html.escape(r['severity'])}</td>"
f"<td>{html.escape(r['source'])}</td>"
"</tr>"
)
except Exception as e:
err = f"<p class=\"module-err\">{html.escape(str(e))}</p>"
inner = f"""<h1>Инциденты</h1>
{err}
<table class="irm-table">
<thead><tr><th>ID</th><th>Заголовок</th><th>Статус</th><th>Важность</th><th>Источник</th></tr></thead>
<tbody>{rows_html or '<tr><td colspan="5">Пока нет записей</td></tr>'}</tbody>
</table>
<p><small>Создание из Grafana: webhook → запись в <code>ingress_events</code> → событие → строка здесь.</small></p>"""
return HTMLResponse(
wrap_module_html_page(
document_title="Инциденты — onGuard24",
current_slug="incidents",
main_inner_html=inner,
)
)

View File

@ -0,0 +1,107 @@
"""Единая точка регистрации модулей: API, веб-UI и подписки на события.
Новый модуль: файл в `onguard24/modules/`, запись в `MODULE_MOUNTS` — см. docs/MODULES.md.
"""
from __future__ import annotations
import asyncpg
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,
escalations,
incidents,
schedules,
statusboard,
tasks,
)
# async (Request) -> str — фрагмент HTML для главной страницы (опционально)
HomeFragment = Callable[[Request], Awaitable[str]]
RegisterEvents = Callable[[EventBus, asyncpg.Pool | None], None]
@dataclass(frozen=True)
class ModuleMount:
"""Один модуль: API под url_prefix, UI под /ui/modules/{slug}."""
router: APIRouter
url_prefix: str
register_events: RegisterEvents
slug: str
title: str
ui_router: APIRouter | None = None
render_home_fragment: HomeFragment | None = None
def _mounts() -> list[ModuleMount]:
return [
ModuleMount(
router=incidents.router,
url_prefix="/api/v1/modules/incidents",
register_events=incidents.register_events,
slug="incidents",
title="Инциденты",
ui_router=incidents.ui_router,
render_home_fragment=incidents.render_home_fragment,
),
ModuleMount(
router=tasks.router,
url_prefix="/api/v1/modules/tasks",
register_events=tasks.register_events,
slug="tasks",
title="Задачи",
ui_router=tasks.ui_router,
render_home_fragment=tasks.render_home_fragment,
),
ModuleMount(
router=escalations.router,
url_prefix="/api/v1/modules/escalations",
register_events=escalations.register_events,
slug="escalations",
title="Эскалации",
ui_router=escalations.ui_router,
render_home_fragment=escalations.render_home_fragment,
),
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, pool: asyncpg.Pool | None = None) -> None:
for m in MODULE_MOUNTS:
m.register_events(bus, pool)

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, _pool=None) -> 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, _pool=None) -> 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,
)
)

159
onguard24/modules/tasks.py Normal file
View File

@ -0,0 +1,159 @@
"""IRM: задачи по инцидентам (или вне привязки)."""
from __future__ import annotations
import html
from uuid import UUID
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from onguard24.deps import get_pool
from onguard24.domain.events import EventBus
from onguard24.modules.ui_support import wrap_module_html_page
router = APIRouter(tags=["module-tasks"])
ui_router = APIRouter(tags=["web-tasks"], include_in_schema=False)
class TaskCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=500)
incident_id: UUID | None = None
def register_events(_bus: EventBus, _pool: asyncpg.Pool | None = None) -> None:
pass
async def render_home_fragment(request: Request) -> str:
pool = get_pool(request)
if pool is None:
return '<p class="module-note">Нужна БД для задач.</p>'
try:
async with pool.acquire() as conn:
n = await conn.fetchval("SELECT count(*)::int FROM tasks")
except Exception:
return '<p class="module-note">Таблица задач недоступна (миграции?).</p>'
return f'<div class="module-fragment"><p>Задач: <strong>{int(n)}</strong></p></div>'
@router.get("/")
async def list_tasks_api(
pool: asyncpg.Pool | None = Depends(get_pool),
incident_id: UUID | None = None,
limit: int = 100,
):
if pool is None:
return {"items": [], "database": "disabled"}
limit = min(max(limit, 1), 200)
async with pool.acquire() as conn:
if incident_id:
rows = await conn.fetch(
"""
SELECT id, incident_id, title, status, created_at
FROM tasks WHERE incident_id = $1::uuid
ORDER BY created_at DESC LIMIT $2
""",
incident_id,
limit,
)
else:
rows = await conn.fetch(
"""
SELECT id, incident_id, title, status, created_at
FROM tasks
ORDER BY created_at DESC
LIMIT $1
""",
limit,
)
items = []
for r in rows:
items.append(
{
"id": str(r["id"]),
"incident_id": str(r["incident_id"]) if r["incident_id"] else None,
"title": r["title"],
"status": r["status"],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
}
)
return {"items": items}
@router.post("/", status_code=201)
async def create_task_api(body: TaskCreate, pool: asyncpg.Pool | None = Depends(get_pool)):
if pool is None:
raise HTTPException(status_code=503, detail="database disabled")
if body.incident_id:
async with pool.acquire() as conn:
ok = await conn.fetchval(
"SELECT 1 FROM incidents WHERE id = $1::uuid",
body.incident_id,
)
if not ok:
raise HTTPException(status_code=400, detail="incident not found")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO tasks (title, incident_id, status)
VALUES ($1, $2::uuid, 'open')
RETURNING id, incident_id, title, status, created_at
""",
body.title.strip(),
body.incident_id,
)
return {
"id": str(row["id"]),
"incident_id": str(row["incident_id"]) if row["incident_id"] else None,
"title": row["title"],
"status": row["status"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
@ui_router.get("/", response_class=HTMLResponse)
async def tasks_ui_home(request: Request):
pool = get_pool(request)
rows_html = ""
err = ""
if pool is None:
err = "<p>База данных не настроена.</p>"
else:
try:
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT t.id, t.title, t.status, t.incident_id, t.created_at
FROM tasks t
ORDER BY t.created_at DESC
LIMIT 100
"""
)
for r in rows:
iid = str(r["incident_id"])[:8] + "" if r["incident_id"] else ""
rows_html += (
"<tr>"
f"<td>{html.escape(str(r['id']))[:8]}…</td>"
f"<td>{html.escape(r['title'])}</td>"
f"<td>{html.escape(r['status'])}</td>"
f"<td>{html.escape(iid)}</td>"
"</tr>"
)
except Exception as e:
err = f"<p class=\"module-err\">{html.escape(str(e))}</p>"
inner = f"""<h1>Задачи</h1>
{err}
<table class="irm-table">
<thead><tr><th>ID</th><th>Заголовок</th><th>Статус</th><th>Инцидент</th></tr></thead>
<tbody>{rows_html or '<tr><td colspan="4">Пока нет задач</td></tr>'}</tbody>
</table>"""
return HTMLResponse(
wrap_module_html_page(
document_title="Задачи — onGuard24",
current_slug="tasks",
main_inner_html=inner,
)
)

View File

@ -0,0 +1,110 @@
"""Безопасная сборка 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; }
.irm-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.irm-table th, .irm-table td { border: 1px solid #e4e4e7; padding: 0.45rem 0.65rem; text-align: left; }
.irm-table thead th { background: #f4f4f5; }
"""
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.6.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_irm_modules.py Normal file
View File

@ -0,0 +1,69 @@
"""IRM-модули: API без БД и обработчик инцидента по событию."""
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from onguard24.domain.entities import Alert, Severity
from onguard24.domain.events import AlertReceived
def test_incidents_api_list_no_db(client: TestClient) -> None:
r = client.get("/api/v1/modules/incidents/")
assert r.status_code == 200
assert r.json() == {"items": [], "database": "disabled"}
def test_tasks_api_list_no_db(client: TestClient) -> None:
r = client.get("/api/v1/modules/tasks/")
assert r.status_code == 200
assert r.json()["database"] == "disabled"
def test_escalations_api_list_no_db(client: TestClient) -> None:
r = client.get("/api/v1/modules/escalations/")
assert r.status_code == 200
assert r.json()["database"] == "disabled"
@pytest.mark.asyncio
async def test_incident_inserted_on_alert_received() -> None:
"""При пуле БД подписка создаёт инцидент (INSERT)."""
inserted: dict = {}
async def fake_execute(_query, *args):
inserted["args"] = args
return "INSERT 0 1"
mock_conn = AsyncMock()
mock_conn.execute = fake_execute
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)
from onguard24.domain.events import InMemoryEventBus
from onguard24.modules import incidents as inc_mod
bus = InMemoryEventBus()
inc_mod.register_events(bus, mock_pool)
uid = uuid4()
ev = AlertReceived(
alert=Alert(source="grafana", title="CPU high", severity=Severity.WARNING),
raw_payload_ref=uid,
)
await bus.publish(ev)
assert inserted.get("args") is not None
assert inserted["args"][0] == "CPU high"
assert inserted["args"][1] == "warning"
assert inserted["args"][2] == uid
def test_incidents_post_requires_db(client: TestClient) -> None:
r = client.post("/api/v1/modules/incidents/", json={"title": "x"})
assert r.status_code == 503

79
tests/test_root_ui.py Normal file
View File

@ -0,0 +1,79 @@
"""Главная страница и изолированные 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 = (
("incidents", "Инциденты"),
("tasks", "Задачи"),
("escalations", "Эскалации"),
("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 (
"incidents",
"tasks",
"escalations",
"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