Тиха година й драбина: вада окремо, вибір окремо
Асиметрія: аварія о 21:59 ескалювала всю ніч, о 22:01 не ескалювала ніколи. Дві хвилини різниці — протилежні наслідки, причому гірший (повна тиша) виглядав як тиша справна. ВАДА. targets() повертав порожньо в тиху годину, а взведення читало це як «немає куди слати». Взводять лише новий алерт, тож драбина не з'являлась уже ніколи: тиха година вимикала механізм саме тоді, коли перше сповіщення не спрацювало. Тепер targets() розрізняє «каналів немає» і «канали є, просто зараз ніч». ВИБІР. 0072 додає respect_quiet_hours на драбину: false (типово, як діяло) — драбина пробивається; true — сходинка відкладається до ранку і НЕ витрачається. Залежить від того, чи є в кабінету нічна зміна — це вирішує кабінет. disaster пробивається за будь-якого значення, як і в targets(). Відлік драбини — від першого сповіщення, а не від started_at: інакше для розглушеного алерту вона протухла б ще у вікні. І сам прогін проти бази брехав: dbtest.sh котив схему готовим образом (старі міграції), а тести брав із нового дерева. Тепер міграції з того ж дерева. 64 міграції, усе зелене. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
954be1d643
commit
ec4b2cd54b
15 changed files with 691 additions and 53 deletions
55
HISTORY.md
55
HISTORY.md
|
|
@ -7557,3 +7557,58 @@ NULL і валить запис. Правило без власних канал
|
||||||
подвійне сповіщення (`ApplyEscalation` не звіряє оренду токеном);
|
подвійне сповіщення (`ApplyEscalation` не звіряє оренду токеном);
|
||||||
`LoadChannels` падає цілком через один нерозшифровний секрет, і кабінет
|
`LoadChannels` падає цілком через один нерозшифровний секрет, і кабінет
|
||||||
лишається без ескалацій до стелі життя драбини.
|
лишається без ескалацій до стелі життя драбини.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-08-28 — Тиха година й драбина: обидві відповіді правильні, тому це вибір
|
||||||
|
|
||||||
|
Асиметрія, яку неможливо було пояснити людині, що її переживе:
|
||||||
|
|
||||||
|
```
|
||||||
|
аварія о 21:59 — сповіщення пішло, драбина взвелась і дзвонить усю ніч
|
||||||
|
аварія о 22:01 — сповіщення притлумила тиха година, каналів «не
|
||||||
|
знайшлось», драбина не взвелась — і не взведеться
|
||||||
|
НІКОЛИ, бо взводять лише новий алерт
|
||||||
|
```
|
||||||
|
|
||||||
|
Дві хвилини різниці — протилежні наслідки, причому гірший (повна тиша на
|
||||||
|
аварії) виглядає як тиша справна.
|
||||||
|
|
||||||
|
**Половина перша — вада, і її просто виправлено.** Тиха година
|
||||||
|
роззброювала драбину назавжди, бо `targets()` повертав порожньо, а
|
||||||
|
`shouldArmEscalation` бачив у цьому «немає куди слати». Тепер `targets()`
|
||||||
|
розрізняє «каналів немає» і «канали є, просто зараз ніч», і драбина
|
||||||
|
взводиться в другому випадку. Механізм, що існує на випадок «перше
|
||||||
|
сповіщення не спрацювало», більше не вимикається саме тоді, коли перше
|
||||||
|
сповіщення не спрацювало.
|
||||||
|
|
||||||
|
**Половина друга — не вада, а вибір, і тому це прапорець.** Міграція 0072
|
||||||
|
додає `alr.escalation_policies.respect_quiet_hours`:
|
||||||
|
|
||||||
|
* `false` (типово, поведінка що діяла) — драбина пробивається. Тиха
|
||||||
|
година стосується ПЕРШОГО сповіщення, а сенс драбини рівно в тому, щоб
|
||||||
|
дійти, коли перше проґавили. Так працює чергування.
|
||||||
|
* `true` — сходинка відкладається до кінця тихої години, як під
|
||||||
|
заглушенням, і НЕ витрачається: інакше одна ніч тихо роззброювала б
|
||||||
|
драбину до кінця життя алерту.
|
||||||
|
|
||||||
|
Правильна відповідь залежить від того, чи є в кабінету нічна зміна — це
|
||||||
|
вирішує кабінет, не ми. Типове значення зберігає наявну поведінку:
|
||||||
|
оновлення не має тихо роззброїти нічні дзвінки в тих, у кого вони
|
||||||
|
працюють.
|
||||||
|
|
||||||
|
`disaster` пробивається за будь-якого значення — виняток не новий,
|
||||||
|
`targets()` уже пропускає його крізь тиху годину маршруту й правила. Одне
|
||||||
|
слово в двох місцях мусить означати одне.
|
||||||
|
|
||||||
|
Відлік драбини ведеться від ПЕРШОГО СПОВІЩЕННЯ, а не від `started_at`:
|
||||||
|
для алерту, з якого щойно зняли заглушення, це різниця між «драбина
|
||||||
|
попереду» і «драбина протухла ще у вікні й висиплеться одним залпом».
|
||||||
|
|
||||||
|
### Прогін проти бази знову збрехав — і це виправлено в самому прогоні
|
||||||
|
|
||||||
|
`dbtest.sh` на стенді котив схему готовим ОБРАЗОМ, а тести брав із нового
|
||||||
|
дерева. Образ зібрано з розгорнутого коду, тож він знав лише старі
|
||||||
|
міграції: падіння виглядало як «немає стовпця respect_quiet_hours», хоча
|
||||||
|
стовпець у гілці є. Тепер міграції котяться з того ж дерева, що й тести
|
||||||
|
(`go run ./cmd/netpulse-migrate`). Перевірено: 64 міграції, усе зелене.
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,11 @@ cleanup() {
|
||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
# Міграції котяться З ЦЬОГО Ж ДЕРЕВА (go run), а не готовим образом — і
|
||||||
|
# це не дрібниця. Образ зібрано з розгорнутого коду, тож він знає лише
|
||||||
|
# старі міграції, і тести нового коду йшли б проти старої схеми: падіння
|
||||||
|
# виглядало б як «немає такого стовпця», хоч стовпець у гілці є. Один
|
||||||
|
# такий прогін уже збрехав саме так.
|
||||||
echo "== накат міграцій"
|
echo "== накат міграцій"
|
||||||
( cd "$SRC/server" && NETPULSE_DSN="$NETPULSE_TEST_DSN" go run ./cmd/netpulse-migrate ) || exit 1
|
( cd "$SRC/server" && NETPULSE_DSN="$NETPULSE_TEST_DSN" go run ./cmd/netpulse-migrate ) || exit 1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1198,7 +1198,8 @@ JSON у таблиці правил.
|
||||||
{"after_min": 45, "channel_ids": ["…керівник зміни"]}
|
{"after_min": 45, "channel_ids": ["…керівник зміни"]}
|
||||||
],
|
],
|
||||||
"repeat_after_min": 60, // 0 — не повторювати драбину
|
"repeat_after_min": 60, // 0 — не повторювати драбину
|
||||||
"max_repeats": 2
|
"max_repeats": 2,
|
||||||
|
"respect_quiet_hours": false // false (типово) — драбина йде і в тиху годину
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1218,9 +1219,18 @@ JSON у таблиці правил.
|
||||||
відкладають: вікно на пів години інакше тихо роззброїло б драбину до
|
відкладають: вікно на пів години інакше тихо роззброїло б драбину до
|
||||||
кінця життя алерту. Відкладання обмежене стелею життя драбини.
|
кінця життя алерту. Відкладання обмежене стелею життя драбини.
|
||||||
- **Драбина взводиться лише тоді, коли перше сповіщення справді пішло.**
|
- **Драбина взводиться лише тоді, коли перше сповіщення справді пішло.**
|
||||||
Якщо каналів не знайшлось (тиха година, поріг серйозності, вимкнений
|
Якщо каналів не знайшлось (поріг серйозності, вимкнений канал,
|
||||||
канал), ескалації не буде: інакше о 15-й хвилині пішло б те, що на
|
порожній перелік), ескалації не буде: інакше о 15-й хвилині пішло б
|
||||||
нульовій свідомо не надсилали.
|
те, що на нульовій свідомо не надсилали.
|
||||||
|
- **Тиха година до цієї умови не належить**: канали є, просто зараз ніч.
|
||||||
|
Драбина взводиться в будь-якому разі, а `respect_quiet_hours` вирішує,
|
||||||
|
що вона робить із сходинкою, яка припала на тиху годину правила:
|
||||||
|
`false` (типово) — доставляє, бо тиха година стримує ПЕРШЕ сповіщення,
|
||||||
|
а драбина йде саме тоді, коли на перше ніхто не відповів; `true` —
|
||||||
|
відкладає до кінця тихої години, не витрачаючи сходинки (як заглушення
|
||||||
|
й вікно обслуговування). Серйозність `disaster` проходить за будь-якого
|
||||||
|
значення — той самий виняток, що вже діє для тихої години маршруту й
|
||||||
|
правила. Відсутнє поле в запиті = `false`.
|
||||||
- **Подієві алерти** (`syslog`, `ncm`, `compliance`) проходять драбину
|
- **Подієві алерти** (`syslog`, `ncm`, `compliance`) проходять драбину
|
||||||
один раз, без повторів. Повтор — це ставка на те, що проблема триває, а
|
один раз, без повторів. Повтор — це ставка на те, що проблема триває, а
|
||||||
її можна робити лише там, де існування алерту саме по собі є доказом:
|
її можна робити лише там, де існування алерту саме по собі є доказом:
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ func act() store.RuleAction {
|
||||||
|
|
||||||
// Контроль: нормальний випадок драбину взводить.
|
// Контроль: нормальний випадок драбину взводить.
|
||||||
func TestEscalationArmedForFiringAlert(t *testing.T) {
|
func TestEscalationArmedForFiringAlert(t *testing.T) {
|
||||||
if !shouldArmEscalation(store.Alert{State: "firing"}, act(), true, 2) {
|
if !shouldArmEscalation(store.Alert{State: "firing"}, act(), true, true) {
|
||||||
t.Fatal("алерт, про який справді повідомили, має отримати драбину")
|
t.Fatal("алерт, про який справді повідомили, має отримати драбину")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -35,11 +35,11 @@ func TestEscalationArmedForFiringAlert(t *testing.T) {
|
||||||
// почати будити людей тільки тому, що з'явилась нова можливість.
|
// почати будити людей тільки тому, що з'явилась нова можливість.
|
||||||
func TestNoPolicyMeansNoEscalation(t *testing.T) {
|
func TestNoPolicyMeansNoEscalation(t *testing.T) {
|
||||||
empty := store.RuleAction{Source: "icmp"}
|
empty := store.RuleAction{Source: "icmp"}
|
||||||
if shouldArmEscalation(store.Alert{State: "firing"}, empty, true, 3) {
|
if shouldArmEscalation(store.Alert{State: "firing"}, empty, true, true) {
|
||||||
t.Fatal("правило без політики не має ескалювати")
|
t.Fatal("правило без політики не має ескалювати")
|
||||||
}
|
}
|
||||||
// Правила взагалі немає (його видалили, алерт лишився).
|
// Правила взагалі немає (його видалили, алерт лишився).
|
||||||
if shouldArmEscalation(store.Alert{State: "firing"}, store.RuleAction{}, false, 3) {
|
if shouldArmEscalation(store.Alert{State: "firing"}, store.RuleAction{}, false, true) {
|
||||||
t.Fatal("алерт без правила ескалювати нікуди")
|
t.Fatal("алерт без правила ескалювати нікуди")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -48,20 +48,24 @@ func TestNoPolicyMeansNoEscalation(t *testing.T) {
|
||||||
// турбувати», а драбина — це рівно турбування, тільки за розкладом.
|
// турбувати», а драбина — це рівно турбування, тільки за розкладом.
|
||||||
func TestSuppressedAlertIsNotArmed(t *testing.T) {
|
func TestSuppressedAlertIsNotArmed(t *testing.T) {
|
||||||
for _, st := range []string{"suppressed", "acknowledged", "resolved", "expired"} {
|
for _, st := range []string{"suppressed", "acknowledged", "resolved", "expired"} {
|
||||||
if shouldArmEscalation(store.Alert{State: st}, act(), true, 2) {
|
if shouldArmEscalation(store.Alert{State: st}, act(), true, true) {
|
||||||
t.Errorf("стан %q отримав драбину", st)
|
t.Errorf("стан %q отримав драбину", st)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Найтонший випадок: перше сповіщення нікуди не пішло.
|
// Найтонший випадок: алерту не було куди піти.
|
||||||
//
|
//
|
||||||
// Каналів могло не знайтись через тиху годину правила, поріг
|
// Каналів не знайшлось через поріг серйозності, вимкнений канал або
|
||||||
// серйозності або вимкнений канал. Взвести драбину означало б надіслати
|
// порожній перелік. Взвести драбину означало б надіслати о 15-й хвилині
|
||||||
// о 15-й хвилині те, що на нульовій ми свідомо не надсилали, — і людина
|
// те, що на нульовій ми свідомо не надсилали, — і людина прочитала б це
|
||||||
// прочитала б це як несправність. Нею це й було б.
|
// як несправність. Нею це й було б.
|
||||||
|
//
|
||||||
|
// Тиха година сюди НЕ належить: там канали є, просто зараз ніч, і
|
||||||
|
// targets() повідомляє це окремо. Раніше вона потрапляла в цю ж гілку й
|
||||||
|
// роззброювала драбину назавжди — див. TestQuietHourStillArms.
|
||||||
func TestEscalationNotArmedWhenNothingWasSent(t *testing.T) {
|
func TestEscalationNotArmedWhenNothingWasSent(t *testing.T) {
|
||||||
if shouldArmEscalation(store.Alert{State: "firing"}, act(), true, 0) {
|
if shouldArmEscalation(store.Alert{State: "firing"}, act(), true, false) {
|
||||||
t.Fatal("драбина взведена попри те, що перше сповіщення нікуди не пішло")
|
t.Fatal("драбина взведена попри те, що перше сповіщення нікуди не пішло")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,11 +95,11 @@ func (n *Notifier) Dispatch(ctx context.Context, tenantID string, alerts []store
|
||||||
if hasAct && a.State == "resolved" && !act.NotifyOnResolve {
|
if hasAct && a.State == "resolved" && !act.NotifyOnResolve {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
targets := n.targets(a, act, hasAct, routes, channels, byID, now, tenantID)
|
targets, reachable := n.targets(a, act, hasAct, routes, channels, byID, now, tenantID)
|
||||||
for _, c := range targets {
|
for _, c := range targets {
|
||||||
n.send(ctx, tenantID, a, c)
|
n.send(ctx, tenantID, a, c)
|
||||||
}
|
}
|
||||||
n.armEscalation(ctx, tenantID, a, act, hasAct, policies, len(targets))
|
n.armEscalation(ctx, tenantID, a, act, hasAct, policies, reachable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,19 +112,26 @@ func (n *Notifier) Dispatch(ctx context.Context, tenantID string, alerts []store
|
||||||
// турбувати», а сповіщення про відновлення ескалювати нема сенсу:
|
// турбувати», а сповіщення про відновлення ескалювати нема сенсу:
|
||||||
// воно вже добра новина.
|
// воно вже добра новина.
|
||||||
//
|
//
|
||||||
// - Драбина взводиться лише тоді, коли перше сповіщення справді мало
|
// - Драбина взводиться лише тоді, коли алерту БУЛО КУДИ піти. Якщо
|
||||||
// куди піти. Якщо жодного каналу не знайшлось — вимкнені канали,
|
// жодного каналу не знайшлось — вимкнені канали, поріг серйозності,
|
||||||
// поріг серйозності, тиха година правила — то ескалація о 15-й
|
// порожній перелік — то ескалація о 15-й хвилині надіслала б те, що
|
||||||
// хвилині надіслала б те, що на нульовій ми свідомо не надіслали.
|
// на нульовій ми свідомо не надіслали. Це виглядало б як
|
||||||
// Це виглядало б як несправність, і саме так і було б.
|
// несправність, і саме так і було б.
|
||||||
|
//
|
||||||
|
// Тиха година до цієї умови НЕ належить, хоч і виглядає так само:
|
||||||
|
// канали є, просто зараз ніч. Раніше вона роззброювала драбину
|
||||||
|
// назавжди (взводять лише новий алерт), тобто аварія о 22:01 не
|
||||||
|
// отримувала резервного механізму вже ніколи. Тепер драбина
|
||||||
|
// взводиться, а зважати їй на тиху годину чи ні — вирішує сама
|
||||||
|
// драбина (respect_quiet_hours).
|
||||||
//
|
//
|
||||||
// - Політику називає саме правило. Немає політики — немає драбини, і
|
// - Політику називає саме правило. Немає політики — немає драбини, і
|
||||||
// це типовий стан: після оновлення жоден кабінет не має раптом
|
// це типовий стан: після оновлення жоден кабінет не має раптом
|
||||||
// почати будити людей.
|
// почати будити людей.
|
||||||
func (n *Notifier) armEscalation(ctx context.Context, tenantID string, a store.Alert,
|
func (n *Notifier) armEscalation(ctx context.Context, tenantID string, a store.Alert,
|
||||||
act store.RuleAction, hasAct bool, policies map[string]store.EscalationPolicy, sent int) {
|
act store.RuleAction, hasAct bool, policies map[string]store.EscalationPolicy, reachable bool) {
|
||||||
|
|
||||||
if !shouldArmEscalation(a, act, hasAct, sent) {
|
if !shouldArmEscalation(a, act, hasAct, reachable) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, ok := policies[act.EscalationPolicyID]
|
p, ok := policies[act.EscalationPolicyID]
|
||||||
|
|
@ -156,8 +163,16 @@ func (n *Notifier) armEscalation(ctx context.Context, tenantID string, a store.A
|
||||||
// взведено» доводить мало; тести на «драбину НЕ взведено, бо каналів не
|
// взведено» доводить мало; тести на «драбину НЕ взведено, бо каналів не
|
||||||
// знайшлось / алерт придушено / політики немає» — це і є те, заради чого
|
// знайшлось / алерт придушено / політики немає» — це і є те, заради чого
|
||||||
// функція існує.
|
// функція існує.
|
||||||
func shouldArmEscalation(a store.Alert, act store.RuleAction, hasAct bool, sent int) bool {
|
// reachable — чи БУЛО КУДИ слати цей алерт: або знайшлись канали, або
|
||||||
return a.State == "firing" && sent > 0 && hasAct && act.EscalationPolicyID != ""
|
// вони знайшлись би, якби не тиха година.
|
||||||
|
//
|
||||||
|
// Друга половина — не дрібниця. Доти драбина не взводилась, коли перше
|
||||||
|
// сповіщення притлумила тиха година, а взводять лише НОВИЙ алерт — тобто
|
||||||
|
// аварія о 22:01 не отримувала драбини вже ніколи, до кінця свого життя.
|
||||||
|
// Тиха година мовчки роззброювала саме той механізм, який існує на
|
||||||
|
// випадок, коли перше сповіщення не спрацювало.
|
||||||
|
func shouldArmEscalation(a store.Alert, act store.RuleAction, hasAct bool, reachable bool) bool {
|
||||||
|
return a.State == "firing" && reachable && hasAct && act.EscalationPolicyID != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// targets вирішує, у які канали піде цей алерт.
|
// targets вирішує, у які канали піде цей алерт.
|
||||||
|
|
@ -168,7 +183,7 @@ func shouldArmEscalation(a store.Alert, act store.RuleAction, hasAct bool, sent
|
||||||
// підключили Telegram і чекають, що працюватиме.
|
// підключили Telegram і чекають, що працюватиме.
|
||||||
func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool,
|
func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool,
|
||||||
routes []store.Route, all []store.Channel,
|
routes []store.Route, all []store.Channel,
|
||||||
byID map[string]store.Channel, now time.Time, tenantID string) []store.Channel {
|
byID map[string]store.Channel, now time.Time, tenantID string) ([]store.Channel, bool) {
|
||||||
|
|
||||||
allow := func(c store.Channel) bool {
|
allow := func(c store.Channel) bool {
|
||||||
return c.Enabled && severityRank[a.Severity] >= severityRank[c.MinSeverity]
|
return c.Enabled && severityRank[a.Severity] >= severityRank[c.MinSeverity]
|
||||||
|
|
@ -181,7 +196,10 @@ func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool,
|
||||||
if act.Schedule.IsQuiet(now) && severityRank[a.Severity] < severityRank["disaster"] {
|
if act.Schedule.IsQuiet(now) && severityRank[a.Severity] < severityRank["disaster"] {
|
||||||
_ = n.st.RecordNotification(context.Background(), tenantID, a.ID, "",
|
_ = n.st.RecordNotification(context.Background(), tenantID, a.ID, "",
|
||||||
"throttled", "тиха година правила", "", nil)
|
"throttled", "тиха година правила", "", nil)
|
||||||
return nil
|
// Каналів немає не тому, що їх не налаштували, — тому що
|
||||||
|
// зараз ніч. Різницю треба донести нагору: від неї залежить,
|
||||||
|
// чи взведеться драбина.
|
||||||
|
return nil, true
|
||||||
}
|
}
|
||||||
var out []store.Channel
|
var out []store.Channel
|
||||||
for _, id := range act.ChannelIDs {
|
for _, id := range act.ChannelIDs {
|
||||||
|
|
@ -189,7 +207,7 @@ func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool,
|
||||||
out = append(out, c)
|
out = append(out, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out, len(out) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Тенант без жодного маршруту — найчастіший випадок: підключили
|
// Тенант без жодного маршруту — найчастіший випадок: підключили
|
||||||
|
|
@ -202,11 +220,12 @@ func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool,
|
||||||
out = append(out, c)
|
out = append(out, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out, len(out) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var out []store.Channel
|
var out []store.Channel
|
||||||
|
quiet := false
|
||||||
for _, r := range routes {
|
for _, r := range routes {
|
||||||
if !r.Matches(a) {
|
if !r.Matches(a) {
|
||||||
continue
|
continue
|
||||||
|
|
@ -217,6 +236,10 @@ func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool,
|
||||||
if severityRank[a.Severity] < severityRank["disaster"] {
|
if severityRank[a.Severity] < severityRank["disaster"] {
|
||||||
_ = n.st.RecordNotification(context.Background(), tenantID, a.ID, "",
|
_ = n.st.RecordNotification(context.Background(), tenantID, a.ID, "",
|
||||||
"throttled", "тиха година маршруту "+r.Name, "", nil)
|
"throttled", "тиха година маршруту "+r.Name, "", nil)
|
||||||
|
// Маршрут підійшов, канали в нього є — мовчить лише ніч.
|
||||||
|
if len(r.ChannelIDs) > 0 {
|
||||||
|
quiet = true
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -229,7 +252,7 @@ func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool,
|
||||||
out = append(out, c)
|
out = append(out, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out, len(out) > 0 || quiet
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Notifier) send(ctx context.Context, tenantID string, a store.Alert, c store.Channel) {
|
func (n *Notifier) send(ctx context.Context, tenantID string, a store.Alert, c store.Channel) {
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ func TestTargetsFallBackToAllChannelsWithoutRoutes(t *testing.T) {
|
||||||
byID[c.ID] = c
|
byID[c.ID] = c
|
||||||
}
|
}
|
||||||
|
|
||||||
got := n.targets(store.Alert{Severity: "high"}, store.RuleAction{}, false,
|
got, _ := n.targets(store.Alert{Severity: "high"}, store.RuleAction{}, false,
|
||||||
nil, channels, byID, time.Now(), "t1")
|
nil, channels, byID, time.Now(), "t1")
|
||||||
|
|
||||||
if len(got) != 1 || got[0].ID != "c1" {
|
if len(got) != 1 || got[0].ID != "c1" {
|
||||||
|
|
@ -142,10 +142,10 @@ func TestTargetsRespectChannelSeverityFloor(t *testing.T) {
|
||||||
channels := []store.Channel{{ID: "c1", Enabled: true, MinSeverity: "high"}}
|
channels := []store.Channel{{ID: "c1", Enabled: true, MinSeverity: "high"}}
|
||||||
byID := map[string]store.Channel{"c1": channels[0]}
|
byID := map[string]store.Channel{"c1": channels[0]}
|
||||||
|
|
||||||
if got := n.targets(store.Alert{Severity: "warning"}, store.RuleAction{}, false, nil, channels, byID, time.Now(), "t1"); len(got) != 0 {
|
if got, _ := n.targets(store.Alert{Severity: "warning"}, store.RuleAction{}, false, nil, channels, byID, time.Now(), "t1"); len(got) != 0 {
|
||||||
t.Fatalf("warning не має проходити поріг high, отримали %v", ids(got))
|
t.Fatalf("warning не має проходити поріг high, отримали %v", ids(got))
|
||||||
}
|
}
|
||||||
if got := n.targets(store.Alert{Severity: "disaster"}, store.RuleAction{}, false, nil, channels, byID, time.Now(), "t1"); len(got) != 1 {
|
if got, _ := n.targets(store.Alert{Severity: "disaster"}, store.RuleAction{}, false, nil, channels, byID, time.Now(), "t1"); len(got) != 1 {
|
||||||
t.Fatal("disaster має проходити поріг high")
|
t.Fatal("disaster має проходити поріг high")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -188,13 +188,13 @@ func TestRuleChannelsOverrideRoutes(t *testing.T) {
|
||||||
routes := []store.Route{{ID: "r1", Name: "усі", ChannelIDs: []string{"c1", "c2"}}}
|
routes := []store.Route{{ID: "r1", Name: "усі", ChannelIDs: []string{"c1", "c2"}}}
|
||||||
act := store.RuleAction{ChannelIDs: []string{"c2"}}
|
act := store.RuleAction{ChannelIDs: []string{"c2"}}
|
||||||
|
|
||||||
got := n.targets(store.Alert{Severity: "high"}, act, true, routes, channels, byID, time.Now(), "t1")
|
got, _ := n.targets(store.Alert{Severity: "high"}, act, true, routes, channels, byID, time.Now(), "t1")
|
||||||
if len(got) != 1 || got[0].ID != "c2" {
|
if len(got) != 1 || got[0].ID != "c2" {
|
||||||
t.Fatalf("очікували лише c2, отримали %v", ids(got))
|
t.Fatalf("очікували лише c2, отримали %v", ids(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Правило без власних каналів лишає рішення маршрутам.
|
// Правило без власних каналів лишає рішення маршрутам.
|
||||||
got = n.targets(store.Alert{Severity: "high"}, store.RuleAction{}, true,
|
got, _ = n.targets(store.Alert{Severity: "high"}, store.RuleAction{}, true,
|
||||||
routes, channels, byID, time.Now(), "t1")
|
routes, channels, byID, time.Now(), "t1")
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("очікували обидва канали з маршруту, отримали %v", ids(got))
|
t.Fatalf("очікували обидва канали з маршруту, отримали %v", ids(got))
|
||||||
|
|
@ -208,7 +208,7 @@ func TestRuleChannelsRespectDisabled(t *testing.T) {
|
||||||
channels := []store.Channel{{ID: "c1", Enabled: false, MinSeverity: "info"}}
|
channels := []store.Channel{{ID: "c1", Enabled: false, MinSeverity: "info"}}
|
||||||
byID := map[string]store.Channel{"c1": channels[0]}
|
byID := map[string]store.Channel{"c1": channels[0]}
|
||||||
|
|
||||||
got := n.targets(store.Alert{Severity: "disaster"},
|
got, _ := n.targets(store.Alert{Severity: "disaster"},
|
||||||
store.RuleAction{ChannelIDs: []string{"c1"}}, true, nil, channels, byID, time.Now(), "t1")
|
store.RuleAction{ChannelIDs: []string{"c1"}}, true, nil, channels, byID, time.Now(), "t1")
|
||||||
if len(got) != 0 {
|
if len(got) != 0 {
|
||||||
t.Fatalf("вимкнений канал отримав алерт: %v", ids(got))
|
t.Fatalf("вимкнений канал отримав алерт: %v", ids(got))
|
||||||
|
|
|
||||||
|
|
@ -679,6 +679,10 @@ func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Reque
|
||||||
Steps []store.EscalationStep `json:"steps"`
|
Steps []store.EscalationStep `json:"steps"`
|
||||||
RepeatAfterMin int `json:"repeat_after_min"`
|
RepeatAfterMin int `json:"repeat_after_min"`
|
||||||
MaxRepeats int `json:"max_repeats"`
|
MaxRepeats int `json:"max_repeats"`
|
||||||
|
// Відсутнє поле = false: та поведінка, що діяла досі. Оновлення
|
||||||
|
// не має тихо роззброїти нічні дзвінки в тих, у кого вони
|
||||||
|
// працюють, лише тому, що старий клієнт про поле не знає.
|
||||||
|
RespectQuietHours bool `json:"respect_quiet_hours"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
|
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
|
||||||
|
|
@ -720,6 +724,8 @@ func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Reque
|
||||||
Steps: req.Steps,
|
Steps: req.Steps,
|
||||||
RepeatAfterMin: req.RepeatAfterMin,
|
RepeatAfterMin: req.RepeatAfterMin,
|
||||||
MaxRepeats: req.MaxRepeats,
|
MaxRepeats: req.MaxRepeats,
|
||||||
|
|
||||||
|
RespectQuietHours: req.RespectQuietHours,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,17 @@ type EscalationPolicy struct {
|
||||||
// 0 — не повторювати.
|
// 0 — не повторювати.
|
||||||
RepeatAfterMin int `json:"repeat_after_min"`
|
RepeatAfterMin int `json:"repeat_after_min"`
|
||||||
MaxRepeats int `json:"max_repeats"`
|
MaxRepeats int `json:"max_repeats"`
|
||||||
|
// Чи відкладати сходинки на час тихої години правила.
|
||||||
|
//
|
||||||
|
// false (типово) — драбина пробивається: тиха година стосується
|
||||||
|
// ПЕРШОГО сповіщення, а сенс драбини рівно в тому, щоб дійти, коли
|
||||||
|
// перше проґавили. true — «ці люди зараз не на зміні», і будити їх
|
||||||
|
// драбиною так само неправильно, як першим сповіщенням.
|
||||||
|
//
|
||||||
|
// Вибір не технічний і залежить від того, чи є в кабінету нічна
|
||||||
|
// зміна, — тому він тут, а не в нашому коді. Disaster пробивається
|
||||||
|
// за будь-якого значення.
|
||||||
|
RespectQuietHours bool `json:"respect_quiet_hours"`
|
||||||
// Скільки правил уже посилаються на цю політику — щоб видалення не
|
// Скільки правил уже посилаються на цю політику — щоб видалення не
|
||||||
// було мовчазним вимкненням ескалації на десятку тригерів.
|
// було мовчазним вимкненням ескалації на десятку тригерів.
|
||||||
RuleCount int `json:"rule_count"`
|
RuleCount int `json:"rule_count"`
|
||||||
|
|
@ -157,6 +168,7 @@ func (s *Store) ListEscalationPolicies(ctx context.Context, tenantID string) ([]
|
||||||
rows, err := tx.Query(ctx, `
|
rows, err := tx.Query(ctx, `
|
||||||
SELECT p.id::text, p.name, COALESCE(p.description,''), p.steps::text,
|
SELECT p.id::text, p.name, COALESCE(p.description,''), p.steps::text,
|
||||||
COALESCE(p.repeat_after_min, 0), p.max_repeats,
|
COALESCE(p.repeat_after_min, 0), p.max_repeats,
|
||||||
|
p.respect_quiet_hours,
|
||||||
(SELECT count(*)::int FROM alr.rules r WHERE r.escalation_policy_id = p.id)
|
(SELECT count(*)::int FROM alr.rules r WHERE r.escalation_policy_id = p.id)
|
||||||
FROM alr.escalation_policies p
|
FROM alr.escalation_policies p
|
||||||
WHERE p.tenant_id = $1
|
WHERE p.tenant_id = $1
|
||||||
|
|
@ -170,7 +182,8 @@ func (s *Store) ListEscalationPolicies(ctx context.Context, tenantID string) ([]
|
||||||
var p EscalationPolicy
|
var p EscalationPolicy
|
||||||
var steps string
|
var steps string
|
||||||
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &steps,
|
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &steps,
|
||||||
&p.RepeatAfterMin, &p.MaxRepeats, &p.RuleCount); err != nil {
|
&p.RepeatAfterMin, &p.MaxRepeats, &p.RespectQuietHours,
|
||||||
|
&p.RuleCount); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(steps), &p.Steps); err != nil {
|
if err := json.Unmarshal([]byte(steps), &p.Steps); err != nil {
|
||||||
|
|
@ -219,19 +232,21 @@ func (s *Store) SaveEscalationPolicy(ctx context.Context, tenantID, id string, p
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return tx.QueryRow(ctx, `
|
return tx.QueryRow(ctx, `
|
||||||
INSERT INTO alr.escalation_policies
|
INSERT INTO alr.escalation_policies
|
||||||
(tenant_id, name, description, steps, repeat_after_min, max_repeats)
|
(tenant_id, name, description, steps, repeat_after_min,
|
||||||
VALUES ($1, $2, $3, $4::jsonb, $5, $6)
|
max_repeats, respect_quiet_hours)
|
||||||
|
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7)
|
||||||
RETURNING id::text
|
RETURNING id::text
|
||||||
`, tenantID, p.Name, nullString(p.Description), string(steps),
|
`, tenantID, p.Name, nullString(p.Description), string(steps),
|
||||||
repeat, p.MaxRepeats).Scan(&id)
|
repeat, p.MaxRepeats, p.RespectQuietHours).Scan(&id)
|
||||||
}
|
}
|
||||||
ct, err := tx.Exec(ctx, `
|
ct, err := tx.Exec(ctx, `
|
||||||
UPDATE alr.escalation_policies
|
UPDATE alr.escalation_policies
|
||||||
SET name = $3, description = $4, steps = $5::jsonb,
|
SET name = $3, description = $4, steps = $5::jsonb,
|
||||||
repeat_after_min = $6, max_repeats = $7, updated_at = now()
|
repeat_after_min = $6, max_repeats = $7,
|
||||||
|
respect_quiet_hours = $8, updated_at = now()
|
||||||
WHERE tenant_id = $1 AND id = $2
|
WHERE tenant_id = $1 AND id = $2
|
||||||
`, tenantID, id, p.Name, nullString(p.Description), string(steps),
|
`, tenantID, id, p.Name, nullString(p.Description), string(steps),
|
||||||
repeat, p.MaxRepeats)
|
repeat, p.MaxRepeats, p.RespectQuietHours)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -311,6 +326,22 @@ func (s *Store) ArmEscalation(ctx context.Context, tenantID, alertID, policyID s
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// quietHoldsStep — чи тримає тиха година цю сходинку.
|
||||||
|
//
|
||||||
|
// Окремою функцією, бо це рішення про чийсь сон, і в ньому три умови,
|
||||||
|
// кожна з яких має право на власний тест: драбина мусить сама зважати на
|
||||||
|
// тиху годину, розклад мусить існувати й попадати, а disaster мусить
|
||||||
|
// проходити попри все.
|
||||||
|
func quietHoldsStep(s EscalationSnapshot, now time.Time) bool {
|
||||||
|
if !s.RespectQuietHours || s.Schedule == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if SeverityRank(s.Alert.Severity) >= SeverityRank("disaster") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.Schedule.IsQuiet(now)
|
||||||
|
}
|
||||||
|
|
||||||
// escalationSpan — скільки триває драбина, якщо ніхто не втручається.
|
// escalationSpan — скільки триває драбина, якщо ніхто не втручається.
|
||||||
func escalationSpan(p EscalationPolicy, isEvent bool) time.Duration {
|
func escalationSpan(p EscalationPolicy, isEvent bool) time.Duration {
|
||||||
if len(p.Steps) == 0 {
|
if len(p.Steps) == 0 {
|
||||||
|
|
@ -348,6 +379,13 @@ type EscalationSnapshot struct {
|
||||||
RepeatAfterMin int
|
RepeatAfterMin int
|
||||||
MaxRepeats int
|
MaxRepeats int
|
||||||
|
|
||||||
|
// Тиха година правила, за яким піднято алерт, і чи зважає на неї ця
|
||||||
|
// драбина. Читаються разом зі сходинкою, бо рішення «дзвонити чи
|
||||||
|
// відкласти» ухвалюється в момент сходинки, а не в момент взведення:
|
||||||
|
// розклад могли переписати за ті години, що драбина йшла.
|
||||||
|
RespectQuietHours bool
|
||||||
|
Schedule *RouteSchedule
|
||||||
|
|
||||||
// Стан алерту на момент читання. Порожньо — алерту вже немає.
|
// Стан алерту на момент читання. Порожньо — алерту вже немає.
|
||||||
AlertState string
|
AlertState string
|
||||||
// Алерт у вигляді, придатному для тексту повідомлення.
|
// Алерт у вигляді, придатному для тексту повідомлення.
|
||||||
|
|
@ -473,6 +511,32 @@ func PlanEscalation(s EscalationSnapshot, now time.Time) EscalationDecision {
|
||||||
return stop("closed", "невідомий стан алерту "+s.AlertState)
|
return stop("closed", "невідомий стан алерту "+s.AlertState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if quietHoldsStep(s, now) {
|
||||||
|
// Кабінет сказав, що вночі його драбина мовчить: тиха година для
|
||||||
|
// нього означає «цих людей зараз немає», а не «не турбуйте
|
||||||
|
// дрібницями». Сходинка відкладається так само, як під
|
||||||
|
// заглушенням, і з тієї ж причини — «не зараз» не є «не треба».
|
||||||
|
//
|
||||||
|
// Перевіряємо ту саму тиху годину, що глушила перше сповіщення
|
||||||
|
// (розклад правила), інакше одне слово в двох місцях означало б
|
||||||
|
// різне. І так само, як там, disaster проходить: сенс чергування
|
||||||
|
// в тому, щоб найважче підняли.
|
||||||
|
next := now.Add(EscalationRecheck)
|
||||||
|
if !next.Before(s.Deadline) {
|
||||||
|
return stop("deadline", "тиха година пережила стелю життя драбини")
|
||||||
|
}
|
||||||
|
return EscalationDecision{
|
||||||
|
Action: EscDefer, Outcome: "quiet_hours",
|
||||||
|
Detail: "тиха година правила — сходинку відкладено",
|
||||||
|
StepIdx: s.StepIdx,
|
||||||
|
RepeatIdx: s.RepeatIdx,
|
||||||
|
NextStepIdx: s.StepIdx,
|
||||||
|
NextRepeatIdx: s.RepeatIdx,
|
||||||
|
NextPassStart: s.PassStart,
|
||||||
|
NextAt: &next,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
d := EscalationDecision{
|
d := EscalationDecision{
|
||||||
Action: EscFire,
|
Action: EscFire,
|
||||||
Outcome: "sent",
|
Outcome: "sent",
|
||||||
|
|
@ -587,6 +651,8 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
|
||||||
t.is_event, t.step_idx, t.repeat_idx, t.pass_start, t.deadline,
|
t.is_event, t.step_idx, t.repeat_idx, t.pass_start, t.deadline,
|
||||||
COALESCE(p.name,''), COALESCE(p.steps::text,'[]'),
|
COALESCE(p.name,''), COALESCE(p.steps::text,'[]'),
|
||||||
COALESCE(p.repeat_after_min,0), COALESCE(p.max_repeats,0),
|
COALESCE(p.repeat_after_min,0), COALESCE(p.max_repeats,0),
|
||||||
|
COALESCE(p.respect_quiet_hours,false),
|
||||||
|
COALESCE(rl.notify_schedule::text,''),
|
||||||
COALESCE(a.state::text,''), COALESCE(a.severity::text,'info'),
|
COALESCE(a.state::text,''), COALESCE(a.severity::text,'info'),
|
||||||
COALESCE(a.title,''), COALESCE(a.message,''),
|
COALESCE(a.title,''), COALESCE(a.message,''),
|
||||||
COALESCE(a.device_id::text,''), COALESCE(d.name,''),
|
COALESCE(a.device_id::text,''), COALESCE(d.name,''),
|
||||||
|
|
@ -596,6 +662,7 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
|
||||||
LEFT JOIN alr.alerts a ON a.id = t.alert_id
|
LEFT JOIN alr.alerts a ON a.id = t.alert_id
|
||||||
LEFT JOIN alr.escalation_policies p ON p.id = t.policy_id
|
LEFT JOIN alr.escalation_policies p ON p.id = t.policy_id
|
||||||
LEFT JOIN inv.devices d ON d.id = a.device_id
|
LEFT JOIN inv.devices d ON d.id = a.device_id
|
||||||
|
LEFT JOIN alr.rules rl ON rl.id = a.rule_id
|
||||||
ORDER BY t.pass_start
|
ORDER BY t.pass_start
|
||||||
`, limit, EscalationLease.String())
|
`, limit, EscalationLease.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -606,15 +673,22 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
|
||||||
var out []EscalationSnapshot
|
var out []EscalationSnapshot
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s EscalationSnapshot
|
var s EscalationSnapshot
|
||||||
var steps string
|
var steps, sched string
|
||||||
if err := rows.Scan(&s.AlertID, &s.TenantID, &s.PolicyID, &s.IsEvent,
|
if err := rows.Scan(&s.AlertID, &s.TenantID, &s.PolicyID, &s.IsEvent,
|
||||||
&s.StepIdx, &s.RepeatIdx, &s.PassStart, &s.Deadline,
|
&s.StepIdx, &s.RepeatIdx, &s.PassStart, &s.Deadline,
|
||||||
&s.PolicyName, &steps, &s.RepeatAfterMin, &s.MaxRepeats,
|
&s.PolicyName, &steps, &s.RepeatAfterMin, &s.MaxRepeats,
|
||||||
|
&s.RespectQuietHours, &sched,
|
||||||
&s.AlertState, &s.Alert.Severity, &s.Alert.Title, &s.Alert.Message,
|
&s.AlertState, &s.Alert.Severity, &s.Alert.Title, &s.Alert.Message,
|
||||||
&s.Alert.DeviceID, &s.Alert.DeviceName, &s.Alert.RuleID,
|
&s.Alert.DeviceID, &s.Alert.DeviceName, &s.Alert.RuleID,
|
||||||
&s.Alert.SuppressedBy, &s.Alert.StartedAt); err != nil {
|
&s.Alert.SuppressedBy, &s.Alert.StartedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if sched != "" {
|
||||||
|
var sc RouteSchedule
|
||||||
|
if json.Unmarshal([]byte(sched), &sc) == nil {
|
||||||
|
s.Schedule = &sc
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := json.Unmarshal([]byte(steps), &s.Steps); err != nil {
|
if err := json.Unmarshal([]byte(steps), &s.Steps); err != nil {
|
||||||
// Політика з нечитабельними сходинками не має валити чергу:
|
// Політика з нечитабельними сходинками не має валити чергу:
|
||||||
// решта драбин у кабінеті ні в чому не винна.
|
// решта драбин у кабінеті ні в чому не винна.
|
||||||
|
|
@ -714,14 +788,14 @@ func (s *Store) LogEscalationStep(ctx context.Context, snap EscalationSnapshot,
|
||||||
|
|
||||||
// Відкладання пишеться ОДИН раз на сходинку, а не щоперевірки.
|
// Відкладання пишеться ОДИН раз на сходинку, а не щоперевірки.
|
||||||
//
|
//
|
||||||
// Заглушений алерт переглядається кожні EscalationRecheck, тобто
|
// Відкладена сходинка (заглушення або тиха година) переглядається
|
||||||
// приблизно 288 разів на добу, і драбина може чекати тижнями (стеля
|
// кожні EscalationRecheck, тобто близько 288 разів на добу, а драбина
|
||||||
// життя — до ~21 доби). Без цієї умови один алерт під вікном
|
// може чекати тижнями (стеля життя — до ~21 доби). Без цієї умови
|
||||||
// обслуговування лишав би тисячі однакових рядків, і журнал, у який
|
// одна ніч під тихою годиною лишала б сотні однакових рядків, і
|
||||||
// заходять з питанням «чому мене не розбудили», перестав би бути
|
// журнал, у який заходять із питанням «чому мене не розбудили»,
|
||||||
// придатним для читання саме тоді, коли він потрібен. Один рядок
|
// ставав би нечитабельним рівно тоді, коли він потрібен. Один рядок
|
||||||
// каже рівно те саме: цю сходинку відкладено через заглушення.
|
// каже те саме: цю сходинку відкладено, і ось чому.
|
||||||
once := outcome == "suppressed"
|
once := outcome == "suppressed" || outcome == "quiet_hours"
|
||||||
|
|
||||||
return s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error {
|
return s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error {
|
||||||
_, err := tx.Exec(ctx, `
|
_, err := tx.Exec(ctx, `
|
||||||
|
|
|
||||||
122
server/internal/store/alerts_escalation_quiet_test.go
Normal file
122
server/internal/store/alerts_escalation_quiet_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тиха година 22:00–08:00 за Києвом, щодня.
|
||||||
|
func nightSchedule() *RouteSchedule {
|
||||||
|
var s RouteSchedule
|
||||||
|
s.TZ = "Europe/Kyiv"
|
||||||
|
s.Quiet = append(s.Quiet, struct {
|
||||||
|
Days []int `json:"days"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
}{From: "22:00", To: "08:00"})
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func quietSnap(respect bool, severity string) EscalationSnapshot {
|
||||||
|
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||||
|
start := time.Date(2026, 8, 28, 21, 0, 0, 0, kyiv)
|
||||||
|
return EscalationSnapshot{
|
||||||
|
AlertID: "a1",
|
||||||
|
TenantID: "t1",
|
||||||
|
PolicyID: "p1",
|
||||||
|
AlertState: "firing",
|
||||||
|
StepIdx: 0,
|
||||||
|
PassStart: start,
|
||||||
|
Deadline: start.Add(48 * time.Hour),
|
||||||
|
Steps: []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"c1"}}},
|
||||||
|
RespectQuietHours: respect,
|
||||||
|
Schedule: nightSchedule(),
|
||||||
|
Alert: Alert{Severity: severity, State: "firing"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кабінет сказав «драбина вночі мовчить» — сходинка чекає ранку.
|
||||||
|
//
|
||||||
|
// І головне: вона НЕ витрачається. Списана сходинка означала б, що одна
|
||||||
|
// ніч тихо роззброює драбину до кінця життя алерту, тобто рівно та
|
||||||
|
// мовчазна відмова, від якої ескалація й рятує.
|
||||||
|
func TestQuietHoursDeferStepWhenRespected(t *testing.T) {
|
||||||
|
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||||
|
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
|
||||||
|
|
||||||
|
d := PlanEscalation(quietSnap(true, "high"), night)
|
||||||
|
if d.Action != EscDefer {
|
||||||
|
t.Fatalf("сходинка мала відкластись, а не %v (%s)", d.Action, d.Outcome)
|
||||||
|
}
|
||||||
|
if d.Outcome != "quiet_hours" {
|
||||||
|
t.Errorf("причина відкладання нечитабельна: %q", d.Outcome)
|
||||||
|
}
|
||||||
|
if d.NextStepIdx != 0 {
|
||||||
|
t.Errorf("сходинку витрачено: наступна %d замість 0", d.NextStepIdx)
|
||||||
|
}
|
||||||
|
if d.NextAt == nil || !d.NextAt.After(night) {
|
||||||
|
t.Error("немає часу повторної перевірки — драбина застрягне")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Той самий кабінет удень: тиха година скінчилась, драбина йде.
|
||||||
|
func TestQuietHoursDoNotHoldByDay(t *testing.T) {
|
||||||
|
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||||
|
day := time.Date(2026, 8, 28, 11, 0, 0, 0, kyiv)
|
||||||
|
|
||||||
|
if d := PlanEscalation(quietSnap(true, "high"), day); d.Action != EscFire {
|
||||||
|
t.Fatalf("удень сходинка мала спрацювати: %v/%s", d.Action, d.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кабінет НЕ просив зважати на тиху годину — драбина пробивається.
|
||||||
|
//
|
||||||
|
// Це типове значення й поведінка, що діяла досі: оновлення не має тихо
|
||||||
|
// роззброїти нічні дзвінки в тих, у кого вони працюють.
|
||||||
|
func TestQuietHoursIgnoredByDefault(t *testing.T) {
|
||||||
|
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||||
|
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
|
||||||
|
|
||||||
|
if d := PlanEscalation(quietSnap(false, "high"), night); d.Action != EscFire {
|
||||||
|
t.Fatalf("драбина без прапорця мала пробитись: %v/%s", d.Action, d.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disaster будить попри все — так само, як у першому сповіщенні.
|
||||||
|
//
|
||||||
|
// Одне слово в двох місцях мусить означати одне: targets() уже пропускає
|
||||||
|
// disaster крізь тиху годину маршруту й правила.
|
||||||
|
func TestDisasterBreaksThroughQuietHours(t *testing.T) {
|
||||||
|
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||||
|
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
|
||||||
|
|
||||||
|
if d := PlanEscalation(quietSnap(true, "disaster"), night); d.Action != EscFire {
|
||||||
|
t.Fatalf("disaster мав пробитись крізь тиху годину: %v/%s", d.Action, d.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Драбина, яку ніч не пускала аж до стелі життя, зупиняється — а не
|
||||||
|
// відкладається вічно.
|
||||||
|
func TestQuietHoursCannotOutliveDeadline(t *testing.T) {
|
||||||
|
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||||
|
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
|
||||||
|
|
||||||
|
s := quietSnap(true, "high")
|
||||||
|
s.Deadline = night.Add(time.Minute)
|
||||||
|
d := PlanEscalation(s, night)
|
||||||
|
if d.Action != EscStop || d.Outcome != "deadline" {
|
||||||
|
t.Fatalf("драбина мала протухнути, а не %v/%s", d.Action, d.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Розкладу немає — тиха година не може тримати те, чого не описано.
|
||||||
|
func TestNoScheduleNoHold(t *testing.T) {
|
||||||
|
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||||
|
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
|
||||||
|
|
||||||
|
s := quietSnap(true, "high")
|
||||||
|
s.Schedule = nil
|
||||||
|
if d := PlanEscalation(s, night); d.Action != EscFire {
|
||||||
|
t.Fatalf("без розкладу сходинка мала спрацювати: %v/%s", d.Action, d.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
54
server/migrations/0072_escalation_quiet_hours.sql
Normal file
54
server/migrations/0072_escalation_quiet_hours.sql
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- NetPulse :: 0072_escalation_quiet_hours.sql
|
||||||
|
-- Що тиха година правила означає для драбини ескалації.
|
||||||
|
--
|
||||||
|
-- ЩО БУЛО НЕ ТАК
|
||||||
|
--
|
||||||
|
-- Тиха година діяла на перше сповіщення й не діяла на драбину взагалі.
|
||||||
|
-- Це давало асиметрію, яку неможливо пояснити людині, що її переживе:
|
||||||
|
--
|
||||||
|
-- аварія о 21:59 — перше сповіщення пішло, драбина взвелась, і вона
|
||||||
|
-- дзвонить усю ніч, бо про тиху годину не знає;
|
||||||
|
-- аварія о 22:01 — перше сповіщення притлумлено тихою годиною, тож
|
||||||
|
-- каналів «не знайшлось», тож драбина не взвелась —
|
||||||
|
-- і не взведеться вже НІКОЛИ, бо взводять лише
|
||||||
|
-- новий алерт.
|
||||||
|
--
|
||||||
|
-- Дві хвилини різниці — і протилежні наслідки, причому гірший із них
|
||||||
|
-- (повна тиша на аварії) виглядає як тиша справна.
|
||||||
|
--
|
||||||
|
-- ЧОМУ ЦЕ ПРАПОРЕЦЬ, А НЕ ОБРАНА НАМИ ПОВЕДІНКА
|
||||||
|
--
|
||||||
|
-- Обидві відповіді правильні, і вибір між ними — не технічний:
|
||||||
|
--
|
||||||
|
-- * «драбина не зважає на тиху годину» — тиха година стосується
|
||||||
|
-- ПЕРШОГО сповіщення, а сенс драбини рівно в тому, щоб пробитись,
|
||||||
|
-- коли перше проґавили. Так працює чергування: тебе не будять по
|
||||||
|
-- дрібницях, але якщо ніхто не відповів — будять.
|
||||||
|
--
|
||||||
|
-- * «драбина відкладає сходинку до кінця тихої години» — тиха година
|
||||||
|
-- означає «ці люди зараз не на зміні», і будити їх драбиною так
|
||||||
|
-- само неправильно, як першим сповіщенням.
|
||||||
|
--
|
||||||
|
-- Правильна відповідь залежить від того, чи є в кабінету нічна зміна, а
|
||||||
|
-- це вирішує кабінет, а не ми. Тому прапорець на драбині — там же, де
|
||||||
|
-- людина вирішує, кого й через скільки будити.
|
||||||
|
--
|
||||||
|
-- Типове значення false — та поведінка, яка вже діє для взведених
|
||||||
|
-- драбин. Оновлення не має тихо роззброїти нічні дзвінки в тих, у кого
|
||||||
|
-- вони працюють.
|
||||||
|
--
|
||||||
|
-- DISASTER ПРОБИВАЄТЬСЯ ЗАВЖДИ
|
||||||
|
--
|
||||||
|
-- Виняток не новий: targets() уже пропускає disaster крізь тиху годину
|
||||||
|
-- маршруту й правила з тим самим міркуванням — сенс чергування в тому,
|
||||||
|
-- щоб найважче підняли. Драбина мусить поводитись так само, інакше одне
|
||||||
|
-- і те саме слово в двох місцях означало б різне.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
ALTER TABLE alr.escalation_policies
|
||||||
|
ADD COLUMN IF NOT EXISTS respect_quiet_hours boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN alr.escalation_policies.respect_quiet_hours IS
|
||||||
|
'Чи відкладати сходинки на час тихої години правила. false — драбина '
|
||||||
|
'пробивається крізь тиху годину (типово). disaster пробивається завжди.';
|
||||||
|
|
@ -987,6 +987,14 @@ export const api = {
|
||||||
(r) => r.policies ?? [],
|
(r) => r.policies ?? [],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// respect_quiet_hours тут ОБОВ'ЯЗКОВЕ, а не `?: boolean`.
|
||||||
|
//
|
||||||
|
// PUT замінює драбину цілком, а відсутнє поле сервер читає як false.
|
||||||
|
// Необов'язкове поле означало б, що форма може мовчки не надіслати
|
||||||
|
// його — і драбина, якій увімкнули повагу до тихої години, при
|
||||||
|
// будь-якій наступній правці назви тихо почала б дзвонити вночі.
|
||||||
|
// Ми вже наступали на це з `enabled`, коли сервер домислював true.
|
||||||
|
// Обов'язковість перекладає цю помилку з ночі до збірки.
|
||||||
saveEscalationPolicy: (
|
saveEscalationPolicy: (
|
||||||
id: string | null,
|
id: string | null,
|
||||||
p: {
|
p: {
|
||||||
|
|
@ -995,6 +1003,7 @@ export const api = {
|
||||||
steps: EscalationStep[]
|
steps: EscalationStep[]
|
||||||
repeat_after_min: number
|
repeat_after_min: number
|
||||||
max_repeats: number
|
max_repeats: number
|
||||||
|
respect_quiet_hours: boolean
|
||||||
},
|
},
|
||||||
) =>
|
) =>
|
||||||
request<{ id: string }>(
|
request<{ id: string }>(
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,34 @@ export function EscalationsPage() {
|
||||||
<span className="text-slate-600">—</span>
|
<span className="text-slate-600">—</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Обидва стани — словами, і жоден не прочерк.
|
||||||
|
//
|
||||||
|
// Прочерк на «пробивається» читався б як «не
|
||||||
|
// налаштовано», а це найгучніший з двох станів: саме
|
||||||
|
// він дзвонить о третій ночі. Без цього стовпчика
|
||||||
|
// різницю між драбинами видно лише відкривши кожну —
|
||||||
|
// тобто рівно тоді, коли вже пізно.
|
||||||
|
key: 'quiet',
|
||||||
|
header: 'Тиха година',
|
||||||
|
hideOnMobile: true,
|
||||||
|
cell: (p) =>
|
||||||
|
p.respect_quiet_hours ? (
|
||||||
|
<span
|
||||||
|
className="text-[11px] text-slate-300"
|
||||||
|
title="Сходинка, що припала на тиху годину тригера, чекає до її кінця. Серйозність disaster проходить завжди."
|
||||||
|
>
|
||||||
|
відкладає сходинки
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-[11px] text-slate-400"
|
||||||
|
title="Драбина дзвонить і в тиху годину: вона йде саме тоді, коли на перше сповіщення ніхто не відповів."
|
||||||
|
>
|
||||||
|
пробивається
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'rules',
|
key: 'rules',
|
||||||
header: 'Тригерів',
|
header: 'Тригерів',
|
||||||
|
|
@ -281,6 +309,11 @@ function PolicyForm({
|
||||||
})
|
})
|
||||||
const [repeatAfter, setRepeatAfter] = useState(String(policy?.repeat_after_min ?? 0))
|
const [repeatAfter, setRepeatAfter] = useState(String(policy?.repeat_after_min ?? 0))
|
||||||
const [maxRepeats, setMaxRepeats] = useState(String(policy?.max_repeats ?? 0))
|
const [maxRepeats, setMaxRepeats] = useState(String(policy?.max_repeats ?? 0))
|
||||||
|
// Нова драбина — false, тобто та поведінка, яка вже діє в усіх, хто
|
||||||
|
// ескалацію налаштував. Значення наявної драбини читається як є:
|
||||||
|
// редагування назви не має роззброювати нічну тишу, яку хтось
|
||||||
|
// свідомо ввімкнув.
|
||||||
|
const [respectQuiet, setRespectQuiet] = useState(policy?.respect_quiet_hours === true)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -327,6 +360,11 @@ function PolicyForm({
|
||||||
steps,
|
steps,
|
||||||
repeat_after_min: Number(repeatAfter) || 0,
|
repeat_after_min: Number(repeatAfter) || 0,
|
||||||
max_repeats: Number(maxRepeats) || 0,
|
max_repeats: Number(maxRepeats) || 0,
|
||||||
|
// Надсилаємо завжди, обома значеннями. Пропущене поле сервер
|
||||||
|
// читає як false, тож «не слати, коли вимкнено» виглядало б
|
||||||
|
// однаково — рівно до дня, коли типове значення на сервері
|
||||||
|
// зміниться, і форма почне мовчки означати не те, що показує.
|
||||||
|
respect_quiet_hours: respectQuiet,
|
||||||
})
|
})
|
||||||
await onSaved()
|
await onSaved()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -460,6 +498,32 @@ function PolicyForm({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 border-t border-slate-800 pt-3">
|
||||||
|
<div className="text-xs font-medium text-slate-400">Тиха година</div>
|
||||||
|
<label className="flex items-start gap-2.5 rounded border border-slate-800 bg-slate-900 p-2.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={respectQuiet}
|
||||||
|
onChange={(e) => setRespectQuiet(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs leading-snug text-slate-400">
|
||||||
|
Чекати кінця тихої години тригера
|
||||||
|
<span className="mt-0.5 block text-slate-600">
|
||||||
|
Увімкнено — сходинка, що припала на тиху годину, нікому не подзвонить, а
|
||||||
|
зачекає до ранку: ці люди зараз не на зміні. Вимкнено (як зараз) — драбина
|
||||||
|
дзвонить і серед ночі: тиха година стосується першого сповіщення, а драбина
|
||||||
|
йде саме тому, що на нього ніхто не відповів.
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-slate-600">
|
||||||
|
Повної тиші цей перемикач не дає за жодного значення: серйозність{' '}
|
||||||
|
<b className="font-medium text-slate-500">disaster</b> проходить крізь тиху
|
||||||
|
годину завжди — так само, як уже проходить перше сповіщення.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ErrorNote>{err ?? problem}</ErrorNote>
|
<ErrorNote>{err ?? problem}</ErrorNote>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,12 @@ describe('сходинка, що посилається на видалений
|
||||||
expect(screen.getByText(/сходинка 2 не має жодного каналу/)).toBeTruthy()
|
expect(screen.getByText(/сходинка 2 не має жодного каналу/)).toBeTruthy()
|
||||||
|
|
||||||
// Дали сходинці канал — тепер зберігається.
|
// Дали сходинці канал — тепер зберігається.
|
||||||
const boxes = screen.getAllByRole('checkbox')
|
//
|
||||||
fireEvent.click(boxes[boxes.length - 1])
|
// Шукаємо саме галочку каналу, а не «останню на формі»: у формі є й
|
||||||
|
// галочки, що каналів не стосуються (тиха година), і «остання»
|
||||||
|
// мовчки перестала б робити те, заради чого цей рядок написано —
|
||||||
|
// тест ловив би відсутність PUT замість зниклого каналу.
|
||||||
|
fireEvent.click(screen.getAllByRole('checkbox', { name: /Черговий/ })[1])
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Зберегти' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Зберегти' }))
|
||||||
|
|
||||||
let put: Call | undefined
|
let put: Call | undefined
|
||||||
|
|
|
||||||
196
web/src/test/escalationquiet.test.tsx
Normal file
196
web/src/test/escalationquiet.test.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { EscalationsPage } from '../pages/EscalationsPage'
|
||||||
|
import { session } from '../api/session'
|
||||||
|
import { fetchRouter, res, type Call } from './support'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прапорець драбини «зважати на тиху годину».
|
||||||
|
*
|
||||||
|
* ЧОГО ЦЕЙ ФАЙЛ БОЇТЬСЯ. Прапорець вирішує, чи подзвонить драбина о
|
||||||
|
* третій ночі, а форма замінює драбину цілком (PUT). Тому кожен спосіб
|
||||||
|
* НЕ надіслати поле коштує чийогось сну, і всі вони мовчазні:
|
||||||
|
*
|
||||||
|
* * форма не шле поле взагалі — сервер читає відсутнє як false, і
|
||||||
|
* кабінет, який свідомо вимкнув нічні дзвінки, отримує їх назад
|
||||||
|
* після того, як хтось правив у драбині назву;
|
||||||
|
* * форма шле поле лише коли воно true — те саме, тільки помітно
|
||||||
|
* стане в день, коли типове значення на сервері зміниться;
|
||||||
|
* * форма відкриває наявну драбину з незаповненим станом — галочка
|
||||||
|
* не стоїть, людина зберігає, тиша зникає.
|
||||||
|
*
|
||||||
|
* Ми вже наступали на це з `enabled`, коли форма поля не слала, а
|
||||||
|
* сервер домислював true. Тому перевіряється не «галочка є», а те, ЩО
|
||||||
|
* САМЕ їде в тілі запиту — обома значеннями.
|
||||||
|
*
|
||||||
|
* Форма підставних відповідей звірена з
|
||||||
|
* `server/internal/httpapi/alerts.go` (`handleListEscalationPolicies`,
|
||||||
|
* `handleSaveEscalationPolicy`) і зі `store.EscalationPolicy`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const duty = {
|
||||||
|
id: 'ch-duty',
|
||||||
|
kind: 'telegram',
|
||||||
|
name: 'Черговий',
|
||||||
|
config: {},
|
||||||
|
min_severity: 'warning',
|
||||||
|
enabled: true,
|
||||||
|
has_secret: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Драбина, якій нічну тишу ввімкнули свідомо. */
|
||||||
|
const quiet = {
|
||||||
|
id: 'p-quiet',
|
||||||
|
name: 'Денна зміна',
|
||||||
|
description: '',
|
||||||
|
steps: [{ after_min: 15, channel_ids: ['ch-duty'] }],
|
||||||
|
repeat_after_min: 0,
|
||||||
|
max_repeats: 0,
|
||||||
|
respect_quiet_hours: true,
|
||||||
|
rule_count: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Драбина зі звичною поведінкою: пробивається крізь тиху годину. */
|
||||||
|
const loud = {
|
||||||
|
...quiet,
|
||||||
|
id: 'p-loud',
|
||||||
|
name: 'Нічне чергування',
|
||||||
|
respect_quiet_hours: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тіло PUT/POST у тому вигляді, у якому його бачить сервер. */
|
||||||
|
type SavedBody = { name: string; respect_quiet_hours?: boolean }
|
||||||
|
|
||||||
|
function saved(calls: Call[]): SavedBody {
|
||||||
|
const c = calls.find((x) => x.method === 'PUT' || x.method === 'POST')
|
||||||
|
if (!c) throw new Error('форма не надіслала збереження')
|
||||||
|
return c.body as SavedBody
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('WebSocket', class {})
|
||||||
|
session.set('tok', {
|
||||||
|
userID: 'u-me',
|
||||||
|
username: 'me',
|
||||||
|
tenantID: 't-1',
|
||||||
|
permissions: ['alerts:read', 'alerts:write'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('форма драбини надсилає прапорець явно', () => {
|
||||||
|
it('нова драбина їде з false, навіть якщо галочки не торкались', async () => {
|
||||||
|
const srv = fetchRouter({
|
||||||
|
'GET /api/v1/escalation-policies': { policies: [] },
|
||||||
|
'GET /api/v1/channels': { channels: [duty] },
|
||||||
|
'POST /api/v1/escalation-policies': () => res(200, { id: 'p-new' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<EscalationsPage />)
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '+ Драбина' }))
|
||||||
|
await screen.findByText('Нова драбина')
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Нічне чергування/), {
|
||||||
|
target: { value: 'Нова' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getAllByRole('checkbox')[0]) // канал сходинці
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Створити' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(saved(srv.calls)).toBeTruthy())
|
||||||
|
// Саме false, а не undefined: сервер домислить відсутнє поле сам, і
|
||||||
|
// сьогодні домислить так само — але тоді форма означала б не те, що
|
||||||
|
// показує, і залежала б від чужого типового значення.
|
||||||
|
expect(saved(srv.calls).respect_quiet_hours).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('увімкнена галочка їде з true', async () => {
|
||||||
|
const srv = fetchRouter({
|
||||||
|
'GET /api/v1/escalation-policies': { policies: [loud] },
|
||||||
|
'GET /api/v1/channels': { channels: [duty] },
|
||||||
|
'PUT /api/v1/escalation-policies/p-loud': () => res(200, { id: 'p-loud' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<EscalationsPage />)
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Змінити' }))
|
||||||
|
await screen.findByText('Драбина: Нічне чергування')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: /тихої години/ }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Зберегти' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(saved(srv.calls)).toBeTruthy())
|
||||||
|
expect(saved(srv.calls).respect_quiet_hours).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('редагування чужого поля не роззброює вже ввімкнену тишу', async () => {
|
||||||
|
const srv = fetchRouter({
|
||||||
|
'GET /api/v1/escalation-policies': { policies: [quiet] },
|
||||||
|
'GET /api/v1/channels': { channels: [duty] },
|
||||||
|
'PUT /api/v1/escalation-policies/p-quiet': () => res(200, { id: 'p-quiet' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<EscalationsPage />)
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Змінити' }))
|
||||||
|
await screen.findByText('Драбина: Денна зміна')
|
||||||
|
|
||||||
|
// Галочка має стояти ще до того, як людина щось натисне: інакше
|
||||||
|
// збереження мовчки поверне нічні дзвінки.
|
||||||
|
expect((screen.getByRole('checkbox', { name: /тихої години/ }) as HTMLInputElement).checked).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Правимо те, що до тихої години стосунку не має.
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Нічне чергування/), {
|
||||||
|
target: { value: 'Денна зміна (2)' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Зберегти' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(saved(srv.calls)).toBeTruthy())
|
||||||
|
expect(saved(srv.calls).name).toBe('Денна зміна (2)')
|
||||||
|
expect(saved(srv.calls).respect_quiet_hours).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('форма попереджає, що disaster проходить попри перемикач', async () => {
|
||||||
|
fetchRouter({
|
||||||
|
'GET /api/v1/escalation-policies': { policies: [] },
|
||||||
|
'GET /api/v1/channels': { channels: [duty] },
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<EscalationsPage />)
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '+ Драбина' }))
|
||||||
|
await screen.findByText('Нова драбина')
|
||||||
|
|
||||||
|
// Без цього рядка людина вважає, що вмикає повну тишу, — і дізнається
|
||||||
|
// протилежне з дзвінка.
|
||||||
|
expect(screen.getByText('disaster')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('перелік драбин', () => {
|
||||||
|
it('показує обидва стани словами, не відкриваючи кожну', async () => {
|
||||||
|
fetchRouter({
|
||||||
|
'GET /api/v1/escalation-policies': { policies: [quiet, loud] },
|
||||||
|
'GET /api/v1/channels': { channels: [duty] },
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<EscalationsPage />)
|
||||||
|
|
||||||
|
await screen.findByText('Денна зміна')
|
||||||
|
expect(screen.getByText('відкладає сходинки')).toBeTruthy()
|
||||||
|
// Прочерк тут читався б як «не налаштовано», а це найгучніший стан.
|
||||||
|
expect(screen.getByText('пробивається')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('драбина, збережена до появи поля, читається як «пробивається»', async () => {
|
||||||
|
const old = { ...loud, id: 'p-old', name: 'Стара' }
|
||||||
|
delete (old as { respect_quiet_hours?: boolean }).respect_quiet_hours
|
||||||
|
|
||||||
|
fetchRouter({
|
||||||
|
'GET /api/v1/escalation-policies': { policies: [old] },
|
||||||
|
'GET /api/v1/channels': { channels: [duty] },
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<EscalationsPage />)
|
||||||
|
|
||||||
|
await screen.findByText('Стара')
|
||||||
|
expect(screen.getByText('пробивається')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -816,6 +816,18 @@ export interface EscalationPolicy {
|
||||||
/** 0 — не повторювати драбину. */
|
/** 0 — не повторювати драбину. */
|
||||||
repeat_after_min: number
|
repeat_after_min: number
|
||||||
max_repeats: number
|
max_repeats: number
|
||||||
|
/**
|
||||||
|
* Чи відкладати сходинки на час тихої години правила.
|
||||||
|
*
|
||||||
|
* false (типово) — драбина пробивається крізь тиху годину: тиха
|
||||||
|
* година стримує ПЕРШЕ сповіщення, а драбина йде саме тоді, коли на
|
||||||
|
* перше ніхто не відповів. true — «цих людей зараз немає на зміні»,
|
||||||
|
* і сходинка чекає до кінця тихої години.
|
||||||
|
*
|
||||||
|
* Не обов'язкове поле: сервер вважає відсутнє за false, і драбини,
|
||||||
|
* збережені до появи прапорця, читаються без нього.
|
||||||
|
*/
|
||||||
|
respect_quiet_hours?: boolean
|
||||||
/** Скільки тригерів на неї посилаються — щоб видалення не було тихим. */
|
/** Скільки тригерів на неї посилаються — щоб видалення не було тихим. */
|
||||||
rule_count: number
|
rule_count: number
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue