From cedf1d261da3a11a47a5cc663c5308c238491569 Mon Sep 17 00:00:00 2001
From: byrsapty
Date: Fri, 28 Aug 2026 17:20:18 +0300
Subject: [PATCH] =?UTF-8?q?=D0=95=D1=81=D0=BA=D0=B0=D0=BB=D0=B0=D1=86?=
=?UTF-8?q?=D1=96=D1=97:=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=20=D0=B1?=
=?UTF-8?q?=D1=96=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=B1=D1=80?=
=?UTF-8?q?=D0=B5=D1=88=D0=B5,=20ack=20=D0=BD=D0=B5=20=D0=B2=D0=BE=D1=81?=
=?UTF-8?q?=D0=BA=D1=80=D0=B5=D1=88=D0=B0=D1=94=20=D0=B4=D1=80=D0=B0=D0=B1?=
=?UTF-8?q?=D0=B8=D0=BD=D1=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Три вади, знайдені рецензією, яких щасливий шлях показати не міг:
* outcome='sent' писався до доставки; помилка читання каналів клала в
кеш порожню мапу й з'їдала всі сходинки кабінету за тік — усі зі
слідом «надіслано». Канали тепер читаються до просування стану,
журнал пишеться після доставки, з правдою.
* UPDATE не мав stopped_at IS NULL — підтвердження алерту посеред
партії не рятувало людину від дзвінка.
* час брався раз на партію.
Плюс суміжне: UpdateRule не гасив алертів вимкненого правила, сервер
домислював enabled на оновленні, channel_ids сходинок не звірялись із
каналами кабінету (зокрема чужого).
І scripts/dbtest.sh — тести проти бази перестали мовчки пропускатись.
Co-Authored-By: Claude Opus 5
---
HISTORY.md | 131 +++++++++++
scripts/dbtest.sh | 70 ++++++
server/API.md | 12 +
server/internal/alerting/escalation.go | 108 ++++++---
server/internal/httpapi/alerts.go | 45 +++-
.../store/alerts_channel_refs_db_test.go | 168 ++++++++++++++
server/internal/store/alerts_channels.go | 145 ++++++++++++
server/internal/store/alerts_escalation.go | 130 ++++++++++-
.../store/alerts_escalation_channels_test.go | 166 ++++++++++++++
.../store/alerts_escalation_db_test.go | 80 ++++++-
server/internal/store/alerts_query.go | 70 +++++-
.../store/alerts_rule_update_db_test.go | 210 ++++++++++++++++++
.../internal/store/alerts_rule_update_test.go | 92 ++++++++
web/src/api/client.ts | 7 +
web/src/pages/ChannelsPage.tsx | 24 +-
web/src/pages/EscalationsPage.tsx | 68 +++++-
web/src/pages/RulesPage.tsx | 7 +
web/src/test/escalationchannels.test.tsx | 133 +++++++++++
web/src/test/rules.test.tsx | 102 +++++++++
web/src/types.ts | 7 +
20 files changed, 1719 insertions(+), 56 deletions(-)
create mode 100644 scripts/dbtest.sh
create mode 100644 server/internal/store/alerts_channel_refs_db_test.go
create mode 100644 server/internal/store/alerts_escalation_channels_test.go
create mode 100644 server/internal/store/alerts_rule_update_db_test.go
create mode 100644 server/internal/store/alerts_rule_update_test.go
create mode 100644 web/src/test/escalationchannels.test.tsx
create mode 100644 web/src/test/rules.test.tsx
diff --git a/HISTORY.md b/HISTORY.md
index d8c6cfd..ca1a582 100644
--- a/HISTORY.md
+++ b/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`.
diff --git a/scripts/dbtest.sh b/scripts/dbtest.sh
new file mode 100644
index 0000000..7ce6423
--- /dev/null
+++ b/scripts/dbtest.sh
@@ -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
diff --git a/server/API.md b/server/API.md
index a98f248..d8cdb99 100644
--- a/server/API.md
+++ b/server/API.md
@@ -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` вічно: вимкнене правило
+випадає з вибірки движка, тобто закрити їх немає кому, а драбина
+ескалації справно будила б за ними людей тижнями.
+
### Ескалація
Сповіщення, надіслане один раз, нічого не гарантує: черговий може спати.
diff --git a/server/internal/alerting/escalation.go b/server/internal/alerting/escalation.go
index 9b9c1ea..7004545 100644
--- a/server/internal/alerting/escalation.go
+++ b/server/internal/alerting/escalation.go
@@ -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{}
- continue
- }
- byID = make(map[string]store.Channel, len(cs))
- for _, c := range cs {
- byID[c.ID] = c
- }
- channels[snap.TenantID] = byID
+ if sent := e.notifier.deliverEscalation(ctx, snap, d, byID); sent == 0 {
+ // Сходинка списана — інакше вона поверталася б щотіку. Але в
+ // журнал іде правда, а не намір: «надіслано» на сходинці, яка
+ // нікуди не пішла, — саме та мовчазна відмова, від якої
+ // ескалація рятує.
+ e.logStep(ctx, snap, d, "no_channels",
+ "жоден канал сходинки не прийняв повідомлення (видалено, вимкнено або поріг серйозності)")
+ continue
}
-
- e.notifier.deliverEscalation(ctx, snap, d, byID)
+ e.logStep(ctx, snap, d, d.Outcome, d.Detail)
}
}
-// deliverEscalation шле сходинку в її канали.
+// 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
+ }
+ cache[tenantID] = byID
+ return byID, nil
+}
+
+// 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 шле сходинку в її канали й повертає, скільком дійшло.
+//
+// Кількість потрібна тому, хто пише журнал: сходинка без жодного каналу
+// має лишити слід «нікуди не пішло», а не «надіслано».
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 пояснює людині, чому вона це читає.
diff --git a/server/internal/httpapi/alerts.go b/server/internal/httpapi/alerts.go
index 4313ffd..ba9160c 100644
--- a/server/internal/httpapi/alerts.go
+++ b/server/internal/httpapi/alerts.go
@@ -303,10 +303,24 @@ 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,
- ChannelIDs: req.ChannelIDs,
- NotifySchedule: string(req.NotifySchedule),
- NotifyOnResolve: req.NotifyOnResolve == nil || *req.NotifyOnResolve,
+ // `enabled` передається як є, разом з «поля не було».
+ //
+ // На створенні відсутність поля лишається згодою ввімкнути — це
+ // розумно й так було завжди (правило, заведене вимкненим, не
+ // робить нічого). На ОНОВЛЕННІ домислювати вже не можна: сервер
+ // ставив `true` кожному, хто поля не надіслав, тож правка опису
+ // вимкненого правила мовчки його вмикала — і людина дізнавалась
+ // про це зі сповіщення, а не з форми.
+ //
+ // Обрано саме «не було = не чіпати», а не «не було = 400»: PUT
+ // без `enabled` шле і власна форма, і будь-який чужий скрипт,
+ // написаний за цим API, тож вимога поля зламала б їх усі одним
+ // оновленням. Прапорець при цьому лишається керованим — його
+ // явно шлють і форма (тепер), і PATCH-перемикач.
+ Enabled: req.Enabled,
+ ChannelIDs: req.ChannelIDs,
+ NotifySchedule: string(req.NotifySchedule),
+ NotifyOnResolve: req.NotifyOnResolve == nil || *req.NotifyOnResolve,
AutoCloseSeconds: autoClose,
MinIntervalSeconds: minInterval,
@@ -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
diff --git a/server/internal/store/alerts_channel_refs_db_test.go b/server/internal/store/alerts_channel_refs_db_test.go
new file mode 100644
index 0000000..5cd67dd
--- /dev/null
+++ b/server/internal/store/alerts_channel_refs_db_test.go
@@ -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)
+ }
+}
diff --git a/server/internal/store/alerts_channels.go b/server/internal/store/alerts_channels.go
index 6f516b0..22fb1d9 100644
--- a/server/internal/store/alerts_channels.go
+++ b/server/internal/store/alerts_channels.go
@@ -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
})
}
diff --git a/server/internal/store/alerts_escalation.go b/server/internal/store/alerts_escalation.go
index 0bb227b..0188aa2 100644
--- a/server/internal/store/alerts_escalation.go
+++ b/server/internal/store/alerts_escalation.go
@@ -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
})
}
diff --git a/server/internal/store/alerts_escalation_channels_test.go b/server/internal/store/alerts_escalation_channels_test.go
new file mode 100644
index 0000000..60db077
--- /dev/null
+++ b/server/internal/store/alerts_escalation_channels_test.go
@@ -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("порожню сходинку не спіймала жодна перевірка")
+ }
+}
diff --git a/server/internal/store/alerts_escalation_db_test.go b/server/internal/store/alerts_escalation_db_test.go
index 11a6a0c..ab26dc0 100644
--- a/server/internal/store/alerts_escalation_db_test.go
+++ b/server/internal/store/alerts_escalation_db_test.go
@@ -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"}}},
+ Name: "Нічне чергування",
+ 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 {
diff --git a/server/internal/store/alerts_query.go b/server/internal/store/alerts_query.go
index 0cac4c1..39f1b21 100644
--- a/server/internal/store/alerts_query.go
+++ b/server/internal/store/alerts_query.go
@@ -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
})
}
diff --git a/server/internal/store/alerts_rule_update_db_test.go b/server/internal/store/alerts_rule_update_db_test.go
new file mode 100644
index 0000000..5d3ec6e
--- /dev/null
+++ b/server/internal/store/alerts_rule_update_db_test.go
@@ -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` не ввімкнуло правило")
+ }
+}
diff --git a/server/internal/store/alerts_rule_update_test.go b/server/internal/store/alerts_rule_update_test.go
new file mode 100644
index 0000000..b0464c5
--- /dev/null
+++ b/server/internal/store/alerts_rule_update_test.go
@@ -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("гасити нічого: стан не змінився")
+ }
+}
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 4bf7639..6176fda 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -949,6 +949,13 @@ export const api = {
selector?: Record
for_seconds: number
depends_on_topology: boolean
+ /**
+ * Стан перемикача. Необов'язкове лише для СТВОРЕННЯ, де сервер
+ * бере `true`; на правці мовчання означає «не чіпати», і покластись
+ * на це замість явного значення — те саме, що не надіслати поле,
+ * яке форма насправді знає.
+ */
+ enabled?: boolean
channel_ids?: string[]
notify_schedule?: Record | null
notify_on_resolve?: boolean
diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx
index 66260a3..dae4bc0 100644
--- a/web/src/pages/ChannelsPage.tsx
+++ b/web/src/pages/ChannelsPage.tsx
@@ -123,6 +123,25 @@ export function ChannelsPage() {
—
),
},
+ {
+ // Той самий стовпчик, що «Тригерів» у переліку драбин, і
+ // з тієї ж причини: перед видаленням має бути видно, що
+ // на цьому каналі тримається чиєсь нічне чергування.
+ key: 'esc',
+ header: 'Драбини',
+ hideOnMobile: true,
+ cell: (c) =>
+ c.escalations?.length ? (
+
+ {c.escalations.length}
+
+ ) : (
+ —
+ ),
+ },
{
key: 'state',
header: 'Стан',
@@ -172,8 +191,9 @@ export function ChannelsPage() {
Канал {c.name} буде видалено разом із його токеном.
>
),
- detail:
- 'Алерти, які йшли сюди, більше не доставлятимуться. Якщо це був єдиний канал, сповіщення припиняться взагалі.',
+ detail: c.escalations?.length
+ ? `Алерти, які йшли сюди, більше не доставлятимуться. На цей канал спираються драбини ескалації (${c.escalations.length}): ${c.escalations.join(', ')} — їхні сходинки лишаться без каналу й нікого не розбудять, поки в них не оберуть інший.`
+ : 'Алерти, які йшли сюди, більше не доставлятимуться. Якщо це був єдиний канал, сповіщення припиняться взагалі.',
onConfirm: async () => {
await api.deleteChannel(c.id)
await reload()
diff --git a/web/src/pages/EscalationsPage.tsx b/web/src/pages/EscalationsPage.tsx
index 9b9cc9c..f63cbd2 100644
--- a/web/src/pages/EscalationsPage.tsx
+++ b/web/src/pages/EscalationsPage.tsx
@@ -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 {c.name}
+ return (
+
+ канал видалено
+
+ )
+ }
return (
<>
@@ -117,7 +131,21 @@ export function EscalationsPage() {
+{s.after_min} хв
{' → '}
- {s.channel_ids.map(channelName).join(', ')}
+ {s.channel_ids.length === 0 ? (
+
+ жодного каналу
+
+ ) : (
+ s.channel_ids.map((id, k) => (
+
+ {k > 0 && ', '}
+ {channelCell(id)}
+
+ ))
+ )}
))}
@@ -225,9 +253,32 @@ function PolicyForm({
}) {
const [name, setName] = useState(policy?.name ?? '')
const [description, setDescription] = useState(policy?.description ?? '')
- const [steps, setSteps] = useState(
- 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(() => {
+ 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({
сповіщенням і виглядала б як здвоєне повідомлення.
+ {dropped > 0 && (
+
+ У драбині лишались посилання на видалені канали ({dropped}) — їх прибрано з форми.
+ Перевірте сходинки нижче: та, що лишилась без каналу, нікого не розбудить.
+
+ )}
+
{channels.length === 0 && (
Каналів ще немає. Заведіть їх на сторінці «Канали» — без жодного каналу сходинка
diff --git a/web/src/pages/RulesPage.tsx b/web/src/pages/RulesPage.tsx
index 2e7b6bd..113dee8 100644
--- a/web/src/pages/RulesPage.tsx
+++ b/web/src/pages/RulesPage.tsx
@@ -467,6 +467,13 @@ function RuleForm({
// найчастіший випадок, і вимагати вибору означало б змусити
// людину відмічати всі групи по черзі.
selector: groupIDs.length > 0 ? { group_ids: groupIDs } : {},
+ // Прапорець «увімкнено» ця форма не показує — його перемикають
+ // у списку, — але надсилати його вона мусить: PUT замінює
+ // правило ЦІЛКОМ, і поле, про яке клієнт змовчав, сервер мусив
+ // би домислити. Раніше домислював `true`, і правка вимкненого
+ // правила мовчки його вмикала: моніторинг, який навмисно
+ // зупинили, знову починав будити людей.
+ enabled: rule?.enabled ?? true,
for_seconds: Number(forSec) || 60,
// Кореляція за топологією рахується з того, які хости зараз
// лежать, — а подія не робить хост мертвим. Для подієвих правил
diff --git a/web/src/test/escalationchannels.test.tsx b/web/src/test/escalationchannels.test.tsx
new file mode 100644
index 0000000..8c37031
--- /dev/null
+++ b/web/src/test/escalationchannels.test.tsx
@@ -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()
+
+ 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()
+ 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()
+ fireEvent.click(await screen.findByRole('button', { name: 'Видалити' }))
+
+ // Те саме, що вже показує видалення драбини: скільки чужих
+ // налаштувань зараз перестане працювати, і яких саме.
+ expect(await screen.findByText(/Нічне чергування, Вихідні/)).toBeTruthy()
+ })
+
+ it('без драбин попередження про них не вигадується', async () => {
+ fetchRouter({ 'GET /api/v1/channels': { channels: [duty] } })
+
+ render()
+ fireEvent.click(await screen.findByRole('button', { name: 'Видалити' }))
+
+ await screen.findByText(/більше не доставлятимуться/)
+ expect(screen.queryByText(/драбини ескалації/)).toBeNull()
+ })
+})
diff --git a/web/src/test/rules.test.tsx b/web/src/test/rules.test.tsx
new file mode 100644
index 0000000..76dd77d
--- /dev/null
+++ b/web/src/test/rules.test.tsx
@@ -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()
+ 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
+}
+
+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,
+ )
+ })
+})
diff --git a/web/src/types.ts b/web/src/types.ts
index 9dc7625..da65e2a 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -838,6 +838,13 @@ export interface Channel {
enabled: boolean
/** Чи збережено секрет. Сам секрет назад не віддається ніколи. */
has_secret: boolean
+ /**
+ * Назви драбин ескалації, сходинки яких шлють у цей канал.
+ *
+ * Потрібне лише перед видаленням: канал, на який спирається нічне
+ * чергування, не має зникати мовчки. Приходить лише в переліку.
+ */
+ escalations?: string[]
}
/**