Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8ccf1d35c | |||
| 3cb75eb7b7 | |||
| 420821f3a0 | |||
| c324b4732f | |||
| 36945ed796 | |||
| cf16a57442 | |||
| 719991d60b | |||
| 9f2aa2d2b5 |
@ -6,6 +6,9 @@ LOG_LEVEL=info
|
|||||||
# Опционально: общий секрет для вебхуков (если у источника в JSON не задан свой webhook_secret)
|
# Опционально: общий секрет для вебхуков (если у источника в JSON не задан свой webhook_secret)
|
||||||
# GRAFANA_WEBHOOK_SECRET=
|
# GRAFANA_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# Устаревшее: автосоздание инцидента на каждый вебхук (дублирует irm_alerts). Обычно не нужно.
|
||||||
|
# AUTO_INCIDENT_FROM_ALERT=1
|
||||||
|
|
||||||
# Несколько Grafana: JSON-массив. slug — часть URL вебхука: /api/v1/ingress/grafana/{slug}
|
# Несколько Grafana: JSON-массив. slug — часть URL вебхука: /api/v1/ingress/grafana/{slug}
|
||||||
# Пример: [{"slug":"adibrov","api_url":"https://grafana-adibrov.example","api_token":"glsa_...","webhook_secret":"длинный-секрет"}]
|
# Пример: [{"slug":"adibrov","api_url":"https://grafana-adibrov.example","api_token":"glsa_...","webhook_secret":"длинный-секрет"}]
|
||||||
# Если пусто, но задан GRAFANA_URL — один источник со slug "default" (вебхук /api/v1/ingress/grafana/default)
|
# Если пусто, но задан GRAFANA_URL — один источник со slug "default" (вебхук /api/v1/ingress/grafana/default)
|
||||||
@ -36,3 +39,7 @@ VAULT_TOKEN=
|
|||||||
# Токен: Forgejo → Settings → Applications → Generate New Token (или старый PAT)
|
# Токен: Forgejo → Settings → Applications → Generate New Token (или старый PAT)
|
||||||
FORGEJO_URL=https://forgejo.pvenode.ru
|
FORGEJO_URL=https://forgejo.pvenode.ru
|
||||||
# FORGEJO_TOKEN=
|
# FORGEJO_TOKEN=
|
||||||
|
|
||||||
|
# --- Деплой через Forgejo Actions (НЕ сюда) ---
|
||||||
|
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, DEPLOY_SSH_KEY задаются только в веб-UI:
|
||||||
|
# репозиторий → Настройки → Actions → Secrets. Контейнер onGuard24 их не читает.
|
||||||
|
|||||||
@ -1,18 +1,27 @@
|
|||||||
# Forgejo / Gitea Actions — проверка перед деплоем
|
# Forgejo / Gitea Actions — проверка перед деплоем (совместимо с синтаксисом GitHub Actions).
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Без cache: pip — на Forgejo/act часто таймаут getCacheEntry (172.17.x), долгий шаг и warning.
|
||||||
- name: Python 3.12
|
- name: Python 3.12
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@ -20,5 +29,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Pytest
|
- name: Pytest
|
||||||
run: |
|
run: |
|
||||||
pip install -e ".[dev]"
|
set -euo pipefail
|
||||||
pytest -q
|
python -m pip install -U pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
python -m pytest tests/ -q --tb=short
|
||||||
|
|||||||
@ -8,7 +8,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
description: "Git ref (тег для релиза или отката, напр. v1.5.0 или v1.4.1)"
|
description: "main, v1.7.0 или 1.7.0 (без v подставится автоматически)."
|
||||||
required: true
|
required: true
|
||||||
default: "main"
|
default: "main"
|
||||||
|
|
||||||
@ -18,31 +18,74 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Определить ревизию
|
- name: Определить ревизию
|
||||||
id: pick
|
id: pick
|
||||||
|
env:
|
||||||
|
RAW_REF: ${{ inputs.ref }}
|
||||||
run: |
|
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
|
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:]]*$//')
|
||||||
else
|
R=$(normalize_ref "$R")
|
||||||
echo "ref=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
# Теги в 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
|
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
|
- name: SSH — fetch, checkout, docker compose
|
||||||
uses: appleboy/ssh-action@v1.2.0
|
uses: appleboy/ssh-action@v1.2.0
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.DEPLOY_HOST }}
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
port: "22"
|
||||||
username: ${{ secrets.DEPLOY_USER }}
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
script_stop: true
|
script_stop: true
|
||||||
command_timeout: 20m
|
command_timeout: 20m
|
||||||
script: |
|
script: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
REF="${{ steps.pick.outputs.ref }}"
|
||||||
cd "${{ secrets.DEPLOY_PATH }}"
|
cd "${{ secrets.DEPLOY_PATH }}"
|
||||||
|
echo "=== deploy REF=$REF ==="
|
||||||
git fetch origin --tags --prune
|
git fetch origin --tags --prune
|
||||||
git checkout "${{ steps.pick.outputs.ref }}"
|
git checkout "$REF"
|
||||||
if git show-ref --verify --quiet "refs/remotes/origin/${{ steps.pick.outputs.ref }}"; then
|
# Теги не дают refs/remotes/origin/<тег> — только ветки; для v* срабатывает else.
|
||||||
git reset --hard "origin/${{ steps.pick.outputs.ref }}"
|
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
|
||||||
|
git reset --hard "origin/$REF"
|
||||||
else
|
else
|
||||||
git reset --hard "${{ steps.pick.outputs.ref }}"
|
git reset --hard "$REF"
|
||||||
fi
|
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 up -d
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,7 @@
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
# локальные секреты на сервере (compose использует только .env)
|
||||||
|
/env
|
||||||
*.pem
|
*.pem
|
||||||
dist/
|
dist/
|
||||||
bin/
|
bin/
|
||||||
|
|||||||
28
CHANGELOG.md
28
CHANGELOG.md
@ -2,6 +2,34 @@
|
|||||||
|
|
||||||
Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md).
|
Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md).
|
||||||
|
|
||||||
|
## [1.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
|
## [1.7.0] — 2026-04-03
|
||||||
|
|
||||||
Каталог Grafana (топология правил), доработки ingress/IRM, тесты.
|
Каталог Grafana (топология правил), доработки ingress/IRM, тесты.
|
||||||
|
|||||||
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;")
|
||||||
17
docs/CICD.md
17
docs/CICD.md
@ -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`. |
|
| `.gitea/workflows/deploy.yml` | На `push` тега `v*`: SSH на сервер → `git checkout` → `docker compose build/up`. |
|
||||||
| `Dockerfile` | Образ Python 3.12, `pip install .`, entrypoint: `alembic upgrade` + `uvicorn`. |
|
| `Dockerfile` | Образ Python 3.12, `pip install .`, entrypoint: `alembic upgrade` + `uvicorn`. |
|
||||||
| `docker-compose.yml` | Сервис `onguard24`, порт `8080`, `env_file: .env`. |
|
| `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).
|
Стабильные теги: [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:**
|
**Настройки репозитория → Actions → Secrets:**
|
||||||
@ -84,7 +92,7 @@ curl -s http://127.0.0.1:8080/health
|
|||||||
## Откат версии (простой процесс)
|
## Откат версии (простой процесс)
|
||||||
|
|
||||||
1. В Forgejo: **Actions → Deploy → Run workflow**.
|
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`.
|
3. Запустить. На сервере выполнится `checkout` и `reset` на этот ref, затем `docker compose build && up -d`.
|
||||||
|
|
||||||
Убедитесь, что старый тег есть в `origin` (`git push --tags` не удалялся).
|
Убедитесь, что старый тег есть в `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.
|
- **Runner не берёт job** — проверьте `runs-on` и метки runner.
|
||||||
- **SSH fails** — `ssh -i key root@DEPLOY_HOST` с машины runner; `known_hosts` при необходимости добавьте в экшен (расширение `appleboy/ssh-action` / `ssh-keyscan`).
|
- **SSH fails** — `ssh -i key root@DEPLOY_HOST` с машины runner; `known_hosts` при необходимости добавьте в экшен (расширение `appleboy/ssh-action` / `ssh-keyscan`).
|
||||||
- **`git checkout` fails** — выполните на сервере `git fetch --tags` вручную, проверьте remote URL и ключ.
|
- **`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` где поддерживается).
|
- **База недоступна из контейнера** — в `DATABASE_URL` укажите хост, доступный **из Docker** (не `127.0.0.1` хоста, если БД на хосте — используйте IP хоста или `host.docker.internal` где поддерживается).
|
||||||
|
|
||||||
См. также [VERSIONING.md](VERSIONING.md) и [IRM.md](IRM.md).
|
См. также [VERSIONING.md](VERSIONING.md) и [IRM.md](IRM.md).
|
||||||
|
|||||||
12
docs/IRM.md
12
docs/IRM.md
@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
| Область | Назначение | onGuard24 | Grafana / внешнее |
|
| Область | Назначение | 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 |
|
| **Задачи** | Подзадачи по инциденту (разбор, фикс) | Модуль `tasks`: таблица `tasks`, привязка к `incident_id` | Опционально: ссылки из алерта; основная работа в onGuard24 |
|
||||||
| **Цепочки эскалаций** | Кого звать и в каком порядке при таймаутах | Модуль `escalations`: таблица `escalation_policies` (JSON `steps`), API/UI заготовка | Маршрутизация уведомлений может дублироваться в Grafana contact points; целевая логика — в onGuard24 |
|
| **Цепочки эскалаций** | Кого звать и в каком порядке при таймаутах | Модуль `escalations`: таблица `escalation_policies` (JSON `steps`), API/UI заготовка | Маршрутизация уведомлений может дублироваться в Grafana contact points; целевая логика — в onGuard24 |
|
||||||
| **Календарь дежурств** | Кто в смене, расписание | Модуль `schedules` (развитие) | Календари/команды — данные в onGuard24; уведомления — через интеграции |
|
| **Календарь дежурств** | Кто в смене, расписание | Модуль `schedules` (развитие) | Календари/команды — данные в onGuard24; уведомления — через интеграции |
|
||||||
@ -17,11 +18,12 @@
|
|||||||
| **Пользователи / права** | RBAC | *Пока нет* | SSO Grafana, сеть за reverse proxy |
|
| **Пользователи / права** | RBAC | *Пока нет* | SSO Grafana, сеть за reverse proxy |
|
||||||
| **SLO** | Цели по доступности | *Вне скоупа v1* | Grafana SLO / Mimir |
|
| **SLO** | Цели по доступности | *Вне скоупа v1* | Grafana SLO / Mimir |
|
||||||
|
|
||||||
## Поток данных (алерт → инцидент)
|
## Поток данных (как в Grafana IRM)
|
||||||
|
|
||||||
1. Grafana срабатывает правило → шлёт JSON на **webhook** onGuard24.
|
1. Grafana срабатывает правило → JSON на **webhook** onGuard24.
|
||||||
2. Сервис пишет строку в `ingress_events`, публикует **`alert.received`**.
|
2. В одной транзакции: **`ingress_events`** + **`irm_alerts`** (статус `firing`), публикуется **`alert.received`**.
|
||||||
3. Модуль **incidents** подписан на событие и создаёт запись в **`incidents`** с ссылкой на `ingress_event_id`.
|
3. Дежурный в модуле **Алерты** читает заголовок, лейблы, **Acknowledge** / **Resolve** — это не создание инцидента.
|
||||||
|
4. **Инцидент** создаётся отдельно (вручную или из карточки алерта), опционально с привязкой **`alert_ids`**. Авто-инцидент из вебхука только при **`AUTO_INCIDENT_FROM_ALERT=1`** (legacy).
|
||||||
|
|
||||||
## Что настроить в Grafana (обязательно для приёма алертов)
|
## Что настроить в Grafana (обязательно для приёма алертов)
|
||||||
|
|
||||||
|
|||||||
39
docs/IRM_GRAFANA_PARITY.md
Normal file
39
docs/IRM_GRAFANA_PARITY.md
Normal 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, где **алерт** и **инцидент** разведены.
|
||||||
@ -1,3 +1,3 @@
|
|||||||
"""onGuard24 — модульный монолит (ядро + модули)."""
|
"""onGuard24 — модульный монолит (ядро + модули)."""
|
||||||
|
|
||||||
__version__ = "1.7.0"
|
__version__ = "1.9.0"
|
||||||
|
|||||||
@ -34,6 +34,11 @@ class Settings(BaseSettings):
|
|||||||
forgejo_url: str = Field(default="", validation_alias="FORGEJO_URL")
|
forgejo_url: str = Field(default="", validation_alias="FORGEJO_URL")
|
||||||
forgejo_token: str = Field(default="", validation_alias="FORGEJO_TOKEN")
|
forgejo_token: str = Field(default="", validation_alias="FORGEJO_TOKEN")
|
||||||
log_level: str = Field(default="info", validation_alias="LOG_LEVEL")
|
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:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from starlette.responses import Response
|
|||||||
|
|
||||||
from onguard24.domain.entities import Alert, Severity
|
from onguard24.domain.entities import Alert, Severity
|
||||||
from onguard24.grafana_sources import sources_by_slug, webhook_authorized
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["ingress"])
|
router = APIRouter(tags=["ingress"])
|
||||||
@ -119,7 +120,9 @@ async def _grafana_webhook_impl(
|
|||||||
logger.warning("ingress: database not configured, event not persisted")
|
logger.warning("ingress: database not configured, event not persisted")
|
||||||
return Response(status_code=202)
|
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:
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
INSERT INTO ingress_events (source, body, org_slug, service_name)
|
INSERT INTO ingress_events (source, body, org_slug, service_name)
|
||||||
@ -132,6 +135,23 @@ async def _grafana_webhook_impl(
|
|||||||
service_name,
|
service_name,
|
||||||
)
|
)
|
||||||
raw_id = row["id"] if row else None
|
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)
|
bus = getattr(request.app.state, "event_bus", None)
|
||||||
if bus and raw_id is not None:
|
if bus and raw_id is not None:
|
||||||
title = str(body.get("title") or body.get("ruleName") or "")[:500]
|
title = str(body.get("title") or body.get("ruleName") or "")[:500]
|
||||||
|
|||||||
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
|
||||||
346
onguard24/modules/alerts.py
Normal file
346
onguard24/modules/alerts.py
Normal 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>'
|
||||||
|
)
|
||||||
@ -238,15 +238,9 @@ async def list_meta_api(pool: asyncpg.Pool | None = Depends(get_pool)):
|
|||||||
return {"items": items}
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tree")
|
async def _build_catalog_tree_dict(conn: asyncpg.Connection, instance_slug: str) -> dict | None:
|
||||||
async def tree_api(
|
"""Сборка дерева из БД (общая логика для API и UI)."""
|
||||||
instance_slug: str,
|
|
||||||
pool: asyncpg.Pool | None = Depends(get_pool),
|
|
||||||
):
|
|
||||||
if pool is None:
|
|
||||||
raise HTTPException(status_code=503, detail="database disabled")
|
|
||||||
slug = instance_slug.strip().lower()
|
slug = instance_slug.strip().lower()
|
||||||
async with pool.acquire() as conn:
|
|
||||||
meta = await conn.fetchrow(
|
meta = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM grafana_catalog_meta
|
SELECT * FROM grafana_catalog_meta
|
||||||
@ -256,7 +250,7 @@ async def tree_api(
|
|||||||
slug,
|
slug,
|
||||||
)
|
)
|
||||||
if not meta:
|
if not meta:
|
||||||
raise HTTPException(status_code=404, detail="no catalog for this slug; run POST /sync first")
|
return None
|
||||||
oid = meta["grafana_org_id"]
|
oid = meta["grafana_org_id"]
|
||||||
folders = await conn.fetch(
|
folders = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
@ -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 = []
|
folder_nodes = []
|
||||||
for f in folders:
|
for f in folders:
|
||||||
uid = f["folder_uid"]
|
uid = f["folder_uid"]
|
||||||
@ -311,14 +309,118 @@ async def tree_api(
|
|||||||
"org_name": meta["org_name"],
|
"org_name": meta["org_name"],
|
||||||
"synced_at": meta["synced_at"].isoformat() if meta["synced_at"] else None,
|
"synced_at": meta["synced_at"].isoformat() if meta["synced_at"] else None,
|
||||||
"folders": folder_nodes,
|
"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)
|
@ui_router.get("/", response_class=HTMLResponse)
|
||||||
async def grafana_catalog_ui(request: Request):
|
async def grafana_catalog_ui(request: Request):
|
||||||
pool = get_pool(request)
|
pool = get_pool(request)
|
||||||
inner = ""
|
inner = ""
|
||||||
|
tree_html = ""
|
||||||
if pool is None:
|
if pool is None:
|
||||||
inner = "<p>База не настроена.</p>"
|
inner = "<p>База не настроена.</p>"
|
||||||
else:
|
else:
|
||||||
@ -331,12 +433,26 @@ async def grafana_catalog_ui(request: Request):
|
|||||||
ORDER BY instance_slug
|
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:
|
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:
|
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:
|
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 "—"
|
st = r["synced_at"].isoformat() if r["synced_at"] else "—"
|
||||||
inner += (
|
inner += (
|
||||||
f"<tr><td>{html.escape(r['instance_slug'])}</td>"
|
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>{r['folder_count']}</td><td>{r['rule_count']}</td>"
|
||||||
f"<td>{err}</td></tr>"
|
f"<td>{err}</td></tr>"
|
||||||
)
|
)
|
||||||
|
slug = str(r["instance_slug"])
|
||||||
|
if slug not in seen_slugs:
|
||||||
|
seen_slugs.add(slug)
|
||||||
inner += "</tbody></table>"
|
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:
|
except Exception as e:
|
||||||
inner = f"<p class='module-err'>{html.escape(str(e))}</p>"
|
inner = f"<p class='module-err'>{html.escape(str(e))}</p>"
|
||||||
page = f"""<h1>Каталог Grafana</h1>
|
page = f"""<h1>Каталог Grafana</h1>
|
||||||
<p>Иерархия: инстанс (slug) → организация → папки → правила. Синхронизация по HTTP API.</p>
|
<p>Иерархия: инстанс (slug) → организация → папки (имена и UID) → правила (название, UID, группа, интервал, labels).</p>
|
||||||
{inner}
|
{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(
|
return HTMLResponse(
|
||||||
wrap_module_html_page(
|
wrap_module_html_page(
|
||||||
document_title="Каталог Grafana — onGuard24",
|
document_title="Каталог Grafana — onGuard24",
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import html
|
import html
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@ -11,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from onguard24.config import get_settings
|
||||||
from onguard24.deps import get_pool
|
from onguard24.deps import get_pool
|
||||||
from onguard24.domain.events import AlertReceived, DomainEvent, EventBus
|
from onguard24.domain.events import AlertReceived, DomainEvent, EventBus
|
||||||
from onguard24.modules.ui_support import wrap_module_html_page
|
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)
|
title: str = Field(..., min_length=1, max_length=500)
|
||||||
status: str = Field(default="open", max_length=64)
|
status: str = Field(default="open", max_length=64)
|
||||||
severity: str = Field(default="warning", max_length=32)
|
severity: str = Field(default="warning", max_length=32)
|
||||||
|
alert_ids: list[UUID] = Field(default_factory=list, description="Привязка к irm_alerts")
|
||||||
|
|
||||||
|
|
||||||
class IncidentPatch(BaseModel):
|
class IncidentPatch(BaseModel):
|
||||||
@ -38,6 +41,8 @@ def register_events(bus: EventBus, pool: asyncpg.Pool | None = None) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
async def on_alert(ev: DomainEvent) -> None:
|
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:
|
if not isinstance(ev, AlertReceived) or ev.raw_payload_ref is None:
|
||||||
return
|
return
|
||||||
a = ev.alert
|
a = ev.alert
|
||||||
@ -135,6 +140,7 @@ async def create_incident_api(
|
|||||||
if pool is None:
|
if pool is None:
|
||||||
raise HTTPException(status_code=503, detail="database disabled")
|
raise HTTPException(status_code=503, detail="database disabled")
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
INSERT INTO incidents (title, status, severity, source, grafana_org_slug, service_name)
|
INSERT INTO incidents (title, status, severity, source, grafana_org_slug, service_name)
|
||||||
@ -146,6 +152,17 @@ async def create_incident_api(
|
|||||||
body.status,
|
body.status,
|
||||||
body.severity,
|
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 {
|
return {
|
||||||
"id": str(row["id"]),
|
"id": str(row["id"]),
|
||||||
"title": row["title"],
|
"title": row["title"],
|
||||||
@ -261,6 +278,11 @@ async def patch_incident_api(
|
|||||||
return _incident_row_dict(row)
|
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)
|
@ui_router.get("/", response_class=HTMLResponse)
|
||||||
async def incidents_ui_home(request: Request):
|
async def incidents_ui_home(request: Request):
|
||||||
pool = get_pool(request)
|
pool = get_pool(request)
|
||||||
@ -280,17 +302,22 @@ async def incidents_ui_home(request: Request):
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
iid = r["id"]
|
||||||
|
iid_s = str(iid)
|
||||||
org = html.escape(str(r["grafana_org_slug"] or "—"))
|
org = html.escape(str(r["grafana_org_slug"] or "—"))
|
||||||
svc = html.escape(str(r["service_name"] or "—"))
|
svc = html.escape(str(r["service_name"] or "—"))
|
||||||
|
ca = r["created_at"].isoformat() if r["created_at"] else "—"
|
||||||
rows_html += (
|
rows_html += (
|
||||||
"<tr>"
|
"<tr>"
|
||||||
f"<td>{html.escape(str(r['id']))[:8]}…</td>"
|
f"<td><a href=\"/ui/modules/incidents/{html.escape(iid_s, quote=True)}\">"
|
||||||
f"<td>{html.escape(r['title'])}</td>"
|
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['status'])}</td>"
|
||||||
f"<td>{html.escape(r['severity'])}</td>"
|
f"<td>{html.escape(r['severity'])}</td>"
|
||||||
f"<td>{html.escape(r['source'])}</td>"
|
f"<td>{html.escape(r['source'])}</td>"
|
||||||
f"<td>{org}</td>"
|
f"<td>{org}</td>"
|
||||||
f"<td>{svc}</td>"
|
f"<td>{svc}</td>"
|
||||||
|
f"<td>{html.escape(ca)}</td>"
|
||||||
"</tr>"
|
"</tr>"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -298,10 +325,10 @@ async def incidents_ui_home(request: Request):
|
|||||||
inner = f"""<h1>Инциденты</h1>
|
inner = f"""<h1>Инциденты</h1>
|
||||||
{err}
|
{err}
|
||||||
<table class="irm-table">
|
<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>
|
<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="7">Пока нет записей</td></tr>'}</tbody>
|
<tbody>{rows_html or '<tr><td colspan="8">Пока нет записей</td></tr>'}</tbody>
|
||||||
</table>
|
</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(
|
return HTMLResponse(
|
||||||
wrap_module_html_page(
|
wrap_module_html_page(
|
||||||
document_title="Инциденты — onGuard24",
|
document_title="Инциденты — onGuard24",
|
||||||
@ -309,3 +336,90 @@ async def incidents_ui_home(request: Request):
|
|||||||
main_inner_html=inner,
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from starlette.requests import Request
|
|||||||
|
|
||||||
from onguard24.domain.events import EventBus
|
from onguard24.domain.events import EventBus
|
||||||
from onguard24.modules import (
|
from onguard24.modules import (
|
||||||
|
alerts,
|
||||||
contacts,
|
contacts,
|
||||||
escalations,
|
escalations,
|
||||||
grafana_catalog,
|
grafana_catalog,
|
||||||
@ -52,6 +53,15 @@ def _mounts() -> list[ModuleMount]:
|
|||||||
ui_router=grafana_catalog.ui_router,
|
ui_router=grafana_catalog.ui_router,
|
||||||
render_home_fragment=grafana_catalog.render_home_fragment,
|
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(
|
ModuleMount(
|
||||||
router=incidents.router,
|
router=incidents.router,
|
||||||
url_prefix="/api/v1/modules/incidents",
|
url_prefix="/api/v1/modules/incidents",
|
||||||
|
|||||||
@ -30,6 +30,22 @@ APP_SHELL_CSS = """
|
|||||||
.irm-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
.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 th, .irm-table td { border: 1px solid #e4e4e7; padding: 0.45rem 0.65rem; text-align: left; }
|
||||||
.irm-table thead th { background: #f4f4f5; }
|
.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; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "onguard24"
|
name = "onguard24"
|
||||||
version = "1.7.0"
|
version = "1.9.0"
|
||||||
description = "onGuard24 — модульный сервис (аналог IRM)"
|
description = "onGuard24 — модульный сервис (аналог IRM)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@ -25,15 +25,28 @@ class Row:
|
|||||||
return self._data.get(key, default)
|
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:
|
class IrmFakeConn:
|
||||||
def __init__(self, store: IrmFakeStore) -> None:
|
def __init__(self, store: IrmFakeStore) -> None:
|
||||||
self.store = store
|
self.store = store
|
||||||
|
|
||||||
|
def transaction(self) -> _FakeTxn:
|
||||||
|
return _FakeTxn()
|
||||||
|
|
||||||
def _q(self, query: str) -> str:
|
def _q(self, query: str) -> str:
|
||||||
return " ".join(query.split())
|
return " ".join(query.split())
|
||||||
|
|
||||||
async def execute(self, query: str, *args: Any) -> str:
|
async def execute(self, query: str, *args: Any) -> str:
|
||||||
q = self._q(query)
|
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:
|
if "INSERT INTO incidents" in q and "ingress_event_id" in q:
|
||||||
self.store.insert_incident_alert(
|
self.store.insert_incident_alert(
|
||||||
args[0], args[1], args[2], args[3], args[4]
|
args[0], args[1], args[2], args[3], args[4]
|
||||||
|
|||||||
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"}
|
||||||
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"
|
||||||
@ -1,9 +1,23 @@
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
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:
|
def test_grafana_webhook_no_db(client: TestClient) -> None:
|
||||||
"""Без пула БД — 202, запись не падает."""
|
"""Без пула БД — 202, запись не падает."""
|
||||||
r = client.post(
|
r = client.post(
|
||||||
@ -41,12 +55,7 @@ def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None:
|
|||||||
mock_conn = AsyncMock()
|
mock_conn = AsyncMock()
|
||||||
uid = uuid4()
|
uid = uuid4()
|
||||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||||
mock_cm = AsyncMock()
|
mock_pool = _webhook_mock_pool(mock_conn)
|
||||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
||||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
|
||||||
|
|
||||||
app = client.app
|
app = client.app
|
||||||
real_pool = app.state.pool
|
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
|
assert r.status_code == 202
|
||||||
mock_conn.fetchrow.assert_called_once()
|
mock_conn.fetchrow.assert_called_once()
|
||||||
|
mock_conn.execute.assert_called_once()
|
||||||
finally:
|
finally:
|
||||||
app.state.pool = real_pool
|
app.state.pool = real_pool
|
||||||
|
|
||||||
@ -69,11 +79,7 @@ def test_grafana_webhook_auto_org_from_external_url(client: TestClient) -> None:
|
|||||||
mock_conn = AsyncMock()
|
mock_conn = AsyncMock()
|
||||||
uid = uuid4()
|
uid = uuid4()
|
||||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||||
mock_cm = AsyncMock()
|
mock_pool = _webhook_mock_pool(mock_conn)
|
||||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
||||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
|
||||||
|
|
||||||
app = client.app
|
app = client.app
|
||||||
real_pool = app.state.pool
|
real_pool = app.state.pool
|
||||||
@ -99,11 +105,7 @@ def test_grafana_webhook_publishes_alert_received(client: TestClient) -> None:
|
|||||||
mock_conn = AsyncMock()
|
mock_conn = AsyncMock()
|
||||||
uid = uuid4()
|
uid = uuid4()
|
||||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||||
mock_cm = AsyncMock()
|
mock_pool = _webhook_mock_pool(mock_conn)
|
||||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
||||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
|
||||||
|
|
||||||
app = client.app
|
app = client.app
|
||||||
bus = app.state.event_bus
|
bus = app.state.event_bus
|
||||||
@ -130,11 +132,7 @@ def test_grafana_webhook_org_any_slug_without_json_config(client: TestClient) ->
|
|||||||
mock_conn = AsyncMock()
|
mock_conn = AsyncMock()
|
||||||
uid = uuid4()
|
uid = uuid4()
|
||||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||||
mock_cm = AsyncMock()
|
mock_pool = _webhook_mock_pool(mock_conn)
|
||||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
||||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
|
||||||
|
|
||||||
app = client.app
|
app = client.app
|
||||||
real_pool = app.state.pool
|
real_pool = app.state.pool
|
||||||
@ -157,11 +155,7 @@ def test_grafana_webhook_org_ok(client: TestClient) -> None:
|
|||||||
mock_conn = AsyncMock()
|
mock_conn = AsyncMock()
|
||||||
uid = uuid4()
|
uid = uuid4()
|
||||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||||
mock_cm = AsyncMock()
|
mock_pool = _webhook_mock_pool(mock_conn)
|
||||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
||||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
mock_pool = MagicMock()
|
|
||||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
|
||||||
|
|
||||||
app = client.app
|
app = client.app
|
||||||
real_json = app.state.settings.grafana_sources_json
|
real_json = app.state.settings.grafana_sources_json
|
||||||
|
|||||||
@ -29,8 +29,41 @@ def test_escalations_api_list_no_db(client: TestClient) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_incident_inserted_on_alert_received() -> None:
|
async def test_incident_not_created_from_alert_by_default() -> None:
|
||||||
"""При пуле БД подписка создаёт инцидент (INSERT)."""
|
"""По умолчанию 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 = {}
|
inserted: dict = {}
|
||||||
|
|
||||||
async def fake_execute(_query, *args):
|
async def fake_execute(_query, *args):
|
||||||
|
|||||||
@ -33,6 +33,7 @@ def test_rail_lists_all_registered_ui_modules(client: TestClient) -> None:
|
|||||||
t = r.text
|
t = r.text
|
||||||
expected = (
|
expected = (
|
||||||
("grafana-catalog", "Каталог Grafana"),
|
("grafana-catalog", "Каталог Grafana"),
|
||||||
|
("alerts", "Алерты"),
|
||||||
("incidents", "Инциденты"),
|
("incidents", "Инциденты"),
|
||||||
("tasks", "Задачи"),
|
("tasks", "Задачи"),
|
||||||
("escalations", "Эскалации"),
|
("escalations", "Эскалации"),
|
||||||
@ -49,6 +50,7 @@ def test_each_module_page_single_active_nav_item(client: TestClient) -> None:
|
|||||||
"""На странице модуля ровно один пункт с aria-current (текущий раздел)."""
|
"""На странице модуля ровно один пункт с aria-current (текущий раздел)."""
|
||||||
for slug in (
|
for slug in (
|
||||||
"grafana-catalog",
|
"grafana-catalog",
|
||||||
|
"alerts",
|
||||||
"incidents",
|
"incidents",
|
||||||
"tasks",
|
"tasks",
|
||||||
"escalations",
|
"escalations",
|
||||||
|
|||||||
Reference in New Issue
Block a user