Ескалації: журнал більше не бреше, ack не воскрешає драбину
Три вади, знайдені рецензією, яких щасливий шлях показати не міг: * outcome='sent' писався до доставки; помилка читання каналів клала в кеш порожню мапу й з'їдала всі сходинки кабінету за тік — усі зі слідом «надіслано». Канали тепер читаються до просування стану, журнал пишеться після доставки, з правдою. * UPDATE не мав stopped_at IS NULL — підтвердження алерту посеред партії не рятувало людину від дзвінка. * час брався раз на партію. Плюс суміжне: UpdateRule не гасив алертів вимкненого правила, сервер домислював enabled на оновленні, channel_ids сходинок не звірялись із каналами кабінету (зокрема чужого). І scripts/dbtest.sh — тести проти бази перестали мовчки пропускатись. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3b9217de3c
commit
cedf1d261d
20 changed files with 1719 additions and 56 deletions
131
HISTORY.md
131
HISTORY.md
|
|
@ -7320,3 +7320,134 @@ FAIL modal > тло стає inert, поки вікно відкрите
|
|||
|
||||
**Чого тести НЕ покривають:** саму подорож події від прогону відповідності
|
||||
до каналу — це перевірено вручну на стенді, не в CI.
|
||||
|
||||
## 2026-08-28 — Вимкнення правила через форму: алерти висіли вічно, а правка вмикала назад
|
||||
|
||||
Дві вади навколо одного прапорця, знайдені рецензією й підтверджені на коді.
|
||||
|
||||
**Вада А: `PUT` вимикав правило, не гасячи його алертів.** `SetRuleEnabled`
|
||||
(перемикач у списку) після вимкнення кличе `resolveRuleAlerts` — і має на це
|
||||
причину: вимкнене правило випадає з `ActiveRules`, тобто `ResolveMissing` за
|
||||
ним більше не біжить і закрити свої алерти воно вже не зможе. `UpdateRule`
|
||||
(та сама дія, але з форми) цього не робила. Алерт лишався в `firing`
|
||||
назавжди, а драбина ескалації продовжувала будити за ним людей — з повторами
|
||||
це тижні.
|
||||
|
||||
**Вада Б: відсутнє `enabled` сервер читав як згоду ввімкнути.** Форма правил
|
||||
поля не надсилала взагалі (перемикача в ній немає — він у списку), а
|
||||
`httpapi/alerts.go` ставив `req.Enabled == nil || *req.Enabled`. Тобто
|
||||
відкрити НАВМИСНО вимкнене правило, поправити в ньому будь-що й зберегти —
|
||||
означало мовчки його ввімкнути. У формі при цьому не змінювалось нічого.
|
||||
|
||||
**Що зроблено.**
|
||||
|
||||
* `store.RuleInput.Enabled` став `*bool`: «поля не було» і «поле = false» —
|
||||
різні наміри, і `bool` їх не розрізняв. На створенні `nil` досі означає
|
||||
«увімкнене», на правці — «не чіпати».
|
||||
* `store.planRuleEnabled` — рішення окремою чистою функцією: яким стане
|
||||
прапорець і чи гасити алерти. Гасіння прив'язане до ПЕРЕХОДУ
|
||||
«увімкнене → вимкнене», а не до нового значення: повторне збереження вже
|
||||
вимкненого правила не додає зайвих подій у `event_outbox`.
|
||||
* `store.UpdateRule` читає стан до правки через `SELECT … FOR UPDATE` у тій
|
||||
самій транзакції — інакше між читанням і записом уміщається перемикач зі
|
||||
списку, і гасіння не спрацювало б у жодному з двох записів.
|
||||
* Форма (`web/src/pages/RulesPage.tsx`) тепер надсилає `enabled` явно.
|
||||
Обидва боки полагоджені навмисно: сервер — щоб не ламати чужі скрипти,
|
||||
написані за цим API, форма — щоб не покладатись на здогад узагалі.
|
||||
* Тести: `alerts_rule_update_test.go` (чотири випадки рішення),
|
||||
`alerts_rule_update_db_test.go` (проти бази: гасіння, черга подій,
|
||||
відсутність зайвого проходу), `web/src/test/rules.test.tsx` (форма
|
||||
надсилає стан обома боками — вимкнений і увімкнений).
|
||||
|
||||
**Чого тести НЕ покривають:** прогін проти бази вимагає `NETPULSE_TEST_DSN`
|
||||
і на машині, де це писалось, не запускався — Postgres там немає. Тобто SQL
|
||||
самої `UpdateRule` перевірено лише компілятором і читанням. Не покрито також
|
||||
дальший ланцюг: що погашений алерт справді знімає взведену драбину (це
|
||||
робить фон за подією `alert.resolved`, не сама `UpdateRule`) і що гонка
|
||||
«форма проти перемикача» справді розв'язується замком — обидва потребують
|
||||
живого стенду.
|
||||
|
||||
**Помічено, але НЕ виправлено (окремий обсяг).** Зміна селектора чи умови
|
||||
правила старі алерти не чіпає. Для опитуваних джерел це самолікується
|
||||
наступним тіком: `ResolveMissing` закриває все, чого немає серед свіжих
|
||||
кандидатів. Для ПОДІЄВИХ (`syslog`, `ncm`, `compliance`, `trap`) — ні:
|
||||
`engine.go` їх у цьому циклі пропускає взагалі, тож алерт, який більше не
|
||||
відповідає жодному селектору, висить до `auto_close_seconds`, а при нулі —
|
||||
доки його не закриє людина. Те саме стосується зміни `source` з опитуваного
|
||||
на подієвий.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-28 — Ескалації: рецензія, три виправлення й перший справжній прогін тестів проти бази
|
||||
|
||||
### Ескалації доведено до кінця на живих даних
|
||||
|
||||
Наскрізно, без вигаданого правила: прогін відповідності → 6 критичних
|
||||
порушень → 6 алертів → драбина. 18 доставок у Telegram (перше сповіщення
|
||||
+ дві сходинки × 6 алертів), усі `sent`. Драбина зупинилась сама з
|
||||
причиною «подієвий алерт не повторюється».
|
||||
|
||||
### Рецензія знайшла три вади, яких щасливий шлях показати не міг
|
||||
|
||||
**Журнал ескалацій брехав.** `outcome='sent'` писався ДО доставки. Сходинка,
|
||||
чиї канали видалили, вимкнули або підняли їм поріг серйозності, лишала в
|
||||
журналі «надіслано» — доказ, заради якого журнал існує, стверджував
|
||||
протилежне. Гірше: помилка читання каналів клала в кеш **порожню мапу**,
|
||||
і одна тимчасова невдача з'їдала всі належні сходинки кабінету за тік,
|
||||
кожна з них — зі слідом «надіслано».
|
||||
|
||||
Виправлено перестановкою порядку: канали читаються ДО просування стану
|
||||
(не прочитались — сходинка лишається належною й повториться), стан
|
||||
просувається ДО доставки (щоб не надіслати двічі), а журнал пишеться
|
||||
ПІСЛЯ доставки — з тим, що сталося насправді (`no_channels`, а не
|
||||
`sent`). `ApplyEscalation` розділено на просування стану й
|
||||
`LogEscalationStep`.
|
||||
|
||||
**Драбину можна було воскресити після «Прийняти».** `UPDATE` у
|
||||
`ApplyEscalation` не мав умови `stopped_at IS NULL`. Партія обробляється
|
||||
послідовно, кожна доставка з власним таймаутом, тож розрив між «взяли
|
||||
сходинку в чергу» і «надіслали» вимірюється хвилинами. Людина підтверджує
|
||||
алерт — а `UPDATE` знімає `stopped_at` і будить її знову. Тепер такий
|
||||
`UPDATE` не влучає в рядок, сходинка скасовується з записом у лог.
|
||||
|
||||
**Час брався один раз на партію** — рішення пізніх сходинок рахувались від
|
||||
застарілого моменту. Тепер на кожну сходинку свій.
|
||||
|
||||
### Суміжні вади, знайдені тією ж рецензією
|
||||
|
||||
* `UpdateRule` з `enabled:false` не гасив алертів правила (на відміну від
|
||||
`SetRuleEnabled`) — вони висіли `firing` вічно, а драбина будила людей
|
||||
до ~21 доби. Рішення винесено в чисту `planRuleEnabled`, гасіння
|
||||
прив'язане до переходу «увімкнене → вимкнене».
|
||||
* Сервер домислював `enabled: true` за відсутнім полем і на ОНОВЛЕННІ, а
|
||||
форма правил це поле не слала — редагування вимкненого правила мовчки
|
||||
його вмикало. Полагоджено обидва боки.
|
||||
* `channel_ids` сходинок драбини не звірялись із реальними каналами —
|
||||
можна було зберегти драбину з мертвими або **чужими** id, і вона
|
||||
виглядала налаштованою. Перевірка закрита у store, а не в HTTP.
|
||||
Видалення каналу тепер прибирає його зі сходинок і попереджає, які
|
||||
драбини зачепить. Сходинка лишається порожньою, а не викидається:
|
||||
викинута мовчки зсунула б чергування.
|
||||
|
||||
### scripts/dbtest.sh — і перший прогін тестів проти справжньої бази
|
||||
|
||||
Найдорожча тиха відмова проєкту: тести проти бази мовчки пропускаються
|
||||
без `NETPULSE_TEST_DSN`, тож `go test ./...` півтора року показував «ok»,
|
||||
а всередині кожного стояв `t.Skip`. Ізоляція кабінетів, стеля тарифу,
|
||||
гасіння алертів, драбини, чистка пристрою, SLA, запис карт — усе
|
||||
компілювалось і не виконувалось.
|
||||
|
||||
Тепер є `scripts/dbtest.sh`: піднімає одноразовий Postgres, котить
|
||||
міграції, ганяє `./internal/...`. Прогнано на стенді проти чистої бази —
|
||||
**63 міграції, усі пакети зелені**, включно з новими перевірками
|
||||
підтвердження посеред партії, оновлення правила й посилань на канали.
|
||||
|
||||
`scripts/check.sh` лишається швидким (без Docker) — його ганяють на кожну
|
||||
правку. Цей — перед розгортанням.
|
||||
|
||||
**Що ще НЕ покрито:** доставка ескалації блокує весь такт движка (мертвий
|
||||
вебхук одного кабінету затримує обчислення правил усім); алерт, народжений
|
||||
під заглушенням або в тиху годину, не отримує ні першого сповіщення, ні
|
||||
драбини — ніколи; втрата advisory-lock може дати подвійне сповіщення, бо
|
||||
`ApplyEscalation` не звіряє оренду; та сама відсутність перевірки каналів
|
||||
живе в `alr.rules.channel_ids` і `alr.routes.channel_ids`.
|
||||
|
|
|
|||
70
scripts/dbtest.sh
Normal file
70
scripts/dbtest.sh
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
#!/bin/sh
|
||||
# Прогін тестів проти СПРАВЖНЬОЇ бази.
|
||||
#
|
||||
# ЧОМУ ЦЕ ОКРЕМИЙ СКРИПТ
|
||||
#
|
||||
# Тести проти бази мовчки пропускаються без NETPULSE_TEST_DSN — і саме
|
||||
# тому вони півтора року нічого не перевіряли: `go test ./...` показував
|
||||
# «ok», а всередині кожного з них стояв t.Skip. «Пропущено» в підсумку
|
||||
# виглядає рівно як «пройдено», і це найдорожча тиха відмова в проєкті:
|
||||
# перевірка ізоляції кабінетів, стелі тарифу, гасіння алертів і драбин
|
||||
# ескалації існували, компілювались і не виконувались.
|
||||
#
|
||||
# scripts/check.sh лишається швидким (без Docker і без бази) — його
|
||||
# ганяють на кожну правку. Цей скрипт повільніший і потребує Postgres,
|
||||
# тож викликається окремо: перед розгортанням і в CI.
|
||||
#
|
||||
# БАЗА МУСИТЬ БУТИ ОДНОРАЗОВОЮ. Тести пишуть, видаляють і перемикають
|
||||
# ролі; напрямляти їх на робочу базу не можна. Скрипт свою базу створює
|
||||
# сам і дропає на початку кожного прогону.
|
||||
#
|
||||
# ВИКОРИСТАННЯ
|
||||
# scripts/dbtest.sh # підніме свій Postgres у Docker
|
||||
# NETPULSE_TEST_DSN=... scripts/dbtest.sh # проти готової бази
|
||||
#
|
||||
set -u
|
||||
|
||||
SRC="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OWN_DB=0
|
||||
|
||||
if [ -z "${NETPULSE_TEST_DSN:-}" ]; then
|
||||
# Своя база на час прогону. Порт випадковий-таки ні: фіксований, але
|
||||
# нетиповий, щоб не зіткнутись із локальним Postgres розробника.
|
||||
PORT=${NETPULSE_TEST_PORT:-55433}
|
||||
NAME=netpulse-dbtest
|
||||
echo "== піднімаю одноразовий Postgres :$PORT"
|
||||
docker rm -f "$NAME" >/dev/null 2>&1
|
||||
docker run -d --name "$NAME" -p "$PORT:5432" \
|
||||
-e POSTGRES_USER=netpulse -e POSTGRES_PASSWORD=probe \
|
||||
-e POSTGRES_DB=netpulse_probe \
|
||||
timescale/timescaledb:2.17.2-pg16 >/dev/null || exit 1
|
||||
OWN_DB=1
|
||||
NETPULSE_TEST_DSN="postgres://netpulse:probe@127.0.0.1:$PORT/netpulse_probe?sslmode=disable"
|
||||
export NETPULSE_TEST_DSN
|
||||
n=0
|
||||
until docker exec "$NAME" pg_isready -h 127.0.0.1 -U netpulse >/dev/null 2>&1; do
|
||||
n=$((n+1)); [ "$n" -gt 60 ] && { echo "база не піднялась"; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
[ "$OWN_DB" = 1 ] && docker rm -f netpulse-dbtest >/dev/null 2>&1
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "== накат міграцій"
|
||||
( cd "$SRC/server" && NETPULSE_DSN="$NETPULSE_TEST_DSN" go run ./cmd/netpulse-migrate ) || exit 1
|
||||
|
||||
echo "== тести"
|
||||
( cd "$SRC/server" && go test ./internal/... -count=1 )
|
||||
rc=$?
|
||||
|
||||
echo
|
||||
if [ "$rc" = 0 ]; then
|
||||
echo "усе зелене проти бази"
|
||||
else
|
||||
echo "!!! тести проти бази не пройшли"
|
||||
fi
|
||||
exit $rc
|
||||
|
|
@ -107,6 +107,7 @@ JWT — ні.
|
|||
| `POST` | `/api/v1/mutes` | заглушити пристрій (`alerts:ack`) |
|
||||
| `GET` | `/api/v1/alert-rules` | правила з лічильником активних |
|
||||
| `POST` | `/api/v1/alert-rules` | створити правило (`alerts:write`) |
|
||||
| `PUT` | `/api/v1/alert-rules/{id}` | замінити правило цілком (`alerts:write`) |
|
||||
| `PATCH` | `/api/v1/alert-rules/{id}` | увімкнути/вимкнути (`alerts:write`) |
|
||||
| `DELETE` | `/api/v1/alert-rules/{id}` | видалити правило (`alerts:write`) |
|
||||
| `GET` | `/api/v1/channels` | канали доставки (без секретів) |
|
||||
|
|
@ -1172,6 +1173,17 @@ JSON у таблиці правил.
|
|||
|
||||
`PUT /api/v1/alert-rules/{id}` замінює правило цілком.
|
||||
|
||||
**`enabled` на правці не домислюється.** Поля немає в тілі — стан
|
||||
перемикача лишається таким, яким був. Раніше сервер підставляв `true`
|
||||
кожному, хто поля не надіслав, і правка вимкненого правила мовчки його
|
||||
вмикала. На СТВОРЕННІ відсутнє поле досі означає «увімкнене»: правило,
|
||||
заведене вимкненим, не робить нічого й виглядає як забуте.
|
||||
|
||||
**Вимкнення правила гасить його активні алерти** — і через `PATCH`, і
|
||||
через `PUT`. Без цього вони висіли б у `firing` вічно: вимкнене правило
|
||||
випадає з вибірки движка, тобто закрити їх немає кому, а драбина
|
||||
ескалації справно будила б за ними людей тижнями.
|
||||
|
||||
### Ескалація
|
||||
|
||||
Сповіщення, надіслане один раз, нічого не гарантує: черговий може спати.
|
||||
|
|
|
|||
|
|
@ -54,46 +54,101 @@ func (e *Engine) escalate(ctx context.Context) {
|
|||
// може бути десяток.
|
||||
channels := map[string]map[string]store.Channel{}
|
||||
|
||||
now := time.Now()
|
||||
for _, snap := range due {
|
||||
d := store.PlanEscalation(snap, now)
|
||||
// Час береться на кожну сходинку, а не на партію: між першою і
|
||||
// останньою може пройти скільки завгодно — кожна доставка має
|
||||
// власний таймаут. Застарілий момент зсував би стелю життя й
|
||||
// підлогу інтервалу рівно на цю затримку.
|
||||
d := store.PlanEscalation(snap, time.Now())
|
||||
|
||||
// Запис ДО надсилання — той самий порядок, що й у журналі
|
||||
// доставки, і з тієї ж причини: якщо процес упаде між ними,
|
||||
// краще не надіслати сходинку, ніж надіслати її вдруге.
|
||||
if err := e.st.ApplyEscalation(ctx, snap, d); err != nil {
|
||||
// Канали читаються ДО просування стану. Порядок не косметичний:
|
||||
// якщо їх не вдалось прочитати, сходинка має лишитись належною й
|
||||
// повторитись наступного тіку. У зворотному порядку тимчасова
|
||||
// помилка бази списувала б сходинку назавжди — і в журналі
|
||||
// стояло б «надіслано».
|
||||
var byID map[string]store.Channel
|
||||
if d.Action == store.EscFire {
|
||||
var err error
|
||||
byID, err = e.escalationChannels(ctx, snap.TenantID, channels)
|
||||
if err != nil {
|
||||
e.log.Error("читання каналів для ескалації — сходинку відкладено",
|
||||
"tenant", snap.TenantID, "алерт", snap.AlertID, "помилка", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
applied, err := e.st.ApplyEscalation(ctx, snap, d)
|
||||
if err != nil {
|
||||
e.log.Error("запис рішення ескалації", "алерт", snap.AlertID, "помилка", err)
|
||||
continue
|
||||
}
|
||||
if !applied {
|
||||
// Драбину зупинили, поки сходинка чекала своєї черги —
|
||||
// найчастіше людина натиснула «Прийняти». Це не помилка, це
|
||||
// той випадок, заради якого кнопка й існує.
|
||||
e.log.Info("сходинку скасовано: драбину вже зупинено",
|
||||
"алерт", snap.AlertID, "сходинка", d.StepIdx+1)
|
||||
continue
|
||||
}
|
||||
|
||||
if d.Action != store.EscFire {
|
||||
e.logStep(ctx, snap, d, d.Outcome, d.Detail)
|
||||
e.log.Debug("ескалацію не продовжено", "алерт", snap.AlertID,
|
||||
"причина", d.Outcome, "деталі", d.Detail)
|
||||
continue
|
||||
}
|
||||
|
||||
byID, ok := channels[snap.TenantID]
|
||||
if !ok {
|
||||
cs, err := e.st.LoadChannels(ctx, snap.TenantID, e.ring)
|
||||
if err != nil {
|
||||
e.log.Error("читання каналів для ескалації",
|
||||
"tenant", snap.TenantID, "помилка", err)
|
||||
channels[snap.TenantID] = map[string]store.Channel{}
|
||||
if sent := e.notifier.deliverEscalation(ctx, snap, d, byID); sent == 0 {
|
||||
// Сходинка списана — інакше вона поверталася б щотіку. Але в
|
||||
// журнал іде правда, а не намір: «надіслано» на сходинці, яка
|
||||
// нікуди не пішла, — саме та мовчазна відмова, від якої
|
||||
// ескалація рятує.
|
||||
e.logStep(ctx, snap, d, "no_channels",
|
||||
"жоден канал сходинки не прийняв повідомлення (видалено, вимкнено або поріг серйозності)")
|
||||
continue
|
||||
}
|
||||
byID = make(map[string]store.Channel, len(cs))
|
||||
e.logStep(ctx, snap, d, d.Outcome, d.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// escalationChannels читає канали кабінету, кешуючи лише успіх.
|
||||
//
|
||||
// Кешувати помилку не можна: порожня мапа в кеші означала б, що одна
|
||||
// тимчасова невдача з'їдає всі належні сходинки кабінету за цей тік, і
|
||||
// кожна з них виглядала б доставленою.
|
||||
func (e *Engine) escalationChannels(ctx context.Context, tenantID string,
|
||||
cache map[string]map[string]store.Channel) (map[string]store.Channel, error) {
|
||||
|
||||
if byID, ok := cache[tenantID]; ok {
|
||||
return byID, nil
|
||||
}
|
||||
cs, err := e.st.LoadChannels(ctx, tenantID, e.ring)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[string]store.Channel, len(cs))
|
||||
for _, c := range cs {
|
||||
byID[c.ID] = c
|
||||
}
|
||||
channels[snap.TenantID] = byID
|
||||
cache[tenantID] = byID
|
||||
return byID, nil
|
||||
}
|
||||
|
||||
e.notifier.deliverEscalation(ctx, snap, d, byID)
|
||||
// logStep пише рядок журналу й не дає невдалому запису зупинити чергу.
|
||||
func (e *Engine) logStep(ctx context.Context, snap store.EscalationSnapshot,
|
||||
d store.EscalationDecision, outcome, detail string) {
|
||||
|
||||
if err := e.st.LogEscalationStep(ctx, snap, d, outcome, detail); err != nil {
|
||||
e.log.Error("журнал ескалації", "алерт", snap.AlertID, "помилка", err)
|
||||
}
|
||||
}
|
||||
|
||||
// deliverEscalation шле сходинку в її канали.
|
||||
// deliverEscalation шле сходинку в її канали й повертає, скільком дійшло.
|
||||
//
|
||||
// Кількість потрібна тому, хто пише журнал: сходинка без жодного каналу
|
||||
// має лишити слід «нікуди не пішло», а не «надіслано».
|
||||
func (n *Notifier) deliverEscalation(ctx context.Context, snap store.EscalationSnapshot,
|
||||
d store.EscalationDecision, byID map[string]store.Channel) {
|
||||
d store.EscalationDecision, byID map[string]store.Channel) int {
|
||||
|
||||
a := snap.Alert
|
||||
head := escalationHeader(snap, d)
|
||||
|
|
@ -117,14 +172,11 @@ func (n *Notifier) deliverEscalation(ctx context.Context, snap store.EscalationS
|
|||
}
|
||||
|
||||
if sent == 0 {
|
||||
// Сходинка вже списана (рішення записано до надсилання), і це
|
||||
// правильно: інакше вона поверталася б щотіку. Але мовчазна
|
||||
// втрата сходинки — саме те, від чого ескалація рятує, тож слід
|
||||
// лишається в журналі процесу.
|
||||
n.log.Warn("сходинка ескалації не мала куди піти",
|
||||
"алерт", snap.AlertID, "сходинка", d.StepIdx+1,
|
||||
"каналів у сходинці", len(d.ChannelIDs))
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
// escalationHeader пояснює людині, чому вона це читає.
|
||||
|
|
|
|||
|
|
@ -303,7 +303,21 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
|
|||
Condition: string(req.Condition),
|
||||
ForSeconds: req.ForSeconds,
|
||||
DependsOnTopology: req.DependsOnTopology == nil || *req.DependsOnTopology,
|
||||
Enabled: req.Enabled == nil || *req.Enabled,
|
||||
// `enabled` передається як є, разом з «поля не було».
|
||||
//
|
||||
// На створенні відсутність поля лишається згодою ввімкнути — це
|
||||
// розумно й так було завжди (правило, заведене вимкненим, не
|
||||
// робить нічого). На ОНОВЛЕННІ домислювати вже не можна: сервер
|
||||
// ставив `true` кожному, хто поля не надіслав, тож правка опису
|
||||
// вимкненого правила мовчки його вмикала — і людина дізнавалась
|
||||
// про це зі сповіщення, а не з форми.
|
||||
//
|
||||
// Обрано саме «не було = не чіпати», а не «не було = 400»: PUT
|
||||
// без `enabled` шле і власна форма, і будь-який чужий скрипт,
|
||||
// написаний за цим API, тож вимога поля зламала б їх усі одним
|
||||
// оновленням. Прапорець при цьому лишається керованим — його
|
||||
// явно шлють і форма (тепер), і PATCH-перемикач.
|
||||
Enabled: req.Enabled,
|
||||
ChannelIDs: req.ChannelIDs,
|
||||
NotifySchedule: string(req.NotifySchedule),
|
||||
NotifyOnResolve: req.NotifyOnResolve == nil || *req.NotifyOnResolve,
|
||||
|
|
@ -424,6 +438,20 @@ func (s *Server) handleListChannels(w http.ResponseWriter, r *http.Request, p *P
|
|||
if channels == nil {
|
||||
channels = []store.Channel{}
|
||||
}
|
||||
// Драбини, що спираються на канал, — частина відповіді на питання
|
||||
// «що зламається, якщо його видалити». Те саме питання вже має
|
||||
// відповідь у переліку драбин (rule_count), і тут вона потрібна з
|
||||
// тієї ж причини.
|
||||
//
|
||||
// Невдача не валить перелік: без назв драбин сторінка лишається
|
||||
// робочою, а без каналів — ні.
|
||||
if refs, err := s.store.ChannelEscalationRefs(r.Context(), p.TenantID); err != nil {
|
||||
s.log.Error("драбини на каналах", "err", err)
|
||||
} else {
|
||||
for i := range channels {
|
||||
channels[i].Escalations = refs[channels[i].ID]
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"channels": channels})
|
||||
}
|
||||
|
||||
|
|
@ -685,6 +713,15 @@ func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Reque
|
|||
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
|
||||
return
|
||||
}
|
||||
// Відмова від store — це відмова людині, а не збій сервера:
|
||||
// саме тут повертається «сходинка N посилається на канал, якого
|
||||
// немає в цьому кабінеті». Без цієї гілки вона вийшла б назовні
|
||||
// як «внутрішня помилка», тобто перевірка спрацювала б, а
|
||||
// причини ніхто б не побачив.
|
||||
if errors.Is(err, store.ErrInvalid) {
|
||||
writeError(w, http.StatusBadRequest, "bad_steps", err.Error())
|
||||
return
|
||||
}
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "duplicate", "політика з такою назвою вже є")
|
||||
return
|
||||
|
|
|
|||
168
server/internal/store/alerts_channel_refs_db_test.go
Normal file
168
server/internal/store/alerts_channel_refs_db_test.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Канали в сходинках драбини ПРОТИ БАЗИ.
|
||||
//
|
||||
// Чиста ValidateStepChannels перевіряє рішення, і це половина. Друга
|
||||
// половина існує тільки в базі й рішенням не перевіряється взагалі:
|
||||
//
|
||||
// - чи справді перелік каналів, який отримує перевірка, звужений до
|
||||
// кабінету (тобто чи не проходить UUID сусіда);
|
||||
// - чи прибирає видалення каналу посилання на нього зі сходинок —
|
||||
// на JSONB зовнішнього ключа немає, і ON DELETE SET NULL, яким
|
||||
// 0066 чистить драбину з правила, тут не спрацює.
|
||||
//
|
||||
// Мовчки пропускається без NETPULSE_TEST_DSN. Запускати треба на
|
||||
// ОДНОРАЗОВІЙ базі — тест створює два кабінети й видаляє їх з усім
|
||||
// вмістом:
|
||||
//
|
||||
// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \
|
||||
// go test ./internal/store/ -run ChannelRefs -v
|
||||
func TestChannelRefsAgainstDB(t *testing.T) {
|
||||
dsn := os.Getenv("NETPULSE_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
st, err := New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("підключення: %v", err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().Format("150405.000"), ".", "")
|
||||
|
||||
newTenant := func(prefix string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO core.tenants (slug, name) VALUES ($1, $2) RETURNING id::text
|
||||
`, prefix+"-"+stamp, "Перевірка каналів драбини").Scan(&id); err != nil {
|
||||
t.Fatalf("кабінет %s: %v", prefix, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = st.pool.Exec(context.Background(), `DELETE FROM core.tenants WHERE id = $1`, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
newChannel := func(tenantID, name string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO alr.channels (tenant_id, kind, name, config, min_severity, enabled)
|
||||
VALUES ($1, 'webhook', $2, '{}'::jsonb, 'warning', true) RETURNING id::text
|
||||
`, tenantID, name).Scan(&id); err != nil {
|
||||
t.Fatalf("канал %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
ours := newTenant("chref-a")
|
||||
theirs := newTenant("chref-b")
|
||||
|
||||
duty := newChannel(ours, "черговий")
|
||||
lead := newChannel(ours, "старший зміни")
|
||||
alien := newChannel(theirs, "чужий канал")
|
||||
|
||||
// --- Канал чужого кабінету не зберігається -------------------------
|
||||
//
|
||||
// Найдорожча з двох вад. Ззовні це не витік (доставити в чужий
|
||||
// канал усе одно нікуди — движок читає канали лише свого кабінету),
|
||||
// а гірше: драбина виглядає налаштованою й мовчить. Закривати це в
|
||||
// HTTP-шарі марно — обійде будь-який інший шлях запису, тому тест
|
||||
// б'є прямо в store.
|
||||
_, err = st.SaveEscalationPolicy(ctx, ours, "", EscalationPolicy{
|
||||
Name: "Підсунутий сусід",
|
||||
Steps: []EscalationStep{{AfterMin: 15, ChannelIDs: []string{alien}}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("драбина з каналом ЧУЖОГО кабінету збереглася")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("відмова має бути ErrInvalid: %v", err)
|
||||
}
|
||||
|
||||
// --- Неіснуючий канал не зберігається ------------------------------
|
||||
_, err = st.SaveEscalationPolicy(ctx, ours, "", EscalationPolicy{
|
||||
Name: "Привид",
|
||||
Steps: []EscalationStep{
|
||||
{AfterMin: 15, ChannelIDs: []string{duty}},
|
||||
{AfterMin: 45, ChannelIDs: []string{"00000000-0000-4000-8000-0000000000ff"}},
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "сходинка 2") {
|
||||
t.Fatalf("драбина з неіснуючим каналом на другій сходинці: %v", err)
|
||||
}
|
||||
|
||||
// --- Справна драбина зберігається ----------------------------------
|
||||
policyID, err := st.SaveEscalationPolicy(ctx, ours, "", EscalationPolicy{
|
||||
Name: "Нічне чергування",
|
||||
Steps: []EscalationStep{
|
||||
{AfterMin: 15, ChannelIDs: []string{duty}},
|
||||
{AfterMin: 45, ChannelIDs: []string{duty, lead}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("справна драбина не збереглася: %v", err)
|
||||
}
|
||||
|
||||
// --- Перелік «хто на кого спирається» ------------------------------
|
||||
//
|
||||
// Це те, що бачить людина перед видаленням каналу. Порожній перелік
|
||||
// означав би те саме мовчазне видалення, з якого все й почалось.
|
||||
refs, err := st.ChannelEscalationRefs(ctx, ours)
|
||||
if err != nil {
|
||||
t.Fatalf("посилання драбин: %v", err)
|
||||
}
|
||||
if len(refs[duty]) != 1 || refs[duty][0] != "Нічне чергування" {
|
||||
t.Fatalf("канал чергового не показано як зайнятий драбиною: %v", refs[duty])
|
||||
}
|
||||
// Канал згадано у двох сходинках однієї драбини — назва має бути
|
||||
// одна: людині цікаво, ЩО зламається, а не скільки разів.
|
||||
if len(refs[lead]) != 1 {
|
||||
t.Fatalf("драбина порахована двічі: %v", refs[lead])
|
||||
}
|
||||
if len(refs[alien]) != 0 {
|
||||
t.Fatal("у перелік кабінету потрапив чужий канал")
|
||||
}
|
||||
|
||||
// --- Видалення каналу чистить сходинки -----------------------------
|
||||
if err := st.DeleteChannel(ctx, ours, duty); err != nil {
|
||||
t.Fatalf("видалення каналу: %v", err)
|
||||
}
|
||||
ps, err := st.ListEscalationPolicies(ctx, ours)
|
||||
if err != nil {
|
||||
t.Fatalf("перелік драбин: %v", err)
|
||||
}
|
||||
var got EscalationPolicy
|
||||
for _, p := range ps {
|
||||
if p.ID == policyID {
|
||||
got = p
|
||||
}
|
||||
}
|
||||
if len(got.Steps) != 2 {
|
||||
t.Fatalf("видалення каналу зсунуло драбину: %d сходинок замість 2", len(got.Steps))
|
||||
}
|
||||
// Перша сходинка лишається — порожня й видима. Саме порожня, а не
|
||||
// викинута: зникла сходинка мовчки змінила б чергування, якого
|
||||
// ніхто не міняв.
|
||||
if len(got.Steps[0].ChannelIDs) != 0 {
|
||||
t.Fatalf("посилання на видалений канал лишилось у сходинці 1: %v", got.Steps[0].ChannelIDs)
|
||||
}
|
||||
if len(got.Steps[1].ChannelIDs) != 1 || got.Steps[1].ChannelIDs[0] != lead {
|
||||
t.Fatalf("сходинка 2 має лишитись зі старшим зміни: %v", got.Steps[1].ChannelIDs)
|
||||
}
|
||||
if got.Steps[0].AfterMin != 15 || got.Steps[1].AfterMin != 45 {
|
||||
t.Fatalf("хвилини сходинок змінились: %v", got.Steps)
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,12 @@ type Channel struct {
|
|||
Enabled bool `json:"enabled"`
|
||||
Secret string `json:"-"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
// Назви драбин ескалації, сходинки яких посилаються на цей канал.
|
||||
//
|
||||
// Заповнюється лише для переліку в UI (ChannelEscalationRefs), а не
|
||||
// в LoadChannels: движку доставки це не потрібно, а рахувати на
|
||||
// кожному тіку — платити без причини.
|
||||
Escalations []string `json:"escalations,omitempty"`
|
||||
}
|
||||
|
||||
// Route — правило маршрутизації алерту в канали.
|
||||
|
|
@ -320,6 +326,113 @@ func (s *Store) CreateChannel(ctx context.Context, tenantID string, in ChannelIn
|
|||
return id, err
|
||||
}
|
||||
|
||||
// escalationLadder — драбина в тому вигляді, в якому її читає чистка
|
||||
// посилань: ідентифікатор, назва й розібрані сходинки.
|
||||
type escalationLadder struct {
|
||||
id string
|
||||
name string
|
||||
steps []EscalationStep
|
||||
}
|
||||
|
||||
// readLadders читає драбини кабінету з уже розібраними сходинками.
|
||||
//
|
||||
// Читання окремо від запису навмисно: pgx не дає слати новий запит,
|
||||
// поки не дочитано попередній, а чистка посилань — це саме «прочитати
|
||||
// всі, переписати деякі».
|
||||
func readLadders(ctx context.Context, tx pgx.Tx, tenantID string) ([]escalationLadder, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id::text, name, steps::text
|
||||
FROM alr.escalation_policies WHERE tenant_id = $1 ORDER BY name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []escalationLadder
|
||||
for rows.Next() {
|
||||
var l escalationLadder
|
||||
var steps string
|
||||
if err := rows.Scan(&l.id, &l.name, &steps); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(steps), &l.steps); err != nil {
|
||||
return nil, fmt.Errorf("політика %s: сходинки: %w", l.name, err)
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// removeChannelFromSteps прибирає канал зі сходинок і каже, чи щось
|
||||
// змінилось.
|
||||
//
|
||||
// Сходинка, яка лишилась без каналів, ЛИШАЄТЬСЯ порожньою, а не
|
||||
// зникає. Викинута сходинка мовчки зсунула б усе чергування нижче,
|
||||
// якого людина не міняла; порожню видно і в переліку драбин, і у формі
|
||||
// (вона не збережеться, поки канал не оберуть), а движок пише про неї
|
||||
// в журнал окремим рядком.
|
||||
func removeChannelFromSteps(steps []EscalationStep, channelID string) ([]EscalationStep, bool) {
|
||||
changed := false
|
||||
out := make([]EscalationStep, len(steps))
|
||||
for i, s := range steps {
|
||||
out[i] = s
|
||||
kept := make([]string, 0, len(s.ChannelIDs))
|
||||
for _, id := range s.ChannelIDs {
|
||||
if id == channelID {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, id)
|
||||
}
|
||||
out[i].ChannelIDs = kept
|
||||
}
|
||||
return out, changed
|
||||
}
|
||||
|
||||
// ChannelEscalationRefs каже, які драбини посилаються на які канали:
|
||||
// ідентифікатор каналу → назви драбин.
|
||||
//
|
||||
// Потрібне рівно для одного: щоб «Видалити канал» показало те саме, що
|
||||
// вже показує «Видалити драбину» — скільки чужих налаштувань зараз
|
||||
// перестане працювати. Мовчазне видалення каналу, на який спирається
|
||||
// нічне чергування, коштує однієї пропущеної аварії, і дізнаються про
|
||||
// це не в момент видалення.
|
||||
func (s *Store) ChannelEscalationRefs(ctx context.Context, tenantID string) (map[string][]string, error) {
|
||||
refs := map[string][]string{}
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
ladders, err := readLadders(ctx, tx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range ladders {
|
||||
// Драбину називаємо один раз, скільки б сходинок у неї не
|
||||
// вело в цей канал: людині перед видаленням цікаво, ЩО
|
||||
// зламається, а не скільки разів воно згадане.
|
||||
for _, id := range ladderChannelIDs(l.steps) {
|
||||
refs[id] = append(refs[id], l.name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return refs, err
|
||||
}
|
||||
|
||||
// ladderChannelIDs — канали драбини без повторів, у порядку появи.
|
||||
func ladderChannelIDs(steps []EscalationStep) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, s := range steps {
|
||||
for _, id := range s.ChannelIDs {
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
// Секрет видаляється разом із каналом: залишений «на всякий
|
||||
|
|
@ -348,6 +461,38 @@ func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) e
|
|||
if deleted == 0 {
|
||||
return ErrAlertNotFound
|
||||
}
|
||||
|
||||
// Посилання зі сходинок драбин прибираємо руками, бо прибрати їх
|
||||
// нікому: 0066 чистить драбину з правила через ON DELETE SET
|
||||
// NULL, але на масив усередині JSONB зовнішнього ключа немає.
|
||||
// Залишений UUID видаленого каналу — це сходинка, яка виглядає
|
||||
// налаштованою й не йде нікуди, тобто рівно та мовчазна
|
||||
// обіцянка, заради якої ескалацію й заводили.
|
||||
//
|
||||
// Переписуємо в Go, а не запитом по JSONB: рішення «що саме
|
||||
// лишається в сходинці» перевіряється тоді без Postgres, а
|
||||
// запити зводяться до читання й запису.
|
||||
ladders, err := readLadders(ctx, tx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range ladders {
|
||||
steps, changed := removeChannelFromSteps(l.steps, channelID)
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(steps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE alr.escalation_policies
|
||||
SET steps = $3::jsonb, updated_at = now()
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
`, tenantID, l.id, string(raw)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,65 @@ func ValidateEscalationSteps(steps []EscalationStep) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ValidateStepChannels відмовляє в драбині, сходинка якої посилається на
|
||||
// канал, якого в цьому кабінеті немає.
|
||||
//
|
||||
// Перевірка окремо від ValidateEscalationSteps, бо вона єдина потребує
|
||||
// бази: решта драбини перевіряється як текст, а «чи є такий канал» —
|
||||
// лише запитом. Розділення дозволяє тримати першу половину чистою й
|
||||
// перевіреною без Postgres.
|
||||
//
|
||||
// Ціна відсутності цієї перевірки — рівно та сама мовчазна обіцянка,
|
||||
// заради якої й написана вся 0066. Сходинка з неіснуючим UUID
|
||||
// виглядає в переліку налаштованою, движок не знаходить для неї жодного
|
||||
// каналу, списує її й пише в журнал «нікуди не пішло» — але читає той
|
||||
// журнал уже той, хто прийшов розбиратися вранці, а не той, кого мали
|
||||
// розбудити вночі.
|
||||
//
|
||||
// known — ідентифікатори каналів ЦЬОГО кабінету. Саме тому перевірка
|
||||
// закриває й підстановку чужого UUID: перелік читається під RLS у тій
|
||||
// самій транзакції, що й запис.
|
||||
func ValidateStepChannels(steps []EscalationStep, known map[string]bool) error {
|
||||
for i, s := range steps {
|
||||
for _, id := range s.ChannelIDs {
|
||||
if known[id] {
|
||||
continue
|
||||
}
|
||||
// Ідентифікатор у тексті лишаємо навмисно: у формі канали
|
||||
// обираються галочками, тож людина, яка це побачила,
|
||||
// надсилає драбину не з форми — і їй потрібно знати, який
|
||||
// саме рядок не прийнято.
|
||||
return fmt.Errorf("%w: сходинка %d посилається на канал %s, якого немає в цьому "+
|
||||
"кабінеті — оберіть канал зі списку на сторінці «Канали»", ErrInvalid, i+1, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tenantChannelIDs — ідентифікатори каналів кабінету, як їх бачить ця
|
||||
// транзакція.
|
||||
//
|
||||
// Читається саме в транзакції запису, а не окремим викликом до неї:
|
||||
// інакше між перевіркою й записом лишалась би щілина, в якій канал
|
||||
// встигає зникнути.
|
||||
func tenantChannelIDs(ctx context.Context, tx pgx.Tx, tenantID string) (map[string]bool, error) {
|
||||
rows, err := tx.Query(ctx,
|
||||
`SELECT id::text FROM alr.channels WHERE tenant_id = $1`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
known := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known[id] = true
|
||||
}
|
||||
return known, rows.Err()
|
||||
}
|
||||
|
||||
// ListEscalationPolicies читає політики кабінету.
|
||||
func (s *Store) ListEscalationPolicies(ctx context.Context, tenantID string) ([]EscalationPolicy, error) {
|
||||
var out []EscalationPolicy
|
||||
|
|
@ -142,6 +201,21 @@ func (s *Store) SaveEscalationPolicy(ctx context.Context, tenantID, id string, p
|
|||
}
|
||||
|
||||
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
// Обидві перевірки тут, а не лише в HTTP: драбина — це список
|
||||
// людей, яких будять уночі, і єдине місце, де він може бути
|
||||
// перевірений раз і назавжди, — це запис у базу. Перевірка,
|
||||
// продубльована в кожному обробнику, розходиться на першому ж
|
||||
// новому шляху запису (імпорт, шаблон, API-токен).
|
||||
if err := ValidateEscalationSteps(p.Steps); err != nil {
|
||||
return err
|
||||
}
|
||||
known, err := tenantChannelIDs(ctx, tx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateStepChannels(p.Steps, known); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == "" {
|
||||
return tx.QueryRow(ctx, `
|
||||
INSERT INTO alr.escalation_policies
|
||||
|
|
@ -550,22 +624,38 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ApplyEscalation записує рішення й веде журнал.
|
||||
// ApplyEscalation просуває стан драбини й повертає, чи вдалось.
|
||||
//
|
||||
// Записується ДО надсилання. Порядок той самий, що в RecordNotification,
|
||||
// і з тієї ж причини: падіння між записом і надсиланням лишає слід
|
||||
// «сходинку пройдено» на недоставленому повідомленні, а зворотний
|
||||
// порядок лишав би драбину на місці — і після підйому вона надіслала б
|
||||
// те саме вдруге.
|
||||
func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d EscalationDecision) error {
|
||||
// Стан записується ДО надсилання — з тієї ж причини, що й у
|
||||
// RecordNotification: падіння між записом і надсиланням лишає
|
||||
// непройдену сходинку, а зворотний порядок лишав би драбину на місці, і
|
||||
// після підйому вона надіслала б те саме вдруге. Краще не надіслати, ніж
|
||||
// надіслати двічі о третій ночі.
|
||||
//
|
||||
// А от ЖУРНАЛ пишеться після доставки (LogEscalationStep), і це окреме
|
||||
// рішення. Журнал існує рівно для відповіді на «чому мене розбудили» та
|
||||
// «чому не розбудили», тож рядок «надіслано» на сходинці, якій не
|
||||
// знайшлось жодного каналу, гірший за відсутній: він перетворює
|
||||
// доказ на брехню. Ціна — рідкісний випадок падіння між просуванням і
|
||||
// записом: сходинка лишиться без рядка. Це видно (step_idx більший за
|
||||
// кількість рядків) і це чесно.
|
||||
//
|
||||
// stopped_at IS NULL — не оптимізація, а перевірка стану. Між тим, як
|
||||
// сходинку взяли в чергу, і тим, як до неї дійшли руки, людина могла
|
||||
// натиснути «Прийняти»: партія обробляється послідовно, кожна доставка
|
||||
// має власний таймаут, і розрив вимірюється хвилинами. Без цієї умови
|
||||
// UPDATE зняв би stopped_at і воскресив зупинену драбину — тобто
|
||||
// розбудив би саме того, хто щойно сказав «я цим займаюсь».
|
||||
func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d EscalationDecision) (bool, error) {
|
||||
var next any
|
||||
if d.NextAt != nil {
|
||||
next = *d.NextAt
|
||||
}
|
||||
fired := d.Action == EscFire
|
||||
|
||||
return s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
applied := false
|
||||
err := s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE alr.alert_escalations
|
||||
SET step_idx = $2,
|
||||
repeat_idx = $3,
|
||||
|
|
@ -576,16 +666,34 @@ func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d
|
|||
stopped_at = CASE WHEN $5::timestamptz IS NULL THEN now() ELSE NULL END,
|
||||
stop_reason = CASE WHEN $5::timestamptz IS NULL THEN $7 ELSE NULL END
|
||||
WHERE alert_id = $1
|
||||
AND stopped_at IS NULL
|
||||
`, snap.AlertID, d.NextStepIdx, d.NextRepeatIdx, d.NextPassStart,
|
||||
next, fired, d.Outcome); err != nil {
|
||||
next, fired, d.Outcome)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applied = tag.RowsAffected() > 0
|
||||
return nil
|
||||
})
|
||||
return applied, err
|
||||
}
|
||||
|
||||
// LogEscalationStep записує в журнал те, що СПРАВДІ сталося зі сходинкою.
|
||||
//
|
||||
// Викликається після доставки, тому outcome тут може відрізнятись від
|
||||
// того, що планувалось: сходинка, чиї канали видалили, вимкнули або
|
||||
// підняли їм поріг серйозності, отримує 'no_channels', а не 'sent'.
|
||||
// Саме заради цієї різниці журнал і винесено з ApplyEscalation.
|
||||
func (s *Store) LogEscalationStep(ctx context.Context, snap EscalationSnapshot,
|
||||
d EscalationDecision, outcome, detail string) error {
|
||||
|
||||
return s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO alr.escalation_steps
|
||||
(tenant_id, alert_id, policy_id, step_idx, repeat_idx, outcome, detail)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`, snap.TenantID, snap.AlertID, nullUUID(snap.PolicyID),
|
||||
d.StepIdx, d.RepeatIdx, d.Outcome, nullString(d.Detail))
|
||||
d.StepIdx, d.RepeatIdx, outcome, nullString(detail))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
|
|
|||
166
server/internal/store/alerts_escalation_channels_test.go
Normal file
166
server/internal/store/alerts_escalation_channels_test.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Перевірки сходинки, яка посилається на канал.
|
||||
//
|
||||
// Ці тести описують одну ваду й одну її ціну. Драбина зберігалась із
|
||||
// будь-яким рядком у channel_ids — хоч із UUID видаленого каналу, хоч
|
||||
// із UUID каналу чужого кабінету. Форма показувала таку сходинку
|
||||
// налаштованою; движок не знаходив для неї жодного каналу, списував її
|
||||
// й ішов далі. Тобто драбина існувала, виглядала робочою і не будила
|
||||
// нікого — рівно той стан, від якого ескалацію й заводили.
|
||||
//
|
||||
// ValidateEscalationSteps перевіряє форму драбини й не має доступу до
|
||||
// бази; ValidateStepChannels перевіряє належність каналів і отримує
|
||||
// перелік готовим. Розділені саме тому, що перша половина має лишитись
|
||||
// перевіреною без Postgres — а без другої перша дає хибну впевненість.
|
||||
|
||||
func knownChannels(ids ...string) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
m[id] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Головний випадок: канал, якого в кабінеті немає.
|
||||
func TestStepChannelUnknownRefused(t *testing.T) {
|
||||
steps := []EscalationStep{
|
||||
{AfterMin: 15, ChannelIDs: []string{"ch-duty"}},
|
||||
{AfterMin: 45, ChannelIDs: []string{"ch-lead", "ch-gone"}},
|
||||
}
|
||||
err := ValidateStepChannels(steps, knownChannels("ch-duty", "ch-lead"))
|
||||
if err == nil {
|
||||
t.Fatal("драбина з неіснуючим каналом збереглася — вона нікого не розбудить")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("відмова має бути ErrInvalid (інакше HTTP віддасть 500): %v", err)
|
||||
}
|
||||
// Відмова без номера сходинки марна: у драбині їх до десяти, і
|
||||
// «щось не так із каналами» не каже людині, що саме виправляти.
|
||||
if !strings.Contains(err.Error(), "сходинка 2") {
|
||||
t.Fatalf("відмова не називає сходинку: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Дзеркальний випадок: усе на місці — відмови бути не має.
|
||||
func TestStepChannelsKnownAccepted(t *testing.T) {
|
||||
steps := []EscalationStep{
|
||||
{AfterMin: 15, ChannelIDs: []string{"ch-duty"}},
|
||||
{AfterMin: 45, ChannelIDs: []string{"ch-duty", "ch-lead"}},
|
||||
}
|
||||
if err := ValidateStepChannels(steps, knownChannels("ch-duty", "ch-lead", "ch-boss")); err != nil {
|
||||
t.Fatalf("справна драбина не збереглася: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Канал чужого кабінету — той самий випадок, і це головне в ньому.
|
||||
//
|
||||
// Перевірка не знає слова «чужий»: їй дають перелік каналів ЦЬОГО
|
||||
// кабінету, прочитаний під RLS у транзакції запису. Тому підставлений
|
||||
// UUID сусіда не проходить не як окремий випадок, а як частина
|
||||
// загального правила — і його не можна забути закрити окремо.
|
||||
func TestStepChannelFromOtherTenantRefused(t *testing.T) {
|
||||
const foreign = "00000000-0000-4000-8000-000000000001"
|
||||
steps := []EscalationStep{{AfterMin: 15, ChannelIDs: []string{foreign}}}
|
||||
err := ValidateStepChannels(steps, knownChannels("ch-duty"))
|
||||
if err == nil {
|
||||
t.Fatal("канал чужого кабінету прийнято в сходинку")
|
||||
}
|
||||
if !strings.Contains(err.Error(), foreign) {
|
||||
t.Fatalf("відмова не називає ідентифікатор: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Вимкнений канал — не привід відмовляти.
|
||||
//
|
||||
// Перелік каналів кабінету не фільтрується за enabled навмисно:
|
||||
// «вимкнув Telegram на час переїзду» не має ламати збереження драбини,
|
||||
// у якій він стоїть. Про вимкнений канал говорить форма, і це інша
|
||||
// розмова, ніж «такого каналу немає».
|
||||
func TestStepChannelDisabledStillValid(t *testing.T) {
|
||||
steps := []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"ch-off"}}}
|
||||
if err := ValidateStepChannels(steps, knownChannels("ch-off")); err != nil {
|
||||
t.Fatalf("вимкнений канал відхилено: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Чистка сходинок при видаленні каналу ----------------------------
|
||||
//
|
||||
// Друга з підтверджених вад: видалення каналу не чіпало сходинок, що на
|
||||
// нього посилались. Зовнішнього ключа на масив усередині JSONB немає,
|
||||
// тож ON DELETE SET NULL, яким 0066 прибирає драбину з правила, тут не
|
||||
// спрацьовує — і в сходинці лишався UUID каналу, якого вже немає.
|
||||
|
||||
func TestRemoveChannelKeepsEmptyStep(t *testing.T) {
|
||||
steps := []EscalationStep{
|
||||
{AfterMin: 15, ChannelIDs: []string{"ch-gone"}},
|
||||
{AfterMin: 45, ChannelIDs: []string{"ch-gone", "ch-lead"}},
|
||||
}
|
||||
got, changed := removeChannelFromSteps(steps, "ch-gone")
|
||||
if !changed {
|
||||
t.Fatal("посилання на видалений канал лишилось у драбині")
|
||||
}
|
||||
// Сходинка лишається на місці порожньою. Викинута зникла б
|
||||
// безслідно й мовчки зсунула б усе чергування нижче: «через 45» на
|
||||
// другій сходинці стало б першим підйомом, якого ніхто не просив.
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("драбину зсунуто: %d сходинок замість 2", len(got))
|
||||
}
|
||||
if len(got[0].ChannelIDs) != 0 {
|
||||
t.Fatalf("сходинка 1 мала лишитись порожньою: %v", got[0].ChannelIDs)
|
||||
}
|
||||
if got[0].AfterMin != 15 || got[1].AfterMin != 45 {
|
||||
t.Fatalf("хвилини сходинок змінились: %+v", got)
|
||||
}
|
||||
if len(got[1].ChannelIDs) != 1 || got[1].ChannelIDs[0] != "ch-lead" {
|
||||
t.Fatalf("сходинка 2 втратила чужий канал: %v", got[1].ChannelIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// Драбина, яка каналу не знає, не має переписуватись.
|
||||
//
|
||||
// Не заради швидкості: зайвий UPDATE зсунув би updated_at і в переліку
|
||||
// правок виглядав би як зміна чергування, якої не було.
|
||||
func TestRemoveChannelUntouchedLadder(t *testing.T) {
|
||||
steps := []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"ch-duty"}}}
|
||||
got, changed := removeChannelFromSteps(steps, "ch-gone")
|
||||
if changed {
|
||||
t.Fatal("драбину без цього каналу оголошено зміненою")
|
||||
}
|
||||
if len(got[0].ChannelIDs) != 1 {
|
||||
t.Fatalf("канали чужої драбини змінились: %v", got[0].ChannelIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// Драбина називається один раз, скільки б сходинок у неї не вело в цей
|
||||
// канал: перед видаленням цікаво, ЩО зламається, а не скільки разів
|
||||
// воно згадане.
|
||||
func TestLadderChannelIDsDeduplicated(t *testing.T) {
|
||||
ids := ladderChannelIDs([]EscalationStep{
|
||||
{AfterMin: 15, ChannelIDs: []string{"ch-duty"}},
|
||||
{AfterMin: 45, ChannelIDs: []string{"ch-duty", "ch-lead"}},
|
||||
})
|
||||
if len(ids) != 2 || ids[0] != "ch-duty" || ids[1] != "ch-lead" {
|
||||
t.Fatalf("перелік каналів драбини: %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// Порожня сходинка лишається справою ValidateEscalationSteps.
|
||||
//
|
||||
// Тут вона проходить — і має проходити: дві перевірки не мають
|
||||
// дублювати одна одну, інакше повідомлення розійдуться.
|
||||
func TestStepChannelsEmptyStepIsOtherCheck(t *testing.T) {
|
||||
steps := []EscalationStep{{AfterMin: 15, ChannelIDs: nil}}
|
||||
if err := ValidateStepChannels(steps, knownChannels()); err != nil {
|
||||
t.Fatalf("порожню сходинку має ловити ValidateEscalationSteps, а не ця перевірка: %v", err)
|
||||
}
|
||||
if err := ValidateEscalationSteps(steps); err == nil {
|
||||
t.Fatal("порожню сходинку не спіймала жодна перевірка")
|
||||
}
|
||||
}
|
||||
|
|
@ -59,9 +59,22 @@ func TestEscalationAgainstDB(t *testing.T) {
|
|||
t.Fatalf("хост: %v", err)
|
||||
}
|
||||
|
||||
// Канал справжній, а не рядок «ch»: збереження драбини тепер
|
||||
// відмовляє в сходинці, що посилається на неіснуючий канал.
|
||||
var channelID string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO alr.channels (tenant_id, kind, name, config, min_severity, enabled)
|
||||
VALUES ($1, 'webhook', $2, '{}'::jsonb, 'warning', true) RETURNING id::text
|
||||
`, tenantID, slug+"-ch").Scan(&channelID); err != nil {
|
||||
t.Fatalf("канал: %v", err)
|
||||
}
|
||||
|
||||
policy := EscalationPolicy{
|
||||
Name: "Нічне чергування",
|
||||
Steps: []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"ch"}}, {AfterMin: 45, ChannelIDs: []string{"ch"}}},
|
||||
Steps: []EscalationStep{
|
||||
{AfterMin: 15, ChannelIDs: []string{channelID}},
|
||||
{AfterMin: 45, ChannelIDs: []string{channelID}},
|
||||
},
|
||||
}
|
||||
policyID, err := st.SaveEscalationPolicy(ctx, tenantID, "", policy)
|
||||
if err != nil {
|
||||
|
|
@ -139,9 +152,16 @@ func TestEscalationAgainstDB(t *testing.T) {
|
|||
if d.Action != EscFire {
|
||||
t.Fatalf("сходинка мала спрацювати: %v/%s", d.Action, d.Outcome)
|
||||
}
|
||||
if err := st.ApplyEscalation(ctx, due[0], d); err != nil {
|
||||
applied, err := st.ApplyEscalation(ctx, due[0], d)
|
||||
if err != nil {
|
||||
t.Fatalf("запис рішення: %v", err)
|
||||
}
|
||||
if !applied {
|
||||
t.Fatal("рішення не застосовано до живої драбини")
|
||||
}
|
||||
if err := st.LogEscalationStep(ctx, due[0], d, d.Outcome, d.Detail); err != nil {
|
||||
t.Fatalf("журнал сходинки: %v", err)
|
||||
}
|
||||
|
||||
var stepIdx int
|
||||
var leased *time.Time
|
||||
|
|
@ -201,6 +221,60 @@ func TestEscalationAgainstDB(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Підтвердження ПОСЕРЕД партії не має воскрешати драбину --------
|
||||
//
|
||||
// Найдовший розрив у механізмі: сходинку взяли в чергу, і поки до неї
|
||||
// дійшли руки (кожна доставка попередніх — з власним таймаутом),
|
||||
// людина натиснула «Прийняти». Тут відтворено саме цей порядок:
|
||||
// знімок узято ДО ack, рішення застосовується ПІСЛЯ.
|
||||
a3 := newAlert(ruleID + ":dev:" + deviceID + ":3")
|
||||
if err := st.ArmEscalation(ctx, tenantID, a3, policyID, false, policy, started); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.pool.Exec(ctx,
|
||||
`UPDATE alr.alert_escalations SET next_at = now() - interval '1 minute' WHERE alert_id = $1`,
|
||||
a3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dueRace, err := st.TakeDueEscalations(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snap EscalationSnapshot
|
||||
for _, x := range dueRace {
|
||||
if x.AlertID == a3 {
|
||||
snap = x
|
||||
}
|
||||
}
|
||||
if snap.AlertID == "" {
|
||||
t.Fatal("сходинка не потрапила в чергу")
|
||||
}
|
||||
dRace := PlanEscalation(snap, time.Now())
|
||||
if dRace.Action != EscFire {
|
||||
t.Fatalf("сходинка мала спрацювати: %v/%s", dRace.Action, dRace.Outcome)
|
||||
}
|
||||
if _, err := st.AckAlert(ctx, tenantID, a3, "", "беру"); err != nil {
|
||||
t.Fatalf("підтвердження: %v", err)
|
||||
}
|
||||
appliedRace, err := st.ApplyEscalation(ctx, snap, dRace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appliedRace {
|
||||
t.Error("рішення застосовано до вже зупиненої драбини — людину розбудять після ack")
|
||||
}
|
||||
var raceStop string
|
||||
var raceNext *time.Time
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(stop_reason,''), next_at
|
||||
FROM alr.alert_escalations WHERE alert_id = $1
|
||||
`, a3).Scan(&raceStop, &raceNext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raceStop != "acked" || raceNext != nil {
|
||||
t.Errorf("зупинену драбину воскрешено: причина %q, наступна %v", raceStop, raceNext)
|
||||
}
|
||||
|
||||
// --- Закритий алерт: сходинка не спрацьовує навіть якщо настала ----
|
||||
a2 := newAlert(ruleID + ":dev:" + deviceID + ":2")
|
||||
if err := st.ArmEscalation(ctx, tenantID, a2, policyID, false, policy, started); err != nil {
|
||||
|
|
|
|||
|
|
@ -350,7 +350,17 @@ type RuleInput struct {
|
|||
Condition string
|
||||
ForSeconds int
|
||||
DependsOnTopology bool
|
||||
Enabled bool
|
||||
// Enabled — вказівник, бо «поле не прийшло» і «поле прийшло зі
|
||||
// значенням false» — це різні наміри, а bool їх не розрізняє.
|
||||
//
|
||||
// nil на СТВОРЕННІ означає «увімкнене»: правило, заведене вимкненим,
|
||||
// не робить нічого й виглядає як забуте.
|
||||
//
|
||||
// nil на ОНОВЛЕННІ означає «не чіпати». Домислювати тут `true` —
|
||||
// саме та вада, через яку правка опису мовчки вмикала вимкнене
|
||||
// правило: форма поля не надсилала, а сервер читав його відсутність
|
||||
// як згоду ввімкнути.
|
||||
Enabled *bool
|
||||
// Куди слати. Порожньо — за загальними маршрутами тенанта.
|
||||
ChannelIDs []string
|
||||
NotifySchedule string
|
||||
|
|
@ -440,7 +450,7 @@ func (s *Store) CreateRule(ctx context.Context, tenantID, userID string, in Rule
|
|||
RETURNING id::text
|
||||
`, tenantID, in.Name, nullString(in.Description), in.Source, in.Severity,
|
||||
in.Selector, in.Condition, in.ForSeconds, in.DependsOnTopology,
|
||||
in.Enabled, nullUUID(userID), in.ChannelIDs, in.NotifySchedule,
|
||||
in.Enabled == nil || *in.Enabled, nullUUID(userID), in.ChannelIDs, in.NotifySchedule,
|
||||
in.NotifyOnResolve, in.AutoCloseSeconds, in.MinIntervalSeconds,
|
||||
nullUUID(in.EscalationPolicyID)).Scan(&id)
|
||||
})
|
||||
|
|
@ -520,12 +530,57 @@ type jsonRaw string
|
|||
|
||||
func (j jsonRaw) MarshalJSON() ([]byte, error) { return []byte(j), nil }
|
||||
|
||||
// planRuleEnabled рахує, яким стане прапорець «увімкнено» після правки
|
||||
// правила й чи треба при цьому погасити його активні алерти.
|
||||
//
|
||||
// Винесене окремою функцією, бо це рішення, а не запит: перевіряти його
|
||||
// на живій базі означало б стенд із правилом, алертом і драбиною на
|
||||
// кожен із чотирьох випадків, а помилка в будь-якому з них не видна
|
||||
// одразу — вона видна за тиждень, коли комусь дзвонять о третій ночі за
|
||||
// алертом правила, вимкненого в понеділок.
|
||||
//
|
||||
// current — стан у базі ДО правки; want — те, що прийшло в запиті
|
||||
// (nil = поля не було, стан не чіпаємо).
|
||||
func planRuleEnabled(current bool, want *bool) (enabled, resolve bool) {
|
||||
enabled = current
|
||||
if want != nil {
|
||||
enabled = *want
|
||||
}
|
||||
// Гасити треба рівно на ПЕРЕХОДІ «увімкнене → вимкнене». Не на
|
||||
// кожному збереженні вимкненого правила: його алерти вже погашені
|
||||
// тим переходом, який його вимкнув, і повторний прохід був би
|
||||
// зайвим записом у event_outbox на кожну правку.
|
||||
return enabled, current && !enabled
|
||||
}
|
||||
|
||||
// UpdateRule замінює правило цілком.
|
||||
//
|
||||
// Цілком, а не полями: форма показує повний стан правила, і часткові
|
||||
// оновлення дали б спосіб отримати комбінацію, якої людина не бачила.
|
||||
func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in RuleInput) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
// Стан ДО правки читається окремо й під замком.
|
||||
//
|
||||
// Окремо — бо після UPDATE його вже не відновити, а рішення про
|
||||
// алерти приймається саме за переходом, а не за новим значенням.
|
||||
// Під замком (FOR UPDATE) — бо між читанням і записом уміщається
|
||||
// перемикач «Увімк.» зі списку правил: без замка два записи
|
||||
// могли б лягти в порядку, у якому правило лишається вимкненим,
|
||||
// а гасіння не спрацьовує в жодному з них.
|
||||
var was bool
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT enabled FROM alr.rules
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
FOR UPDATE
|
||||
`, ruleID, tenantID).Scan(&was)
|
||||
if isNoRows(err) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enabled, resolve := planRuleEnabled(was, in.Enabled)
|
||||
|
||||
ct, err := tx.Exec(ctx, `
|
||||
UPDATE alr.rules SET
|
||||
name = $3, description = $4, source = $5::alr.rule_source,
|
||||
|
|
@ -542,7 +597,7 @@ func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in Rule
|
|||
WHERE id = $1 AND tenant_id = $2
|
||||
`, ruleID, tenantID, in.Name, nullString(in.Description), in.Source,
|
||||
in.Severity, in.Selector, in.Condition, in.ForSeconds,
|
||||
in.DependsOnTopology, in.Enabled, in.ChannelIDs, in.NotifySchedule,
|
||||
in.DependsOnTopology, enabled, in.ChannelIDs, in.NotifySchedule,
|
||||
in.NotifyOnResolve, in.AutoCloseSeconds, in.MinIntervalSeconds,
|
||||
nullUUID(in.EscalationPolicyID))
|
||||
if err != nil {
|
||||
|
|
@ -551,6 +606,15 @@ func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in Rule
|
|||
if ct.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
// Те саме, що робить SetRuleEnabled, і з тієї ж причини:
|
||||
// вимкнене правило випадає з ActiveRules, тобто ResolveMissing
|
||||
// за ним більше не біжить і закрити свої алерти воно вже не
|
||||
// зможе. Без цього рядка алерти висіли б у firing вічно — а
|
||||
// драбина ескалації справно будила б за ними людей, з повторами
|
||||
// на тижні вперед.
|
||||
if resolve {
|
||||
return resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило вимкнено")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
|
|
|||
210
server/internal/store/alerts_rule_update_db_test.go
Normal file
210
server/internal/store/alerts_rule_update_db_test.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Правка правила ПРОТИ БАЗИ.
|
||||
//
|
||||
// planRuleEnabled поруч покриває рішення, і це головна половина. Друга
|
||||
// половина рішенням не перевіряється взагалі: чи справді UpdateRule
|
||||
// читає стан ДО запису, чи справді гасить алерти в тій самій
|
||||
// транзакції й чи не вмикає правило, якого його не просили вмикати. Це
|
||||
// властивість трьох запитів, а не функції.
|
||||
//
|
||||
// Мовчки пропускається без NETPULSE_TEST_DSN: `go test ./...` не має
|
||||
// вимагати бази. Запускати треба на ОДНОРАЗОВІЙ базі — тест створює
|
||||
// кабінет і видаляє його разом з усім вмістом:
|
||||
//
|
||||
// docker run --rm -d --name np-test -e POSTGRES_PASSWORD=x \
|
||||
// -e POSTGRES_DB=np timescale/timescaledb:2.17.2-pg16
|
||||
// NETPULSE_DSN=postgres://postgres:x@localhost/np go run ./cmd/netpulse-migrate
|
||||
// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \
|
||||
// go test ./internal/store/ -run UpdateRuleAgainstDB -v
|
||||
func TestUpdateRuleAgainstDB(t *testing.T) {
|
||||
dsn := os.Getenv("NETPULSE_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
st, err := New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("підключення: %v", err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
|
||||
slug := "upd-" + strings.ReplaceAll(time.Now().Format("150405.000"), ".", "")
|
||||
var tenantID string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO core.tenants (slug, name) VALUES ($1, $2) RETURNING id::text
|
||||
`, slug, "Перевірка правки правил").Scan(&tenantID); err != nil {
|
||||
t.Fatalf("кабінет: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = st.pool.Exec(context.Background(),
|
||||
`DELETE FROM core.tenants WHERE id = $1`, tenantID)
|
||||
})
|
||||
|
||||
var deviceID string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO inv.devices (tenant_id, name, address, kind)
|
||||
VALUES ($1, $2, '10.78.0.1', 'switch') RETURNING id::text
|
||||
`, tenantID, slug+"-sw").Scan(&deviceID); err != nil {
|
||||
t.Fatalf("хост: %v", err)
|
||||
}
|
||||
|
||||
// Правило й алерт до нього — рівно те, що бачить людина на екрані
|
||||
// перед тим, як відкрити форму й натиснути «Зберегти».
|
||||
newRule := func(name string, enabled bool) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO alr.rules (tenant_id, name, source, severity, condition, enabled)
|
||||
VALUES ($1, $2, 'icmp', 'high',
|
||||
'{"metric":"loss_pct","op":">","value":20}'::jsonb, $3)
|
||||
RETURNING id::text
|
||||
`, tenantID, name, enabled).Scan(&id); err != nil {
|
||||
t.Fatalf("правило %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
newAlert := func(ruleID string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO alr.alerts (tenant_id, rule_id, device_id, severity, state,
|
||||
title, dedup_key)
|
||||
VALUES ($1, $2, $3, 'high', 'firing', 'ядро не відповідає', $4)
|
||||
RETURNING id::text
|
||||
`, tenantID, ruleID, deviceID, ruleID+":dev:"+deviceID).Scan(&id); err != nil {
|
||||
t.Fatalf("алерт: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
state := func(alertID string) string {
|
||||
t.Helper()
|
||||
var s string
|
||||
if err := st.pool.QueryRow(ctx,
|
||||
`SELECT state::text FROM alr.alerts WHERE id = $1`, alertID).Scan(&s); err != nil {
|
||||
t.Fatalf("стан алерту: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
ruleEnabled := func(ruleID string) bool {
|
||||
t.Helper()
|
||||
var e bool
|
||||
if err := st.pool.QueryRow(ctx,
|
||||
`SELECT enabled FROM alr.rules WHERE id = $1`, ruleID).Scan(&e); err != nil {
|
||||
t.Fatalf("стан правила: %v", err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
base := func(name string) RuleInput {
|
||||
return RuleInput{
|
||||
Name: name,
|
||||
Source: "icmp",
|
||||
Severity: "high",
|
||||
Selector: "{}",
|
||||
Condition: `{"metric":"loss_pct","op":">","value":20}`,
|
||||
ForSeconds: 60,
|
||||
DependsOnTopology: true,
|
||||
ChannelIDs: []string{},
|
||||
NotifyOnResolve: true,
|
||||
}
|
||||
}
|
||||
off, on := false, true
|
||||
|
||||
// --- ВАДА А: вимкнення правкою гасить алерти ----------------------
|
||||
//
|
||||
// Вимкнене правило випадає з ActiveRules, тобто ResolveMissing за
|
||||
// ним більше не біжить. Якщо алерт не погасити тут, він лишиться в
|
||||
// firing назавжди — а драбина ескалації будитиме за ним людей
|
||||
// повторами на тижні вперед.
|
||||
r1 := newRule(slug+"-перше", true)
|
||||
a1 := newAlert(r1)
|
||||
|
||||
in := base(slug + "-перше")
|
||||
in.Description = "правку опису самої по собі мало б бути видно лише в описі"
|
||||
if err := st.UpdateRule(ctx, tenantID, r1, in); err != nil {
|
||||
t.Fatalf("правка без зміни стану: %v", err)
|
||||
}
|
||||
if !ruleEnabled(r1) {
|
||||
t.Fatal("правка без поля `enabled` вимкнула увімкнене правило")
|
||||
}
|
||||
if state(a1) != "firing" {
|
||||
t.Fatalf("правка опису погасила алерт (стан %q) — проблема ж не зникла", state(a1))
|
||||
}
|
||||
|
||||
in.Enabled = &off
|
||||
if err := st.UpdateRule(ctx, tenantID, r1, in); err != nil {
|
||||
t.Fatalf("вимкнення правкою: %v", err)
|
||||
}
|
||||
if ruleEnabled(r1) {
|
||||
t.Fatal("правило не вимкнулось")
|
||||
}
|
||||
if got := state(a1); got != "resolved" {
|
||||
t.Fatalf("алерт вимкненого правила лишився в %q: закрити його вже нікому, "+
|
||||
"а ескалація за ним працює далі", got)
|
||||
}
|
||||
var outbox int
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM core.event_outbox
|
||||
WHERE tenant_id = $1 AND topic = 'alert.resolved'
|
||||
AND payload->>'alert_id' = $2
|
||||
`, tenantID, a1).Scan(&outbox); err != nil {
|
||||
t.Fatalf("черга подій: %v", err)
|
||||
}
|
||||
if outbox != 1 {
|
||||
t.Fatalf("подій alert.resolved для алерту: %d, очікували 1 — "+
|
||||
"без неї екран показуватиме закритий алерт активним", outbox)
|
||||
}
|
||||
|
||||
// Повторне збереження вже вимкненого правила не має додавати ще
|
||||
// одну подію: гасити нічого, а зайвий запис у чергу — це зайве
|
||||
// сповіщення «відновлено» тим, хто підписаний на канал.
|
||||
if err := st.UpdateRule(ctx, tenantID, r1, in); err != nil {
|
||||
t.Fatalf("повторна правка вимкненого: %v", err)
|
||||
}
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM core.event_outbox
|
||||
WHERE tenant_id = $1 AND topic = 'alert.resolved'
|
||||
AND payload->>'alert_id' = $2
|
||||
`, tenantID, a1).Scan(&outbox); err != nil {
|
||||
t.Fatalf("черга подій: %v", err)
|
||||
}
|
||||
if outbox != 1 {
|
||||
t.Fatalf("повторне збереження додало подій: %d", outbox)
|
||||
}
|
||||
|
||||
// --- ВАДА Б: відсутнє поле не вмикає правило ----------------------
|
||||
//
|
||||
// Саме так виглядав кожен PUT із форми правил: поля `enabled` у
|
||||
// тілі не було взагалі, а сервер читав його відсутність як згоду
|
||||
// ввімкнути.
|
||||
r2 := newRule(slug+"-друге", false)
|
||||
|
||||
in2 := base(slug + "-друге")
|
||||
in2.Description = "поправили опис вимкненого правила"
|
||||
if err := st.UpdateRule(ctx, tenantID, r2, in2); err != nil {
|
||||
t.Fatalf("правка вимкненого правила: %v", err)
|
||||
}
|
||||
if ruleEnabled(r2) {
|
||||
t.Fatal("правка без поля `enabled` увімкнула вимкнене правило — " +
|
||||
"моніторинг, який навмисно зупинили, тихо запрацював знову")
|
||||
}
|
||||
|
||||
// І другий бік тієї ж пари: явне `true` мусить вмикати.
|
||||
in2.Enabled = &on
|
||||
if err := st.UpdateRule(ctx, tenantID, r2, in2); err != nil {
|
||||
t.Fatalf("увімкнення правкою: %v", err)
|
||||
}
|
||||
if !ruleEnabled(r2) {
|
||||
t.Fatal("явне `enabled: true` не ввімкнуло правило")
|
||||
}
|
||||
}
|
||||
92
server/internal/store/alerts_rule_update_test.go
Normal file
92
server/internal/store/alerts_rule_update_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
// Рішення про прапорець «увімкнено» при правці правила.
|
||||
//
|
||||
// ЧОГО ЦЕЙ ФАЙЛ БОЇТЬСЯ. Обидві вади, які він закриває, тихі: система
|
||||
// після них не падає й нічого не пише в журнал. Видно їх тільки з
|
||||
// телефона о третій ночі — або, що гірше, не видно взагалі.
|
||||
//
|
||||
// 1. Правило вимкнули через PUT (форма надсилає правило цілком) — і
|
||||
// його алерти лишились у firing НАЗАВЖДИ: вимкнене правило випадає
|
||||
// з ActiveRules, тобто ResolveMissing за ним більше не біжить і
|
||||
// погасити їх немає кому. Драбина ескалації при цьому працює далі —
|
||||
// з повторами вона будить людей тижнями за проблемою, за якою вже
|
||||
// ніхто не стежить.
|
||||
// 2. Форма не надсилала `enabled` взагалі, а сервер читав відсутність
|
||||
// поля як `true`. Тобто відкрити вимкнене правило, поправити в ньому
|
||||
// будь-що й зберегти — означало мовчки його ввімкнути.
|
||||
//
|
||||
// Перевіряється чистою функцією, а не базою, з тієї ж причини, що й
|
||||
// planRestore поруч: на живій базі кожен із чотирьох випадків — це
|
||||
// окремий стенд із правилом, алертом і драбиною, а ціна помилки в
|
||||
// будь-якому з них платиться не тут і не одразу.
|
||||
|
||||
func TestPlanRuleEnabledResolvesOnDisable(t *testing.T) {
|
||||
// Головний випадок: увімкнене правило вимикають правкою. Алерти
|
||||
// треба гасити тут же — іншої нагоди в них не буде.
|
||||
off := false
|
||||
enabled, resolve := planRuleEnabled(true, &off)
|
||||
if enabled {
|
||||
t.Fatal("правило мало лишитись вимкненим")
|
||||
}
|
||||
if !resolve {
|
||||
t.Fatal("алерти не гасяться: після вимкнення їх нікому закрити, " +
|
||||
"і драбина ескалації будитиме людей за ними далі")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRuleEnabledKeepsAlertsWhenStillDisabled(t *testing.T) {
|
||||
// Правку вимкненого правила зберігають повторно. Гасити нема чого:
|
||||
// його алерти закрив той перехід, який його вимкнув, а зайвий
|
||||
// прохід — це ще один запис у event_outbox на кожне збереження.
|
||||
off := false
|
||||
enabled, resolve := planRuleEnabled(false, &off)
|
||||
if enabled {
|
||||
t.Fatal("правило мало лишитись вимкненим")
|
||||
}
|
||||
if resolve {
|
||||
t.Fatal("гасити нічого: правило вже було вимкнене до правки")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRuleEnabledKeepsAlertsWhenEnabling(t *testing.T) {
|
||||
// Увімкнення — не привід чіпати алерти: рішення про них ухвалить
|
||||
// найближчий тік движка, і саме він знає, чи проблема ще триває.
|
||||
on := true
|
||||
enabled, resolve := planRuleEnabled(false, &on)
|
||||
if !enabled {
|
||||
t.Fatal("правило мало ввімкнутись")
|
||||
}
|
||||
if resolve {
|
||||
t.Fatal("увімкнення гасить алерти — цього не мало статись")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRuleEnabledLeavesDisabledRuleAlone(t *testing.T) {
|
||||
// ВАДА Б. Поля `enabled` у тілі немає — саме так виглядав кожен
|
||||
// PUT із форми правил. Вимкнене правило мусить лишитись вимкненим:
|
||||
// «поля не було» означає «не чіпайте», а не «вмикайте».
|
||||
enabled, resolve := planRuleEnabled(false, nil)
|
||||
if enabled {
|
||||
t.Fatal("правка без поля `enabled` увімкнула вимкнене правило — " +
|
||||
"людина дізнається про це зі сповіщення, а не з форми")
|
||||
}
|
||||
if resolve {
|
||||
t.Fatal("гасити нічого: стан не змінився")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRuleEnabledLeavesEnabledRuleAlone(t *testing.T) {
|
||||
// Другий бік тієї ж пари, і без нього перший нічого не доводить:
|
||||
// функція, яка завжди повертає false, пройшла б попередній тест.
|
||||
enabled, resolve := planRuleEnabled(true, nil)
|
||||
if !enabled {
|
||||
t.Fatal("правка без поля `enabled` вимкнула увімкнене правило — " +
|
||||
"це мовчазна зупинка моніторингу")
|
||||
}
|
||||
if resolve {
|
||||
t.Fatal("гасити нічого: стан не змінився")
|
||||
}
|
||||
}
|
||||
|
|
@ -949,6 +949,13 @@ export const api = {
|
|||
selector?: Record<string, unknown>
|
||||
for_seconds: number
|
||||
depends_on_topology: boolean
|
||||
/**
|
||||
* Стан перемикача. Необов'язкове лише для СТВОРЕННЯ, де сервер
|
||||
* бере `true`; на правці мовчання означає «не чіпати», і покластись
|
||||
* на це замість явного значення — те саме, що не надіслати поле,
|
||||
* яке форма насправді знає.
|
||||
*/
|
||||
enabled?: boolean
|
||||
channel_ids?: string[]
|
||||
notify_schedule?: Record<string, unknown> | null
|
||||
notify_on_resolve?: boolean
|
||||
|
|
|
|||
|
|
@ -123,6 +123,25 @@ export function ChannelsPage() {
|
|||
<span className="text-slate-600">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
// Той самий стовпчик, що «Тригерів» у переліку драбин, і
|
||||
// з тієї ж причини: перед видаленням має бути видно, що
|
||||
// на цьому каналі тримається чиєсь нічне чергування.
|
||||
key: 'esc',
|
||||
header: 'Драбини',
|
||||
hideOnMobile: true,
|
||||
cell: (c) =>
|
||||
c.escalations?.length ? (
|
||||
<span
|
||||
className="tabular-nums text-slate-200"
|
||||
title={c.escalations.join(', ')}
|
||||
>
|
||||
{c.escalations.length}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-600">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
header: 'Стан',
|
||||
|
|
@ -172,8 +191,9 @@ export function ChannelsPage() {
|
|||
Канал <b>{c.name}</b> буде видалено разом із його токеном.
|
||||
</>
|
||||
),
|
||||
detail:
|
||||
'Алерти, які йшли сюди, більше не доставлятимуться. Якщо це був єдиний канал, сповіщення припиняться взагалі.',
|
||||
detail: c.escalations?.length
|
||||
? `Алерти, які йшли сюди, більше не доставлятимуться. На цей канал спираються драбини ескалації (${c.escalations.length}): ${c.escalations.join(', ')} — їхні сходинки лишаться без каналу й нікого не розбудять, поки в них не оберуть інший.`
|
||||
: 'Алерти, які йшли сюди, більше не доставлятимуться. Якщо це був єдиний канал, сповіщення припиняться взагалі.',
|
||||
onConfirm: async () => {
|
||||
await api.deleteChannel(c.id)
|
||||
await reload()
|
||||
|
|
|
|||
|
|
@ -56,7 +56,21 @@ export function EscalationsPage() {
|
|||
void reload()
|
||||
}, [reload])
|
||||
|
||||
const channelName = (id: string) => channels.find((c) => c.id === id)?.name ?? '—'
|
||||
// Канал, якого вже немає, показуємо словами, а не прочерком.
|
||||
//
|
||||
// Прочерк читається як «нічого не налаштовано», тобто як спокійний
|
||||
// стан. А це протилежне: сходинка налаштована, виглядає робочою і не
|
||||
// йде нікуди. Драбини, збережені до перевірки каналів, — єдине місце,
|
||||
// де таке ще може лишитись.
|
||||
const channelCell = (id: string) => {
|
||||
const c = channels.find((x) => x.id === id)
|
||||
if (c) return <span key={id}>{c.name}</span>
|
||||
return (
|
||||
<span key={id} className="text-amber-500" title={`Каналу ${id} більше немає`}>
|
||||
канал видалено
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -117,7 +131,21 @@ export function EscalationsPage() {
|
|||
<div key={i} className="text-[11px] text-slate-400">
|
||||
<span className="tabular-nums text-slate-300">+{s.after_min} хв</span>
|
||||
{' → '}
|
||||
{s.channel_ids.map(channelName).join(', ')}
|
||||
{s.channel_ids.length === 0 ? (
|
||||
<span
|
||||
className="text-amber-500"
|
||||
title="Сходинка без каналів: драбина порахує її пройденою, не розбудивши нікого"
|
||||
>
|
||||
жодного каналу
|
||||
</span>
|
||||
) : (
|
||||
s.channel_ids.map((id, k) => (
|
||||
<span key={id}>
|
||||
{k > 0 && ', '}
|
||||
{channelCell(id)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -225,9 +253,32 @@ function PolicyForm({
|
|||
}) {
|
||||
const [name, setName] = useState(policy?.name ?? '')
|
||||
const [description, setDescription] = useState(policy?.description ?? '')
|
||||
const [steps, setSteps] = useState<EscalationStep[]>(
|
||||
policy?.steps?.length ? policy.steps : [{ after_min: 15, channel_ids: [] }],
|
||||
)
|
||||
|
||||
// Ідентифікатори видалених каналів прибираємо ще при відкритті форми.
|
||||
//
|
||||
// Інакше вийшов би глухий кут: галочки для неіснуючого каналу в
|
||||
// переліку немає, тобто прибрати його руками неможливо, а сервер
|
||||
// драбину з ним уже не приймає — форма відмовлялась би зберігатись і
|
||||
// не показувала б, через що.
|
||||
//
|
||||
// Тільки коли канали справді прочитались: порожній перелік буває й
|
||||
// від невдалого запиту, і тоді «чистка» стерла б драбину цілком.
|
||||
const [dropped] = useState(() => {
|
||||
if (!policy?.steps?.length || channels.length === 0) return 0
|
||||
let n = 0
|
||||
for (const s of policy.steps) {
|
||||
n += s.channel_ids.filter((id) => !channels.some((c) => c.id === id)).length
|
||||
}
|
||||
return n
|
||||
})
|
||||
const [steps, setSteps] = useState<EscalationStep[]>(() => {
|
||||
if (!policy?.steps?.length) return [{ after_min: 15, channel_ids: [] }]
|
||||
if (channels.length === 0) return policy.steps
|
||||
return policy.steps.map((s) => ({
|
||||
...s,
|
||||
channel_ids: s.channel_ids.filter((id) => channels.some((c) => c.id === id)),
|
||||
}))
|
||||
})
|
||||
const [repeatAfter, setRepeatAfter] = useState(String(policy?.repeat_after_min ?? 0))
|
||||
const [maxRepeats, setMaxRepeats] = useState(String(policy?.max_repeats ?? 0))
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
|
@ -320,6 +371,13 @@ function PolicyForm({
|
|||
сповіщенням і виглядала б як здвоєне повідомлення.
|
||||
</p>
|
||||
|
||||
{dropped > 0 && (
|
||||
<p className="text-[11px] leading-relaxed text-amber-500">
|
||||
У драбині лишались посилання на видалені канали ({dropped}) — їх прибрано з форми.
|
||||
Перевірте сходинки нижче: та, що лишилась без каналу, нікого не розбудить.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{channels.length === 0 && (
|
||||
<p className="text-[11px] leading-relaxed text-amber-500">
|
||||
Каналів ще немає. Заведіть їх на сторінці «Канали» — без жодного каналу сходинка
|
||||
|
|
|
|||
|
|
@ -467,6 +467,13 @@ function RuleForm({
|
|||
// найчастіший випадок, і вимагати вибору означало б змусити
|
||||
// людину відмічати всі групи по черзі.
|
||||
selector: groupIDs.length > 0 ? { group_ids: groupIDs } : {},
|
||||
// Прапорець «увімкнено» ця форма не показує — його перемикають
|
||||
// у списку, — але надсилати його вона мусить: PUT замінює
|
||||
// правило ЦІЛКОМ, і поле, про яке клієнт змовчав, сервер мусив
|
||||
// би домислити. Раніше домислював `true`, і правка вимкненого
|
||||
// правила мовчки його вмикала: моніторинг, який навмисно
|
||||
// зупинили, знову починав будити людей.
|
||||
enabled: rule?.enabled ?? true,
|
||||
for_seconds: Number(forSec) || 60,
|
||||
// Кореляція за топологією рахується з того, які хости зараз
|
||||
// лежать, — а подія не робить хост мертвим. Для подієвих правил
|
||||
|
|
|
|||
133
web/src/test/escalationchannels.test.tsx
Normal file
133
web/src/test/escalationchannels.test.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { EscalationsPage } from '../pages/EscalationsPage'
|
||||
import { ChannelsPage } from '../pages/ChannelsPage'
|
||||
import { session } from '../api/session'
|
||||
import { fetchRouter, res, type Call } from './support'
|
||||
|
||||
/**
|
||||
* Канал, на який посилається сходинка драбини.
|
||||
*
|
||||
* ЧОГО ЦЕЙ ФАЙЛ БОЇТЬСЯ. Драбина зберігалась із будь-яким
|
||||
* ідентифікатором каналу, а видалення каналу не чіпало сходинок, що на
|
||||
* нього посилались. Ззовні це виглядало як робоче нічне чергування:
|
||||
* сходинка стоїть, хвилини стоять, у стовпчику каналів — прочерк, який
|
||||
* читається як «не заповнено» й нікого не турбує. Насправді ця
|
||||
* сходинка не йде нікуди й списується мовчки.
|
||||
*
|
||||
* У формі це давало ще й глухий кут: галочки для видаленого каналу в
|
||||
* переліку немає, тобто прибрати його руками неможливо, а сервер
|
||||
* (після виправлення) такої драбини вже не приймає.
|
||||
*
|
||||
* Форма підставних відповідей звірена з
|
||||
* `server/internal/httpapi/alerts.go` (`handleListEscalationPolicies`,
|
||||
* `handleListChannels`) і зі `store.EscalationPolicy` / `store.Channel`.
|
||||
*/
|
||||
|
||||
const duty = {
|
||||
id: 'ch-duty',
|
||||
kind: 'telegram',
|
||||
name: 'Черговий',
|
||||
config: {},
|
||||
min_severity: 'warning',
|
||||
enabled: true,
|
||||
has_secret: true,
|
||||
}
|
||||
|
||||
/** Драбина, друга сходинка якої посилається на вже видалений канал. */
|
||||
const policy = {
|
||||
id: 'p-1',
|
||||
name: 'Нічне чергування',
|
||||
description: '',
|
||||
steps: [
|
||||
{ after_min: 15, channel_ids: ['ch-duty'] },
|
||||
{ after_min: 45, channel_ids: ['ch-gone'] },
|
||||
],
|
||||
repeat_after_min: 0,
|
||||
max_repeats: 0,
|
||||
rule_count: 2,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('WebSocket', class {})
|
||||
session.set('tok', {
|
||||
userID: 'u-me',
|
||||
username: 'me',
|
||||
tenantID: 't-1',
|
||||
permissions: ['alerts:read', 'alerts:write'],
|
||||
})
|
||||
})
|
||||
|
||||
describe('сходинка, що посилається на видалений канал', () => {
|
||||
it('у переліку її видно словами, а не прочерком', async () => {
|
||||
fetchRouter({
|
||||
'GET /api/v1/escalation-policies': { policies: [policy] },
|
||||
'GET /api/v1/channels': { channels: [duty] },
|
||||
})
|
||||
|
||||
render(<EscalationsPage />)
|
||||
|
||||
await screen.findByText('Черговий')
|
||||
// Прочерк читався б як «нічого не налаштовано», тобто як спокійний
|
||||
// стан. Тут стан протилежний.
|
||||
expect(await screen.findByText('канал видалено')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('форма прибирає привида, говорить про це і не шле його на сервер', async () => {
|
||||
const srv = fetchRouter({
|
||||
'GET /api/v1/escalation-policies': { policies: [policy] },
|
||||
'GET /api/v1/channels': { channels: [duty] },
|
||||
'PUT /api/v1/escalation-policies/p-1': () => res(200, { id: 'p-1' }),
|
||||
})
|
||||
|
||||
render(<EscalationsPage />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Змінити' }))
|
||||
await screen.findByText('Драбина: Нічне чергування')
|
||||
|
||||
expect(screen.getByText(/лишались посилання на видалені канали/)).toBeTruthy()
|
||||
|
||||
// Друга сходинка лишилась без каналу — і форма має сказати про це
|
||||
// ДО збереження, а не віддати серверу відмову без пояснення.
|
||||
expect(screen.getByText(/сходинка 2 не має жодного каналу/)).toBeTruthy()
|
||||
|
||||
// Дали сходинці канал — тепер зберігається.
|
||||
const boxes = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(boxes[boxes.length - 1])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Зберегти' }))
|
||||
|
||||
let put: Call | undefined
|
||||
await waitFor(() => {
|
||||
put = srv.calls.find((c) => c.method === 'PUT')
|
||||
expect(put).toBeTruthy()
|
||||
})
|
||||
const body = put!.body as { steps: { channel_ids: string[] }[] }
|
||||
expect(body.steps.map((s) => s.channel_ids)).toEqual([['ch-duty'], ['ch-duty']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('видалення каналу, на який спирається драбина', () => {
|
||||
it('називає драбини, які через це замовкнуть', async () => {
|
||||
fetchRouter({
|
||||
'GET /api/v1/channels': {
|
||||
channels: [{ ...duty, escalations: ['Нічне чергування', 'Вихідні'] }],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ChannelsPage />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Видалити' }))
|
||||
|
||||
// Те саме, що вже показує видалення драбини: скільки чужих
|
||||
// налаштувань зараз перестане працювати, і яких саме.
|
||||
expect(await screen.findByText(/Нічне чергування, Вихідні/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('без драбин попередження про них не вигадується', async () => {
|
||||
fetchRouter({ 'GET /api/v1/channels': { channels: [duty] } })
|
||||
|
||||
render(<ChannelsPage />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Видалити' }))
|
||||
|
||||
await screen.findByText(/більше не доставлятимуться/)
|
||||
expect(screen.queryByText(/драбини ескалації/)).toBeNull()
|
||||
})
|
||||
})
|
||||
102
web/src/test/rules.test.tsx
Normal file
102
web/src/test/rules.test.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { RulesPage } from '../pages/RulesPage'
|
||||
import { session } from '../api/session'
|
||||
import { fetchRouter, res, type Call } from './support'
|
||||
|
||||
/**
|
||||
* Збереження тригера з форми.
|
||||
*
|
||||
* ЧОГО ЦЕЙ ФАЙЛ БОЇТЬСЯ. Форма показує тригер цілком і надсилає його
|
||||
* цілком (`PUT /api/v1/alert-rules/{id}` замінює правило), але
|
||||
* перемикача «увімкнено» в ній немає — його місце в списку. Через це
|
||||
* поле `enabled` у тілі запиту не було ВЗАГАЛІ, а сервер читав його
|
||||
* відсутність як згоду ввімкнути.
|
||||
*
|
||||
* Наслідок тихий і найгіршого можливого сорту: людина відкриває
|
||||
* НАВМИСНО вимкнений тригер, правит у ньому будь-що, натискає
|
||||
* «Зберегти» — і моніторинг, який зупинили свідомо, знову починає
|
||||
* будити людей. У формі при цьому не змінюється нічого: вона про цей
|
||||
* прапорець не знає й не показує його ні до, ні після.
|
||||
*
|
||||
* Перевірка йде парою — вимкнений і увімкнений тригер. Один бік
|
||||
* окремо не доводить нічого: `enabled: false`, вписане намертво,
|
||||
* пройшло б перший тест і зупинило б моніторинг усім.
|
||||
*/
|
||||
|
||||
const disabledRule = {
|
||||
id: 'r-off',
|
||||
name: 'Втрати пакетів на магістралі',
|
||||
description: '',
|
||||
source: 'icmp',
|
||||
severity: 'high',
|
||||
selector: {},
|
||||
condition: { metric: 'loss_pct', op: '>', value: 20 },
|
||||
for_seconds: 180,
|
||||
depends_on_topology: true,
|
||||
enabled: false,
|
||||
active_alerts: 0,
|
||||
channel_ids: [],
|
||||
notify_on_resolve: true,
|
||||
}
|
||||
|
||||
const enabledRule = { ...disabledRule, id: 'r-on', name: 'Ядро не відповідає', enabled: true }
|
||||
|
||||
/** Довідники форми: порожні — це робочий стан, а не збій. */
|
||||
const refs = {
|
||||
'GET /api/v1/device-groups': { groups: [] },
|
||||
'GET /api/v1/channels': { channels: [] },
|
||||
'GET /api/v1/escalation-policies': { policies: [] },
|
||||
'GET /api/v1/traps/meta': { names: [] },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// liveEvents підписується на WebSocket одразу: без заглушки jsdom
|
||||
// пішов би у справжню мережу, і тест залежав би від того, що зараз
|
||||
// слухає localhost.
|
||||
vi.stubGlobal('WebSocket', class {})
|
||||
session.set('tok', {
|
||||
userID: 'u-me',
|
||||
username: 'me',
|
||||
tenantID: 't-1',
|
||||
permissions: ['alerts:read', 'alerts:write'],
|
||||
})
|
||||
})
|
||||
|
||||
/** Відкрити тригер на правку й одразу зберегти, нічого не змінивши. */
|
||||
async function saveWithoutChanges(rule: typeof disabledRule) {
|
||||
const srv = fetchRouter({
|
||||
'GET /api/v1/alert-rules': { rules: [rule] },
|
||||
...refs,
|
||||
[`PUT /api/v1/alert-rules/${rule.id}`]: () => res(200, { id: rule.id }),
|
||||
})
|
||||
|
||||
render(<RulesPage />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Змінити' }))
|
||||
await screen.findByText(`Тригер: ${rule.name}`)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Зберегти' }))
|
||||
|
||||
let put: Call | undefined
|
||||
await waitFor(() => {
|
||||
put = srv.calls.find((c) => c.method === 'PUT')
|
||||
expect(put).toBeDefined()
|
||||
})
|
||||
return put!.body as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('форма тригера', () => {
|
||||
it('не вмикає вимкнений тригер збереженням', async () => {
|
||||
const body = await saveWithoutChanges(disabledRule)
|
||||
expect(
|
||||
body.enabled,
|
||||
'форма змовчала про `enabled` — сервер домислить його сам, і вимкнений тригер увімкнеться',
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('лишає увімкнений тригер увімкненим', async () => {
|
||||
const body = await saveWithoutChanges(enabledRule)
|
||||
expect(body.enabled, 'збереження вимкнуло робочий тригер — це тиха зупинка моніторингу').toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -838,6 +838,13 @@ export interface Channel {
|
|||
enabled: boolean
|
||||
/** Чи збережено секрет. Сам секрет назад не віддається ніколи. */
|
||||
has_secret: boolean
|
||||
/**
|
||||
* Назви драбин ескалації, сходинки яких шлють у цей канал.
|
||||
*
|
||||
* Потрібне лише перед видаленням: канал, на який спирається нічне
|
||||
* чергування, не має зникати мовчки. Приходить лише в переліку.
|
||||
*/
|
||||
escalations?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue