8 Commits

Author SHA1 Message Date
a8ccf1d35c release: v1.9.0 — IRM-алерты отдельно от инцидентов
Some checks failed
Deploy / deploy (push) Has been cancelled
CI / test (push) Successful in 37s
- Alembic 005: таблицы irm_alerts и incident_alert_links
- Модуль alerts: API/UI, Ack/Resolve, привязка к инциденту через alert_ids
- Вебхук Grafana: одна транзакция ingress + irm_alerts; разбор payload в grafana_payload
- По умолчанию инцидент из вебхука не создаётся (AUTO_INCIDENT_FROM_ALERT)
- Документация IRM_GRAFANA_PARITY.md, обновления IRM.md и CHANGELOG

Made-with: Cursor
2026-04-03 15:26:38 +03:00
3cb75eb7b7 chore: release 1.8.0
Some checks failed
CI / test (push) Successful in 36s
Deploy / deploy (push) Failing after 18s
Made-with: Cursor
2026-04-03 15:06:17 +03:00
420821f3a0 ui: синхронизация каталога Grafana в браузере, дерево папок/правил; инциденты — ссылка, деталка, сырой JSON; ci: docker compose --progress plain
All checks were successful
CI / test (push) Successful in 37s
Made-with: Cursor
2026-04-03 15:04:22 +03:00
c324b4732f ci: deploy — нормализация refs/heads/* и refs/tags/* (Forgejo main)
All checks were successful
CI / test (push) Successful in 38s
Made-with: Cursor
2026-04-03 14:41:15 +03:00
36945ed796 ci: deploy ref 1.7.0 → v1.7.0 если введён semver без префикса v
All checks were successful
CI / test (push) Successful in 37s
Made-with: Cursor
2026-04-03 14:39:03 +03:00
cf16a57442 ci: deploy — лог ref, git HEAD, docker compose build --progress=plain
All checks were successful
CI / test (push) Successful in 35s
Made-with: Cursor
2026-04-03 14:36:01 +03:00
719991d60b ci: отключить pip cache в setup-python (таймаут Actions cache на Forgejo)
All checks were successful
CI / test (push) Successful in 35s
Made-with: Cursor
2026-04-03 14:32:16 +03:00
9f2aa2d2b5 ci: CI на тегах v*, проверка DEPLOY_*, документация деплоя и тегов
Some checks failed
CI / test (push) Has been cancelled
Made-with: Cursor
2026-04-03 14:26:39 +03:00
25 changed files with 1113 additions and 123 deletions

View File

@ -6,6 +6,9 @@ LOG_LEVEL=info
# Опционально: общий секрет для вебхуков (если у источника в JSON не задан свой webhook_secret)
# GRAFANA_WEBHOOK_SECRET=
# Устаревшее: автосоздание инцидента на каждый вебхук (дублирует 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)
@ -36,3 +39,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 их не читает.

View File

@ -1,18 +1,27 @@
# Forgejo / Gitea Actions — проверка перед деплоем
# 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:
@ -20,5 +29,7 @@ jobs:
- name: Pytest
run: |
pip install -e ".[dev]"
pytest -q
set -euo pipefail
python -m pip install -U pip
python -m pip install -e ".[dev]"
python -m pytest tests/ -q --tb=short

View File

@ -8,7 +8,7 @@ on:
workflow_dispatch:
inputs:
ref:
description: "Git ref (тег для релиза или отката, напр. v1.5.0 или v1.4.1)"
description: "main, v1.7.0 или 1.7.0 (без v подставится автоматически)."
required: true
default: "main"
@ -18,31 +18,74 @@ jobs:
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
echo "ref=${{ inputs.ref }}" >> "$GITHUB_OUTPUT"
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
echo "ref=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
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 "${{ steps.pick.outputs.ref }}"
if git show-ref --verify --quiet "refs/remotes/origin/${{ steps.pick.outputs.ref }}"; then
git reset --hard "origin/${{ steps.pick.outputs.ref }}"
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 "${{ steps.pick.outputs.ref }}"
git reset --hard "$REF"
fi
docker compose build
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
View File

@ -1,5 +1,7 @@
.env
.env.local
# локальные секреты на сервере (compose использует только .env)
/env
*.pem
dist/
bin/

View File

@ -2,6 +2,34 @@
Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.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, тесты.

View 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;")

View File

@ -6,7 +6,7 @@
| Файл | Назначение |
|------|------------|
| `.gitea/workflows/ci.yml` | На `push` в `main` и PR: `pytest`. |
| `.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`. |
@ -36,6 +36,14 @@ sudo docker run -d --restart always --name act_runner \
Стабильные теги: [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:**
@ -84,7 +92,7 @@ curl -s http://127.0.0.1:8080/health
## Откат версии (простой процесс)
1. В Forgejo: **Actions → Deploy → Run workflow**.
2. В поле **ref** указать **старый тег**, например `v1.4.1`.
2. В поле **ref** указать **старый тег** (`v1.4.1` или для удобства просто `1.4.1` — workflow добавит `v`).
3. Запустить. На сервере выполнится `checkout` и `reset` на этот ref, затем `docker compose build && up -d`.
Убедитесь, что старый тег есть в `origin` (`git push --tags` не удалялся).
@ -95,9 +103,14 @@ curl -s http://127.0.0.1:8080/health
## Устранение неполадок
- **`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).

View File

@ -6,7 +6,8 @@
| Область | Назначение | onGuard24 | Grafana / внешнее |
|---------|------------|-----------|-------------------|
| **Инциденты** | Учёт сбоев, статусы (open → resolved), связь с алертом | Модуль `incidents`: таблица `incidents`, API, UI, авто-создание из `alert.received` | Contact point **Webhook**`POST /api/v1/ingress/grafana`; правила алертинга в 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 |
| **Календарь дежурств** | Кто в смене, расписание | Модуль `schedules` (развитие) | Календари/команды — данные в onGuard24; уведомления — через интеграции |
@ -17,11 +18,12 @@
| **Пользователи / права** | RBAC | *Пока нет* | SSO Grafana, сеть за reverse proxy |
| **SLO** | Цели по доступности | *Вне скоупа v1* | Grafana SLO / Mimir |
## Поток данных (алерт → инцидент)
## Поток данных (как в Grafana IRM)
1. Grafana срабатывает правило → шлёт JSON на **webhook** onGuard24.
2. Сервис пишет строку в `ingress_events`, публикует **`alert.received`**.
3. Модуль **incidents** подписан на событие и создаёт запись в **`incidents`** с ссылкой на `ingress_event_id`.
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 (обязательно для приёма алертов)

View File

@ -0,0 +1,39 @@
# Сравнение 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-задел |
## Пока нет (зрелые следующие этапы)
| Функция Grafana IRM | Заметка |
|---------------------|---------|
| **Teams** с фильтрами и привязкой маршрутов | Нет сущности `team`; алерты не маршрутизируются по команде |
| **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, где **алерт** и **инцидент** разведены.

View File

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

View File

@ -34,6 +34,11 @@ 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")
# Устаревшее: автосоздание инцидента на каждый вебхук (без учёта irm_alerts). По умолчанию выкл.
auto_incident_from_alert: bool = Field(
default=False,
validation_alias="AUTO_INCIDENT_FROM_ALERT",
)
def get_settings() -> Settings:

View File

@ -9,6 +9,7 @@ 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
logger = logging.getLogger(__name__)
router = APIRouter(tags=["ingress"])
@ -119,19 +120,38 @@ async def _grafana_webhook_impl(
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:
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),
stored_org_slug,
service_name,
)
raw_id = row["id"] if row else None
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),
stored_org_slug,
service_name,
)
raw_id = row["id"] if row else None
if raw_id is not None:
await conn.execute(
"""
INSERT INTO irm_alerts (
ingress_event_id, status, title, severity, source,
grafana_org_slug, service_name, labels, fingerprint
)
VALUES ($1, 'firing', $2, $3, 'grafana', $4, $5, $6::jsonb, $7)
""",
raw_id,
title_row or "",
sev_row,
stored_org_slug,
service_name,
json.dumps(labels_row),
fp_row,
)
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]

View 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

346
onguard24/modules/alerts.py Normal file
View File

@ -0,0 +1,346 @@
"""Учёт входящих алертов (отдельно от инцидентов): 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:
return {
"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"],
"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,
}
@router.get("/")
async def list_alerts_api(
pool: asyncpg.Pool | None = Depends(get_pool),
status: str | 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:
rows = await conn.fetch(
"""
SELECT * FROM irm_alerts WHERE status = $1
ORDER BY created_at DESC LIMIT $2
""",
st,
limit,
)
else:
rows = await conn.fetch(
"""
SELECT * FROM irm_alerts
ORDER BY 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 conn.fetchrow("SELECT * FROM irm_alerts WHERE id = $1::uuid", 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:
row = await conn.fetchrow(
"""
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 *
""",
alert_id,
who,
)
if not row:
raise HTTPException(status_code=409, detail="alert not in firing state or not found")
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:
row = await conn.fetchrow(
"""
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 *
""",
alert_id,
who,
)
if not row:
raise HTTPException(
status_code=409,
detail="alert cannot be resolved from current state or not found",
)
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 = ""
if pool is None:
body = "<p>База не настроена.</p>"
else:
try:
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, status, title, severity, grafana_org_slug, service_name, created_at, fingerprint
FROM irm_alerts
ORDER BY created_at DESC
LIMIT 150
"""
)
if not rows:
body = "<p>Пока нет алертов. События появляются после вебхука Grafana.</p>"
else:
trs = []
for r in rows:
aid = str(r["id"])
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>{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>"
)
body = (
"<p class='gc-muted'>Алерт — запись о входящем уведомлении. "
"<strong>Инцидент</strong> создаётся вручную (из карточки алерта или раздела «Инциденты») "
"и может ссылаться на один или несколько алертов.</p>"
"<table class='irm-table'><thead><tr><th>Статус</th><th>ID</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 conn.fetchrow("SELECT * FROM irm_alerts WHERE id = $1::uuid", 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>"
)
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>"
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>'
)

View File

@ -238,47 +238,41 @@ async def list_meta_api(pool: asyncpg.Pool | None = Depends(get_pool)):
return {"items": items}
@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 def _build_catalog_tree_dict(conn: asyncpg.Connection, instance_slug: str) -> dict | None:
"""Сборка дерева из БД (общая логика для API и UI)."""
slug = instance_slug.strip().lower()
async with pool.acquire() as conn:
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:
raise HTTPException(status_code=404, detail="no catalog for this slug; run POST /sync first")
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,
)
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:
@ -293,6 +287,10 @@ async def tree_api(
}
)
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"]
@ -311,14 +309,118 @@ async def tree_api(
"org_name": meta["org_name"],
"synced_at": meta["synced_at"].isoformat() if meta["synced_at"] else None,
"folders": folder_nodes,
"orphan_rule_namespaces": sorted(set(by_ns.keys()) - {f["folder_uid"] for f in folders}),
"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:
@ -331,12 +433,26 @@ async def grafana_catalog_ui(request: Request):
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 = "<p>Каталог пуст. Вызовите <code>POST /api/v1/modules/grafana-catalog/sync</code>.</p>"
inner = (
sync_bar
+ "<p>Каталог пуст — нажмите кнопку выше (нужны <code>GRAFANA_URL</code> и токен в .env).</p>"
)
else:
inner = "<table class='irm-table'><thead><tr><th>Slug</th><th>Org</th><th>Синхр.</th><th>Папок</th><th>Правил</th><th>Ошибка</th></tr></thead><tbody>"
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 ""))[:120]
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>"
@ -345,13 +461,27 @@ async def grafana_catalog_ui(request: Request):
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) → организация → папки → правила. Синхронизация по HTTP API.</p>
<p>Иерархия: инстанс (slug) → организация → папки (имена и UID) → правила (название, UID, группа, интервал, labels).</p>
{inner}
<p><small>API: <code>POST …/grafana-catalog/sync</code>, <code>GET …/grafana-catalog/tree?instance_slug=…</code></small></p>"""
{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",

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import html
import json
import logging
from uuid import UUID
@ -11,6 +12,7 @@ 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
@ -25,6 +27,7 @@ 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):
@ -38,6 +41,8 @@ def register_events(bus: EventBus, pool: asyncpg.Pool | None = None) -> 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
@ -135,17 +140,29 @@ async def create_incident_api(
if pool is None:
raise HTTPException(status_code=503, detail="database disabled")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO incidents (title, status, severity, source, 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,
)
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"],
@ -261,6 +278,11 @@ async def patch_incident_api(
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)
@ -280,17 +302,22 @@ async def incidents_ui_home(request: Request):
"""
)
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>{html.escape(str(r['id']))[:8]}…</td>"
f"<td>{html.escape(r['title'])}</td>"
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:
@ -298,10 +325,10 @@ async def incidents_ui_home(request: Request):
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></tr></thead>
<tbody>{rows_html or '<tr><td colspan="7">Пока нет записей</td></tr>'}</tbody>
<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>Создание из Grafana: webhook → запись в <code>ingress_events</code> → событие → строка здесь.</small></p>"""
<p><small>Сначала вебхук создаёт <a href="/ui/modules/alerts/">алерт</a> (учёт, Ack/Resolve). Инцидент — отдельная сущность: создаётся вручную или из карточки алерта, к нему можно привязать один или несколько алертов. Пустой заголовок в списке — часто тестовый JSON без полей правила.</small></p>"""
return HTMLResponse(
wrap_module_html_page(
document_title="Инциденты — onGuard24",
@ -309,3 +336,90 @@ async def incidents_ui_home(request: Request):
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,
)
)

View File

@ -14,6 +14,7 @@ from starlette.requests import Request
from onguard24.domain.events import EventBus
from onguard24.modules import (
alerts,
contacts,
escalations,
grafana_catalog,
@ -52,6 +53,15 @@ def _mounts() -> list[ModuleMount]:
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=incidents.router,
url_prefix="/api/v1/modules/incidents",

View File

@ -30,6 +30,22 @@ APP_SHELL_CSS = """
.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; }
"""

View File

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

View File

@ -25,15 +25,28 @@ class Row:
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]

9
tests/test_alerts_api.py Normal file
View 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"}

View 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"

View File

@ -1,9 +1,23 @@
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.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(
@ -41,12 +55,7 @@ def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None:
mock_conn = AsyncMock()
uid = uuid4()
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
mock_cm.__aexit__ = AsyncMock(return_value=None)
mock_pool = MagicMock()
mock_pool.acquire = MagicMock(return_value=mock_cm)
mock_pool = _webhook_mock_pool(mock_conn)
app = client.app
real_pool = app.state.pool
@ -59,6 +68,7 @@ def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None:
)
assert r.status_code == 202
mock_conn.fetchrow.assert_called_once()
mock_conn.execute.assert_called_once()
finally:
app.state.pool = real_pool
@ -69,11 +79,7 @@ def test_grafana_webhook_auto_org_from_external_url(client: TestClient) -> None:
mock_conn = AsyncMock()
uid = uuid4()
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
mock_cm.__aexit__ = AsyncMock(return_value=None)
mock_pool = MagicMock()
mock_pool.acquire = MagicMock(return_value=mock_cm)
mock_pool = _webhook_mock_pool(mock_conn)
app = client.app
real_pool = app.state.pool
@ -99,11 +105,7 @@ def test_grafana_webhook_publishes_alert_received(client: TestClient) -> None:
mock_conn = AsyncMock()
uid = uuid4()
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
mock_cm.__aexit__ = AsyncMock(return_value=None)
mock_pool = MagicMock()
mock_pool.acquire = MagicMock(return_value=mock_cm)
mock_pool = _webhook_mock_pool(mock_conn)
app = client.app
bus = app.state.event_bus
@ -130,11 +132,7 @@ def test_grafana_webhook_org_any_slug_without_json_config(client: TestClient) ->
mock_conn = AsyncMock()
uid = uuid4()
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
mock_cm.__aexit__ = AsyncMock(return_value=None)
mock_pool = MagicMock()
mock_pool.acquire = MagicMock(return_value=mock_cm)
mock_pool = _webhook_mock_pool(mock_conn)
app = client.app
real_pool = app.state.pool
@ -157,11 +155,7 @@ def test_grafana_webhook_org_ok(client: TestClient) -> None:
mock_conn = AsyncMock()
uid = uuid4()
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
mock_cm.__aexit__ = AsyncMock(return_value=None)
mock_pool = MagicMock()
mock_pool.acquire = MagicMock(return_value=mock_cm)
mock_pool = _webhook_mock_pool(mock_conn)
app = client.app
real_json = app.state.settings.grafana_sources_json

View File

@ -29,8 +29,41 @@ def test_escalations_api_list_no_db(client: TestClient) -> None:
@pytest.mark.asyncio
async def test_incident_inserted_on_alert_received() -> None:
"""При пуле БД подписка создаёт инцидент (INSERT)."""
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):

View File

@ -33,6 +33,7 @@ def test_rail_lists_all_registered_ui_modules(client: TestClient) -> None:
t = r.text
expected = (
("grafana-catalog", "Каталог Grafana"),
("alerts", "Алерты"),
("incidents", "Инциденты"),
("tasks", "Задачи"),
("escalations", "Эскалации"),
@ -49,6 +50,7 @@ def test_each_module_page_single_active_nav_item(client: TestClient) -> None:
"""На странице модуля ровно один пункт с aria-current (текущий раздел)."""
for slug in (
"grafana-catalog",
"alerts",
"incidents",
"tasks",
"escalations",