7 Commits

Author SHA1 Message Date
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
11 changed files with 394 additions and 66 deletions

View File

@ -36,3 +36,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 их не читает.

View File

@ -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

View File

@ -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
View File

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

View File

@ -2,6 +2,17 @@
Формат: семантическое версионирование `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.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, тесты.

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`. | | `.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).

View File

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

View File

@ -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",

View File

@ -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
@ -261,6 +262,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 +286,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 +309,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>Создание из Grafana: webhook → <code>ingress_events</code> → событие → строка здесь. Пустой заголовок бывает при тестовом JSON без полей алерта.</small></p>"""
return HTMLResponse( return HTMLResponse(
wrap_module_html_page( wrap_module_html_page(
document_title="Инциденты — onGuard24", document_title="Инциденты — onGuard24",
@ -309,3 +320,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,
)
)

View File

@ -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; }
""" """

View File

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