Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6c37435f0 | |||
| c9b97814a5 | |||
| 80645713a0 | |||
| 18ba48e8d0 | |||
| a8ccf1d35c | |||
| 3cb75eb7b7 | |||
| 420821f3a0 | |||
| c324b4732f | |||
| 36945ed796 | |||
| cf16a57442 | |||
| 719991d60b | |||
| 9f2aa2d2b5 | |||
| 5788f995b9 | |||
| f275260b0d | |||
| 89b5983526 | |||
| 0787745098 | |||
| 349cea85a3 |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@ -0,0 +1,13 @@
|
||||
.git
|
||||
.github
|
||||
.gitea
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.env
|
||||
.env.*
|
||||
web/node_modules
|
||||
web/dist
|
||||
*.md
|
||||
!README.md
|
||||
21
.env.example
21
.env.example
@ -3,10 +3,23 @@
|
||||
HTTP_ADDR=0.0.0.0:8080
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Опционально: если задан — POST /api/v1/ingress/grafana требует заголовок X-OnGuard-Secret
|
||||
# Запись логов в файл с авто-ротацией (10 МБ × 5 файлов = ~50 МБ).
|
||||
# Пусто = не писать в файл (логи только в stdout и страницу /ui/logs).
|
||||
# Пример для docker-compose (volume /logs): LOG_FILE=/logs/onguard24.log
|
||||
# LOG_FILE=
|
||||
|
||||
# Опционально: общий секрет для вебхуков (если у источника в JSON не задан свой webhook_secret)
|
||||
# GRAFANA_WEBHOOK_SECRET=
|
||||
|
||||
# --- Grafana HTTP API (service account, не пароль admin) ---
|
||||
# Устаревшее: автосоздание инцидента на каждый вебхук (дублирует irm_alerts). Обычно не нужно.
|
||||
# AUTO_INCIDENT_FROM_ALERT=1
|
||||
|
||||
# Несколько Grafana: JSON-массив. slug — часть URL вебхука: /api/v1/ingress/grafana/{slug}
|
||||
# Пример: [{"slug":"adibrov","api_url":"https://grafana-adibrov.example","api_token":"glsa_...","webhook_secret":"длинный-секрет"}]
|
||||
# Если пусто, но задан GRAFANA_URL — один источник со slug "default" (вебхук /api/v1/ingress/grafana/default)
|
||||
# GRAFANA_SOURCES_JSON=
|
||||
|
||||
# --- Grafana HTTP API (один инстанс, если без GRAFANA_SOURCES_JSON) ---
|
||||
# URL без завершающего слэша. Токен: Grafana → Administration → Service accounts → onguard24 → Add service account token
|
||||
GRAFANA_URL=https://grafana.pvenode.ru
|
||||
# GRAFANA_SERVICE_ACCOUNT_TOKEN=
|
||||
@ -31,3 +44,7 @@ VAULT_TOKEN=
|
||||
# Токен: Forgejo → Settings → Applications → Generate New Token (или старый PAT)
|
||||
FORGEJO_URL=https://forgejo.pvenode.ru
|
||||
# FORGEJO_TOKEN=
|
||||
|
||||
# --- Деплой через Forgejo Actions (НЕ сюда) ---
|
||||
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, DEPLOY_SSH_KEY задаются только в веб-UI:
|
||||
# репозиторий → Настройки → Actions → Secrets. Контейнер onGuard24 их не читает.
|
||||
|
||||
35
.gitea/workflows/ci.yml
Normal file
35
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,35 @@
|
||||
# Forgejo / Gitea Actions — проверка перед деплоем (совместимо с синтаксисом GitHub Actions).
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Без cache: pip — на Forgejo/act часто таймаут getCacheEntry (172.17.x), долгий шаг и warning.
|
||||
- name: Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Pytest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install -U pip
|
||||
python -m pip install -e ".[dev]"
|
||||
python -m pytest tests/ -q --tb=short
|
||||
91
.gitea/workflows/deploy.yml
Normal file
91
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,91 @@
|
||||
# Деплой на сервер по SSH после пуша тега v* или вручную (в т.ч. откат на старый тег).
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "main, v1.7.0 или 1.7.0 (без v подставится автоматически)."
|
||||
required: true
|
||||
default: "main"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Определить ревизию
|
||||
id: pick
|
||||
env:
|
||||
RAW_REF: ${{ inputs.ref }}
|
||||
run: |
|
||||
# Forgejo иногда подставляет refs/heads/main вместо main — иначе нет origin/refs/heads/main.
|
||||
normalize_ref() {
|
||||
case "$1" in
|
||||
refs/heads/*) echo "${1#refs/heads/}" ;;
|
||||
refs/tags/*) echo "${1#refs/tags/}" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
R=$(printf '%s' "$RAW_REF" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
R=$(normalize_ref "$R")
|
||||
# Теги в git: v1.7.0; если ввели 1.7.0 — подставляем v.
|
||||
if echo "$R" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::notice::Указано $R без префикса v — деплой на v${R}"
|
||||
R="v${R}"
|
||||
fi
|
||||
echo "ref=$R" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
R=$(normalize_ref "${{ github.ref_name }}")
|
||||
echo "ref=$R" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Без секретов appleboy/ssh-action падает с «missing server host» — даём явную подсказку.
|
||||
- name: Проверить секреты деплоя
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
ok=0
|
||||
[ -n "$DEPLOY_HOST" ] || { echo "::error::Секрет DEPLOY_HOST пустой. Forgejo → репозиторий → Настройки → Actions → Secrets."; ok=1; }
|
||||
[ -n "$DEPLOY_USER" ] || { echo "::error::Секрет DEPLOY_USER пустой."; ok=1; }
|
||||
[ -n "$DEPLOY_PATH" ] || { echo "::error::Секрет DEPLOY_PATH пустой (каталог клона на сервере, напр. /opt/onGuard24)."; ok=1; }
|
||||
[ -n "$DEPLOY_SSH_KEY" ] || { echo "::error::Секрет DEPLOY_SSH_KEY пустой (приватный SSH-ключ целиком, PEM)."; ok=1; }
|
||||
exit "$ok"
|
||||
|
||||
- name: SSH — fetch, checkout, docker compose
|
||||
uses: appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
port: "22"
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script_stop: true
|
||||
command_timeout: 20m
|
||||
script: |
|
||||
set -euo pipefail
|
||||
REF="${{ steps.pick.outputs.ref }}"
|
||||
cd "${{ secrets.DEPLOY_PATH }}"
|
||||
echo "=== deploy REF=$REF ==="
|
||||
git fetch origin --tags --prune
|
||||
git checkout "$REF"
|
||||
# Теги не дают refs/remotes/origin/<тег> — только ветки; для v* срабатывает else.
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
|
||||
git reset --hard "origin/$REF"
|
||||
else
|
||||
git reset --hard "$REF"
|
||||
fi
|
||||
echo "=== git HEAD ==="
|
||||
git log -1 --oneline
|
||||
echo "=== docker compose version ==="
|
||||
docker compose version
|
||||
echo "=== docker compose build ==="
|
||||
docker compose --progress plain build
|
||||
echo "=== docker compose up ==="
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,7 @@
|
||||
.env
|
||||
.env.local
|
||||
# локальные секреты на сервере (compose использует только .env)
|
||||
/env
|
||||
*.pem
|
||||
dist/
|
||||
bin/
|
||||
|
||||
125
CHANGELOG.md
125
CHANGELOG.md
@ -2,6 +2,131 @@
|
||||
|
||||
Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md).
|
||||
|
||||
## [1.10.1] — 2026-04-03
|
||||
|
||||
### Добавлено
|
||||
|
||||
- **Страница логов** `/ui/logs` — кольцевой буфер (600 записей) + **SSE real-time** поток; фильтр по уровню, авто-прокрутка; ссылка на главной и в nav rail (раздел «📋 Логи»).
|
||||
|
||||
### Исправлено
|
||||
|
||||
- **Вебхук Grafana:** санитизация тела перед записью в `jsonb` — `NaN` / `±Inf` → `None`, удаление `\x00` в строках (иначе PostgreSQL и строгий JSON часто давали **500** на реальных алертах с метриками, тогда как «Test contact point» оставался рабочим).
|
||||
- Ошибки подписчиков **`alert.received`** после успешного коммита в БД больше не рвут ответ вебхука (логируются).
|
||||
|
||||
## [1.10.0] — 2026-04-03
|
||||
|
||||
Команды (teams) по лейблам, как ориентир на Grafana IRM **Team**.
|
||||
|
||||
### Добавлено
|
||||
|
||||
- **Alembic `006_teams`:** таблицы `teams`, `team_label_rules`, колонка **`irm_alerts.team_id`**.
|
||||
- **Модуль «Команды»:** CRUD команд, правила лейблов (`priority`), UI список и карточка.
|
||||
- **Вебхук Grafana:** подстановка `team_id` по первому совпадению правила.
|
||||
- **Алерты:** в API и UI колонка команды, фильтр `team_id`, `GET /alerts/?team_id=…`.
|
||||
|
||||
### Изменено
|
||||
|
||||
- Документация [IRM_GRAFANA_PARITY.md](docs/IRM_GRAFANA_PARITY.md), [IRM.md](docs/IRM.md).
|
||||
|
||||
## [1.9.0] — 2026-04-03
|
||||
|
||||
Алерты отдельно от инцидентов (модель ближе к Grafana IRM).
|
||||
|
||||
### Добавлено
|
||||
|
||||
- **Alembic `005_irm_alerts`:** таблицы `irm_alerts`, `incident_alert_links`.
|
||||
- **Модуль «Алерты»:** API и UI, статусы firing → acknowledged → resolved, полный JSON вебхука, кнопка «Создать инцидент».
|
||||
- **Вебхук Grafana:** в одной транзакции `ingress_events` + `irm_alerts`.
|
||||
- **`extract_alert_row_from_grafana_body`** — заголовок, severity, labels, fingerprint.
|
||||
- **Документация:** [docs/IRM_GRAFANA_PARITY.md](docs/IRM_GRAFANA_PARITY.md).
|
||||
|
||||
### Изменено
|
||||
|
||||
- **Инцидент из вебхука по умолчанию не создаётся**; включение старого поведения: `AUTO_INCIDENT_FROM_ALERT=1`.
|
||||
- **POST /incidents:** опционально `alert_ids` для привязки к `irm_alerts`.
|
||||
|
||||
## [1.8.0] — 2026-04-03
|
||||
|
||||
UI каталога Grafana и инцидентов; правки CI/CD деплоя.
|
||||
|
||||
### Добавлено / изменено
|
||||
|
||||
- **Каталог Grafana (UI):** кнопка синхронизации без `curl`, раскрывающееся дерево папок (имя, UID, родитель) и правил (title, rule_uid, группа, интервал, labels); блок namespace без папки в API.
|
||||
- **Инциденты (UI):** колонка «Создан», ссылка на карточку; карточка с полями и сырым JSON вебхука из `ingress_events`.
|
||||
- **API `GET …/grafana-catalog/tree`:** поле `orphan_rule_groups`.
|
||||
- **CI/CD:** нормализация ref (`refs/heads/main`, semver без `v`), `docker compose --progress plain`; отключён pip cache при таймауте Actions cache.
|
||||
|
||||
## [1.7.0] — 2026-04-03
|
||||
|
||||
Каталог Grafana (топология правил), доработки ingress/IRM, тесты.
|
||||
|
||||
### Добавлено
|
||||
|
||||
- **Alembic `003_ingress_org_service`**, **`004_grafana_catalog`** — метаданные и снимок папок/правил Grafana.
|
||||
- **Модуль `grafana-catalog`:** `POST …/sync`, `GET …/meta`, `GET …/tree`, UI.
|
||||
- **`onguard24/integrations/grafana_topology.py`**, **`grafana_sources.py`**.
|
||||
- **Документация:** [docs/GRAFANA_TOPOLOGY.md](docs/GRAFANA_TOPOLOGY.md).
|
||||
- **Тесты:** `test_grafana_topology.py`, `test_grafana_catalog_api.py`, `irm_db_fake.py`, `test_irm_api_with_fake_db.py`.
|
||||
|
||||
## [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
|
||||
|
||||
Инфраструктура разработки и задел под домен IRM.
|
||||
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal 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"]
|
||||
19
README.md
19
README.md
@ -1,6 +1,6 @@
|
||||
# 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/AI_CONTEXT.md](docs/AI_CONTEXT.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)
|
||||
|
||||
@ -20,7 +23,7 @@
|
||||
- **PostgreSQL:** пул asyncpg, таблица `ingress_events` для сырых тел webhook Grafana; схема через **Alembic** (отдельные ревизии в `alembic/versions/`).
|
||||
- **POST `/api/v1/ingress/grafana`** — приём JSON алерта (опционально защита `X-OnGuard-Secret`).
|
||||
- **GET `/`**, **GET `/api/v1/status`** — проверки: БД, Vault, Grafana (service account), Forgejo (PAT).
|
||||
- **Модули-заглушки:** `/api/v1/modules/schedules|contacts|statusboard/`.
|
||||
- **Модули (API + веб-UI):** 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.
|
||||
|
||||
Чего **ещё нет** (следующие версии): авторизация публичных API (кроме секрета webhook), полноценная бизнес-логика IRM в коде (эскалации, дежурства, светофор), фоновые задачи. Доменные сущности и задел под модули описаны в [docs/DOMAIN.md](docs/DOMAIN.md).
|
||||
@ -34,7 +37,7 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Скопируй `.env.example` в `.env` и заполни секреты (см. ниже). Перед первым запуском с БД примените миграции:
|
||||
Скопируй `.env.example` в `.env` и заполни секреты (см. ниже). Перед первым запуском с БД примените миграции (в т.ч. таблицы IRM: `incidents`, `tasks`, `escalation_policies`):
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
@ -81,7 +84,7 @@ Vite проксирует `/api` на `http://127.0.0.1:8080` (см. `web/vite.c
|
||||
|
||||
## Миграции БД (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 revision -m "описание"` и правка файла в `alembic/versions/`.
|
||||
|
||||
@ -95,3 +98,11 @@ pytest
|
||||
```
|
||||
|
||||
Покрытие: `/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` = нужный старый тег.
|
||||
|
||||
@ -20,6 +20,14 @@ if config.config_file_name is not 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:
|
||||
url = os.environ.get("DATABASE_URL", "").strip()
|
||||
if not url:
|
||||
@ -30,7 +38,7 @@ def get_sync_url() -> str:
|
||||
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||
if "+asyncpg" in url:
|
||||
url = url.replace("+asyncpg", "+psycopg")
|
||||
return url
|
||||
return _ensure_psycopg_client_encoding(url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
|
||||
72
alembic/versions/002_irm_core_tables.py
Normal file
72
alembic/versions/002_irm_core_tables.py
Normal 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;")
|
||||
48
alembic/versions/003_ingress_org_service.py
Normal file
48
alembic/versions/003_ingress_org_service.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""ingress org/service + incident dimensions for multi-grafana
|
||||
|
||||
Revision ID: 003_ingress_org
|
||||
Revises: 002_irm_core
|
||||
Create Date: 2026-04-03
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "003_ingress_org"
|
||||
down_revision: Union[str, None] = "002_irm_core"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE ingress_events
|
||||
ADD COLUMN IF NOT EXISTS org_slug text,
|
||||
ADD COLUMN IF NOT EXISTS service_name text;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE incidents
|
||||
ADD COLUMN IF NOT EXISTS grafana_org_slug text,
|
||||
ADD COLUMN IF NOT EXISTS service_name text;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS incidents_org_service_idx
|
||||
ON incidents (grafana_org_slug, service_name)
|
||||
WHERE grafana_org_slug IS NOT NULL OR service_name IS NOT NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS incidents_org_service_idx;")
|
||||
op.execute("ALTER TABLE incidents DROP COLUMN IF EXISTS service_name;")
|
||||
op.execute("ALTER TABLE incidents DROP COLUMN IF EXISTS grafana_org_slug;")
|
||||
op.execute("ALTER TABLE ingress_events DROP COLUMN IF EXISTS service_name;")
|
||||
op.execute("ALTER TABLE ingress_events DROP COLUMN IF EXISTS org_slug;")
|
||||
88
alembic/versions/004_grafana_catalog.py
Normal file
88
alembic/versions/004_grafana_catalog.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""Grafana topology cache: org, folders, alert rules per instance slug
|
||||
|
||||
Revision ID: 004_grafana_catalog
|
||||
Revises: 003_ingress_org
|
||||
Create Date: 2026-04-03
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "004_grafana_catalog"
|
||||
down_revision: Union[str, None] = "003_ingress_org"
|
||||
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 grafana_catalog_meta (
|
||||
instance_slug text NOT NULL,
|
||||
grafana_org_id int NOT NULL,
|
||||
org_name text NOT NULL DEFAULT '',
|
||||
synced_at timestamptz NOT NULL DEFAULT now(),
|
||||
folder_count int NOT NULL DEFAULT 0,
|
||||
rule_count int NOT NULL DEFAULT 0,
|
||||
error_text text,
|
||||
PRIMARY KEY (instance_slug, grafana_org_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS grafana_catalog_folders (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
instance_slug text NOT NULL,
|
||||
grafana_org_id int NOT NULL,
|
||||
folder_uid text NOT NULL,
|
||||
title text NOT NULL DEFAULT '',
|
||||
parent_uid text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (instance_slug, grafana_org_id, folder_uid)
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS grafana_cat_folders_inst_org_idx
|
||||
ON grafana_catalog_folders (instance_slug, grafana_org_id);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS grafana_catalog_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
instance_slug text NOT NULL,
|
||||
grafana_org_id int NOT NULL,
|
||||
namespace_uid text NOT NULL,
|
||||
rule_group_name text NOT NULL,
|
||||
rule_uid text NOT NULL,
|
||||
title text NOT NULL DEFAULT '',
|
||||
rule_group_interval text,
|
||||
labels jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (instance_slug, grafana_org_id, rule_uid)
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS grafana_cat_rules_inst_org_idx
|
||||
ON grafana_catalog_rules (instance_slug, grafana_org_id);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS grafana_cat_rules_ns_idx
|
||||
ON grafana_catalog_rules (instance_slug, grafana_org_id, namespace_uid);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS grafana_catalog_rules;")
|
||||
op.execute("DROP TABLE IF EXISTS grafana_catalog_folders;")
|
||||
op.execute("DROP TABLE IF EXISTS grafana_catalog_meta;")
|
||||
74
alembic/versions/005_irm_alerts.py
Normal file
74
alembic/versions/005_irm_alerts.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""IRM: алерты отдельно от инцидентов (ack/resolve), связь N:M инцидент↔алерт
|
||||
|
||||
Revision ID: 005_irm_alerts
|
||||
Revises: 004_grafana_catalog
|
||||
Create Date: 2026-04-03
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "005_irm_alerts"
|
||||
down_revision: Union[str, None] = "004_grafana_catalog"
|
||||
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 irm_alerts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ingress_event_id uuid NOT NULL UNIQUE REFERENCES ingress_events(id) ON DELETE CASCADE,
|
||||
status text NOT NULL DEFAULT 'firing'
|
||||
CHECK (status IN ('firing', 'acknowledged', 'resolved', 'silenced')),
|
||||
title text NOT NULL DEFAULT '',
|
||||
severity text NOT NULL DEFAULT 'warning',
|
||||
source text NOT NULL DEFAULT 'grafana',
|
||||
grafana_org_slug text,
|
||||
service_name text,
|
||||
labels jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
fingerprint text,
|
||||
acknowledged_at timestamptz,
|
||||
acknowledged_by text,
|
||||
resolved_at timestamptz,
|
||||
resolved_by text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS irm_alerts_status_created_idx
|
||||
ON irm_alerts (status, created_at DESC);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS irm_alerts_ingress_event_id_idx
|
||||
ON irm_alerts (ingress_event_id);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS incident_alert_links (
|
||||
incident_id uuid NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||
alert_id uuid NOT NULL REFERENCES irm_alerts(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (incident_id, alert_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS incident_alert_links_alert_idx
|
||||
ON incident_alert_links (alert_id);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS incident_alert_links;")
|
||||
op.execute("DROP TABLE IF EXISTS irm_alerts;")
|
||||
78
alembic/versions/006_teams.py
Normal file
78
alembic/versions/006_teams.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""IRM: команды (teams) и правила сопоставления по лейблам алерта
|
||||
|
||||
Revision ID: 006_teams
|
||||
Revises: 005_irm_alerts
|
||||
Create Date: 2026-04-03
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "006_teams"
|
||||
down_revision: Union[str, None] = "005_irm_alerts"
|
||||
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 teams (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS teams_slug_idx ON teams (slug);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS team_label_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
team_id uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
label_key text NOT NULL,
|
||||
label_value text NOT NULL,
|
||||
priority integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT team_label_rules_key_nonempty CHECK (
|
||||
length(trim(label_key)) > 0
|
||||
)
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS team_label_rules_team_idx ON team_label_rules (team_id);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS team_label_rules_priority_idx
|
||||
ON team_label_rules (priority DESC, id ASC);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE irm_alerts
|
||||
ADD COLUMN IF NOT EXISTS team_id uuid REFERENCES teams(id) ON DELETE SET NULL;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS irm_alerts_team_id_idx ON irm_alerts (team_id);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE irm_alerts DROP COLUMN IF EXISTS team_id;")
|
||||
op.execute("DROP TABLE IF EXISTS team_label_rules;")
|
||||
op.execute("DROP TABLE IF EXISTS teams;")
|
||||
7
deploy/entrypoint.sh
Normal file
7
deploy/entrypoint.sh
Normal 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
21
docker-compose.yml
Normal 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
|
||||
@ -1,6 +1,6 @@
|
||||
# Архитектура 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/
|
||||
│ │ ├── grafana_api.py # Grafana HTTP API (Bearer SA)
|
||||
│ │ └── forgejo_api.py # Forgejo/Gitea API (token + probe/fallback)
|
||||
│ └── modules/ # Заглушки: schedules, contacts, statusboard
|
||||
│ └── modules/ # IRM: incidents, tasks, escalations, … + registry + ui_support
|
||||
├── web/ # Vite + React (опционально)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── deploy/entrypoint.sh
|
||||
├── pyproject.toml
|
||||
├── pytest.ini
|
||||
├── tests/ # pytest: health, status, ingress
|
||||
├── .gitea/workflows/ # CI + SSH deploy (Forgejo Actions)
|
||||
├── CHANGELOG.md
|
||||
└── 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).
|
||||
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); позже сервисный слой и БД |
|
||||
| Новая таблица БД | Alembic: `alembic revision`, правка `alembic/versions/`, `alembic upgrade head` |
|
||||
| Новая внешняя интеграция | `onguard24/integrations/<name>.py`, вызов из `status_snapshot` при необходимости |
|
||||
@ -51,6 +55,7 @@ onGuard24/
|
||||
## Зависимости между компонентами
|
||||
|
||||
- `status_snapshot.build(request)` читает `request.app.state.pool` и `request.app.state.settings` (устанавливаются в `lifespan`).
|
||||
- `request.app.state.event_bus` — доменная шина; модули подписываются в `register_events` из `modules/registry.py`.
|
||||
- Модули **не** зависят друг от друга; контракт заделан через **доменные события** (`domain/events.py`, `EventBus`) и описан в [DOMAIN.md](DOMAIN.md); проводка в HTTP пока не подключена.
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
116
docs/CICD.md
Normal file
116
docs/CICD.md
Normal file
@ -0,0 +1,116 @@
|
||||
# CI/CD (Forgejo / Gitea) и деплой на `pvestandt9`
|
||||
|
||||
Цель: **пуш тега `v*`** → автоматический деплой на сервер; **откат** — повторный запуск workflow с другим тегом.
|
||||
|
||||
## Что в репозитории
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `.gitea/workflows/ci.yml` | `pytest` на `push` в `main`, на `push` тегов `v*`, на PR в `main` (см. чеклист ниже). |
|
||||
| `.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 (см. документацию вашей версии).
|
||||
|
||||
### Образ act_runner (Docker Hub)
|
||||
|
||||
Используйте **`gitea/act_runner`** (например тег **`nightly`**), а не `docker.gitea.com/...:latest` — последний часто недоступен.
|
||||
|
||||
```bash
|
||||
sudo docker pull gitea/act_runner:nightly
|
||||
sudo docker run -d --restart always --name act_runner \
|
||||
-e GITEA_INSTANCE_URL="https://forgejo.pvenode.ru" \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN="ТОКЕН_ИЗ_UI" \
|
||||
-e GITEA_RUNNER_NAME="pvestandt9" \
|
||||
-e GITEA_RUNNER_LABELS="ubuntu-latest:docker://catthehacker/ubuntu:act-22.04" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v act_runner_data:/data \
|
||||
gitea/act_runner:nightly
|
||||
```
|
||||
|
||||
Стабильные теги: [hub.docker.com/r/gitea/act_runner/tags](https://hub.docker.com/r/gitea/act_runner/tags).
|
||||
|
||||
## Чеклист: CI и Deploy проходят
|
||||
|
||||
1. **Runner** зарегистрирован, метка совпадает с `runs-on: ubuntu-latest` в `ci.yml` / `deploy.yml` (или поменяйте `runs-on` на свою метку, например `self-hosted`).
|
||||
2. **Сеть:** runner может скачивать образы Docker (для job’ов) и при необходимости — **`github.com`** (экшены `actions/checkout`, `actions/setup-python`, `appleboy/ssh-action`). Если Forgejo без выхода в интернет — в админке задайте **зеркало/прокси для Actions** или подставьте полные URL экшенов с вашего зеркала.
|
||||
3. **CI:** после `push` в `main` или тега `v*` в разделе **Actions** должен быть успешный workflow **CI** (установка Python 3.12, `pytest`). Локально то же самое: `python -m pip install -e ".[dev]" && python -m pytest tests/ -q`.
|
||||
4. **Deploy:** заданы секреты `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PATH`; с хоста runner выполняется `ssh` на сервер (см. раздел про секреты). Если SSH падает с **host key** — при первом подключении с runner можно зафиксировать отпечаток и добавить в Forgejo секрет (см. раздел «Устранение неполадок»).
|
||||
5. **На сервере** в `DEPLOY_PATH`: рабочий `git remote`, `docker compose`, без конфликтов портов.
|
||||
|
||||
## Секреты репозитория
|
||||
|
||||
**Настройки репозитория → 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` или для удобства просто `1.4.1` — workflow добавит `v`).
|
||||
3. Запустить. На сервере выполнится `checkout` и `reset` на этот ref, затем `docker compose build && up -d`.
|
||||
|
||||
Убедитесь, что старый тег есть в `origin` (`git push --tags` не удалялся).
|
||||
|
||||
## Миграции БД
|
||||
|
||||
По умолчанию при каждом старте контейнера выполняется **`alembic upgrade head`**. Если нужно отключить (и гонять миграции вручную), в `.env` на сервере: `SKIP_ALEMBIC=1`.
|
||||
|
||||
## Устранение неполадок
|
||||
|
||||
- **`Error: missing server host` / `cd ""` в логе** — в репозитории **не заданы** (или пустые) секреты **`DEPLOY_HOST`**, **`DEPLOY_USER`**, **`DEPLOY_SSH_KEY`**, **`DEPLOY_PATH`**. Задайте их в **Forgejo → репозиторий → Настройки → Actions → Secrets** (имена **точно** как в таблице выше). Workflow теперь падает раньше с явным сообщением, какой секрет пустой.
|
||||
- **SSH: host key verification failed** — runner впервые видит ключ сервера. Варианты: (а) один раз с машины runner выполнить `ssh-keyscan -H <DEPLOY_HOST>` и настроить доверие в среде act (зависит от образа); (б) в `appleboy/ssh-action` при необходимости задать входной параметр **`fingerprint`** (SHA256), его можно взять так: `ssh-keyscan -t ed25519 <DEPLOY_HOST> 2>/dev/null | ssh-keygen -lf -` (на любой машине, которая видит сервер). Пустой `DEPLOY_SSH_KEY` или ключ с Windows-переводами строк (`\r\n`) тоже даёт ошибки SSH — ключ в секрете должен быть PEM целиком, Unix newlines.
|
||||
- **CI: не качается `actions/checkout@v4`** — настройте зеркало GitHub для Actions в Forgejo или замените `uses:` на URL экшена с доступного вам хоста (см. документацию вашей версии Forgejo).
|
||||
- **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 и ключ.
|
||||
- **`fatal: ambiguous argument 'origin/v1.x.x'`** при ручном деплое — у **тегов** нет ref вида `origin/имя-тега`, только `refs/tags/…`. После `git fetch && git checkout v1.x.x` делайте `git reset --hard v1.x.x`, а не `origin/v1.x.x`. В `.gitea/workflows/deploy.yml` это уже учтено (ветка `origin/<ref>` только для **веток**).
|
||||
- **Deploy остаётся на старом коммите (напр. v1.6.0), в логе `REF=refs/heads/main`** — UI Forgejo может подставлять полный ref. Workflow приводит `refs/heads/main` → `main`, чтобы выполнялся `git reset --hard origin/main` и подтягивался актуальный `main` с Forgejo.
|
||||
- **База недоступна из контейнера** — в `DATABASE_URL` укажите хост, доступный **из Docker** (не `127.0.0.1` хоста, если БД на хосте — используйте IP хоста или `host.docker.internal` где поддерживается).
|
||||
|
||||
См. также [VERSIONING.md](VERSIONING.md) и [IRM.md](IRM.md).
|
||||
@ -1,6 +1,6 @@
|
||||
# Доменная модель onGuard24
|
||||
|
||||
Версия **1.1.0** вводит явные сущности и задел под **события** между модулями. Таблицы БД для инцидентов пока не добавлены — см. [Alembic](../alembic/versions/).
|
||||
Версия **1.1+** вводит явные сущности и задел под **события** между модулями. Таблицы БД для инцидентов пока не добавлены — см. [Alembic](../alembic/versions/).
|
||||
|
||||
## Сущности (код: `onguard24/domain/entities.py`)
|
||||
|
||||
@ -24,9 +24,9 @@
|
||||
|
||||
1. Модуль реализует **`Module`**: свойство `name`, метод `on_event(event)`.
|
||||
2. При старте приложения модуль регистрируется: `bus.subscribe("alert.received", handler)`.
|
||||
3. После успешного INSERT в `ingress_events` (или нормализации) ядро вызывает `await bus.publish(AlertReceived(...))`.
|
||||
3. После успешного INSERT в `ingress_events` ядро вызывает `await bus.publish_alert_received(Alert, raw_payload_ref=id_строки)`.
|
||||
|
||||
Сейчас **ingress** ещё не публикует в шину — подключение в следующих версиях.
|
||||
Подключение к шине и регистрация модулей: **`app.state.event_bus`**, список модулей — **`modules/registry.py`** (см. [MODULES.md](MODULES.md)).
|
||||
|
||||
## Связь с БД
|
||||
|
||||
|
||||
54
docs/GRAFANA_TOPOLOGY.md
Normal file
54
docs/GRAFANA_TOPOLOGY.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Каталог Grafana в onGuard24
|
||||
|
||||
Иерархия **инстанс → организация → папки → правила алертинга** подтягивается **по HTTP API** Grafana и сохраняется в PostgreSQL. Вебхук при этом не заменяет синхронизацию: вебхук даёт события, каталог — актуальную структуру правил.
|
||||
|
||||
## Что нужно
|
||||
|
||||
1. Миграция **`004_grafana_catalog`** (выполняется при старте контейнера через `alembic upgrade head`).
|
||||
2. В `.env` у источника Grafana заданы **`api_url`** и **`api_token`** (service account с правами читать папки и alert rules — обычно роль Viewer/Editor и доступ к Alerting).
|
||||
3. Источник попадает в **`iter_grafana_sources`**: `GRAFANA_SOURCES_JSON` или пара `GRAFANA_URL` + `GRAFANA_SERVICE_ACCOUNT_TOKEN` (slug `default`).
|
||||
|
||||
## API
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
|--------|------|------------|
|
||||
| POST | `/api/v1/modules/grafana-catalog/sync` | Синхронизация. Тело: `{}` — все источники с токеном; `{"instance_slug":"default"}` — один slug. |
|
||||
| GET | `/api/v1/modules/grafana-catalog/meta` | Последние метаданные синхронизации по всем slug. |
|
||||
| GET | `/api/v1/modules/grafana-catalog/tree?instance_slug=default` | Дерево: папки и правила, сгруппированные по `namespace_uid` (обычно UID папки правил). |
|
||||
|
||||
## Проверка на https://onguard24.pvenode.ru/
|
||||
|
||||
1. Убедитесь, что задеплоена версия с модулем **Каталог Grafana** (пункт в боковом меню).
|
||||
2. Выполните синхронизацию (с сервера или с машины с доступом к API):
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "https://onguard24.pvenode.ru/api/v1/modules/grafana-catalog/sync" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
Ответ: `results[]` с полями `ok`, `folders`, `rules`, при ошибке — `error`.
|
||||
|
||||
3. Посмотреть дерево:
|
||||
|
||||
```bash
|
||||
curl -sS "https://onguard24.pvenode.ru/api/v1/modules/grafana-catalog/tree?instance_slug=default" | python3 -m json.tool
|
||||
```
|
||||
|
||||
(Если используете только `GRAFANA_URL`, slug источника — **`default`**.)
|
||||
|
||||
4. В UI: **Каталог Grafana** — таблица последних синхронизаций.
|
||||
|
||||
## Связь с вебхуком
|
||||
|
||||
- В инцидентах по-прежнему используется **`externalURL` / лейблы** из тела вебхука.
|
||||
- Каталог позволяет в UI/API сопоставлять **`rule_uid`** и папку с сохранённым снимком (после доработок привязки).
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Один токен = **одна Grafana-организация** (контекст `GET /api/org`). Обход нескольких org на одном инстансе — отдельные токены или админ-API (не в этой версии).
|
||||
- Эндпоинты Ruler: пробуются `/api/ruler/grafana/api/v1/rules` и `/api/ruler/Grafana/api/v1/rules` (зависит от версии Grafana).
|
||||
|
||||
## Автоматический cron
|
||||
|
||||
Периодический `POST .../sync` пока не встроен: можно повесить **системный cron**, **Forgejo scheduled workflow** или внешний оркестратор.
|
||||
42
docs/IRM.md
Normal file
42
docs/IRM.md
Normal file
@ -0,0 +1,42 @@
|
||||
# IRM: функционал, назначение и реализация в onGuard24
|
||||
|
||||
Краткий ориентир для разработки (аналог облачного IRM: инциденты, задачи, эскалации, дежурства).
|
||||
|
||||
## Матрица: что это, зачем, как у нас, что в Grafana
|
||||
|
||||
| Область | Назначение | onGuard24 | Grafana / внешнее |
|
||||
|---------|------------|-----------|-------------------|
|
||||
| **Инциденты** | Учёт сбоев, статусы (open → resolved), связь с алертами | Модуль `incidents`: `incidents`, `incident_alert_links`, API, UI; создание вручную или с `alert_ids` | См. **Алерты**; [IRM_GRAFANA_PARITY.md](IRM_GRAFANA_PARITY.md) |
|
||||
| **Алерты (IRM)** | Приём, Ack/Resolve, не смешивать с инцидентом | Модуль `alerts`: `irm_alerts`, UI/API, вебхук пишет в одной транзакции с `ingress_events` | Grafana IRM Alert Groups; у нас без группировки/эскалации на уровне алерта |
|
||||
| **Задачи** | Подзадачи по инциденту (разбор, фикс) | Модуль `tasks`: таблица `tasks`, привязка к `incident_id` | Опционально: ссылки из алерта; основная работа в onGuard24 |
|
||||
| **Цепочки эскалаций** | Кого звать и в каком порядке при таймаутах | Модуль `escalations`: таблица `escalation_policies` (JSON `steps`), API/UI заготовка | Маршрутизация уведомлений может дублироваться в Grafana contact points; целевая логика — в onGuard24 |
|
||||
| **Команды (teams)** | Фильтр и маршрутизация как в IRM | Модуль `teams`: `teams`, `team_label_rules`, `irm_alerts.team_id`; правила по лейблам при вебхуке | Колонка **Team** в Grafana IRM; у нас сопоставление по `label_key` = `label_value` |
|
||||
| **Календарь дежурств** | Кто в смене, расписание | Модуль `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 |
|
||||
|
||||
## Поток данных (как в Grafana IRM)
|
||||
|
||||
1. Grafana срабатывает правило → JSON на **webhook** onGuard24.
|
||||
2. В одной транзакции: **`ingress_events`** + **`irm_alerts`** (статус `firing`), публикуется **`alert.received`**.
|
||||
3. Дежурный в модуле **Алерты** читает заголовок, лейблы, **Acknowledge** / **Resolve** — это не создание инцидента.
|
||||
4. **Инцидент** создаётся отдельно (вручную или из карточки алерта), опционально с привязкой **`alert_ids`**. Авто-инцидент из вебхука только при **`AUTO_INCIDENT_FROM_ALERT=1`** (legacy).
|
||||
|
||||
## Что настроить в Grafana (обязательно для приёма алертов)
|
||||
|
||||
### Один URL для всех инстансов и организаций
|
||||
|
||||
1. **Contact point → Webhook**, URL: **`https://<ваш-хост>/api/v1/ingress/grafana`** (POST). **Не нужно** заводить slug в `.env`: источник в БД определяется из JSON Grafana — **`externalURL`** (хост Grafana), при наличии **`orgId`** / **`org_id`**, иначе лейблы первого алерта (`__org_id__`, `grafana_org`, `tenant`, `cluster`, `namespace`).
|
||||
2. В **`grafana.ini`** / настройках сервера корректно задайте **`root_url` / `external URL`**, чтобы в вебхук попадал нужный хост (за NPM — публичный URL).
|
||||
3. Опционально **`X-OnGuard-Secret`** (если задан **`GRAFANA_WEBHOOK_SECRET`**) и **`X-OnGuard-Service`** (имя сервиса в инциденте).
|
||||
4. **Notification policies** — привязать правила к contact point.
|
||||
|
||||
### Дополнительно: путь `/ingress/grafana/<slug>`
|
||||
|
||||
Явный ярлык в URL **не требует** записи в `GRAFANA_SOURCES_JSON`. **`GRAFANA_SOURCES_JSON`** нужен в основном для **`/api/v1/status`** (проверка API каждого инстанса) и для **отдельного `webhook_secret` на slug**.
|
||||
|
||||
Подробнее: [MODULES.md](MODULES.md), [DOMAIN.md](DOMAIN.md).
|
||||
40
docs/IRM_GRAFANA_PARITY.md
Normal file
40
docs/IRM_GRAFANA_PARITY.md
Normal file
@ -0,0 +1,40 @@
|
||||
# Сравнение onGuard24 с Grafana IRM (Alerting / Incident)
|
||||
|
||||
Grafana Cloud / IRM даёт **группы алертов**, **Acknowledge / Resolve**, **инциденты**, **команды (teams)**, **эскалационные цепочки**, **расписания дежурств**. Ниже — что уже есть в onGuard24 и что планировать отдельно.
|
||||
|
||||
## Уже есть (после разделения алерт / инцидент)
|
||||
|
||||
| Grafana IRM (идея) | onGuard24 |
|
||||
|--------------------|-----------|
|
||||
| Входящие уведомления от интеграции | Webhook `POST /api/v1/ingress/grafana` → `ingress_events` + **`irm_alerts`** |
|
||||
| Статусы firing / acknowledged / resolved | Поле **`irm_alerts.status`**, UI **Алерты**, API `PATCH …/acknowledge`, `…/resolve` |
|
||||
| Просмотр labels / сырого payload | Карточка алерта в UI, JSON вебхука |
|
||||
| Инцидент как отдельная сущность | **`incidents`**, создание вручную или кнопка «Создать инцидент» на алерте; связь **`incident_alert_links`** |
|
||||
| Эскалации (JSON-шаги) | Модуль **Эскалации** (`escalation_policies`) — без автодвижка по таймерам |
|
||||
| Контакты / каналы | Модуль **Контакты** |
|
||||
| Расписания (заглушка) | **Календарь дежурств** — UI-задел |
|
||||
| **Teams** (команда по лейблам) | Таблицы **`teams`**, **`team_label_rules`**, поле **`irm_alerts.team_id`**; вебхук подбирает команду по первому совпадению правила (priority); UI/API **Команды**, фильтр по команде в **Алертах** |
|
||||
|
||||
## Пока нет (зрелые следующие этапы)
|
||||
|
||||
| Функция Grafana IRM | Заметка |
|
||||
|---------------------|---------|
|
||||
| **Teams** как маршрутизация уведомлений (кому слать из коробки) | Команда назначена на алерт; **цепочки уведомлений по team** — впереди (связка с escalations / contact points) |
|
||||
| **Alert groups** (несколько алертов в одной группе с общим ID) | Сейчас **одна строка `irm_alerts` на один webhook**; группировка fingerprint / rule_uid — отдельная задача |
|
||||
| **Silence / Restart** из UI | Статус `silenced` в БД зарезервирован, логика не подключена |
|
||||
| **Эскалация по таймеру** (wait 15m → notify next) | Политики есть, **фонового исполнителя** нет |
|
||||
| **On-call из расписания** в цепочке | Нет связи schedules → escalation executor |
|
||||
| **Пользователи / «Mine» / назначение** | Нет учётных записей onGuard24 для дежурного; `acknowledged_by` — свободный текст (можно расширить) |
|
||||
| **Интеграция обратно в Grafana** (resolve в Grafana из IRM) | Не делалось |
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
- **`AUTO_INCIDENT_FROM_ALERT`** — если `1` / `true`, сохраняется старое поведение: **каждый** вебхук ещё и создаёт строку в **`incidents`**. По умолчанию **выключено**: только **`irm_alerts`**.
|
||||
|
||||
## Рекомендуемый поток
|
||||
|
||||
1. Grafana → webhook → **алерт** (`firing`).
|
||||
2. Дежурный в **Алертах**: прочитал → **Ack** → разобрался → **Resolve** (или сразу Resolve).
|
||||
3. При необходимости **Создать инцидент** (документирование, задачи, эскалация вручную).
|
||||
|
||||
Так модель ближе к IRM, где **алерт** и **инцидент** разведены.
|
||||
57
docs/MODULES.md
Normal file
57
docs/MODULES.md
Normal 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).
|
||||
@ -1,3 +1,3 @@
|
||||
"""onGuard24 — модульный монолит (ядро + модули)."""
|
||||
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "1.10.1"
|
||||
|
||||
@ -19,6 +19,9 @@ class Settings(BaseSettings):
|
||||
http_addr: str = Field(default="0.0.0.0:8080", validation_alias="HTTP_ADDR")
|
||||
database_url: str = Field(default="", validation_alias="DATABASE_URL")
|
||||
grafana_webhook_secret: str = Field(default="", validation_alias="GRAFANA_WEBHOOK_SECRET")
|
||||
# JSON-массив: [{"slug":"adibrov","api_url":"https://...","api_token":"glsa_...","webhook_secret":"..."}]
|
||||
# Пусто + задан GRAFANA_URL → один источник slug "default"
|
||||
grafana_sources_json: str = Field(default="", validation_alias="GRAFANA_SOURCES_JSON")
|
||||
# HTTP API (service account): Grafana → Administration → Service accounts → токен
|
||||
grafana_url: str = Field(default="", validation_alias="GRAFANA_URL")
|
||||
grafana_service_account_token: str = Field(
|
||||
@ -31,6 +34,14 @@ class Settings(BaseSettings):
|
||||
forgejo_url: str = Field(default="", validation_alias="FORGEJO_URL")
|
||||
forgejo_token: str = Field(default="", validation_alias="FORGEJO_TOKEN")
|
||||
log_level: str = Field(default="info", validation_alias="LOG_LEVEL")
|
||||
# Путь к лог-файлу. Пусто = не писать в файл. Пример: /var/log/onguard24/app.log
|
||||
# Файл ротируется: 10 МБ × 5 штук (~50 МБ суммарно).
|
||||
log_file: str = Field(default="", validation_alias="LOG_FILE")
|
||||
# Устаревшее: автосоздание инцидента на каждый вебхук (без учёта irm_alerts). По умолчанию выкл.
|
||||
auto_incident_from_alert: bool = Field(
|
||||
default=False,
|
||||
validation_alias="AUTO_INCIDENT_FROM_ALERT",
|
||||
)
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
|
||||
9
onguard24/deps.py
Normal file
9
onguard24/deps.py
Normal 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)
|
||||
@ -26,6 +26,8 @@ class AlertReceived(DomainEvent):
|
||||
name: str = "alert.received"
|
||||
alert: Alert | None = None
|
||||
raw_payload_ref: UUID | None = None
|
||||
grafana_org_slug: str | None = None
|
||||
service_name: str | None = None
|
||||
|
||||
|
||||
Handler = Callable[[DomainEvent], Awaitable[None]]
|
||||
@ -59,6 +61,18 @@ class InMemoryEventBus:
|
||||
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)
|
||||
async def publish_alert_received(
|
||||
self,
|
||||
alert: Alert,
|
||||
raw_payload_ref: UUID | None = None,
|
||||
*,
|
||||
grafana_org_slug: str | None = None,
|
||||
service_name: str | None = None,
|
||||
) -> None:
|
||||
ev = AlertReceived(
|
||||
alert=alert,
|
||||
raw_payload_ref=raw_payload_ref,
|
||||
grafana_org_slug=grafana_org_slug,
|
||||
service_name=service_name,
|
||||
)
|
||||
await self.publish(ev)
|
||||
|
||||
84
onguard24/grafana_sources.py
Normal file
84
onguard24/grafana_sources.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""Несколько инстансов Grafana: URL API, токен, секрет вебхука по slug (организация / стек)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$")
|
||||
|
||||
|
||||
class GrafanaSourceEntry(BaseModel):
|
||||
"""Один инстанс Grafana для API и/или приёма вебхуков."""
|
||||
|
||||
slug: str = Field(..., description="Идентификатор в URL: /ingress/grafana/{slug}")
|
||||
api_url: str = Field(..., description="Базовый URL без завершающего слэша")
|
||||
api_token: str = Field(default="", description="Service account token для HTTP API")
|
||||
webhook_secret: str = Field(
|
||||
default="",
|
||||
description="Если задан — только этот секрет для вебхука этого slug; иначе см. GRAFANA_WEBHOOK_SECRET",
|
||||
)
|
||||
|
||||
@field_validator("slug")
|
||||
@classmethod
|
||||
def slug_ok(cls, v: str) -> str:
|
||||
s = v.strip().lower()
|
||||
if not _SLUG_RE.match(s):
|
||||
raise ValueError(
|
||||
"slug: только a-z, цифры, - и _, длина 1–63, с буквы/цифры"
|
||||
)
|
||||
return s
|
||||
|
||||
@field_validator("api_url")
|
||||
@classmethod
|
||||
def strip_url(cls, v: str) -> str:
|
||||
return v.rstrip("/")
|
||||
|
||||
|
||||
def parse_grafana_sources_json(raw: str) -> list[GrafanaSourceEntry]:
|
||||
if not raw.strip():
|
||||
return []
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("GRAFANA_SOURCES_JSON должен быть JSON-массивом объектов")
|
||||
return [GrafanaSourceEntry.model_validate(x) for x in data]
|
||||
|
||||
|
||||
def iter_grafana_sources(settings: "Settings") -> list[GrafanaSourceEntry]:
|
||||
"""Список источников: из GRAFANA_SOURCES_JSON или один синтетический default из GRAFANA_URL."""
|
||||
try:
|
||||
parsed = parse_grafana_sources_json(settings.grafana_sources_json)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed = []
|
||||
if parsed:
|
||||
return parsed
|
||||
gu = settings.grafana_url.strip()
|
||||
if not gu:
|
||||
return []
|
||||
return [
|
||||
GrafanaSourceEntry(
|
||||
slug="default",
|
||||
api_url=gu.rstrip("/"),
|
||||
api_token=settings.grafana_service_account_token.strip(),
|
||||
webhook_secret="",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def sources_by_slug(settings: "Settings") -> dict[str, GrafanaSourceEntry]:
|
||||
return {s.slug: s for s in iter_grafana_sources(settings)}
|
||||
|
||||
|
||||
def effective_webhook_secret(settings: "Settings", source: GrafanaSourceEntry | None) -> str:
|
||||
"""Пустая строка = проверка вебхука отключена (только для dev)."""
|
||||
if source and source.webhook_secret.strip():
|
||||
return source.webhook_secret.strip()
|
||||
return settings.grafana_webhook_secret.strip()
|
||||
|
||||
|
||||
def webhook_authorized(settings: "Settings", source: GrafanaSourceEntry | None, header: str | None) -> bool:
|
||||
need = effective_webhook_secret(settings, source)
|
||||
if not need:
|
||||
return True
|
||||
return (header or "") == need
|
||||
@ -1,26 +1,131 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from onguard24.domain.entities import Alert, Severity
|
||||
from onguard24.grafana_sources import sources_by_slug, webhook_authorized
|
||||
from onguard24.ingress.grafana_payload import extract_alert_row_from_grafana_body
|
||||
from onguard24.ingress.json_sanitize import sanitize_for_jsonb
|
||||
from onguard24.ingress.team_match import resolve_team_id_for_labels
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["ingress"])
|
||||
|
||||
_PATH_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$")
|
||||
|
||||
|
||||
def sanitize_source_key(raw: str) -> str:
|
||||
s = re.sub(r"[^a-zA-Z0-9._-]+", "-", raw.strip()).strip("-").lower()
|
||||
return s[:200] if s else ""
|
||||
|
||||
|
||||
def extract_grafana_source_key(body: dict) -> str | None:
|
||||
"""
|
||||
Идентификатор инстанса/организации из тела вебхука Grafana (без ручной прописки в .env).
|
||||
См. поля payload: externalURL, orgId, лейблы в alerts[].
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
ext = body.get("externalURL") or body.get("external_url")
|
||||
if isinstance(ext, str) and ext.strip():
|
||||
try:
|
||||
host = urlparse(ext.strip()).hostname
|
||||
if host:
|
||||
chunks.append(host)
|
||||
except Exception:
|
||||
pass
|
||||
for key in ("orgId", "org_id"):
|
||||
v = body.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
chunks.append(f"o{str(v).strip()[:24]}")
|
||||
break
|
||||
alerts = body.get("alerts")
|
||||
if isinstance(alerts, list) and alerts:
|
||||
a0 = alerts[0] if isinstance(alerts[0], dict) else {}
|
||||
labels = a0.get("labels") if isinstance(a0.get("labels"), dict) else {}
|
||||
for lk in ("__org_id__", "grafana_org", "tenant", "cluster", "namespace"):
|
||||
v = labels.get(lk)
|
||||
if v is not None and str(v).strip():
|
||||
chunks.append(str(v).strip()[:64])
|
||||
break
|
||||
if not chunks:
|
||||
return None
|
||||
return sanitize_source_key("-".join(chunks)) or None
|
||||
|
||||
|
||||
async def get_pool(request: Request):
|
||||
return getattr(request.app.state, "pool", None)
|
||||
|
||||
|
||||
@router.post("/ingress/grafana", status_code=202)
|
||||
async def grafana_webhook(
|
||||
def service_hint_from_grafana_body(body: dict, header_service: str | None) -> str | None:
|
||||
"""Имя сервиса: заголовок X-OnGuard-Service или лейблы из Unified Alerting."""
|
||||
if header_service and header_service.strip():
|
||||
return header_service.strip()[:200]
|
||||
alerts = body.get("alerts")
|
||||
if isinstance(alerts, list) and alerts:
|
||||
labels = alerts[0].get("labels") if isinstance(alerts[0], dict) else None
|
||||
if isinstance(labels, dict):
|
||||
for key in ("service", "job", "namespace", "cluster", "instance"):
|
||||
v = labels.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip()[:200]
|
||||
common = body.get("commonLabels")
|
||||
if isinstance(common, dict):
|
||||
for key in ("service", "job", "namespace"):
|
||||
v = common.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip()[:200]
|
||||
return None
|
||||
|
||||
|
||||
def _log_incoming_webhook(body: object, raw: bytes, path_slug: str | None) -> None:
|
||||
"""Логирует каждый входящий вебхук: краткое резюме INFO + полное тело DEBUG."""
|
||||
slug_tag = f"[/{path_slug}]" if path_slug else "[/]"
|
||||
raw_len = len(raw)
|
||||
|
||||
if not isinstance(body, dict):
|
||||
logger.info("grafana webhook %s raw=%dB (non-dict body)", slug_tag, raw_len)
|
||||
logger.debug("grafana webhook %s raw body: %s", slug_tag, raw[:4000].decode(errors="replace"))
|
||||
return
|
||||
|
||||
alerts = body.get("alerts") or []
|
||||
n_alerts = len(alerts) if isinstance(alerts, list) else 0
|
||||
status = body.get("status", "?")
|
||||
title = str(body.get("title") or body.get("ruleName") or "")[:120]
|
||||
first_labels: dict = {}
|
||||
if isinstance(alerts, list) and alerts and isinstance(alerts[0], dict):
|
||||
first_labels = alerts[0].get("labels") or {}
|
||||
|
||||
logger.info(
|
||||
"grafana webhook %s status=%s alerts=%d title=%r labels=%s raw=%dB",
|
||||
slug_tag,
|
||||
status,
|
||||
n_alerts,
|
||||
title,
|
||||
json.dumps(first_labels, ensure_ascii=False)[:300],
|
||||
raw_len,
|
||||
)
|
||||
try:
|
||||
pretty = json.dumps(body, ensure_ascii=False, indent=2)
|
||||
if len(pretty) > 8000:
|
||||
pretty = pretty[:8000] + "\n…(обрезано)"
|
||||
except Exception:
|
||||
pretty = raw[:8000].decode(errors="replace")
|
||||
logger.debug("grafana webhook %s full body:\n%s", slug_tag, pretty)
|
||||
|
||||
|
||||
async def _grafana_webhook_impl(
|
||||
request: Request,
|
||||
pool=Depends(get_pool),
|
||||
x_onguard_secret: str | None = Header(default=None, alias="X-OnGuard-Secret"),
|
||||
):
|
||||
pool,
|
||||
x_onguard_secret: str | None,
|
||||
x_onguard_service: str | None,
|
||||
path_slug: str | None,
|
||||
) -> Response:
|
||||
settings = request.app.state.settings
|
||||
if settings.grafana_webhook_secret and x_onguard_secret != settings.grafana_webhook_secret:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
|
||||
raw = await request.body()
|
||||
if len(raw) > 1_000_000:
|
||||
@ -30,14 +135,128 @@ async def grafana_webhook(
|
||||
except json.JSONDecodeError:
|
||||
body = {}
|
||||
|
||||
# Логируем входящий вебхук ДО любой обработки — чтобы видеть при любой ошибке
|
||||
_log_incoming_webhook(body, raw, path_slug)
|
||||
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
else:
|
||||
body = sanitize_for_jsonb(body)
|
||||
|
||||
derived = extract_grafana_source_key(body)
|
||||
path_key: str | None = None
|
||||
if path_slug is not None:
|
||||
path_key = path_slug.strip().lower()
|
||||
if not _PATH_SLUG_RE.match(path_key):
|
||||
raise HTTPException(status_code=400, detail="invalid path slug")
|
||||
stored_org_slug = path_key
|
||||
else:
|
||||
stored_org_slug = derived
|
||||
|
||||
by = sources_by_slug(settings)
|
||||
source = by.get(path_key) if path_key else None
|
||||
if not webhook_authorized(settings, source, x_onguard_secret):
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
|
||||
service_name = service_hint_from_grafana_body(body, x_onguard_service)
|
||||
|
||||
if pool is None:
|
||||
logger.warning("ingress: database not configured, event not persisted")
|
||||
return Response(status_code=202)
|
||||
|
||||
title_row, sev_row, labels_row, fp_row = extract_alert_row_from_grafana_body(body)
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO ingress_events (source, body) VALUES ($1, $2::jsonb)",
|
||||
"grafana",
|
||||
json.dumps(body),
|
||||
async with conn.transaction():
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO ingress_events (source, body, org_slug, service_name)
|
||||
VALUES ($1, $2::jsonb, $3, $4)
|
||||
RETURNING id
|
||||
""",
|
||||
"grafana",
|
||||
json.dumps(body, ensure_ascii=False, allow_nan=False),
|
||||
stored_org_slug,
|
||||
service_name,
|
||||
)
|
||||
raw_id = row["id"] if row else None
|
||||
if raw_id is not None:
|
||||
team_id = await resolve_team_id_for_labels(conn, labels_row)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO irm_alerts (
|
||||
ingress_event_id, status, title, severity, source,
|
||||
grafana_org_slug, service_name, labels, fingerprint, team_id
|
||||
)
|
||||
VALUES ($1, 'firing', $2, $3, 'grafana', $4, $5, $6::jsonb, $7, $8)
|
||||
""",
|
||||
raw_id,
|
||||
title_row or "—",
|
||||
sev_row,
|
||||
stored_org_slug,
|
||||
service_name,
|
||||
json.dumps(labels_row, ensure_ascii=False, allow_nan=False),
|
||||
fp_row,
|
||||
team_id,
|
||||
)
|
||||
logger.info(
|
||||
"grafana webhook saved: alert_id=%s title=%r sev=%s team=%s",
|
||||
raw_id,
|
||||
(title_row or "—")[:80],
|
||||
sev_row,
|
||||
str(team_id) if team_id else "—",
|
||||
)
|
||||
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),
|
||||
)
|
||||
try:
|
||||
await bus.publish_alert_received(
|
||||
alert,
|
||||
raw_payload_ref=raw_id,
|
||||
grafana_org_slug=stored_org_slug,
|
||||
service_name=service_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"ingress: событие alert.received не доставлено подписчикам (БД уже сохранена)"
|
||||
)
|
||||
return Response(status_code=202)
|
||||
|
||||
|
||||
@router.post("/ingress/grafana", status_code=202)
|
||||
async def grafana_webhook_legacy(
|
||||
request: Request,
|
||||
pool=Depends(get_pool),
|
||||
x_onguard_secret: str | None = Header(default=None, alias="X-OnGuard-Secret"),
|
||||
x_onguard_service: str | None = Header(default=None, alias="X-OnGuard-Service"),
|
||||
):
|
||||
"""
|
||||
Универсальный URL для любого инстанса Grafana: org_slug в БД берётся из тела
|
||||
(externalURL, orgId, лейблы), без преднастройки в .env.
|
||||
"""
|
||||
return await _grafana_webhook_impl(
|
||||
request, pool, x_onguard_secret, x_onguard_service, path_slug=None
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ingress/grafana/{org_slug}", status_code=202)
|
||||
async def grafana_webhook_org(
|
||||
org_slug: str,
|
||||
request: Request,
|
||||
pool=Depends(get_pool),
|
||||
x_onguard_secret: str | None = Header(default=None, alias="X-OnGuard-Secret"),
|
||||
x_onguard_service: str | None = Header(default=None, alias="X-OnGuard-Service"),
|
||||
):
|
||||
"""
|
||||
Опционально: явный ярлык в URL (перекрывает авто-извлечение из JSON).
|
||||
Секрет для пути: webhook_secret из GRAFANA_SOURCES_JSON для этого slug, иначе общий.
|
||||
"""
|
||||
return await _grafana_webhook_impl(
|
||||
request, pool, x_onguard_secret, x_onguard_service, path_slug=org_slug
|
||||
)
|
||||
|
||||
53
onguard24/ingress/grafana_payload.py
Normal file
53
onguard24/ingress/grafana_payload.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""Извлечение полей для учёта алерта из тела вебхука Grafana (Unified Alerting)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def extract_alert_row_from_grafana_body(body: dict[str, Any]) -> tuple[str, str, dict[str, Any], str | None]:
|
||||
"""
|
||||
Возвращает: title, severity (info|warning|critical), labels (dict), fingerprint.
|
||||
"""
|
||||
title = str(body.get("title") or body.get("ruleName") or "").strip()[:500]
|
||||
alerts = body.get("alerts")
|
||||
labels: dict[str, Any] = {}
|
||||
fingerprint: str | None = None
|
||||
sev = "warning"
|
||||
|
||||
if isinstance(alerts, list) and alerts and isinstance(alerts[0], dict):
|
||||
a0 = alerts[0]
|
||||
fp = a0.get("fingerprint")
|
||||
if fp is not None:
|
||||
fingerprint = str(fp)[:500]
|
||||
if isinstance(a0.get("labels"), dict):
|
||||
labels.update(a0["labels"])
|
||||
ann = a0.get("annotations")
|
||||
if isinstance(ann, dict) and not title:
|
||||
title = str(ann.get("summary") or ann.get("description") or "").strip()[:500]
|
||||
|
||||
cl = body.get("commonLabels")
|
||||
if isinstance(cl, dict):
|
||||
for k, v in cl.items():
|
||||
labels.setdefault(k, v)
|
||||
|
||||
if not title and isinstance(alerts, list) and alerts and isinstance(alerts[0], dict):
|
||||
title = str(alerts[0].get("labels", {}).get("alertname") or "").strip()[:500]
|
||||
|
||||
raw_s = None
|
||||
if isinstance(labels.get("severity"), str):
|
||||
raw_s = labels["severity"].lower()
|
||||
elif isinstance(labels.get("priority"), str):
|
||||
raw_s = labels["priority"].lower()
|
||||
if raw_s in ("critical", "error", "fatal"):
|
||||
sev = "critical"
|
||||
elif raw_s in ("warning", "warn"):
|
||||
sev = "warning"
|
||||
elif raw_s in ("info", "informational", "none"):
|
||||
sev = "info"
|
||||
|
||||
# JSONB: только JSON-совместимые значения
|
||||
clean_labels = {str(k): v for k, v in labels.items() if isinstance(v, (str, int, float, bool, type(None)))}
|
||||
|
||||
return title, sev, clean_labels, fingerprint
|
||||
26
onguard24/ingress/json_sanitize.py
Normal file
26
onguard24/ingress/json_sanitize.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""Подготовка структур из JSON к записи в PostgreSQL jsonb."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
def sanitize_for_jsonb(obj: Any) -> Any:
|
||||
"""
|
||||
- float NaN / ±Inf → None (иначе json.dumps даёт невалидный JSON для PG / сюрпризы при записи).
|
||||
- Символ NUL в строках убрать (PostgreSQL text/jsonb NUL в строке не принимает).
|
||||
"""
|
||||
if isinstance(obj, float):
|
||||
if math.isnan(obj) or math.isinf(obj):
|
||||
return None
|
||||
return obj
|
||||
if isinstance(obj, str):
|
||||
if "\x00" not in obj:
|
||||
return obj
|
||||
return obj.replace("\x00", "")
|
||||
if isinstance(obj, dict):
|
||||
return {k: sanitize_for_jsonb(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [sanitize_for_jsonb(x) for x in obj]
|
||||
return obj
|
||||
51
onguard24/ingress/team_match.py
Normal file
51
onguard24/ingress/team_match.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""Сопоставление входящего алерта с командой по правилам лейблов (как Team в Grafana IRM)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
def match_team_for_labels(
|
||||
labels: dict[str, Any],
|
||||
rules: Sequence[asyncpg.Record | tuple[UUID, str, str]],
|
||||
) -> UUID | None:
|
||||
"""
|
||||
rules — упорядочены по приоритету (выше priority — раньше проверка).
|
||||
Первое совпадение label_key == label_value возвращает team_id.
|
||||
"""
|
||||
if not labels or not rules:
|
||||
return None
|
||||
flat: dict[str, str] = {
|
||||
str(k): "" if v is None else str(v) for k, v in labels.items()
|
||||
}
|
||||
for row in rules:
|
||||
if isinstance(row, tuple):
|
||||
tid, key, val = row[0], row[1], row[2]
|
||||
else:
|
||||
tid = row["team_id"]
|
||||
key = row["label_key"]
|
||||
val = row["label_value"]
|
||||
if flat.get(str(key)) == str(val):
|
||||
return tid if isinstance(tid, UUID) else UUID(str(tid))
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_team_rules(conn: asyncpg.Connection) -> list[asyncpg.Record]:
|
||||
return await conn.fetch(
|
||||
"""
|
||||
SELECT team_id, label_key, label_value
|
||||
FROM team_label_rules
|
||||
ORDER BY priority DESC, id ASC
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def resolve_team_id_for_labels(
|
||||
conn: asyncpg.Connection,
|
||||
labels: dict[str, Any],
|
||||
) -> UUID | None:
|
||||
rules = await fetch_team_rules(conn)
|
||||
return match_team_for_labels(labels, list(rules))
|
||||
169
onguard24/integrations/grafana_topology.py
Normal file
169
onguard24/integrations/grafana_topology.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""Чтение иерархии Grafana: org, папки, managed alert rules (Ruler API)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_HEADERS_JSON = {"Accept": "application/json"}
|
||||
|
||||
|
||||
def _auth_headers(token: str) -> dict[str, str]:
|
||||
return {**_HEADERS_JSON, "Authorization": f"Bearer {token.strip()}"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedRuleRow:
|
||||
namespace_uid: str
|
||||
rule_group_name: str
|
||||
rule_group_interval: str | None
|
||||
rule_uid: str
|
||||
title: str
|
||||
labels: dict[str, Any]
|
||||
|
||||
|
||||
async def fetch_org(base_url: str, token: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||
base = base_url.rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, verify=True, follow_redirects=True) as client:
|
||||
r = await client.get(f"{base}/api/org", headers=_auth_headers(token))
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
if r.status_code != 200:
|
||||
return None, f"http {r.status_code}: {(r.text or '')[:300]}"
|
||||
try:
|
||||
return r.json(), None
|
||||
except Exception:
|
||||
return None, "invalid json from /api/org"
|
||||
|
||||
|
||||
async def fetch_all_folders(base_url: str, token: str) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""Обход дерева папок через /api/folders?parentUid=…"""
|
||||
base = base_url.rstrip("/")
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
queue: list[str | None] = [None]
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0, verify=True, follow_redirects=True) as client:
|
||||
while queue:
|
||||
parent_uid = queue.pop(0)
|
||||
params: dict[str, str] = {}
|
||||
if parent_uid is not None:
|
||||
params["parentUid"] = parent_uid
|
||||
r = await client.get(f"{base}/api/folders", headers=_auth_headers(token), params=params)
|
||||
if r.status_code != 200:
|
||||
return out, f"folders http {r.status_code}: {(r.text or '')[:200]}"
|
||||
try:
|
||||
chunk = r.json()
|
||||
except Exception:
|
||||
return out, "invalid json from /api/folders"
|
||||
if not isinstance(chunk, list):
|
||||
return out, "folders response is not a list"
|
||||
for f in chunk:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
uid = f.get("uid")
|
||||
if not uid or uid in seen:
|
||||
continue
|
||||
seen.add(str(uid))
|
||||
out.append(f)
|
||||
queue.append(str(uid))
|
||||
except Exception as e:
|
||||
return out, str(e)
|
||||
return out, None
|
||||
|
||||
|
||||
async def fetch_ruler_rules_raw(base_url: str, token: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""GET Ruler API для Grafana-managed правил (namespace → группы → rules)."""
|
||||
base = base_url.rstrip("/")
|
||||
paths = (
|
||||
"/api/ruler/grafana/api/v1/rules",
|
||||
"/api/ruler/Grafana/api/v1/rules",
|
||||
)
|
||||
last_err: str | None = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=90.0, verify=True, follow_redirects=True) as client:
|
||||
for path in paths:
|
||||
r = await client.get(f"{base}{path}", headers=_auth_headers(token))
|
||||
if r.status_code == 200:
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception:
|
||||
return None, "invalid json from ruler"
|
||||
if isinstance(data, dict):
|
||||
return data, None
|
||||
return None, "ruler response is not an object"
|
||||
last_err = f"ruler {path} http {r.status_code}: {(r.text or '')[:200]}"
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
return None, last_err or "ruler: no path matched"
|
||||
|
||||
|
||||
def parse_ruler_rules(data: dict[str, Any]) -> list[ParsedRuleRow]:
|
||||
rows: list[ParsedRuleRow] = []
|
||||
for namespace_uid, groups in data.items():
|
||||
if not isinstance(namespace_uid, str) or not namespace_uid.strip():
|
||||
continue
|
||||
ns = namespace_uid.strip()
|
||||
if not isinstance(groups, list):
|
||||
continue
|
||||
for grp in groups:
|
||||
if not isinstance(grp, dict):
|
||||
continue
|
||||
gname = str(grp.get("name") or "group")
|
||||
interval = grp.get("interval")
|
||||
interval_s = str(interval) if interval is not None else None
|
||||
rules = grp.get("rules")
|
||||
if not isinstance(rules, list):
|
||||
continue
|
||||
for rule in rules:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
ga = rule.get("grafana_alert")
|
||||
if not isinstance(ga, dict):
|
||||
continue
|
||||
uid = ga.get("uid") or rule.get("uid")
|
||||
if not uid:
|
||||
continue
|
||||
title = str(ga.get("title") or gname or uid)
|
||||
labels = rule.get("labels")
|
||||
if not isinstance(labels, dict):
|
||||
labels = {}
|
||||
lbl = {str(k): str(v) for k, v in labels.items() if v is not None}
|
||||
rows.append(
|
||||
ParsedRuleRow(
|
||||
namespace_uid=ns,
|
||||
rule_group_name=gname,
|
||||
rule_group_interval=interval_s,
|
||||
rule_uid=str(uid),
|
||||
title=title[:500],
|
||||
labels=lbl,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def merge_folder_rows(
|
||||
api_folders: list[dict[str, Any]],
|
||||
rule_namespaces: set[str],
|
||||
) -> list[tuple[str, str, str | None]]:
|
||||
"""(uid, title, parent_uid); добавляем namespace из ruler без записи в /api/folders."""
|
||||
by_uid: dict[str, tuple[str, str | None]] = {}
|
||||
for f in api_folders:
|
||||
uid = f.get("uid")
|
||||
if not uid:
|
||||
continue
|
||||
p = f.get("parentUid")
|
||||
parent = str(p) if p else None
|
||||
by_uid[str(uid)] = (str(f.get("title") or uid), parent)
|
||||
for ns in rule_namespaces:
|
||||
if ns not in by_uid:
|
||||
by_uid[ns] = (ns, None)
|
||||
return [(uid, t[0], t[1]) for uid, t in sorted(by_uid.items(), key=lambda x: x[0])]
|
||||
119
onguard24/log_buffer.py
Normal file
119
onguard24/log_buffer.py
Normal file
@ -0,0 +1,119 @@
|
||||
"""Кольцевой буфер логов + fan-out в SSE-очереди подписчиков.
|
||||
|
||||
Подключается через RingBufferHandler в main.py (install_log_handler()).
|
||||
Потокобезопасен: emit() вызывается в любом потоке, asyncio-очереди
|
||||
обновляются через loop.call_soon_threadsafe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
MAX_HISTORY = 600
|
||||
_lock = threading.Lock()
|
||||
_ring: collections.deque[dict[str, Any]] = collections.deque(maxlen=MAX_HISTORY)
|
||||
_subscribers: list[asyncio.Queue[dict[str, Any]]] = []
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
def set_event_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
global _loop
|
||||
_loop = loop
|
||||
|
||||
|
||||
def get_history() -> list[dict[str, Any]]:
|
||||
with _lock:
|
||||
return list(_ring)
|
||||
|
||||
|
||||
def subscribe() -> asyncio.Queue[dict[str, Any]]:
|
||||
q: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=300)
|
||||
with _lock:
|
||||
_subscribers.append(q)
|
||||
return q
|
||||
|
||||
|
||||
def unsubscribe(q: asyncio.Queue[dict[str, Any]]) -> None:
|
||||
with _lock:
|
||||
try:
|
||||
_subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def _push_to_subscriber(q: asyncio.Queue[dict[str, Any]], entry: dict[str, Any]) -> None:
|
||||
try:
|
||||
q.put_nowait(entry)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
|
||||
class RingBufferHandler(logging.Handler):
|
||||
"""Logging handler — пишет в кольцевой буфер и раздаёт SSE-подписчикам."""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
msg = self.format(record)
|
||||
ts = datetime.fromtimestamp(record.created, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
entry: dict[str, Any] = {
|
||||
"ts": ts,
|
||||
"level": record.levelname,
|
||||
"name": record.name,
|
||||
"msg": msg,
|
||||
}
|
||||
with _lock:
|
||||
_ring.append(entry)
|
||||
subs = list(_subscribers)
|
||||
if subs and _loop is not None and _loop.is_running():
|
||||
for q in subs:
|
||||
_loop.call_soon_threadsafe(_push_to_subscriber, q, entry)
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
def install_log_handler(
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
log_file: str = "",
|
||||
) -> None:
|
||||
"""Вызывается один раз при старте: регистрирует handler на корневом логгере."""
|
||||
set_event_loop(loop)
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
root = logging.getLogger()
|
||||
|
||||
# Кольцевой буфер (SSE-страница логов)
|
||||
if not any(isinstance(h, RingBufferHandler) for h in root.handlers):
|
||||
ring_h = RingBufferHandler()
|
||||
ring_h.setFormatter(logging.Formatter("%(name)s %(message)s"))
|
||||
ring_h.setLevel(logging.DEBUG)
|
||||
root.addHandler(ring_h)
|
||||
|
||||
# Файл с ротацией (если задан LOG_FILE)
|
||||
if log_file.strip():
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
log_path = log_file.strip()
|
||||
os.makedirs(os.path.dirname(log_path) if os.path.dirname(log_path) else ".", exist_ok=True)
|
||||
if not any(isinstance(h, RotatingFileHandler) for h in root.handlers):
|
||||
file_h = RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=10 * 1024 * 1024, # 10 МБ
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_h.setFormatter(fmt)
|
||||
file_h.setLevel(logging.DEBUG)
|
||||
root.addHandler(file_h)
|
||||
logging.getLogger("onguard24").info(
|
||||
"file logging enabled: %s (rotate 10MB×5)", log_path
|
||||
)
|
||||
@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@ -5,16 +6,27 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import HTMLResponse, Response
|
||||
|
||||
from onguard24 import __version__ as app_version
|
||||
from onguard24.config import get_settings
|
||||
from onguard24.db import create_pool
|
||||
from onguard24.domain.events import InMemoryEventBus
|
||||
from onguard24.ingress import grafana as grafana_ingress
|
||||
from onguard24.modules import contacts, schedules, statusboard
|
||||
from onguard24.log_buffer import install_log_handler
|
||||
from onguard24.modules.registry import MODULE_MOUNTS, register_module_events
|
||||
from onguard24.root_html import render_root_page
|
||||
from onguard24.status_snapshot import build as build_status
|
||||
from onguard24 import __version__ as app_version
|
||||
from onguard24.ui_logs import router as logs_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
# Приглушаем шумные низкоуровневые библиотеки
|
||||
for _noisy in (
|
||||
"httpx",
|
||||
"httpcore",
|
||||
"asyncio",
|
||||
"uvicorn.access",
|
||||
"uvicorn.error",
|
||||
):
|
||||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||
log = logging.getLogger("onguard24")
|
||||
|
||||
|
||||
@ -31,10 +43,14 @@ def parse_addr(http_addr: str) -> tuple[str, int]:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
install_log_handler(asyncio.get_event_loop(), log_file=settings.log_file)
|
||||
pool = await create_pool(settings)
|
||||
bus = InMemoryEventBus()
|
||||
register_module_events(bus, pool)
|
||||
app.state.pool = pool
|
||||
app.state.settings = settings
|
||||
log.info("onGuard24 started, db=%s", "ok" if pool else "disabled")
|
||||
app.state.event_bus = bus
|
||||
log.info("onGuard24 started v%s, db=%s", app_version, "ok" if pool else "disabled")
|
||||
yield
|
||||
if pool:
|
||||
await pool.close()
|
||||
@ -78,9 +94,11 @@ def create_app() -> FastAPI:
|
||||
return await build_status(request)
|
||||
|
||||
app.include_router(grafana_ingress.router, prefix="/api/v1")
|
||||
app.include_router(schedules.router, prefix="/api/v1/modules/schedules")
|
||||
app.include_router(contacts.router, prefix="/api/v1/modules/contacts")
|
||||
app.include_router(statusboard.router, prefix="/api/v1/modules/statusboard")
|
||||
app.include_router(logs_router)
|
||||
for mount in MODULE_MOUNTS:
|
||||
app.include_router(mount.router, prefix=mount.url_prefix)
|
||||
if mount.ui_router is not None:
|
||||
app.include_router(mount.ui_router, prefix=f"/ui/modules/{mount.slug}")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
457
onguard24/modules/alerts.py
Normal file
457
onguard24/modules/alerts.py
Normal file
@ -0,0 +1,457 @@
|
||||
"""Учёт входящих алертов (отдельно от инцидентов): firing → acknowledged → resolved."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
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 EventBus
|
||||
from onguard24.modules.ui_support import wrap_module_html_page
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["module-alerts"])
|
||||
ui_router = APIRouter(tags=["web-alerts"], include_in_schema=False)
|
||||
|
||||
_VALID_STATUS = frozenset({"firing", "acknowledged", "resolved", "silenced"})
|
||||
|
||||
|
||||
def register_events(_bus: EventBus, _pool: asyncpg.Pool | None = None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class AckBody(BaseModel):
|
||||
by_user: str | None = Field(default=None, max_length=200, description="Кто подтвердил")
|
||||
|
||||
|
||||
class ResolveBody(BaseModel):
|
||||
by_user: str | None = Field(default=None, max_length=200)
|
||||
|
||||
|
||||
def _row_to_item(r: asyncpg.Record) -> dict:
|
||||
tid = r.get("team_id")
|
||||
out = {
|
||||
"id": str(r["id"]),
|
||||
"ingress_event_id": str(r["ingress_event_id"]),
|
||||
"status": r["status"],
|
||||
"title": r["title"],
|
||||
"severity": r["severity"],
|
||||
"source": r["source"],
|
||||
"grafana_org_slug": r["grafana_org_slug"],
|
||||
"service_name": r["service_name"],
|
||||
"labels": r["labels"] if isinstance(r["labels"], dict) else {},
|
||||
"fingerprint": r["fingerprint"],
|
||||
"team_id": str(tid) if tid is not None else None,
|
||||
"team_slug": r.get("team_slug"),
|
||||
"team_name": r.get("team_name"),
|
||||
"acknowledged_at": r["acknowledged_at"].isoformat() if r["acknowledged_at"] else None,
|
||||
"acknowledged_by": r["acknowledged_by"],
|
||||
"resolved_at": r["resolved_at"].isoformat() if r["resolved_at"] else None,
|
||||
"resolved_by": r["resolved_by"],
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||
"updated_at": r["updated_at"].isoformat() if r["updated_at"] else None,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
_ALERT_SELECT = """
|
||||
SELECT a.*, t.slug AS team_slug, t.name AS team_name
|
||||
FROM irm_alerts a
|
||||
LEFT JOIN teams t ON t.id = a.team_id
|
||||
"""
|
||||
|
||||
|
||||
async def _fetch_alert_with_team(conn: asyncpg.Connection, alert_id: UUID) -> asyncpg.Record | None:
|
||||
return await conn.fetchrow(
|
||||
f"{_ALERT_SELECT} WHERE a.id = $1::uuid",
|
||||
alert_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_alerts_api(
|
||||
pool: asyncpg.Pool | None = Depends(get_pool),
|
||||
status: str | None = None,
|
||||
team_id: UUID | None = None,
|
||||
limit: int = 100,
|
||||
):
|
||||
if pool is None:
|
||||
return {"items": [], "database": "disabled"}
|
||||
limit = min(max(limit, 1), 200)
|
||||
st = (status or "").strip().lower()
|
||||
if st and st not in _VALID_STATUS:
|
||||
raise HTTPException(status_code=400, detail="invalid status filter")
|
||||
async with pool.acquire() as conn:
|
||||
if st and team_id is not None:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
{_ALERT_SELECT}
|
||||
WHERE a.status = $1 AND a.team_id = $2::uuid
|
||||
ORDER BY a.created_at DESC LIMIT $3
|
||||
""",
|
||||
st,
|
||||
team_id,
|
||||
limit,
|
||||
)
|
||||
elif st:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
{_ALERT_SELECT}
|
||||
WHERE a.status = $1
|
||||
ORDER BY a.created_at DESC LIMIT $2
|
||||
""",
|
||||
st,
|
||||
limit,
|
||||
)
|
||||
elif team_id is not None:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
{_ALERT_SELECT}
|
||||
WHERE a.team_id = $1::uuid
|
||||
ORDER BY a.created_at DESC LIMIT $2
|
||||
""",
|
||||
team_id,
|
||||
limit,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
{_ALERT_SELECT}
|
||||
ORDER BY a.created_at DESC LIMIT $1
|
||||
""",
|
||||
limit,
|
||||
)
|
||||
return {"items": [_row_to_item(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/{alert_id}")
|
||||
async def get_alert_api(alert_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 _fetch_alert_with_team(conn, alert_id)
|
||||
raw = None
|
||||
if row and row.get("ingress_event_id"):
|
||||
raw = await conn.fetchrow(
|
||||
"SELECT id, body, received_at FROM ingress_events WHERE id = $1::uuid",
|
||||
row["ingress_event_id"],
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
out = _row_to_item(row)
|
||||
if raw:
|
||||
out["raw_received_at"] = raw["received_at"].isoformat() if raw["received_at"] else None
|
||||
body = raw["body"]
|
||||
out["raw_body"] = dict(body) if hasattr(body, "keys") else body
|
||||
else:
|
||||
out["raw_received_at"] = None
|
||||
out["raw_body"] = None
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/{alert_id}/acknowledge", status_code=200)
|
||||
async def acknowledge_alert_api(
|
||||
alert_id: UUID,
|
||||
body: AckBody,
|
||||
pool: asyncpg.Pool | None = Depends(get_pool),
|
||||
):
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="database disabled")
|
||||
who = (body.by_user or "").strip() or None
|
||||
async with pool.acquire() as conn:
|
||||
uid = await conn.fetchval(
|
||||
"""
|
||||
UPDATE irm_alerts SET
|
||||
status = 'acknowledged',
|
||||
acknowledged_at = now(),
|
||||
acknowledged_by = COALESCE($2, acknowledged_by),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid AND status = 'firing'
|
||||
RETURNING id
|
||||
""",
|
||||
alert_id,
|
||||
who,
|
||||
)
|
||||
if not uid:
|
||||
raise HTTPException(status_code=409, detail="alert not in firing state or not found")
|
||||
row = await _fetch_alert_with_team(conn, alert_id)
|
||||
assert row is not None
|
||||
return _row_to_item(row)
|
||||
|
||||
|
||||
@router.patch("/{alert_id}/resolve", status_code=200)
|
||||
async def resolve_alert_api(
|
||||
alert_id: UUID,
|
||||
body: ResolveBody,
|
||||
pool: asyncpg.Pool | None = Depends(get_pool),
|
||||
):
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="database disabled")
|
||||
who = (body.by_user or "").strip() or None
|
||||
async with pool.acquire() as conn:
|
||||
uid = await conn.fetchval(
|
||||
"""
|
||||
UPDATE irm_alerts SET
|
||||
status = 'resolved',
|
||||
resolved_at = now(),
|
||||
resolved_by = COALESCE($2, resolved_by),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid AND status IN ('firing', 'acknowledged')
|
||||
RETURNING id
|
||||
""",
|
||||
alert_id,
|
||||
who,
|
||||
)
|
||||
if not uid:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="alert cannot be resolved from current state or not found",
|
||||
)
|
||||
row = await _fetch_alert_with_team(conn, alert_id)
|
||||
assert row is not None
|
||||
return _row_to_item(row)
|
||||
|
||||
|
||||
_SYNC_BTN_STYLE = """
|
||||
<script>
|
||||
function ogAck(aid){fetch('/api/v1/modules/alerts/'+aid+'/acknowledge',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({})}).then(r=>{if(r.ok)location.reload();else r.text().then(t=>alert('Ошибка '+r.status+': '+t.slice(0,200)));});}
|
||||
function ogRes(aid){fetch('/api/v1/modules/alerts/'+aid+'/resolve',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({})}).then(r=>{if(r.ok)location.reload();else r.text().then(t=>alert('Ошибка '+r.status+': '+t.slice(0,200)));});}
|
||||
function ogInc(aid,title){var t=prompt('Заголовок инцидента',title||'');if(t===null)return;fetch('/api/v1/modules/incidents/',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:t,alert_ids:[aid]})}).then(r=>{if(r.ok)r.json().then(j=>location.href='/ui/modules/incidents/'+j.id);else r.text().then(x=>alert('Ошибка '+r.status+': '+x.slice(0,200)));});}
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
@ui_router.get("/", response_class=HTMLResponse)
|
||||
async def alerts_ui_list(request: Request):
|
||||
pool = get_pool(request)
|
||||
body = ""
|
||||
filter_tid: UUID | None = None
|
||||
raw_team = (request.query_params.get("team_id") or "").strip()
|
||||
if raw_team:
|
||||
try:
|
||||
filter_tid = UUID(raw_team)
|
||||
except ValueError:
|
||||
filter_tid = None
|
||||
if pool is None:
|
||||
body = "<p>База не настроена.</p>"
|
||||
else:
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
teams_opts = await conn.fetch(
|
||||
"SELECT id, slug, name FROM teams ORDER BY name"
|
||||
)
|
||||
if filter_tid is not None:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT a.id, a.status, a.title, a.severity, a.grafana_org_slug,
|
||||
a.service_name, a.created_at, a.fingerprint,
|
||||
t.slug AS team_slug, t.name AS team_name, a.team_id
|
||||
FROM irm_alerts a
|
||||
LEFT JOIN teams t ON t.id = a.team_id
|
||||
WHERE a.team_id = $1::uuid
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 150
|
||||
""",
|
||||
filter_tid,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT a.id, a.status, a.title, a.severity, a.grafana_org_slug,
|
||||
a.service_name, a.created_at, a.fingerprint,
|
||||
t.slug AS team_slug, t.name AS team_name, a.team_id
|
||||
FROM irm_alerts a
|
||||
LEFT JOIN teams t ON t.id = a.team_id
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 150
|
||||
"""
|
||||
)
|
||||
if not rows:
|
||||
body = "<p>Пока нет алертов. События появляются после вебхука Grafana.</p>"
|
||||
else:
|
||||
trs = []
|
||||
for r in rows:
|
||||
aid = str(r["id"])
|
||||
ts = r.get("team_slug")
|
||||
tn = r.get("team_name")
|
||||
tid = r.get("team_id")
|
||||
team_cell = "—"
|
||||
if tid and ts:
|
||||
team_cell = (
|
||||
f"<a href=\"/ui/modules/teams/{html.escape(str(tid), quote=True)}\">"
|
||||
f"{html.escape(ts)}</a>"
|
||||
)
|
||||
elif tn:
|
||||
team_cell = html.escape(str(tn))
|
||||
trs.append(
|
||||
"<tr>"
|
||||
f"<td>{html.escape(r['status'])}</td>"
|
||||
f"<td><a href=\"/ui/modules/alerts/{html.escape(aid, quote=True)}\">"
|
||||
f"{html.escape(aid[:8])}…</a></td>"
|
||||
f"<td>{html.escape((r['title'] or '—')[:200])}</td>"
|
||||
f"<td>{html.escape(r['severity'])}</td>"
|
||||
f"<td>{team_cell}</td>"
|
||||
f"<td>{html.escape(str(r['grafana_org_slug'] or '—'))}</td>"
|
||||
f"<td>{html.escape(str(r['service_name'] or '—'))}</td>"
|
||||
f"<td>{html.escape(r['created_at'].isoformat() if r['created_at'] else '—')}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
opts = ["<option value=''>Все команды</option>"]
|
||||
for t in teams_opts:
|
||||
tid = str(t["id"])
|
||||
sel = " selected" if filter_tid and str(filter_tid) == tid else ""
|
||||
opts.append(
|
||||
f"<option value='{html.escape(tid, quote=True)}'{sel}>"
|
||||
f"{html.escape(t['slug'])} — {html.escape(t['name'])}</option>"
|
||||
)
|
||||
filter_form = (
|
||||
"<form method='get' class='og-filter-bar' style='margin-bottom:1rem'>"
|
||||
"<label>Команда <select name='team_id' onchange='this.form.submit()'>"
|
||||
+ "".join(opts)
|
||||
+ "</select></label></form>"
|
||||
)
|
||||
body = (
|
||||
"<p class='gc-muted'>Алерт — запись о входящем уведомлении. "
|
||||
"<strong>Инцидент</strong> создаётся вручную (из карточки алерта или раздела «Инциденты») "
|
||||
"и может ссылаться на один или несколько алертов. Команда назначается по "
|
||||
"<a href=\"/ui/modules/teams/\">правилам лейблов</a>.</p>"
|
||||
+ filter_form
|
||||
+ "<table class='irm-table'><thead><tr><th>Статус</th><th>ID</th><th>Заголовок</th>"
|
||||
"<th>Важность</th><th>Команда</th><th>Grafana slug</th><th>Сервис</th><th>Создан</th></tr></thead><tbody>"
|
||||
+ "".join(trs)
|
||||
+ "</tbody></table>"
|
||||
)
|
||||
except Exception as e:
|
||||
body = f"<p class='module-err'>{html.escape(str(e))}</p>"
|
||||
page = f"<h1>Алерты</h1>{body}{_SYNC_BTN_STYLE}"
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Алерты — onGuard24",
|
||||
current_slug="alerts",
|
||||
main_inner_html=page,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ui_router.get("/{alert_id:uuid}", response_class=HTMLResponse)
|
||||
async def alerts_ui_detail(request: Request, alert_id: UUID):
|
||||
pool = get_pool(request)
|
||||
if pool is None:
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Алерт — onGuard24",
|
||||
current_slug="alerts",
|
||||
main_inner_html="<h1>Алерт</h1><p>База не настроена.</p>",
|
||||
)
|
||||
)
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
row = await _fetch_alert_with_team(conn, alert_id)
|
||||
raw = None
|
||||
if row and row.get("ingress_event_id"):
|
||||
raw = await conn.fetchrow(
|
||||
"SELECT body, received_at FROM ingress_events WHERE id = $1::uuid",
|
||||
row["ingress_event_id"],
|
||||
)
|
||||
except Exception as e:
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Алерт — onGuard24",
|
||||
current_slug="alerts",
|
||||
main_inner_html=f"<h1>Алерт</h1><p class='module-err'>{html.escape(str(e))}</p>",
|
||||
)
|
||||
)
|
||||
if not row:
|
||||
inner = "<p>Не найдено.</p>"
|
||||
else:
|
||||
aid = str(row["id"])
|
||||
st = row["status"]
|
||||
title_js = json.dumps(row["title"] or "")
|
||||
btns = []
|
||||
if st == "firing":
|
||||
btns.append(
|
||||
f"<button type='button' class='og-btn og-btn-primary' "
|
||||
f"onclick=\"ogAck('{html.escape(aid, quote=True)}')\">Подтвердить (Ack)</button>"
|
||||
)
|
||||
if st in ("firing", "acknowledged"):
|
||||
btns.append(
|
||||
f"<button type='button' class='og-btn' "
|
||||
f"onclick=\"ogRes('{html.escape(aid, quote=True)}')\">Resolve</button>"
|
||||
)
|
||||
btns.append(
|
||||
f"<button type='button' class='og-btn' "
|
||||
f"onclick=\"ogInc('{html.escape(aid, quote=True)}',{title_js})\">"
|
||||
"Создать инцидент</button>"
|
||||
)
|
||||
lab = row["labels"]
|
||||
lab_s = json.dumps(dict(lab), ensure_ascii=False, indent=2) if isinstance(lab, dict) else "{}"
|
||||
raw_pre = ""
|
||||
if raw:
|
||||
b = raw["body"]
|
||||
pretty = json.dumps(dict(b), ensure_ascii=False, indent=2) if hasattr(b, "keys") else str(b)
|
||||
if len(pretty) > 14000:
|
||||
pretty = pretty[:14000] + "\n…"
|
||||
raw_pre = (
|
||||
"<h2 style='font-size:1.05rem;margin-top:1rem'>Полное тело вебхука</h2>"
|
||||
f"<pre style='overflow:auto;max-height:26rem;font-size:0.78rem;"
|
||||
f"background:#18181b;color:#e4e4e7;padding:0.75rem;border-radius:8px'>"
|
||||
f"{html.escape(pretty)}</pre>"
|
||||
)
|
||||
team_dd = ""
|
||||
if row.get("team_id") and row.get("team_slug"):
|
||||
team_dd = (
|
||||
f"<dt>Команда</dt><dd><a href=\"/ui/modules/teams/{html.escape(str(row['team_id']), quote=True)}\">"
|
||||
f"{html.escape(row['team_slug'])}</a> ({html.escape(row.get('team_name') or '')})</dd>"
|
||||
)
|
||||
elif row.get("team_id"):
|
||||
team_dd = f"<dt>Команда</dt><dd><code>{html.escape(str(row['team_id']))}</code></dd>"
|
||||
inner = (
|
||||
f"<p><a href=\"/ui/modules/alerts/\">← К списку алертов</a></p>"
|
||||
f"<h1>Алерт</h1><div class='og-sync-bar'>{''.join(btns)}</div>"
|
||||
f"<dl style='display:grid;grid-template-columns:11rem 1fr;gap:0.35rem 1rem;font-size:0.9rem'>"
|
||||
f"<dt>ID</dt><dd><code>{html.escape(aid)}</code></dd>"
|
||||
f"<dt>Статус</dt><dd>{html.escape(st)}</dd>"
|
||||
f"<dt>Заголовок</dt><dd>{html.escape(row['title'] or '—')}</dd>"
|
||||
f"<dt>Важность</dt><dd>{html.escape(row['severity'])}</dd>"
|
||||
f"<dt>Grafana slug</dt><dd>{html.escape(str(row['grafana_org_slug'] or '—'))}</dd>"
|
||||
f"<dt>Сервис</dt><dd>{html.escape(str(row['service_name'] or '—'))}</dd>"
|
||||
+ team_dd
|
||||
+ f"<dt>Fingerprint</dt><dd><code>{html.escape(str(row['fingerprint'] or '—'))}</code></dd>"
|
||||
f"<dt>Labels</dt><dd><pre style='margin:0;font-size:0.8rem'>{html.escape(lab_s)}</pre></dd>"
|
||||
f"</dl>{raw_pre}"
|
||||
)
|
||||
page = f"{inner}{_SYNC_BTN_STYLE}"
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Алерт — onGuard24",
|
||||
current_slug="alerts",
|
||||
main_inner_html=page,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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 irm_alerts")
|
||||
nf = await conn.fetchval(
|
||||
"SELECT count(*)::int FROM irm_alerts WHERE status = 'firing'"
|
||||
)
|
||||
except Exception:
|
||||
return '<p class="module-note">Таблица алертов недоступна (миграция 005?).</p>'
|
||||
return (
|
||||
f'<div class="module-fragment"><p>Алертов в учёте: <strong>{int(n)}</strong> '
|
||||
f'(<strong>{int(nf)}</strong> firing). '
|
||||
f'<a href="/ui/modules/alerts/">Открыть</a></p></div>'
|
||||
)
|
||||
@ -1,7 +1,22 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from onguard24.domain.events import EventBus
|
||||
from onguard24.modules.ui_support import wrap_module_html_page
|
||||
|
||||
router = APIRouter(tags=["module-contacts"])
|
||||
|
||||
ui_router = APIRouter(tags=["web-contacts"], include_in_schema=False)
|
||||
|
||||
|
||||
def register_events(_bus: EventBus, _pool=None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def render_home_fragment(request: Request) -> str:
|
||||
del request
|
||||
return '<div class="module-fragment"><p>Контакты, группы, каналы доставки.</p></div>'
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def contacts_root():
|
||||
@ -10,3 +25,17 @@ async def contacts_root():
|
||||
"status": "stub",
|
||||
"note": "люди, группы, каналы доставки",
|
||||
}
|
||||
|
||||
|
||||
@ui_router.get("/", response_class=HTMLResponse)
|
||||
async def contacts_ui_home(request: Request):
|
||||
del request
|
||||
inner = """<h1>Контакты</h1>
|
||||
<p>Люди, группы, каналы уведомлений.</p>"""
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Контакты — onGuard24",
|
||||
current_slug="contacts",
|
||||
main_inner_html=inner,
|
||||
)
|
||||
)
|
||||
|
||||
220
onguard24/modules/escalations.py
Normal file
220
onguard24/modules/escalations.py
Normal file
@ -0,0 +1,220 @@
|
||||
"""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)
|
||||
|
||||
|
||||
class PolicyPatch(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
enabled: bool | None = None
|
||||
steps: list[dict] | 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 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,
|
||||
}
|
||||
|
||||
|
||||
def _policy_dict(row) -> dict:
|
||||
steps = row["steps"]
|
||||
if isinstance(steps, str):
|
||||
steps = json.loads(steps)
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"name": row["name"],
|
||||
"enabled": row["enabled"],
|
||||
"steps": steps if isinstance(steps, list) else [],
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{policy_id}")
|
||||
async def get_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(
|
||||
"""
|
||||
SELECT id, name, enabled, steps, created_at
|
||||
FROM escalation_policies WHERE id = $1::uuid
|
||||
""",
|
||||
policy_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return _policy_dict(row)
|
||||
|
||||
|
||||
@router.patch("/{policy_id}")
|
||||
async def patch_policy_api(
|
||||
policy_id: UUID,
|
||||
body: PolicyPatch,
|
||||
pool: asyncpg.Pool | None = Depends(get_pool),
|
||||
):
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="database disabled")
|
||||
if body.name is None and body.enabled is None and body.steps is None:
|
||||
raise HTTPException(status_code=400, detail="no fields to update")
|
||||
steps_json = json.dumps(body.steps) if body.steps is not None else None
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE escalation_policies SET
|
||||
name = COALESCE($2, name),
|
||||
enabled = COALESCE($3, enabled),
|
||||
steps = COALESCE($4::jsonb, steps)
|
||||
WHERE id = $1::uuid
|
||||
RETURNING id, name, enabled, steps, created_at
|
||||
""",
|
||||
policy_id,
|
||||
body.name.strip() if body.name is not None else None,
|
||||
body.enabled,
|
||||
steps_json,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return _policy_dict(row)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
)
|
||||
510
onguard24/modules/grafana_catalog.py
Normal file
510
onguard24/modules/grafana_catalog.py
Normal file
@ -0,0 +1,510 @@
|
||||
"""Кэш иерархии Grafana (инстанс → org → папки → правила) через API + БД."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from onguard24.config import get_settings
|
||||
from onguard24.deps import get_pool
|
||||
from onguard24.domain.events import EventBus
|
||||
from onguard24.grafana_sources import iter_grafana_sources
|
||||
from onguard24.integrations import grafana_topology as gt
|
||||
from onguard24.modules.ui_support import wrap_module_html_page
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["module-grafana-catalog"])
|
||||
ui_router = APIRouter(tags=["web-grafana-catalog"], include_in_schema=False)
|
||||
|
||||
|
||||
def register_events(_bus: EventBus, _pool: asyncpg.Pool | None = None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class SyncBody(BaseModel):
|
||||
instance_slug: str | None = Field(
|
||||
default=None,
|
||||
description="Slug из GRAFANA_SOURCES_JSON; пусто — все источники с api_token",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PullOutcome:
|
||||
org_id: int
|
||||
org_name: str
|
||||
folder_rows: list[tuple[str, str, str | None]]
|
||||
rules: list[gt.ParsedRuleRow]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
async def pull_topology(api_url: str, token: str) -> tuple[PullOutcome | None, str | None]:
|
||||
org, oerr = await gt.fetch_org(api_url, token)
|
||||
if oerr or not org:
|
||||
return None, oerr or "no org"
|
||||
oid = org.get("id")
|
||||
if oid is None:
|
||||
return None, "org response without id"
|
||||
oname = str(org.get("name") or "")
|
||||
|
||||
warnings: list[str] = []
|
||||
folders_raw, ferr = await gt.fetch_all_folders(api_url, token)
|
||||
if ferr:
|
||||
warnings.append(ferr)
|
||||
|
||||
ruler_raw, rerr = await gt.fetch_ruler_rules_raw(api_url, token)
|
||||
if rerr:
|
||||
warnings.append(rerr)
|
||||
rules: list[gt.ParsedRuleRow] = []
|
||||
namespaces: set[str] = set()
|
||||
else:
|
||||
rules = gt.parse_ruler_rules(ruler_raw or {})
|
||||
namespaces = {r.namespace_uid for r in rules}
|
||||
|
||||
merged = gt.merge_folder_rows(folders_raw, namespaces)
|
||||
return (
|
||||
PullOutcome(
|
||||
org_id=int(oid),
|
||||
org_name=oname,
|
||||
folder_rows=merged,
|
||||
rules=rules,
|
||||
warnings=warnings,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
async def persist_topology(
|
||||
conn: asyncpg.Connection,
|
||||
instance_slug: str,
|
||||
outcome: PullOutcome,
|
||||
) -> None:
|
||||
oid = outcome.org_id
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM grafana_catalog_rules
|
||||
WHERE instance_slug = $1 AND grafana_org_id = $2
|
||||
""",
|
||||
instance_slug,
|
||||
oid,
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM grafana_catalog_folders
|
||||
WHERE instance_slug = $1 AND grafana_org_id = $2
|
||||
""",
|
||||
instance_slug,
|
||||
oid,
|
||||
)
|
||||
|
||||
for uid, title, parent in outcome.folder_rows:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO grafana_catalog_folders
|
||||
(instance_slug, grafana_org_id, folder_uid, title, parent_uid)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""",
|
||||
instance_slug,
|
||||
oid,
|
||||
uid,
|
||||
title,
|
||||
parent,
|
||||
)
|
||||
|
||||
for r in outcome.rules:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO grafana_catalog_rules (
|
||||
instance_slug, grafana_org_id, namespace_uid, rule_group_name,
|
||||
rule_uid, title, rule_group_interval, labels
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||
""",
|
||||
instance_slug,
|
||||
oid,
|
||||
r.namespace_uid,
|
||||
r.rule_group_name,
|
||||
r.rule_uid,
|
||||
r.title,
|
||||
r.rule_group_interval,
|
||||
json.dumps(r.labels),
|
||||
)
|
||||
|
||||
fc = len(outcome.folder_rows)
|
||||
rc = len(outcome.rules)
|
||||
warn_txt = "; ".join(outcome.warnings) if outcome.warnings else None
|
||||
if warn_txt and len(warn_txt) > 1900:
|
||||
warn_txt = warn_txt[:1900] + "…"
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO grafana_catalog_meta (
|
||||
instance_slug, grafana_org_id, org_name, synced_at,
|
||||
folder_count, rule_count, error_text
|
||||
)
|
||||
VALUES ($1, $2, $3, now(), $4, $5, $6)
|
||||
ON CONFLICT (instance_slug, grafana_org_id) DO UPDATE SET
|
||||
org_name = EXCLUDED.org_name,
|
||||
synced_at = EXCLUDED.synced_at,
|
||||
folder_count = EXCLUDED.folder_count,
|
||||
rule_count = EXCLUDED.rule_count,
|
||||
error_text = EXCLUDED.error_text
|
||||
""",
|
||||
instance_slug,
|
||||
oid,
|
||||
outcome.org_name,
|
||||
fc,
|
||||
rc,
|
||||
warn_txt,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync", status_code=200)
|
||||
async def sync_catalog_api(
|
||||
body: SyncBody,
|
||||
pool: asyncpg.Pool | None = Depends(get_pool),
|
||||
):
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="database disabled")
|
||||
sources = iter_grafana_sources(get_settings())
|
||||
if body.instance_slug:
|
||||
sl = body.instance_slug.strip().lower()
|
||||
sources = [s for s in sources if s.slug == sl]
|
||||
if not sources:
|
||||
raise HTTPException(status_code=404, detail="unknown instance slug")
|
||||
to_run = [s for s in sources if s.api_token.strip()]
|
||||
if not to_run:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="нет источников с api_token; задайте GRAFANA_SOURCES_JSON или GRAFANA_SERVICE_ACCOUNT_TOKEN",
|
||||
)
|
||||
|
||||
results: list[dict] = []
|
||||
for src in to_run:
|
||||
outcome, err = await pull_topology(src.api_url, src.api_token)
|
||||
if err or outcome is None:
|
||||
results.append({"slug": src.slug, "ok": False, "error": err})
|
||||
log.warning("grafana_catalog sync failed %s: %s", src.slug, err)
|
||||
continue
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
await persist_topology(conn, src.slug, outcome)
|
||||
results.append(
|
||||
{
|
||||
"slug": src.slug,
|
||||
"ok": True,
|
||||
"org_id": outcome.org_id,
|
||||
"org_name": outcome.org_name,
|
||||
"folders": len(outcome.folder_rows),
|
||||
"rules": len(outcome.rules),
|
||||
"warnings": outcome.warnings,
|
||||
}
|
||||
)
|
||||
return {"results": results}
|
||||
|
||||
|
||||
@router.get("/meta")
|
||||
async def list_meta_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 instance_slug, grafana_org_id, org_name, synced_at,
|
||||
folder_count, rule_count, error_text
|
||||
FROM grafana_catalog_meta
|
||||
ORDER BY instance_slug, grafana_org_id
|
||||
"""
|
||||
)
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append(
|
||||
{
|
||||
"instance_slug": r["instance_slug"],
|
||||
"grafana_org_id": r["grafana_org_id"],
|
||||
"org_name": r["org_name"],
|
||||
"synced_at": r["synced_at"].isoformat() if r["synced_at"] else None,
|
||||
"folder_count": r["folder_count"],
|
||||
"rule_count": r["rule_count"],
|
||||
"error_text": r["error_text"],
|
||||
}
|
||||
)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
async def _build_catalog_tree_dict(conn: asyncpg.Connection, instance_slug: str) -> dict | None:
|
||||
"""Сборка дерева из БД (общая логика для API и UI)."""
|
||||
slug = instance_slug.strip().lower()
|
||||
meta = await conn.fetchrow(
|
||||
"""
|
||||
SELECT * FROM grafana_catalog_meta
|
||||
WHERE instance_slug = $1 AND grafana_org_id > 0
|
||||
ORDER BY synced_at DESC LIMIT 1
|
||||
""",
|
||||
slug,
|
||||
)
|
||||
if not meta:
|
||||
return None
|
||||
oid = meta["grafana_org_id"]
|
||||
folders = await conn.fetch(
|
||||
"""
|
||||
SELECT folder_uid, title, parent_uid
|
||||
FROM grafana_catalog_folders
|
||||
WHERE instance_slug = $1 AND grafana_org_id = $2
|
||||
ORDER BY title
|
||||
""",
|
||||
slug,
|
||||
oid,
|
||||
)
|
||||
rules = await conn.fetch(
|
||||
"""
|
||||
SELECT namespace_uid, rule_group_name, rule_uid, title,
|
||||
rule_group_interval, labels
|
||||
FROM grafana_catalog_rules
|
||||
WHERE instance_slug = $1 AND grafana_org_id = $2
|
||||
ORDER BY namespace_uid, rule_group_name, title
|
||||
""",
|
||||
slug,
|
||||
oid,
|
||||
)
|
||||
|
||||
by_ns: dict[str, list[dict]] = {}
|
||||
for r in rules:
|
||||
ns = r["namespace_uid"]
|
||||
by_ns.setdefault(ns, []).append(
|
||||
{
|
||||
"rule_uid": r["rule_uid"],
|
||||
"title": r["title"],
|
||||
"rule_group": r["rule_group_name"],
|
||||
"interval": r["rule_group_interval"],
|
||||
"labels": r["labels"] if isinstance(r["labels"], dict) else {},
|
||||
}
|
||||
)
|
||||
|
||||
folder_uids = {f["folder_uid"] for f in folders}
|
||||
orphans = sorted(set(by_ns.keys()) - folder_uids)
|
||||
orphan_blocks = [{"namespace_uid": ns, "rules": by_ns[ns]} for ns in orphans]
|
||||
|
||||
folder_nodes = []
|
||||
for f in folders:
|
||||
uid = f["folder_uid"]
|
||||
folder_nodes.append(
|
||||
{
|
||||
"folder_uid": uid,
|
||||
"title": f["title"],
|
||||
"parent_uid": f["parent_uid"],
|
||||
"rules": by_ns.get(uid, []),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"instance_slug": slug,
|
||||
"grafana_org_id": oid,
|
||||
"org_name": meta["org_name"],
|
||||
"synced_at": meta["synced_at"].isoformat() if meta["synced_at"] else None,
|
||||
"folders": folder_nodes,
|
||||
"orphan_rule_namespaces": orphans,
|
||||
"orphan_rule_groups": orphan_blocks,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tree")
|
||||
async def tree_api(
|
||||
instance_slug: str,
|
||||
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:
|
||||
data = await _build_catalog_tree_dict(conn, instance_slug)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="no catalog for this slug; run POST /sync first")
|
||||
return data
|
||||
|
||||
|
||||
def _rules_subtable_html(rules: list[dict]) -> str:
|
||||
if not rules:
|
||||
return "<p class='gc-muted'>Нет правил в этой группе/папке (по данным Ruler).</p>"
|
||||
rows = []
|
||||
for ru in rules:
|
||||
title = html.escape(str(ru.get("title") or "—"))
|
||||
uid = html.escape(str(ru.get("rule_uid") or "—"))
|
||||
grp = html.escape(str(ru.get("rule_group") or "—"))
|
||||
interval = html.escape(str(ru.get("interval") or "—"))
|
||||
labels = ru.get("labels") or {}
|
||||
lab_s = html.escape(json.dumps(labels, ensure_ascii=False)[:240])
|
||||
if len(json.dumps(labels, ensure_ascii=False)) > 240:
|
||||
lab_s += "…"
|
||||
rows.append(
|
||||
f"<tr><td>{title}</td><td><code>{uid}</code></td>"
|
||||
f"<td>{grp}</td><td>{interval}</td><td><code>{lab_s}</code></td></tr>"
|
||||
)
|
||||
head = "<thead><tr><th>Название правила</th><th>rule_uid</th><th>Группа</th><th>Интервал</th><th>Labels (фрагмент)</th></tr></thead>"
|
||||
return f"<table class='gc-subtable irm-table'>{head}<tbody>{''.join(rows)}</tbody></table>"
|
||||
|
||||
|
||||
def _catalog_tree_html(tree: dict) -> str:
|
||||
"""HTML: папки и правила под ними + «осиротевшие» namespace из Ruler."""
|
||||
parts: list[str] = [
|
||||
"<div class='gc-tree'><h2 style='font-size:1.1rem;margin:0 0 0.5rem'>Папки и правила</h2>"
|
||||
"<p class='gc-muted' style='margin:0 0 0.75rem'>Папки — из API Grafana; правила — из Ruler, привязка по UID папки (namespace). "
|
||||
"Название колонки «Правил» наверху — число всех записей с типом grafana_alert.</p>"
|
||||
]
|
||||
for f in tree.get("folders") or []:
|
||||
uid = html.escape(str(f.get("folder_uid") or ""))
|
||||
title = html.escape(str(f.get("title") or "—"))
|
||||
parent = f.get("parent_uid")
|
||||
parent_s = html.escape(str(parent)) if parent else "—"
|
||||
rules: list = f.get("rules") or []
|
||||
n = len(rules)
|
||||
summ = (
|
||||
f"<strong>{title}</strong> "
|
||||
f"<span class='gc-muted'>· UID папки <code>{uid}</code> · родитель <code>{parent_s}</code> · правил: {n}</span>"
|
||||
)
|
||||
parts.append(
|
||||
f"<details class='gc-folder' open><summary>{summ}</summary>{_rules_subtable_html(rules)}</details>"
|
||||
)
|
||||
for block in tree.get("orphan_rule_groups") or []:
|
||||
ns = block.get("namespace_uid")
|
||||
rules = block.get("rules") or []
|
||||
ns_e = html.escape(str(ns))
|
||||
parts.append(
|
||||
f"<div class='gc-orphan'><strong>Namespace Ruler без папки в API</strong> "
|
||||
f"(<code>{ns_e}</code>) — часто системная группа. Правил: {len(rules)}."
|
||||
f"{_rules_subtable_html(rules)}</div>"
|
||||
)
|
||||
parts.append("</div>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
_SYNC_SCRIPT = """
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById('og-sync-btn');
|
||||
var st = document.getElementById('og-sync-status');
|
||||
if (!btn || !st) return;
|
||||
btn.addEventListener('click', function () {
|
||||
st.textContent = 'Синхронизация…';
|
||||
btn.disabled = true;
|
||||
fetch('/api/v1/modules/grafana-catalog/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}'
|
||||
}).then(function (r) {
|
||||
return r.text().then(function (t) {
|
||||
if (!r.ok) {
|
||||
st.textContent = 'Ошибка HTTP ' + r.status + ': ' + (t.slice(0, 400) || r.statusText);
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
st.textContent = 'Готово, обновляем страницу…';
|
||||
location.reload();
|
||||
});
|
||||
}).catch(function (e) {
|
||||
st.textContent = 'Сбой сети: ' + e;
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
@ui_router.get("/", response_class=HTMLResponse)
|
||||
async def grafana_catalog_ui(request: Request):
|
||||
pool = get_pool(request)
|
||||
inner = ""
|
||||
tree_html = ""
|
||||
if pool is None:
|
||||
inner = "<p>База не настроена.</p>"
|
||||
else:
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT instance_slug, org_name, synced_at, folder_count, rule_count, error_text
|
||||
FROM grafana_catalog_meta
|
||||
ORDER BY instance_slug
|
||||
"""
|
||||
)
|
||||
sync_bar = """<div class="og-sync-bar">
|
||||
<button type="button" class="og-btn og-btn-primary" id="og-sync-btn">Синхронизировать с Grafana</button>
|
||||
<span class="og-sync-status" id="og-sync-status"></span>
|
||||
</div>"""
|
||||
if not rows:
|
||||
inner = (
|
||||
sync_bar
|
||||
+ "<p>Каталог пуст — нажмите кнопку выше (нужны <code>GRAFANA_URL</code> и токен в .env).</p>"
|
||||
)
|
||||
else:
|
||||
inner = (
|
||||
sync_bar
|
||||
+ "<p class='gc-muted'>Строка ниже — сводка по последней синхронизации. "
|
||||
"Число <strong>Правил</strong> — все правила алертинга (Grafana-managed) из Ruler для этой org.</p>"
|
||||
+ "<table class='irm-table'><thead><tr><th>Slug</th><th>Org</th><th>Синхр.</th>"
|
||||
"<th>Папок (API)</th><th>Правил (Ruler)</th><th>Ошибка</th></tr></thead><tbody>"
|
||||
)
|
||||
seen_slugs: set[str] = set()
|
||||
for r in rows:
|
||||
err = html.escape(str(r["error_text"] or "—"))[:200]
|
||||
st = r["synced_at"].isoformat() if r["synced_at"] else "—"
|
||||
inner += (
|
||||
f"<tr><td>{html.escape(r['instance_slug'])}</td>"
|
||||
f"<td>{html.escape(str(r['org_name']))}</td>"
|
||||
f"<td>{html.escape(st)}</td>"
|
||||
f"<td>{r['folder_count']}</td><td>{r['rule_count']}</td>"
|
||||
f"<td>{err}</td></tr>"
|
||||
)
|
||||
slug = str(r["instance_slug"])
|
||||
if slug not in seen_slugs:
|
||||
seen_slugs.add(slug)
|
||||
inner += "</tbody></table>"
|
||||
async with pool.acquire() as conn:
|
||||
for slug in sorted(seen_slugs):
|
||||
tree = await _build_catalog_tree_dict(conn, slug)
|
||||
if tree:
|
||||
tree_html += (
|
||||
f"<h2 style='font-size:1.15rem;margin:1.25rem 0 0.35rem'>Инстанс "
|
||||
f"<code>{html.escape(slug)}</code> · {html.escape(str(tree['org_name']))}</h2>"
|
||||
+ _catalog_tree_html(tree)
|
||||
)
|
||||
except Exception as e:
|
||||
inner = f"<p class='module-err'>{html.escape(str(e))}</p>"
|
||||
page = f"""<h1>Каталог Grafana</h1>
|
||||
<p>Иерархия: инстанс (slug) → организация → папки (имена и UID) → правила (название, UID, группа, интервал, labels).</p>
|
||||
{inner}
|
||||
{tree_html}
|
||||
<p><small>API: <code>POST /api/v1/modules/grafana-catalog/sync</code>, <code>GET …/tree?instance_slug=…</code></small></p>
|
||||
{_SYNC_SCRIPT}"""
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Каталог Grafana — onGuard24",
|
||||
current_slug="grafana-catalog",
|
||||
main_inner_html=page,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def render_home_fragment(request: Request) -> str:
|
||||
pool = get_pool(request)
|
||||
if pool is None:
|
||||
return '<p class="module-note">Нужна БД для каталога Grafana.</p>'
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
n = await conn.fetchval("SELECT count(*)::int FROM grafana_catalog_meta WHERE grafana_org_id >= 0")
|
||||
last = await conn.fetchrow(
|
||||
"SELECT max(synced_at) AS m FROM grafana_catalog_meta WHERE grafana_org_id >= 0"
|
||||
)
|
||||
except Exception:
|
||||
return '<p class="module-note">Таблицы каталога недоступны (миграции?).</p>'
|
||||
ts = last["m"].isoformat() if last and last["m"] else "никогда"
|
||||
return (
|
||||
f'<div class="module-fragment"><p>Источников с синхронизацией: <strong>{int(n)}</strong>. '
|
||||
f"Последняя синхр.: <strong>{html.escape(ts)}</strong></p></div>"
|
||||
)
|
||||
425
onguard24/modules/incidents.py
Normal file
425
onguard24/modules/incidents.py
Normal file
@ -0,0 +1,425 @@
|
||||
"""IRM: инциденты — учёт сбоев, связь с сырым ingress и событием alert.received."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
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.config import get_settings
|
||||
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)
|
||||
alert_ids: list[UUID] = Field(default_factory=list, description="Привязка к irm_alerts")
|
||||
|
||||
|
||||
class IncidentPatch(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
status: str | None = Field(default=None, max_length=64)
|
||||
severity: str | None = Field(default=None, 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 get_settings().auto_incident_from_alert:
|
||||
return
|
||||
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,
|
||||
grafana_org_slug, service_name
|
||||
)
|
||||
VALUES ($1, 'open', $2, 'grafana', $3::uuid, $4, $5)
|
||||
""",
|
||||
title,
|
||||
sev,
|
||||
ev.raw_payload_ref,
|
||||
ev.grafana_org_slug,
|
||||
ev.service_name,
|
||||
)
|
||||
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,
|
||||
grafana_org_slug: str | None = None,
|
||||
service_name: str | None = None,
|
||||
):
|
||||
if pool is None:
|
||||
return {"items": [], "database": "disabled"}
|
||||
limit = min(max(limit, 1), 200)
|
||||
conditions: list[str] = []
|
||||
args: list = []
|
||||
if grafana_org_slug and grafana_org_slug.strip():
|
||||
args.append(grafana_org_slug.strip())
|
||||
conditions.append(f"grafana_org_slug = ${len(args)}")
|
||||
if service_name and service_name.strip():
|
||||
args.append(service_name.strip())
|
||||
conditions.append(f"service_name = ${len(args)}")
|
||||
where_sql = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
args.append(limit)
|
||||
lim_ph = f"${len(args)}"
|
||||
q = f"""
|
||||
SELECT id, title, status, severity, source, ingress_event_id, created_at, updated_at,
|
||||
grafana_org_slug, service_name
|
||||
FROM incidents
|
||||
{where_sql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT {lim_ph}
|
||||
"""
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(q, *args)
|
||||
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,
|
||||
"updated_at": r["updated_at"].isoformat() if r.get("updated_at") else None,
|
||||
"grafana_org_slug": r.get("grafana_org_slug"),
|
||||
"service_name": r.get("service_name"),
|
||||
}
|
||||
)
|
||||
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:
|
||||
async with conn.transaction():
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO incidents (title, status, severity, source, grafana_org_slug, service_name)
|
||||
VALUES ($1, $2, $3, 'manual', NULL, NULL)
|
||||
RETURNING id, title, status, severity, source, ingress_event_id, created_at, updated_at,
|
||||
grafana_org_slug, service_name
|
||||
""",
|
||||
body.title.strip(),
|
||||
body.status,
|
||||
body.severity,
|
||||
)
|
||||
iid = row["id"]
|
||||
for aid in body.alert_ids[:50]:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO incident_alert_links (incident_id, alert_id)
|
||||
VALUES ($1::uuid, $2::uuid)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
iid,
|
||||
aid,
|
||||
)
|
||||
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,
|
||||
"updated_at": row["updated_at"].isoformat() if row.get("updated_at") else None,
|
||||
"grafana_org_slug": row.get("grafana_org_slug"),
|
||||
"service_name": row.get("service_name"),
|
||||
}
|
||||
|
||||
|
||||
def _incident_row_dict(row) -> dict:
|
||||
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,
|
||||
"updated_at": row["updated_at"].isoformat() if row.get("updated_at") else None,
|
||||
"grafana_org_slug": row.get("grafana_org_slug"),
|
||||
"service_name": row.get("service_name"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{incident_id}/tasks")
|
||||
async def list_incident_tasks_api(
|
||||
incident_id: UUID,
|
||||
pool: asyncpg.Pool | None = Depends(get_pool),
|
||||
limit: int = 100,
|
||||
):
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="database disabled")
|
||||
limit = min(max(limit, 1), 200)
|
||||
async with pool.acquire() as conn:
|
||||
exists = await conn.fetchval("SELECT 1 FROM incidents WHERE id = $1::uuid", incident_id)
|
||||
if not exists:
|
||||
raise HTTPException(status_code=404, detail="incident not found")
|
||||
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,
|
||||
)
|
||||
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 {"incident_id": str(incident_id), "items": items}
|
||||
|
||||
|
||||
@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, updated_at,
|
||||
grafana_org_slug, service_name
|
||||
FROM incidents WHERE id = $1::uuid
|
||||
""",
|
||||
incident_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return _incident_row_dict(row)
|
||||
|
||||
|
||||
@router.patch("/{incident_id}")
|
||||
async def patch_incident_api(
|
||||
incident_id: UUID,
|
||||
body: IncidentPatch,
|
||||
pool: asyncpg.Pool | None = Depends(get_pool),
|
||||
):
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="database disabled")
|
||||
if body.title is None and body.status is None and body.severity is None:
|
||||
raise HTTPException(status_code=400, detail="no fields to update")
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE incidents SET
|
||||
title = COALESCE($2, title),
|
||||
status = COALESCE($3, status),
|
||||
severity = COALESCE($4, severity),
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
RETURNING id, title, status, severity, source, ingress_event_id, created_at, updated_at,
|
||||
grafana_org_slug, service_name
|
||||
""",
|
||||
incident_id,
|
||||
body.title.strip() if body.title is not None else None,
|
||||
body.status,
|
||||
body.severity,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return _incident_row_dict(row)
|
||||
|
||||
|
||||
def _title_cell(raw: object) -> str:
|
||||
t = (str(raw).strip() if raw is not None else "") or "—"
|
||||
return html.escape(t)
|
||||
|
||||
|
||||
@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, grafana_org_slug, service_name
|
||||
FROM incidents
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
"""
|
||||
)
|
||||
for r in rows:
|
||||
iid = r["id"]
|
||||
iid_s = str(iid)
|
||||
org = html.escape(str(r["grafana_org_slug"] or "—"))
|
||||
svc = html.escape(str(r["service_name"] or "—"))
|
||||
ca = r["created_at"].isoformat() if r["created_at"] else "—"
|
||||
rows_html += (
|
||||
"<tr>"
|
||||
f"<td><a href=\"/ui/modules/incidents/{html.escape(iid_s, quote=True)}\">"
|
||||
f"{html.escape(iid_s[:8])}…</a></td>"
|
||||
f"<td>{_title_cell(r['title'])}</td>"
|
||||
f"<td>{html.escape(r['status'])}</td>"
|
||||
f"<td>{html.escape(r['severity'])}</td>"
|
||||
f"<td>{html.escape(r['source'])}</td>"
|
||||
f"<td>{org}</td>"
|
||||
f"<td>{svc}</td>"
|
||||
f"<td>{html.escape(ca)}</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><th>Grafana slug</th><th>Сервис</th><th>Создан</th></tr></thead>
|
||||
<tbody>{rows_html or '<tr><td colspan="8">Пока нет записей</td></tr>'}</tbody>
|
||||
</table>
|
||||
<p><small>Сначала вебхук создаёт <a href="/ui/modules/alerts/">алерт</a> (учёт, Ack/Resolve). Инцидент — отдельная сущность: создаётся вручную или из карточки алерта, к нему можно привязать один или несколько алертов. Пустой заголовок в списке — часто тестовый JSON без полей правила.</small></p>"""
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Инциденты — onGuard24",
|
||||
current_slug="incidents",
|
||||
main_inner_html=inner,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ui_router.get("/{incident_id:uuid}", response_class=HTMLResponse)
|
||||
async def incident_detail_ui(request: Request, incident_id: UUID):
|
||||
pool = get_pool(request)
|
||||
if pool is None:
|
||||
body = "<p>База данных не настроена.</p>"
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Инцидент — onGuard24",
|
||||
current_slug="incidents",
|
||||
main_inner_html=f"<h1>Инцидент</h1>{body}",
|
||||
)
|
||||
)
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, title, status, severity, source, ingress_event_id, created_at, updated_at,
|
||||
grafana_org_slug, service_name
|
||||
FROM incidents WHERE id = $1::uuid
|
||||
""",
|
||||
incident_id,
|
||||
)
|
||||
raw_row = None
|
||||
if row and row.get("ingress_event_id"):
|
||||
raw_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, source, received_at, body, org_slug, service_name
|
||||
FROM ingress_events WHERE id = $1::uuid
|
||||
""",
|
||||
row["ingress_event_id"],
|
||||
)
|
||||
except Exception as e:
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Инцидент — onGuard24",
|
||||
current_slug="incidents",
|
||||
main_inner_html=f"<h1>Инцидент</h1><p class='module-err'>{html.escape(str(e))}</p>",
|
||||
)
|
||||
)
|
||||
if not row:
|
||||
body = "<p>Запись не найдена.</p>"
|
||||
else:
|
||||
title = _title_cell(row["title"])
|
||||
ing = row["ingress_event_id"]
|
||||
ing_l = html.escape(str(ing)) if ing else "—"
|
||||
meta = f"""<dl style="display:grid;grid-template-columns:10rem 1fr;gap:0.35rem 1rem;font-size:0.9rem">
|
||||
<dt>ID</dt><dd><code>{html.escape(str(row['id']))}</code></dd>
|
||||
<dt>Заголовок</dt><dd>{title}</dd>
|
||||
<dt>Статус</dt><dd>{html.escape(row['status'])}</dd>
|
||||
<dt>Важность</dt><dd>{html.escape(row['severity'])}</dd>
|
||||
<dt>Источник</dt><dd>{html.escape(row['source'])}</dd>
|
||||
<dt>Grafana slug</dt><dd>{html.escape(str(row['grafana_org_slug'] or '—'))}</dd>
|
||||
<dt>Сервис</dt><dd>{html.escape(str(row['service_name'] or '—'))}</dd>
|
||||
<dt>Создан</dt><dd>{html.escape(row['created_at'].isoformat() if row['created_at'] else '—')}</dd>
|
||||
<dt>Обновлён</dt><dd>{html.escape(row['updated_at'].isoformat() if row.get('updated_at') else '—')}</dd>
|
||||
<dt>ingress_event_id</dt><dd><code>{ing_l}</code></dd>
|
||||
</dl>"""
|
||||
raw_block = ""
|
||||
if raw_row:
|
||||
try:
|
||||
body_obj = raw_row["body"]
|
||||
if hasattr(body_obj, "keys"):
|
||||
pretty = json.dumps(dict(body_obj), ensure_ascii=False, indent=2)
|
||||
else:
|
||||
pretty = str(body_obj)
|
||||
if len(pretty) > 12000:
|
||||
pretty = pretty[:12000] + "\n…"
|
||||
raw_block = (
|
||||
"<h2 style='font-size:1.05rem;margin-top:1.25rem'>Сырой JSON вебхука</h2>"
|
||||
f"<p class='gc-muted'>ingress_events · {html.escape(str(raw_row['received_at']))}</p>"
|
||||
f"<pre style='overflow:auto;max-height:28rem;font-size:0.78rem;background:#18181b;color:#e4e4e7;"
|
||||
f"padding:0.75rem;border-radius:8px'>{html.escape(pretty)}</pre>"
|
||||
)
|
||||
except Exception as ex:
|
||||
raw_block = f"<p class='module-err'>Не удалось показать JSON: {html.escape(str(ex))}</p>"
|
||||
body = (
|
||||
f"<p><a href=\"/ui/modules/incidents/\">← К списку</a></p><h1>Инцидент</h1>{meta}{raw_block}"
|
||||
)
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Инцидент — onGuard24",
|
||||
current_slug="incidents",
|
||||
main_inner_html=body,
|
||||
)
|
||||
)
|
||||
137
onguard24/modules/registry.py
Normal file
137
onguard24/modules/registry.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""Единая точка регистрации модулей: 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 (
|
||||
alerts,
|
||||
contacts,
|
||||
escalations,
|
||||
grafana_catalog,
|
||||
incidents,
|
||||
schedules,
|
||||
statusboard,
|
||||
tasks,
|
||||
teams,
|
||||
)
|
||||
|
||||
# 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=grafana_catalog.router,
|
||||
url_prefix="/api/v1/modules/grafana-catalog",
|
||||
register_events=grafana_catalog.register_events,
|
||||
slug="grafana-catalog",
|
||||
title="Каталог Grafana",
|
||||
ui_router=grafana_catalog.ui_router,
|
||||
render_home_fragment=grafana_catalog.render_home_fragment,
|
||||
),
|
||||
ModuleMount(
|
||||
router=alerts.router,
|
||||
url_prefix="/api/v1/modules/alerts",
|
||||
register_events=alerts.register_events,
|
||||
slug="alerts",
|
||||
title="Алерты",
|
||||
ui_router=alerts.ui_router,
|
||||
render_home_fragment=alerts.render_home_fragment,
|
||||
),
|
||||
ModuleMount(
|
||||
router=teams.router,
|
||||
url_prefix="/api/v1/modules/teams",
|
||||
register_events=teams.register_events,
|
||||
slug="teams",
|
||||
title="Команды",
|
||||
ui_router=teams.ui_router,
|
||||
render_home_fragment=teams.render_home_fragment,
|
||||
),
|
||||
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)
|
||||
@ -1,7 +1,28 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from onguard24.domain.events import EventBus
|
||||
from onguard24.modules.ui_support import wrap_module_html_page
|
||||
|
||||
router = APIRouter(tags=["module-schedules"])
|
||||
|
||||
ui_router = APIRouter(tags=["web-schedules"], include_in_schema=False)
|
||||
|
||||
|
||||
def register_events(_bus: EventBus, _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("/")
|
||||
async def schedules_root():
|
||||
@ -10,3 +31,18 @@ async def schedules_root():
|
||||
"status": "stub",
|
||||
"note": "календарь и смены — следующий этап",
|
||||
}
|
||||
|
||||
|
||||
@ui_router.get("/", response_class=HTMLResponse)
|
||||
async def schedules_ui_home(request: Request):
|
||||
"""Полная HTML-страница: /ui/modules/schedules/ — то же правое меню, что на главной."""
|
||||
del request
|
||||
inner = """<h1>Календарь дежурств</h1>
|
||||
<p>Здесь будет функционал модуля: смены, календарь, уведомления.</p>"""
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Календарь дежурств — onGuard24",
|
||||
current_slug="schedules",
|
||||
main_inner_html=inner,
|
||||
)
|
||||
)
|
||||
|
||||
@ -1,7 +1,26 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from onguard24.domain.events import EventBus
|
||||
from onguard24.modules.ui_support import wrap_module_html_page
|
||||
|
||||
router = APIRouter(tags=["module-statusboard"])
|
||||
|
||||
ui_router = APIRouter(tags=["web-statusboard"], include_in_schema=False)
|
||||
|
||||
|
||||
def register_events(_bus: EventBus, _pool=None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def render_home_fragment(request: Request) -> str:
|
||||
del request
|
||||
return (
|
||||
'<div class="module-fragment">'
|
||||
"<p>Сводка по сервисам (светофор) — по данным алертов.</p>"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def statusboard_root():
|
||||
@ -11,3 +30,17 @@ async def statusboard_root():
|
||||
"note": "светофор по сервисам — агрегация по алертам",
|
||||
"demo": [],
|
||||
}
|
||||
|
||||
|
||||
@ui_router.get("/", response_class=HTMLResponse)
|
||||
async def statusboard_ui_home(request: Request):
|
||||
del request
|
||||
inner = """<h1>Светофор</h1>
|
||||
<p>Агрегация статусов сервисов по алертам.</p>"""
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Светофор — onGuard24",
|
||||
current_slug="statusboard",
|
||||
main_inner_html=inner,
|
||||
)
|
||||
)
|
||||
|
||||
221
onguard24/modules/tasks.py
Normal file
221
onguard24/modules/tasks.py
Normal file
@ -0,0 +1,221 @@
|
||||
"""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
|
||||
|
||||
|
||||
class TaskPatch(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
status: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
async def get_task_api(task_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, incident_id, title, status, created_at
|
||||
FROM tasks WHERE id = $1::uuid
|
||||
""",
|
||||
task_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{task_id}")
|
||||
async def patch_task_api(
|
||||
task_id: UUID,
|
||||
body: TaskPatch,
|
||||
pool: asyncpg.Pool | None = Depends(get_pool),
|
||||
):
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="database disabled")
|
||||
if body.title is None and body.status is None:
|
||||
raise HTTPException(status_code=400, detail="no fields to update")
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE tasks SET
|
||||
title = COALESCE($2, title),
|
||||
status = COALESCE($3, status)
|
||||
WHERE id = $1::uuid
|
||||
RETURNING id, incident_id, title, status, created_at
|
||||
""",
|
||||
task_id,
|
||||
body.title.strip() if body.title is not None else None,
|
||||
body.status,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
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,
|
||||
)
|
||||
)
|
||||
378
onguard24/modules/teams.py
Normal file
378
onguard24/modules/teams.py
Normal file
@ -0,0 +1,378 @@
|
||||
"""IRM: команды (teams) — как Team в Grafana IRM; правила сопоставления по лейблам алерта."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
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-teams"])
|
||||
ui_router = APIRouter(tags=["web-teams"], include_in_schema=False)
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$")
|
||||
|
||||
|
||||
def register_events(_bus: EventBus, _pool: asyncpg.Pool | None = None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_slug(raw: str) -> str:
|
||||
s = raw.strip().lower()
|
||||
if not _SLUG_RE.match(s):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="slug: 1–63 символа, a-z 0-9 _ -, начинается с буквы или цифры",
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
class TeamCreate(BaseModel):
|
||||
slug: str = Field(..., min_length=1, max_length=63)
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class TeamPatch(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class RuleCreate(BaseModel):
|
||||
label_key: str = Field(..., min_length=1, max_length=500)
|
||||
label_value: str = Field(..., min_length=1, max_length=2000)
|
||||
priority: int = Field(default=0, ge=-1000, le=100000)
|
||||
|
||||
|
||||
def _team_row(r: asyncpg.Record) -> dict:
|
||||
return {
|
||||
"id": str(r["id"]),
|
||||
"slug": r["slug"],
|
||||
"name": r["name"],
|
||||
"description": r["description"],
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||
}
|
||||
|
||||
|
||||
def _rule_row(r: asyncpg.Record) -> dict:
|
||||
return {
|
||||
"id": str(r["id"]),
|
||||
"team_id": str(r["team_id"]),
|
||||
"label_key": r["label_key"],
|
||||
"label_value": r["label_value"],
|
||||
"priority": int(r["priority"]),
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_teams_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, slug, name, description, created_at
|
||||
FROM teams
|
||||
ORDER BY name
|
||||
"""
|
||||
)
|
||||
return {"items": [_team_row(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/", status_code=201)
|
||||
async def create_team_api(body: TeamCreate, pool: asyncpg.Pool | None = Depends(get_pool)):
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="database disabled")
|
||||
slug = _normalize_slug(body.slug)
|
||||
async with pool.acquire() as conn:
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO teams (slug, name, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, slug, name, description, created_at
|
||||
""",
|
||||
slug,
|
||||
body.name.strip(),
|
||||
(body.description or "").strip() or None,
|
||||
)
|
||||
except asyncpg.UniqueViolationError:
|
||||
raise HTTPException(status_code=409, detail="team slug already exists") from None
|
||||
return _team_row(row)
|
||||
|
||||
|
||||
@router.get("/{team_id}")
|
||||
async def get_team_api(team_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, slug, name, description, created_at
|
||||
FROM teams WHERE id = $1::uuid
|
||||
""",
|
||||
team_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return _team_row(row)
|
||||
|
||||
|
||||
@router.patch("/{team_id}", status_code=200)
|
||||
async def patch_team_api(
|
||||
team_id: UUID,
|
||||
body: TeamPatch,
|
||||
pool: asyncpg.Pool | None = Depends(get_pool),
|
||||
):
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="database disabled")
|
||||
name = body.name.strip() if body.name is not None else None
|
||||
desc = body.description
|
||||
if desc is not None:
|
||||
desc = desc.strip() or None
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE teams SET
|
||||
name = COALESCE($2, name),
|
||||
description = COALESCE($3, description)
|
||||
WHERE id = $1::uuid
|
||||
RETURNING id, slug, name, description, created_at
|
||||
""",
|
||||
team_id,
|
||||
name,
|
||||
desc,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return _team_row(row)
|
||||
|
||||
|
||||
@router.delete("/{team_id}", status_code=204)
|
||||
async def delete_team_api(team_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:
|
||||
r = await conn.execute("DELETE FROM teams WHERE id = $1::uuid", team_id)
|
||||
if r == "DELETE 0":
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
|
||||
|
||||
@router.get("/{team_id}/rules")
|
||||
async def list_rules_api(team_id: UUID, pool: asyncpg.Pool | None = Depends(get_pool)):
|
||||
if pool is None:
|
||||
return {"items": [], "database": "disabled"}
|
||||
async with pool.acquire() as conn:
|
||||
ok = await conn.fetchval("SELECT 1 FROM teams WHERE id = $1::uuid", team_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="team not found")
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, team_id, label_key, label_value, priority, created_at
|
||||
FROM team_label_rules
|
||||
WHERE team_id = $1::uuid
|
||||
ORDER BY priority DESC, id ASC
|
||||
""",
|
||||
team_id,
|
||||
)
|
||||
return {"items": [_rule_row(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/{team_id}/rules", status_code=201)
|
||||
async def create_rule_api(
|
||||
team_id: UUID,
|
||||
body: RuleCreate,
|
||||
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:
|
||||
ok = await conn.fetchval("SELECT 1 FROM teams WHERE id = $1::uuid", team_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="team not found")
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO team_label_rules (team_id, label_key, label_value, priority)
|
||||
VALUES ($1::uuid, $2, $3, $4)
|
||||
RETURNING id, team_id, label_key, label_value, priority, created_at
|
||||
""",
|
||||
team_id,
|
||||
body.label_key.strip(),
|
||||
body.label_value.strip(),
|
||||
body.priority,
|
||||
)
|
||||
return _rule_row(row)
|
||||
|
||||
|
||||
@router.delete("/{team_id}/rules/{rule_id}", status_code=204)
|
||||
async def delete_rule_api(
|
||||
team_id: UUID,
|
||||
rule_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:
|
||||
r = await conn.execute(
|
||||
"""
|
||||
DELETE FROM team_label_rules
|
||||
WHERE id = $1::uuid AND team_id = $2::uuid
|
||||
""",
|
||||
rule_id,
|
||||
team_id,
|
||||
)
|
||||
if r == "DELETE 0":
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
|
||||
|
||||
@ui_router.get("/", response_class=HTMLResponse)
|
||||
async def teams_ui_list(request: Request):
|
||||
pool = get_pool(request)
|
||||
inner = ""
|
||||
if pool is None:
|
||||
inner = "<p>База не настроена.</p>"
|
||||
else:
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT t.id, t.slug, t.name,
|
||||
(SELECT count(*)::int FROM team_label_rules r WHERE r.team_id = t.id) AS n_rules,
|
||||
(SELECT count(*)::int FROM irm_alerts a WHERE a.team_id = t.id) AS n_alerts
|
||||
FROM teams t
|
||||
ORDER BY t.name
|
||||
"""
|
||||
)
|
||||
if not rows:
|
||||
inner = (
|
||||
"<p>Команд пока нет. Создайте команду через API "
|
||||
"<code>POST /api/v1/modules/teams/</code> и добавьте правила лейблов — "
|
||||
"новые алерты из Grafana получат <code>team_id</code> при совпадении.</p>"
|
||||
)
|
||||
else:
|
||||
trs = []
|
||||
for r in rows:
|
||||
tid = str(r["id"])
|
||||
trs.append(
|
||||
"<tr>"
|
||||
f"<td><a href=\"/ui/modules/teams/{html.escape(tid, quote=True)}\">"
|
||||
f"{html.escape(r['slug'])}</a></td>"
|
||||
f"<td>{html.escape(r['name'])}</td>"
|
||||
f"<td>{int(r['n_rules'])}</td>"
|
||||
f"<td>{int(r['n_alerts'])}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
inner = (
|
||||
"<p class='gc-muted'>Команда соответствует колонке <strong>Team</strong> в Grafana IRM. "
|
||||
"Сопоставление: первое правило по приоритету, у которого совпали ключ и значение лейбла.</p>"
|
||||
"<table class='irm-table'><thead><tr><th>Slug</th><th>Название</th>"
|
||||
"<th>Правил</th><th>Алертов</th></tr></thead><tbody>"
|
||||
+ "".join(trs)
|
||||
+ "</tbody></table>"
|
||||
)
|
||||
except Exception as e:
|
||||
inner = f"<p class='module-err'>{html.escape(str(e))}</p>"
|
||||
page = f"<h1>Команды</h1>{inner}"
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Команды — onGuard24",
|
||||
current_slug="teams",
|
||||
main_inner_html=page,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ui_router.get("/{team_id:uuid}", response_class=HTMLResponse)
|
||||
async def teams_ui_detail(request: Request, team_id: UUID):
|
||||
pool = get_pool(request)
|
||||
if pool is None:
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Команда — onGuard24",
|
||||
current_slug="teams",
|
||||
main_inner_html="<h1>Команда</h1><p>База не настроена.</p>",
|
||||
)
|
||||
)
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
team = await conn.fetchrow(
|
||||
"SELECT id, slug, name, description FROM teams WHERE id = $1::uuid",
|
||||
team_id,
|
||||
)
|
||||
rules = await conn.fetch(
|
||||
"""
|
||||
SELECT label_key, label_value, priority, id
|
||||
FROM team_label_rules
|
||||
WHERE team_id = $1::uuid
|
||||
ORDER BY priority DESC, id ASC
|
||||
""",
|
||||
team_id,
|
||||
)
|
||||
except Exception as e:
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Команда — onGuard24",
|
||||
current_slug="teams",
|
||||
main_inner_html=f"<h1>Команда</h1><p class='module-err'>{html.escape(str(e))}</p>",
|
||||
)
|
||||
)
|
||||
if not team:
|
||||
inner = "<p>Не найдено.</p>"
|
||||
else:
|
||||
tid = str(team["id"])
|
||||
rows_html = []
|
||||
for ru in rules:
|
||||
rows_html.append(
|
||||
"<tr>"
|
||||
f"<td><code>{html.escape(ru['label_key'])}</code></td>"
|
||||
f"<td><code>{html.escape(ru['label_value'])}</code></td>"
|
||||
f"<td>{int(ru['priority'])}</td>"
|
||||
f"<td><code>{html.escape(str(ru['id']))}</code></td>"
|
||||
"</tr>"
|
||||
)
|
||||
desc = html.escape(team["description"] or "—")
|
||||
inner = (
|
||||
f"<p><a href=\"/ui/modules/teams/\">← К списку команд</a></p>"
|
||||
f"<h1>{html.escape(team['name'])}</h1>"
|
||||
f"<p><strong>slug:</strong> <code>{html.escape(team['slug'])}</code></p>"
|
||||
f"<p><strong>Описание:</strong> {desc}</p>"
|
||||
"<h2 style='font-size:1.05rem;margin-top:1rem'>Правила лейблов</h2>"
|
||||
"<p class='gc-muted'>Пример: <code>team</code> = <code>infra</code> — как в ваших алертах Grafana.</p>"
|
||||
"<table class='irm-table'><thead><tr><th>Ключ</th><th>Значение</th><th>Priority</th><th>ID правила</th></tr></thead><tbody>"
|
||||
+ ("".join(rows_html) or "<tr><td colspan='4'>Правил нет — добавьте через API.</td></tr>")
|
||||
+ "</tbody></table>"
|
||||
f"<p style='margin-top:1rem;font-size:0.85rem'>API: "
|
||||
f"<code>POST /api/v1/modules/teams/{tid}/rules</code> с JSON "
|
||||
"<code>{\"label_key\":\"team\",\"label_value\":\"infra\",\"priority\":10}</code></p>"
|
||||
)
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Команда — onGuard24",
|
||||
current_slug="teams",
|
||||
main_inner_html=inner,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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 teams")
|
||||
except Exception:
|
||||
return '<p class="module-note">Таблица teams недоступна (миграция 006?).</p>'
|
||||
return (
|
||||
f'<div class="module-fragment"><p>Команд: <strong>{int(n)}</strong>. '
|
||||
f'<a href="/ui/modules/teams/">Открыть</a></p></div>'
|
||||
)
|
||||
132
onguard24/modules/ui_support.py
Normal file
132
onguard24/modules/ui_support.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""Безопасная сборка 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; }
|
||||
.og-btn { padding: 0.45rem 0.9rem; font-size: 0.875rem; border-radius: 6px;
|
||||
border: 1px solid #d4d4d8; background: #fff; cursor: pointer; margin-right: 0.5rem; }
|
||||
.og-btn:hover { background: #f4f4f5; }
|
||||
.og-btn-primary { background: #2563eb; color: #fff; border-color: #1d4ed8; }
|
||||
.og-btn-primary:hover { background: #1d4ed8; }
|
||||
.og-sync-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 1rem 0; }
|
||||
.og-sync-status { font-size: 0.85rem; color: #52525b; min-height: 1.25rem; }
|
||||
.gc-tree { margin-top: 1.25rem; }
|
||||
.gc-folder { margin: 0.4rem 0; border: 1px solid #e4e4e7; border-radius: 8px; padding: 0.35rem 0.6rem; background: #fff; }
|
||||
.gc-folder summary { cursor: pointer; list-style: none; }
|
||||
.gc-folder summary::-webkit-details-marker { display: none; }
|
||||
.gc-muted { color: #71717a; font-size: 0.82rem; font-weight: normal; }
|
||||
.gc-subtable { width: 100%; border-collapse: collapse; font-size: 0.82rem; margin: 0.5rem 0 0.25rem; }
|
||||
.gc-subtable th, .gc-subtable td { border: 1px solid #e4e4e7; padding: 0.3rem 0.45rem; }
|
||||
.gc-subtable th { background: #fafafa; }
|
||||
.gc-orphan { margin-top: 1rem; padding: 0.75rem; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 8px; font-size: 0.88rem; }
|
||||
.rail-item--util { border-top: 1px solid #e4e4e7; margin-top: 0.4rem; padding-top: 0.4rem; }
|
||||
"""
|
||||
|
||||
|
||||
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>'
|
||||
)
|
||||
logs_active = current_slug == "__logs__"
|
||||
items.append(
|
||||
'<li class="rail-item rail-item--util' + (" is-active" if logs_active else "") + '">'
|
||||
'<a href="/ui/logs"' + (' aria-current="page"' if logs_active else "") + ">📋 Логи</a></li>"
|
||||
)
|
||||
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>"
|
||||
)
|
||||
@ -1,6 +1,10 @@
|
||||
import html
|
||||
import json
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from onguard24.modules.registry import MODULE_MOUNTS
|
||||
from onguard24.modules.ui_support import APP_SHELL_CSS, nav_rail_html, safe_fragment
|
||||
from onguard24.status_snapshot import build
|
||||
|
||||
|
||||
@ -34,7 +38,42 @@ def _row(name: str, value: object) -> str:
|
||||
return f"<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)
|
||||
rows = ""
|
||||
for key in ("database", "vault", "grafana", "forgejo"):
|
||||
@ -42,6 +81,8 @@ async def render_root_page(request) -> str:
|
||||
rows += _row(key, data[key])
|
||||
|
||||
payload = html.escape(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
modules_html = await _module_sections_html(request)
|
||||
rail = nav_rail_html(None)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
@ -50,7 +91,7 @@ async def render_root_page(request) -> str:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>onGuard24</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui, sans-serif; margin: 2rem; background: #fafafa; color: #18181b; }}
|
||||
{APP_SHELL_CSS}
|
||||
h1 {{ margin-top: 0; }}
|
||||
.badge {{ display: inline-block; padding: 0.15rem 0.5rem; border-radius: 6px; font-size: 0.85rem; font-weight: 600; }}
|
||||
.ok {{ background: #dcfce7; color: #166534; }}
|
||||
@ -64,15 +105,30 @@ async def render_root_page(request) -> str:
|
||||
.links a {{ margin-right: 1rem; }}
|
||||
.json {{ margin-top: 2rem; max-width: 56rem; }}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<main class="app-main">
|
||||
<h1>onGuard24</h1>
|
||||
<p class="links">
|
||||
<a href="/docs">Swagger</a>
|
||||
<a href="/openapi.json">OpenAPI</a>
|
||||
<a href="/health">/health</a>
|
||||
<a href="/api/v1/status">JSON статус</a>
|
||||
<a href="/ui/logs" style="font-weight:600">📋 Логи</a>
|
||||
</p>
|
||||
<h2>Проверки доступа</h2>
|
||||
<table>
|
||||
@ -80,9 +136,13 @@ async def render_root_page(request) -> str:
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
{modules_html}
|
||||
<div class="json">
|
||||
<h3>Полный ответ <code>/api/v1/status</code></h3>
|
||||
<pre>{payload}</pre>
|
||||
</div>
|
||||
</main>
|
||||
{rail}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
from fastapi import Request
|
||||
|
||||
from onguard24.config import Settings
|
||||
from onguard24.grafana_sources import iter_grafana_sources
|
||||
from onguard24.integrations import forgejo_api, grafana_api
|
||||
from onguard24.vaultcheck import ping as vault_ping
|
||||
|
||||
@ -31,31 +32,46 @@ async def build(request: Request) -> dict:
|
||||
else:
|
||||
out["vault"] = "disabled"
|
||||
|
||||
gu = settings.grafana_url.strip()
|
||||
if not gu:
|
||||
sources = iter_grafana_sources(settings)
|
||||
if not sources:
|
||||
out["grafana"] = "disabled"
|
||||
elif settings.grafana_service_account_token.strip():
|
||||
ok, err = await grafana_api.ping(gu, settings.grafana_service_account_token)
|
||||
if ok:
|
||||
user, _ = await grafana_api.get_signed_in_user(gu, settings.grafana_service_account_token)
|
||||
entry: dict = {"status": "ok", "url": gu, "api": "authenticated"}
|
||||
if user:
|
||||
login = user.get("login") or user.get("email")
|
||||
if login:
|
||||
entry["service_account_login"] = login
|
||||
out["grafana"] = entry
|
||||
else:
|
||||
out["grafana"] = {"status": "error", "detail": err, "url": gu}
|
||||
else:
|
||||
live_ok, live_err = await grafana_api.health_live(gu)
|
||||
if live_ok:
|
||||
out["grafana"] = {
|
||||
"status": "reachable",
|
||||
"url": gu,
|
||||
"detail": "задай GRAFANA_SERVICE_ACCOUNT_TOKEN для вызовов API",
|
||||
}
|
||||
instances: list[dict] = []
|
||||
for src in sources:
|
||||
entry: dict = {"slug": src.slug, "url": src.api_url}
|
||||
if src.api_token.strip():
|
||||
ok, err = await grafana_api.ping(src.api_url, src.api_token)
|
||||
if ok:
|
||||
user, _ = await grafana_api.get_signed_in_user(src.api_url, src.api_token)
|
||||
entry["status"] = "ok"
|
||||
entry["api"] = "authenticated"
|
||||
if user:
|
||||
login = user.get("login") or user.get("email")
|
||||
if login:
|
||||
entry["service_account_login"] = login
|
||||
else:
|
||||
entry["status"] = "error"
|
||||
entry["detail"] = err
|
||||
else:
|
||||
live_ok, live_err = await grafana_api.health_live(src.api_url)
|
||||
if live_ok:
|
||||
entry["status"] = "reachable"
|
||||
entry["detail"] = "задай api_token в GRAFANA_SOURCES_JSON для проверки API"
|
||||
else:
|
||||
entry["status"] = "error"
|
||||
entry["detail"] = live_err
|
||||
instances.append(entry)
|
||||
|
||||
if all(i.get("status") == "ok" for i in instances):
|
||||
agg = "ok"
|
||||
elif any(i.get("status") == "error" for i in instances):
|
||||
agg = "error"
|
||||
else:
|
||||
out["grafana"] = {"status": "error", "detail": live_err, "url": gu}
|
||||
agg = "reachable"
|
||||
out["grafana"] = {
|
||||
"status": agg,
|
||||
"instances": instances,
|
||||
}
|
||||
|
||||
fj = settings.forgejo_url.strip()
|
||||
if not fj:
|
||||
|
||||
234
onguard24/ui_logs.py
Normal file
234
onguard24/ui_logs.py
Normal file
@ -0,0 +1,234 @@
|
||||
"""Страница просмотра логов в реальном времени (SSE).
|
||||
|
||||
Маршруты:
|
||||
GET /ui/logs — HTML-страница с историей + EventSource
|
||||
GET /ui/logs/stream — Server-Sent Events (text/event-stream)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html as _html
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from onguard24 import log_buffer
|
||||
from onguard24.modules.ui_support import APP_SHELL_CSS, nav_rail_html
|
||||
|
||||
router = APIRouter(include_in_schema=False, tags=["web-logs"])
|
||||
|
||||
_LEVEL_COLOR: dict[str, str] = {
|
||||
"DEBUG": "#71717a",
|
||||
"INFO": "#a3e635",
|
||||
"WARNING": "#fbbf24",
|
||||
"ERROR": "#f87171",
|
||||
"CRITICAL": "#ef4444",
|
||||
}
|
||||
|
||||
_LOG_CSS = """
|
||||
.log-wrap { background:#0f0f10; border-radius:10px; padding:0.75rem; min-height:20rem;
|
||||
max-height:75vh; overflow-y:auto; font-family:monospace; font-size:0.8rem;
|
||||
line-height:1.55; color:#e4e4e7; }
|
||||
.log-line { display:flex; gap:0.5rem; border-bottom:1px solid #1e1e21; padding:0.12rem 0; }
|
||||
.log-line:last-child { border-bottom: none; }
|
||||
.log-ts { color:#52525b; flex-shrink:0; }
|
||||
.log-lv { flex-shrink:0; width:5.5rem; font-weight:600; }
|
||||
.log-name { flex-shrink:0; width:16rem; overflow:hidden; text-overflow:ellipsis;
|
||||
white-space:nowrap; color:#a1a1aa; }
|
||||
.log-msg { flex:1; word-break:break-all; white-space:pre-wrap; color:#e4e4e7; }
|
||||
.lv-DEBUG { color:#71717a; }
|
||||
.lv-INFO { color:#a3e635; }
|
||||
.lv-WARNING { color:#fbbf24; }
|
||||
.lv-ERROR { color:#f87171; }
|
||||
.lv-CRITICAL { color:#ef4444; background:#3f0000; border-radius:3px; padding:0 2px; }
|
||||
.log-controls { display:flex; gap:0.75rem; align-items:center; margin-bottom:0.75rem; }
|
||||
.log-controls label { font-size:0.85rem; color:#52525b; display:flex; align-items:center; gap:0.3rem; }
|
||||
.badge-live { display:inline-block; width:8px; height:8px; border-radius:50%;
|
||||
background:#a3e635; box-shadow:0 0 6px #a3e635; animation: pulse 1.6s infinite; }
|
||||
.badge-live.disconnected { background:#f87171; box-shadow:0 0 6px #f87171; animation:none; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||
#status-bar { font-size:0.8rem; color:#52525b; margin-bottom:0.5rem; }
|
||||
"""
|
||||
|
||||
_LOG_JS = """
|
||||
<script>
|
||||
(function(){
|
||||
const wrap = document.getElementById('log-wrap');
|
||||
const cntEl = document.getElementById('log-count');
|
||||
const statusEl = document.getElementById('status-bar');
|
||||
const dot = document.getElementById('live-dot');
|
||||
const autoCheck = document.getElementById('auto-scroll');
|
||||
const levelFilter = document.getElementById('level-filter');
|
||||
let count = parseInt(cntEl.textContent || '0', 10);
|
||||
|
||||
const LEVEL_COLOR = {
|
||||
DEBUG:'#71717a', INFO:'#a3e635', WARNING:'#fbbf24', ERROR:'#f87171', CRITICAL:'#ef4444'
|
||||
};
|
||||
|
||||
function makeRow(d) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'log-line';
|
||||
row.dataset.level = d.level;
|
||||
const lvl = d.level || 'INFO';
|
||||
const col = LEVEL_COLOR[lvl] || '#e4e4e7';
|
||||
row.innerHTML =
|
||||
'<span class="log-ts">' + esc(d.ts || '') + '</span>' +
|
||||
'<span class="log-lv lv-' + lvl + '">' + esc(lvl) + '</span>' +
|
||||
'<span class="log-name">' + esc((d.name||'').slice(0,36)) + '</span>' +
|
||||
'<span class="log-msg">' + esc(d.msg||'') + '</span>';
|
||||
return row;
|
||||
}
|
||||
|
||||
function esc(s){ const d=document.createElement('div');d.textContent=s;return d.innerHTML; }
|
||||
|
||||
function applyFilter() {
|
||||
const lv = levelFilter.value;
|
||||
const ORDER = ['DEBUG','INFO','WARNING','ERROR','CRITICAL'];
|
||||
const minIdx = lv ? ORDER.indexOf(lv) : 0;
|
||||
wrap.querySelectorAll('.log-line').forEach(function(el){
|
||||
const idx = ORDER.indexOf(el.dataset.level);
|
||||
el.style.display = (idx >= minIdx) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
levelFilter.addEventListener('change', applyFilter);
|
||||
applyFilter();
|
||||
|
||||
function scrollBottom() {
|
||||
if (autoCheck.checked) wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
|
||||
scrollBottom();
|
||||
|
||||
const src = new EventSource('/ui/logs/stream');
|
||||
|
||||
src.onopen = function(){
|
||||
dot.className = 'badge-live';
|
||||
statusEl.textContent = 'Live — соединение установлено';
|
||||
};
|
||||
|
||||
src.onmessage = function(e){
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
const row = makeRow(d);
|
||||
wrap.appendChild(row);
|
||||
count++;
|
||||
cntEl.textContent = count;
|
||||
// keep DOM manageable: trim oldest
|
||||
while (wrap.children.length > 1000) wrap.removeChild(wrap.firstChild);
|
||||
const lv = levelFilter.value;
|
||||
if (lv) {
|
||||
const ORDER = ['DEBUG','INFO','WARNING','ERROR','CRITICAL'];
|
||||
if (ORDER.indexOf(d.level) < ORDER.indexOf(lv)) row.style.display='none';
|
||||
}
|
||||
scrollBottom();
|
||||
} catch(ex){}
|
||||
};
|
||||
|
||||
src.onerror = function(){
|
||||
dot.className = 'badge-live disconnected';
|
||||
statusEl.textContent = 'Соединение потеряно — попытка переподключения…';
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
def _line_html(entry: dict) -> str:
|
||||
ts = _html.escape(entry.get("ts", ""))
|
||||
lvl = entry.get("level", "INFO")
|
||||
name = _html.escape((entry.get("name") or "")[:36])
|
||||
msg = _html.escape(entry.get("msg") or "")
|
||||
return (
|
||||
f'<div class="log-line" data-level="{_html.escape(lvl)}">'
|
||||
f'<span class="log-ts">{ts}</span>'
|
||||
f'<span class="log-lv lv-{_html.escape(lvl)}">{_html.escape(lvl)}</span>'
|
||||
f'<span class="log-name">{name}</span>'
|
||||
f'<span class="log-msg">{msg}</span>'
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ui/logs", response_class=HTMLResponse)
|
||||
async def logs_page(request: Request) -> HTMLResponse:
|
||||
history = log_buffer.get_history()
|
||||
lines_html = "\n".join(_line_html(e) for e in history)
|
||||
count = len(history)
|
||||
rail = nav_rail_html("__logs__")
|
||||
|
||||
page = f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>Логи — onGuard24</title>
|
||||
<style>
|
||||
{APP_SHELL_CSS}
|
||||
{_LOG_CSS}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<main class="app-main module-page-main">
|
||||
<h1>Логи приложения</h1>
|
||||
<div class="log-controls">
|
||||
<span><span id="live-dot" class="badge-live"></span> real-time</span>
|
||||
<span>Записей: <strong id="log-count">{count}</strong></span>
|
||||
<label><input type="checkbox" id="auto-scroll" checked> авто-прокрутка</label>
|
||||
<label>
|
||||
Уровень:
|
||||
<select id="level-filter">
|
||||
<option value="">Все</option>
|
||||
<option value="DEBUG">DEBUG+</option>
|
||||
<option value="INFO">INFO+</option>
|
||||
<option value="WARNING">WARNING+</option>
|
||||
<option value="ERROR">ERROR+</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</select>
|
||||
</label>
|
||||
<a href="/ui/logs" class="og-btn" style="text-decoration:none;padding:0.3rem 0.7rem;font-size:0.8rem">Обновить</a>
|
||||
</div>
|
||||
<div id="status-bar">Подключаемся к потоку…</div>
|
||||
<div class="log-wrap" id="log-wrap">
|
||||
{lines_html}
|
||||
</div>
|
||||
</main>
|
||||
{rail}
|
||||
</div>
|
||||
{_LOG_JS}
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(page)
|
||||
|
||||
|
||||
@router.get("/ui/logs/stream")
|
||||
async def logs_stream(request: Request) -> StreamingResponse:
|
||||
q = log_buffer.subscribe()
|
||||
|
||||
async def generator() -> AsyncGenerator[bytes, None]:
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
entry = await asyncio.wait_for(q.get(), timeout=20.0)
|
||||
payload = json.dumps(entry, ensure_ascii=False)
|
||||
yield f"data: {payload}\n\n".encode()
|
||||
except asyncio.TimeoutError:
|
||||
yield b": keepalive\n\n"
|
||||
finally:
|
||||
log_buffer.unsubscribe(q)
|
||||
|
||||
return StreamingResponse(
|
||||
generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "onguard24"
|
||||
version = "1.1.0"
|
||||
version = "1.10.1"
|
||||
description = "onGuard24 — модульный сервис (аналог IRM)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@ -14,6 +14,7 @@ for key in (
|
||||
"FORGEJO_URL",
|
||||
"FORGEJO_TOKEN",
|
||||
"GRAFANA_WEBHOOK_SECRET",
|
||||
"GRAFANA_SOURCES_JSON",
|
||||
):
|
||||
os.environ.pop(key, None)
|
||||
os.environ["DATABASE_URL"] = ""
|
||||
|
||||
299
tests/irm_db_fake.py
Normal file
299
tests/irm_db_fake.py
Normal file
@ -0,0 +1,299 @@
|
||||
"""In-memory «пул» для тестов IRM без реального PostgreSQL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Row:
|
||||
"""Минимальная обёртка под asyncpg.Record (доступ по ключу)."""
|
||||
|
||||
_data: dict[str, Any]
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self._data[key]
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self._data.get(key, default)
|
||||
|
||||
|
||||
class _FakeTxn:
|
||||
async def __aenter__(self) -> None:
|
||||
return None
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class IrmFakeConn:
|
||||
def __init__(self, store: IrmFakeStore) -> None:
|
||||
self.store = store
|
||||
|
||||
def transaction(self) -> _FakeTxn:
|
||||
return _FakeTxn()
|
||||
|
||||
def _q(self, query: str) -> str:
|
||||
return " ".join(query.split())
|
||||
|
||||
async def execute(self, query: str, *args: Any) -> str:
|
||||
q = self._q(query)
|
||||
if "INSERT INTO incident_alert_links" in q:
|
||||
return "INSERT 0 1"
|
||||
if "INSERT INTO incidents" in q and "ingress_event_id" in q:
|
||||
self.store.insert_incident_alert(
|
||||
args[0], args[1], args[2], args[3], args[4]
|
||||
)
|
||||
return "INSERT 0 1"
|
||||
raise AssertionError(f"execute not implemented: {q[:80]}")
|
||||
|
||||
async def fetchval(self, query: str, *args: Any) -> Any:
|
||||
q = self._q(query)
|
||||
if "count(*)" in q and "FROM incidents" in q and "escalation" not in q:
|
||||
return len(self.store.incidents)
|
||||
if "count(*)" in q and "FROM tasks" in q:
|
||||
return len(self.store.tasks)
|
||||
if "count(*)" in q and "escalation_policies" in q:
|
||||
return sum(1 for p in self.store.policies.values() if p["enabled"])
|
||||
if "SELECT 1 FROM incidents WHERE id" in q:
|
||||
uid = args[0]
|
||||
return 1 if uid in self.store.incidents else None
|
||||
raise AssertionError(f"fetchval not implemented: {q[:100]}")
|
||||
|
||||
async def fetch(self, query: str, *args: Any) -> list[Row]:
|
||||
q = self._q(query)
|
||||
if "FROM incidents" in q and "ORDER BY created_at DESC" in q:
|
||||
rows = sorted(self.store.incidents.values(), key=lambda x: x["created_at"], reverse=True)
|
||||
if "grafana_org_slug =" in q and "service_name =" in q:
|
||||
rows = [
|
||||
r
|
||||
for r in rows
|
||||
if r.get("grafana_org_slug") == args[0]
|
||||
and r.get("service_name") == args[1]
|
||||
]
|
||||
lim = args[2]
|
||||
elif "grafana_org_slug =" in q:
|
||||
rows = [r for r in rows if r.get("grafana_org_slug") == args[0]]
|
||||
lim = args[1]
|
||||
elif "service_name =" in q:
|
||||
rows = [r for r in rows if r.get("service_name") == args[0]]
|
||||
lim = args[1]
|
||||
else:
|
||||
lim = args[0]
|
||||
return [Row(dict(r)) for r in rows[:lim]]
|
||||
if "FROM tasks" in q and "WHERE incident_id" in q and "ORDER BY" in q:
|
||||
iid, lim = args[0], args[1]
|
||||
match = [t for t in self.store.tasks.values() if t["incident_id"] == iid]
|
||||
match.sort(key=lambda x: x["created_at"], reverse=True)
|
||||
return [Row(dict(t)) for t in match[:lim]]
|
||||
if "FROM tasks" in q and "WHERE incident_id" in q:
|
||||
iid, lim = args[0], args[1]
|
||||
match = [t for t in self.store.tasks.values() if t["incident_id"] == iid]
|
||||
match.sort(key=lambda x: x["created_at"], reverse=True)
|
||||
return [Row(dict(t)) for t in match[:lim]]
|
||||
if "FROM tasks t" in q or ("FROM tasks" in q and "ORDER BY t.created_at" in q):
|
||||
lim = args[0]
|
||||
rows = sorted(self.store.tasks.values(), key=lambda x: x["created_at"], reverse=True)[:lim]
|
||||
return [Row(dict(r)) for r in rows]
|
||||
if "FROM tasks" in q and "ORDER BY created_at DESC" in q and "WHERE" not in q:
|
||||
lim = args[0]
|
||||
rows = sorted(self.store.tasks.values(), key=lambda x: x["created_at"], reverse=True)[:lim]
|
||||
return [Row(dict(r)) for r in rows]
|
||||
if "FROM escalation_policies" in q and "ORDER BY name" in q:
|
||||
rows = sorted(self.store.policies.values(), key=lambda x: x["name"])
|
||||
return [Row(dict(r)) for r in rows]
|
||||
raise AssertionError(f"fetch not implemented: {q[:120]}")
|
||||
|
||||
async def fetchrow(self, query: str, *args: Any) -> Row | None:
|
||||
q = self._q(query)
|
||||
if "INSERT INTO incidents" in q and "VALUES ($1, $2, $3, 'manual'" in q:
|
||||
return Row(self.store.insert_incident_manual(args[0], args[1], args[2]))
|
||||
if "FROM incidents WHERE id" in q and "UPDATE" not in q and "/tasks" not in query.lower():
|
||||
return self.store.get_incident(args[0])
|
||||
if "UPDATE incidents SET" in q:
|
||||
return self.store.update_incident(args[0], args[1], args[2], args[3])
|
||||
if "INSERT INTO tasks" in q:
|
||||
return Row(self.store.insert_task(args[0], args[1]))
|
||||
if "FROM tasks WHERE id" in q and "UPDATE" not in q:
|
||||
tid = args[0]
|
||||
t = self.store.tasks.get(tid)
|
||||
return Row(dict(t)) if t else None
|
||||
if "UPDATE tasks SET" in q:
|
||||
return self.store.update_task(args[0], args[1], args[2])
|
||||
if "INSERT INTO escalation_policies" in q:
|
||||
return Row(self.store.insert_policy(args[0], args[1], args[2]))
|
||||
if "FROM escalation_policies WHERE id" in q and "UPDATE" not in q and "DELETE" not in q:
|
||||
pid = args[0]
|
||||
p = self.store.policies.get(pid)
|
||||
return Row(dict(p)) if p else None
|
||||
if "UPDATE escalation_policies SET" in q:
|
||||
return self.store.update_policy(args[0], args[1], args[2], args[3])
|
||||
if "DELETE FROM escalation_policies" in q:
|
||||
return self.store.delete_policy(args[0])
|
||||
raise AssertionError(f"fetchrow not implemented: {q[:120]}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class IrmFakeStore:
|
||||
incidents: dict[UUID, dict[str, Any]] = field(default_factory=dict)
|
||||
tasks: dict[UUID, dict[str, Any]] = field(default_factory=dict)
|
||||
policies: dict[UUID, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
def insert_incident_alert(
|
||||
self,
|
||||
title: str,
|
||||
sev: str,
|
||||
ingress_id: UUID,
|
||||
grafana_org_slug: Any,
|
||||
service_name: Any,
|
||||
) -> None:
|
||||
iid = uuid4()
|
||||
now = _now()
|
||||
self.incidents[iid] = {
|
||||
"id": iid,
|
||||
"title": title,
|
||||
"status": "open",
|
||||
"severity": sev,
|
||||
"source": "grafana",
|
||||
"ingress_event_id": ingress_id,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"grafana_org_slug": grafana_org_slug,
|
||||
"service_name": service_name,
|
||||
}
|
||||
|
||||
def insert_incident_manual(self, title: str, status: str, severity: str) -> dict[str, Any]:
|
||||
iid = uuid4()
|
||||
now = _now()
|
||||
row = {
|
||||
"id": iid,
|
||||
"title": title,
|
||||
"status": status,
|
||||
"severity": severity,
|
||||
"source": "manual",
|
||||
"ingress_event_id": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"grafana_org_slug": None,
|
||||
"service_name": None,
|
||||
}
|
||||
self.incidents[iid] = row
|
||||
return row
|
||||
|
||||
def get_incident(self, iid: UUID) -> Row | None:
|
||||
r = self.incidents.get(iid)
|
||||
return Row(dict(r)) if r else None
|
||||
|
||||
def update_incident(
|
||||
self,
|
||||
iid: UUID,
|
||||
title: str | None,
|
||||
status: str | None,
|
||||
severity: str | None,
|
||||
) -> Row | None:
|
||||
r = self.incidents.get(iid)
|
||||
if not r:
|
||||
return None
|
||||
if title is not None:
|
||||
r["title"] = title
|
||||
if status is not None:
|
||||
r["status"] = status
|
||||
if severity is not None:
|
||||
r["severity"] = severity
|
||||
r["updated_at"] = _now()
|
||||
return Row(dict(r))
|
||||
|
||||
def insert_task(self, title: str, incident_id: UUID | None) -> dict[str, Any]:
|
||||
tid = uuid4()
|
||||
now = _now()
|
||||
row = {
|
||||
"id": tid,
|
||||
"incident_id": incident_id,
|
||||
"title": title,
|
||||
"status": "open",
|
||||
"created_at": now,
|
||||
}
|
||||
self.tasks[tid] = row
|
||||
return row
|
||||
|
||||
def update_task(self, tid: UUID, title: str | None, status: str | None) -> Row | None:
|
||||
r = self.tasks.get(tid)
|
||||
if not r:
|
||||
return None
|
||||
if title is not None:
|
||||
r["title"] = title
|
||||
if status is not None:
|
||||
r["status"] = status
|
||||
return Row(dict(r))
|
||||
|
||||
def insert_policy(self, name: str, enabled: bool, steps_json: str) -> dict[str, Any]:
|
||||
import json
|
||||
|
||||
pid = uuid4()
|
||||
now = _now()
|
||||
steps = json.loads(steps_json)
|
||||
row = {
|
||||
"id": pid,
|
||||
"name": name,
|
||||
"enabled": enabled,
|
||||
"steps": steps,
|
||||
"created_at": now,
|
||||
}
|
||||
self.policies[pid] = row
|
||||
return row
|
||||
|
||||
def update_policy(
|
||||
self,
|
||||
pid: UUID,
|
||||
name: str | None,
|
||||
enabled: bool | None,
|
||||
steps_json: str | None,
|
||||
) -> Row | None:
|
||||
import json
|
||||
|
||||
r = self.policies.get(pid)
|
||||
if not r:
|
||||
return None
|
||||
if name is not None:
|
||||
r["name"] = name
|
||||
if enabled is not None:
|
||||
r["enabled"] = enabled
|
||||
if steps_json is not None:
|
||||
r["steps"] = json.loads(steps_json)
|
||||
return Row(dict(r))
|
||||
|
||||
def delete_policy(self, pid: UUID) -> Row | None:
|
||||
if pid not in self.policies:
|
||||
return None
|
||||
self.policies.pop(pid)
|
||||
return Row({"id": pid})
|
||||
|
||||
|
||||
class IrmFakeAcquire:
|
||||
def __init__(self, store: IrmFakeStore) -> None:
|
||||
self.store = store
|
||||
|
||||
async def __aenter__(self) -> IrmFakeConn:
|
||||
return IrmFakeConn(self.store)
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class IrmFakePool:
|
||||
def __init__(self, store: IrmFakeStore | None = None) -> None:
|
||||
self._store = store or IrmFakeStore()
|
||||
|
||||
def acquire(self) -> IrmFakeAcquire:
|
||||
return IrmFakeAcquire(self._store)
|
||||
|
||||
@property
|
||||
def store(self) -> IrmFakeStore:
|
||||
return self._store
|
||||
9
tests/test_alerts_api.py
Normal file
9
tests/test_alerts_api.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""API модуля алертов без БД."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_alerts_list_no_db(client: TestClient) -> None:
|
||||
r = client.get("/api/v1/modules/alerts/")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"items": [], "database": "disabled"}
|
||||
19
tests/test_grafana_catalog_api.py
Normal file
19
tests/test_grafana_catalog_api.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""HTTP-обёртки каталога Grafana без реальной БД."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_grafana_catalog_sync_requires_db(client: TestClient) -> None:
|
||||
r = client.post("/api/v1/modules/grafana-catalog/sync", json={})
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def test_grafana_catalog_meta_no_db(client: TestClient) -> None:
|
||||
r = client.get("/api/v1/modules/grafana-catalog/meta")
|
||||
assert r.status_code == 200
|
||||
assert r.json().get("database") == "disabled"
|
||||
|
||||
|
||||
def test_grafana_catalog_tree_requires_db(client: TestClient) -> None:
|
||||
r = client.get("/api/v1/modules/grafana-catalog/tree?instance_slug=default")
|
||||
assert r.status_code == 503
|
||||
26
tests/test_grafana_payload.py
Normal file
26
tests/test_grafana_payload.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""Парсинг полей из тела вебхука Grafana."""
|
||||
|
||||
from onguard24.ingress.grafana_payload import extract_alert_row_from_grafana_body
|
||||
|
||||
|
||||
def test_extract_title_and_severity_from_unified() -> None:
|
||||
body = {
|
||||
"title": "RuleName",
|
||||
"alerts": [
|
||||
{
|
||||
"labels": {"severity": "critical", "alertname": "X"},
|
||||
"fingerprint": "abc",
|
||||
}
|
||||
],
|
||||
}
|
||||
title, sev, labels, fp = extract_alert_row_from_grafana_body(body)
|
||||
assert title == "RuleName"
|
||||
assert sev == "critical"
|
||||
assert labels.get("alertname") == "X"
|
||||
assert fp == "abc"
|
||||
|
||||
|
||||
def test_extract_empty_title_uses_alertname() -> None:
|
||||
body = {"alerts": [{"labels": {"alertname": "HostDown"}}]}
|
||||
title, _, _, _ = extract_alert_row_from_grafana_body(body)
|
||||
assert title == "HostDown"
|
||||
93
tests/test_grafana_topology.py
Normal file
93
tests/test_grafana_topology.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""Парсинг Ruler / слияние папок и HTTP-mock синхронизации Grafana."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from onguard24.integrations.grafana_topology import (
|
||||
merge_folder_rows,
|
||||
parse_ruler_rules,
|
||||
)
|
||||
from onguard24.modules.grafana_catalog import pull_topology
|
||||
|
||||
|
||||
def test_parse_ruler_grafana_managed() -> None:
|
||||
data = {
|
||||
"nginx": [
|
||||
{
|
||||
"name": "prometheus (Nginx)",
|
||||
"interval": "60s",
|
||||
"rules": [
|
||||
{
|
||||
"grafana_alert": {"uid": "uid1", "title": "Nginx Down"},
|
||||
"labels": {"service": "nginx", "severity": "critical"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
rows = parse_ruler_rules(data)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].namespace_uid == "nginx"
|
||||
assert rows[0].rule_uid == "uid1"
|
||||
assert rows[0].rule_group_name == "prometheus (Nginx)"
|
||||
assert rows[0].labels["service"] == "nginx"
|
||||
|
||||
|
||||
def test_parse_ruler_skips_non_grafana_alert() -> None:
|
||||
data = {"x": [{"name": "g", "rules": [{"expr": "1"}]}]}
|
||||
assert parse_ruler_rules(data) == []
|
||||
|
||||
|
||||
def test_merge_folder_rows_adds_namespaces() -> None:
|
||||
api = [{"uid": "system", "title": "System", "parentUid": None}]
|
||||
merged = merge_folder_rows(api, {"nginx", "system"})
|
||||
uids = {m[0] for m in merged}
|
||||
assert uids == {"system", "nginx"}
|
||||
titles = {m[0]: m[1] for m in merged}
|
||||
assert titles["nginx"] == "nginx"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_pull_topology_end_to_end() -> None:
|
||||
base = "https://grafana.example.com"
|
||||
respx.get(f"{base}/api/org").mock(
|
||||
return_value=httpx.Response(200, json={"id": 3, "name": "adibrov"})
|
||||
)
|
||||
def _folders(request: httpx.Request) -> httpx.Response:
|
||||
if "parentUid" in str(request.url):
|
||||
return httpx.Response(200, json=[])
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"uid": "nginx", "title": "Nginx Alerts", "parentUid": None}],
|
||||
)
|
||||
|
||||
respx.get(f"{base}/api/folders").mock(side_effect=_folders)
|
||||
ruler_body = {
|
||||
"nginx": [
|
||||
{
|
||||
"name": "grp",
|
||||
"interval": "1m",
|
||||
"rules": [
|
||||
{
|
||||
"grafana_alert": {"uid": "r1", "title": "Down"},
|
||||
"labels": {"service": "nginx"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
respx.get(f"{base}/api/ruler/grafana/api/v1/rules").mock(
|
||||
return_value=httpx.Response(200, json=ruler_body)
|
||||
)
|
||||
|
||||
out, err = await pull_topology(base, "test-token")
|
||||
assert err is None
|
||||
assert out is not None
|
||||
assert out.org_id == 3
|
||||
assert out.org_name == "adibrov"
|
||||
assert len(out.folder_rows) == 1
|
||||
assert out.folder_rows[0][0] == "nginx"
|
||||
assert len(out.rules) == 1
|
||||
assert out.rules[0].rule_uid == "r1"
|
||||
@ -1,9 +1,24 @@
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _webhook_mock_pool(mock_conn: AsyncMock) -> MagicMock:
|
||||
"""Пул с транзакцией и execute — как после вставки ingress + irm_alerts."""
|
||||
tx = AsyncMock()
|
||||
tx.__aenter__ = AsyncMock(return_value=None)
|
||||
tx.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_conn.transaction = MagicMock(return_value=tx)
|
||||
mock_conn.fetch = AsyncMock(return_value=[])
|
||||
mock_conn.execute = AsyncMock()
|
||||
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)
|
||||
return mock_pool
|
||||
|
||||
|
||||
def test_grafana_webhook_no_db(client: TestClient) -> None:
|
||||
"""Без пула БД — 202, запись не падает."""
|
||||
r = client.post(
|
||||
@ -36,14 +51,12 @@ def test_grafana_webhook_unauthorized_when_secret_set(client: TestClient) -> Non
|
||||
|
||||
|
||||
def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None:
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.execute = AsyncMock()
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
from uuid import uuid4
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
||||
mock_conn = AsyncMock()
|
||||
uid = uuid4()
|
||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||
mock_pool = _webhook_mock_pool(mock_conn)
|
||||
|
||||
app = client.app
|
||||
real_pool = app.state.pool
|
||||
@ -55,6 +68,114 @@ def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None:
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 202
|
||||
mock_conn.fetchrow.assert_called_once()
|
||||
mock_conn.execute.assert_called_once()
|
||||
finally:
|
||||
app.state.pool = real_pool
|
||||
|
||||
|
||||
def test_grafana_webhook_auto_org_from_external_url(client: TestClient) -> None:
|
||||
from uuid import uuid4
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
uid = uuid4()
|
||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||
mock_pool = _webhook_mock_pool(mock_conn)
|
||||
|
||||
app = client.app
|
||||
real_pool = app.state.pool
|
||||
app.state.pool = mock_pool
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/v1/ingress/grafana",
|
||||
content=json.dumps(
|
||||
{"externalURL": "https://grafana-adibrov.example.com/", "title": "x"}
|
||||
),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 202
|
||||
assert mock_conn.fetchrow.call_args[0][3] == "grafana-adibrov.example.com"
|
||||
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_pool = _webhook_mock_pool(mock_conn)
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_grafana_webhook_org_any_slug_without_json_config(client: TestClient) -> None:
|
||||
"""Путь /{slug} не требует GRAFANA_SOURCES_JSON — slug просто сохраняется."""
|
||||
from uuid import uuid4
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
uid = uuid4()
|
||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||
mock_pool = _webhook_mock_pool(mock_conn)
|
||||
|
||||
app = client.app
|
||||
real_pool = app.state.pool
|
||||
app.state.pool = mock_pool
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/v1/ingress/grafana/other",
|
||||
content=b"{}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 202
|
||||
assert mock_conn.fetchrow.call_args[0][3] == "other"
|
||||
finally:
|
||||
app.state.pool = real_pool
|
||||
|
||||
|
||||
def test_grafana_webhook_org_ok(client: TestClient) -> None:
|
||||
from uuid import uuid4
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
uid = uuid4()
|
||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||
mock_pool = _webhook_mock_pool(mock_conn)
|
||||
|
||||
app = client.app
|
||||
real_json = app.state.settings.grafana_sources_json
|
||||
real_pool = app.state.pool
|
||||
app.state.settings.grafana_sources_json = (
|
||||
'[{"slug":"adibrov","api_url":"http://192.168.0.1:3000","api_token":"","webhook_secret":""}]'
|
||||
)
|
||||
app.state.pool = mock_pool
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/v1/ingress/grafana/adibrov",
|
||||
content='{"title":"t"}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 202
|
||||
call = mock_conn.fetchrow.call_args
|
||||
assert "org_slug" in call[0][0].lower() or "org_slug" in str(call)
|
||||
assert call[0][1] == "grafana"
|
||||
assert call[0][3] == "adibrov"
|
||||
finally:
|
||||
app.state.settings.grafana_sources_json = real_json
|
||||
app.state.pool = real_pool
|
||||
|
||||
118
tests/test_irm_api_with_fake_db.py
Normal file
118
tests/test_irm_api_with_fake_db.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""IRM API с подменённым пулом БД (без PostgreSQL)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.requests import Request
|
||||
|
||||
from onguard24.deps import get_pool
|
||||
from onguard24.main import app
|
||||
|
||||
from tests.irm_db_fake import IrmFakePool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def irm_client() -> tuple[TestClient, IrmFakePool]:
|
||||
pool = IrmFakePool()
|
||||
|
||||
def override_get_pool(_request: Request):
|
||||
return pool
|
||||
|
||||
app.dependency_overrides[get_pool] = override_get_pool
|
||||
with TestClient(app) as client:
|
||||
yield client, pool
|
||||
app.dependency_overrides.pop(get_pool, None)
|
||||
|
||||
|
||||
def test_irm_incident_crud_and_tasks(irm_client: tuple[TestClient, IrmFakePool]) -> None:
|
||||
client, _pool = irm_client
|
||||
r = client.post(
|
||||
"/api/v1/modules/incidents/",
|
||||
json={"title": "Сбой API", "status": "open", "severity": "critical"},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
iid = r.json()["id"]
|
||||
assert r.json()["source"] == "manual"
|
||||
|
||||
r = client.get(f"/api/v1/modules/incidents/{iid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["title"] == "Сбой API"
|
||||
|
||||
r = client.patch(f"/api/v1/modules/incidents/{iid}", json={"status": "resolved"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "resolved"
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/modules/tasks/",
|
||||
json={"title": "Разбор логов", "incident_id": iid},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
tid = r.json()["id"]
|
||||
|
||||
r = client.get(f"/api/v1/modules/incidents/{iid}/tasks")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["items"]) == 1
|
||||
assert r.json()["items"][0]["id"] == tid
|
||||
|
||||
r = client.get(f"/api/v1/modules/tasks/{tid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "open"
|
||||
|
||||
r = client.patch(f"/api/v1/modules/tasks/{tid}", json={"status": "done"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "done"
|
||||
|
||||
|
||||
def test_irm_task_bad_incident(irm_client: tuple[TestClient, IrmFakePool]) -> None:
|
||||
client, _ = irm_client
|
||||
import uuid
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/modules/tasks/",
|
||||
json={"title": "x", "incident_id": str(uuid.uuid4())},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "incident not found"
|
||||
|
||||
|
||||
def test_irm_incident_tasks_unknown(irm_client: tuple[TestClient, IrmFakePool]) -> None:
|
||||
client, _ = irm_client
|
||||
import uuid
|
||||
|
||||
rid = str(uuid.uuid4())
|
||||
r = client.get(f"/api/v1/modules/incidents/{rid}/tasks")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_irm_patch_validation(irm_client: tuple[TestClient, IrmFakePool]) -> None:
|
||||
client, _ = irm_client
|
||||
r = client.post("/api/v1/modules/incidents/", json={"title": "t"})
|
||||
iid = r.json()["id"]
|
||||
r = client.patch(f"/api/v1/modules/incidents/{iid}", json={})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_irm_escalations_crud(irm_client: tuple[TestClient, IrmFakePool]) -> None:
|
||||
client, _ = irm_client
|
||||
r = client.post(
|
||||
"/api/v1/modules/escalations/",
|
||||
json={"name": "L1", "enabled": True, "steps": [{"after_min": 5, "channel": "slack"}]},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
pid = r.json()["id"]
|
||||
assert r.json()["steps"][0]["channel"] == "slack"
|
||||
|
||||
r = client.get(f"/api/v1/modules/escalations/{pid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["name"] == "L1"
|
||||
|
||||
r = client.patch(f"/api/v1/modules/escalations/{pid}", json={"enabled": False})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["enabled"] is False
|
||||
|
||||
r = client.delete(f"/api/v1/modules/escalations/{pid}")
|
||||
assert r.status_code == 204
|
||||
|
||||
r = client.get(f"/api/v1/modules/escalations/{pid}")
|
||||
assert r.status_code == 404
|
||||
104
tests/test_irm_modules.py
Normal file
104
tests/test_irm_modules.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""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_not_created_from_alert_by_default() -> None:
|
||||
"""По умолчанию AUTO_INCIDENT_FROM_ALERT выкл — инцидент из вебхука не создаётся."""
|
||||
calls: list = []
|
||||
|
||||
async def fake_execute(_query, *args):
|
||||
calls.append(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 calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incident_inserted_on_alert_when_auto_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""При AUTO_INCIDENT_FROM_ALERT=1 подписка снова создаёт инцидент (legacy)."""
|
||||
monkeypatch.setenv("AUTO_INCIDENT_FROM_ALERT", "1")
|
||||
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
|
||||
assert inserted["args"][3] is None
|
||||
assert inserted["args"][4] is None
|
||||
|
||||
|
||||
def test_incidents_post_requires_db(client: TestClient) -> None:
|
||||
r = client.post("/api/v1/modules/incidents/", json={"title": "x"})
|
||||
assert r.status_code == 503
|
||||
26
tests/test_json_sanitize.py
Normal file
26
tests/test_json_sanitize.py
Normal file
@ -0,0 +1,26 @@
|
||||
import json
|
||||
import math
|
||||
|
||||
from onguard24.ingress.json_sanitize import sanitize_for_jsonb
|
||||
|
||||
|
||||
def test_sanitize_nan_inf_to_none() -> None:
|
||||
raw = json.loads('{"a": NaN, "b": Infinity, "c": -Infinity, "d": 1.5}')
|
||||
out = sanitize_for_jsonb(raw)
|
||||
assert math.isnan(raw["a"])
|
||||
assert out["a"] is None
|
||||
assert out["b"] is None
|
||||
assert out["c"] is None
|
||||
assert out["d"] == 1.5
|
||||
|
||||
|
||||
def test_sanitize_strips_nul_in_strings() -> None:
|
||||
assert sanitize_for_jsonb({"x": "a\x00b"}) == {"x": "ab"}
|
||||
|
||||
|
||||
def test_dumps_after_sanitize_is_valid_json() -> None:
|
||||
raw = json.loads('{"v": NaN}')
|
||||
clean = sanitize_for_jsonb(raw)
|
||||
s = json.dumps(clean, allow_nan=False)
|
||||
assert "NaN" not in s
|
||||
assert json.loads(s)["v"] is None
|
||||
45
tests/test_log_buffer.py
Normal file
45
tests/test_log_buffer.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""Кольцевой буфер логов и SSE-страница."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from onguard24 import log_buffer
|
||||
|
||||
|
||||
def test_ring_buffer_captures_log_records() -> None:
|
||||
log_buffer._ring.clear()
|
||||
handler = log_buffer.RingBufferHandler()
|
||||
handler.setFormatter(logging.Formatter("%(name)s %(message)s"))
|
||||
logger = logging.getLogger("test.ring")
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
try:
|
||||
logger.info("hello ring")
|
||||
history = log_buffer.get_history()
|
||||
assert any("hello ring" in e["msg"] for e in history)
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
log_buffer._ring.clear()
|
||||
|
||||
|
||||
def test_logs_page_returns_html(client: TestClient) -> None:
|
||||
r = client.get("/ui/logs")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers.get("content-type", "")
|
||||
assert "Логи" in r.text
|
||||
assert "log-wrap" in r.text
|
||||
assert "EventSource" in r.text or "event-stream" in r.text or "ui/logs/stream" in r.text
|
||||
|
||||
|
||||
def test_logs_page_in_nav_rail(client: TestClient) -> None:
|
||||
r = client.get("/ui/logs")
|
||||
assert r.status_code == 200
|
||||
assert "/ui/logs" in r.text
|
||||
|
||||
|
||||
def test_root_has_logs_link(client: TestClient) -> None:
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "/ui/logs" in r.text
|
||||
85
tests/test_root_ui.py
Normal file
85
tests/test_root_ui.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""Главная страница и изолированные 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 = (
|
||||
("grafana-catalog", "Каталог Grafana"),
|
||||
("alerts", "Алерты"),
|
||||
("teams", "Команды"),
|
||||
("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 (
|
||||
"grafana-catalog",
|
||||
"alerts",
|
||||
"teams",
|
||||
"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
|
||||
@ -42,6 +42,7 @@ def test_status_with_mocks(client: TestClient) -> None:
|
||||
vault_token="t",
|
||||
grafana_url="https://grafana.example",
|
||||
grafana_service_account_token="g",
|
||||
grafana_sources_json="",
|
||||
forgejo_url="https://git.example",
|
||||
forgejo_token="f",
|
||||
grafana_webhook_secret="",
|
||||
@ -57,5 +58,6 @@ def test_status_with_mocks(client: TestClient) -> None:
|
||||
d = r.json()
|
||||
assert d["vault"]["status"] == "ok"
|
||||
assert d["grafana"]["status"] == "ok"
|
||||
assert d["grafana"].get("service_account_login") == "tester"
|
||||
assert len(d["grafana"]["instances"]) == 1
|
||||
assert d["grafana"]["instances"][0].get("service_account_login") == "tester"
|
||||
assert d["forgejo"]["status"] == "ok"
|
||||
|
||||
38
tests/test_team_match.py
Normal file
38
tests/test_team_match.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""Сопоставление лейблов с командой (без БД)."""
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from onguard24.ingress.team_match import match_team_for_labels
|
||||
|
||||
|
||||
def test_match_returns_none_when_empty() -> None:
|
||||
assert match_team_for_labels({}, []) is None
|
||||
assert match_team_for_labels({"a": "b"}, []) is None
|
||||
|
||||
|
||||
def test_match_first_rule_wins_in_order() -> None:
|
||||
u_infra = uuid4()
|
||||
u_other = uuid4()
|
||||
labels = {"team": "infra", "env": "prod"}
|
||||
rules: list[tuple[UUID, str, str]] = [
|
||||
(u_infra, "team", "infra"),
|
||||
(u_other, "env", "prod"),
|
||||
]
|
||||
assert match_team_for_labels(labels, rules) == u_infra
|
||||
|
||||
|
||||
def test_match_skips_until_value_matches() -> None:
|
||||
u = uuid4()
|
||||
labels = {"x": "1"}
|
||||
rules: list[tuple[UUID, str, str]] = [
|
||||
(uuid4(), "x", "2"),
|
||||
(u, "x", "1"),
|
||||
]
|
||||
assert match_team_for_labels(labels, rules) == u
|
||||
|
||||
|
||||
def test_match_coerces_label_values_to_str() -> None:
|
||||
u = uuid4()
|
||||
labels = {"port": 8080}
|
||||
rules: list[tuple[UUID, str, str]] = [(u, "port", "8080")]
|
||||
assert match_team_for_labels(labels, rules) == u
|
||||
9
tests/test_teams_api.py
Normal file
9
tests/test_teams_api.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""API модуля команд без БД."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_teams_list_no_db(client: TestClient) -> None:
|
||||
r = client.get("/api/v1/modules/teams/")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"items": [], "database": "disabled"}
|
||||
Reference in New Issue
Block a user