Тиха година й драбина: вада окремо, вибір окремо
All checks were successful
CI / hygiene (push) Successful in 10s
CI / web (push) Successful in 1m18s
CI / server (push) Successful in 1m54s
CI / agent (push) Successful in 1m1s

Асиметрія: аварія о 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:
byrsapty 2026-08-28 18:08:27 +03:00
parent 954be1d643
commit ec4b2cd54b
15 changed files with 691 additions and 53 deletions

View file

@ -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 міграції, усе зелене.

View file

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

View file

@ -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`) проходять драбину
один раз, без повторів. Повтор — це ставка на те, що проблема триває, а один раз, без повторів. Повтор — це ставка на те, що проблема триває, а
її можна робити лише там, де існування алерту саме по собі є доказом: її можна робити лише там, де існування алерту саме по собі є доказом:

View file

@ -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("драбина взведена попри те, що перше сповіщення нікуди не пішло")
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,122 @@
package store
import (
"testing"
"time"
)
// Тиха година 22:0008: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)
}
}

View 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 пробивається завжди.';

View file

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

View file

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

View file

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

View 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()
})
})

View file

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