diff --git a/HISTORY.md b/HISTORY.md index 0bf9e4a..d336535 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -7557,3 +7557,58 @@ NULL і валить запис. Правило без власних канал подвійне сповіщення (`ApplyEscalation` не звіряє оренду токеном); `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 міграції, усе зелене. diff --git a/scripts/dbtest.sh b/scripts/dbtest.sh index ef0c68c..c181690 100644 --- a/scripts/dbtest.sh +++ b/scripts/dbtest.sh @@ -90,6 +90,11 @@ cleanup() { } trap cleanup EXIT INT TERM +# Міграції котяться З ЦЬОГО Ж ДЕРЕВА (go run), а не готовим образом — і +# це не дрібниця. Образ зібрано з розгорнутого коду, тож він знає лише +# старі міграції, і тести нового коду йшли б проти старої схеми: падіння +# виглядало б як «немає такого стовпця», хоч стовпець у гілці є. Один +# такий прогін уже збрехав саме так. echo "== накат міграцій" ( cd "$SRC/server" && NETPULSE_DSN="$NETPULSE_TEST_DSN" go run ./cmd/netpulse-migrate ) || exit 1 diff --git a/server/API.md b/server/API.md index d8cdb99..acf6fd8 100644 --- a/server/API.md +++ b/server/API.md @@ -1198,7 +1198,8 @@ JSON у таблиці правил. {"after_min": 45, "channel_ids": ["…керівник зміни"]} ], "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`) проходять драбину один раз, без повторів. Повтор — це ставка на те, що проблема триває, а її можна робити лише там, де існування алерту саме по собі є доказом: diff --git a/server/internal/alerting/escalation_test.go b/server/internal/alerting/escalation_test.go index f0d642c..6c159e8 100644 --- a/server/internal/alerting/escalation_test.go +++ b/server/internal/alerting/escalation_test.go @@ -24,7 +24,7 @@ func act() store.RuleAction { // Контроль: нормальний випадок драбину взводить. 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("алерт, про який справді повідомили, має отримати драбину") } } @@ -35,11 +35,11 @@ func TestEscalationArmedForFiringAlert(t *testing.T) { // почати будити людей тільки тому, що з'явилась нова можливість. func TestNoPolicyMeansNoEscalation(t *testing.T) { 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("правило без політики не має ескалювати") } // Правила взагалі немає (його видалили, алерт лишився). - if shouldArmEscalation(store.Alert{State: "firing"}, store.RuleAction{}, false, 3) { + if shouldArmEscalation(store.Alert{State: "firing"}, store.RuleAction{}, false, true) { t.Fatal("алерт без правила ескалювати нікуди") } } @@ -48,20 +48,24 @@ func TestNoPolicyMeansNoEscalation(t *testing.T) { // турбувати», а драбина — це рівно турбування, тільки за розкладом. func TestSuppressedAlertIsNotArmed(t *testing.T) { 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) } } } -// Найтонший випадок: перше сповіщення нікуди не пішло. +// Найтонший випадок: алерту не було куди піти. // -// Каналів могло не знайтись через тиху годину правила, поріг -// серйозності або вимкнений канал. Взвести драбину означало б надіслати -// о 15-й хвилині те, що на нульовій ми свідомо не надсилали, — і людина -// прочитала б це як несправність. Нею це й було б. +// Каналів не знайшлось через поріг серйозності, вимкнений канал або +// порожній перелік. Взвести драбину означало б надіслати о 15-й хвилині +// те, що на нульовій ми свідомо не надсилали, — і людина прочитала б це +// як несправність. Нею це й було б. +// +// Тиха година сюди НЕ належить: там канали є, просто зараз ніч, і +// targets() повідомляє це окремо. Раніше вона потрапляла в цю ж гілку й +// роззброювала драбину назавжди — див. TestQuietHourStillArms. 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("драбина взведена попри те, що перше сповіщення нікуди не пішло") } } diff --git a/server/internal/alerting/notify.go b/server/internal/alerting/notify.go index d53aa7a..9f6327d 100644 --- a/server/internal/alerting/notify.go +++ b/server/internal/alerting/notify.go @@ -95,11 +95,11 @@ func (n *Notifier) Dispatch(ctx context.Context, tenantID string, alerts []store if hasAct && a.State == "resolved" && !act.NotifyOnResolve { 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 { 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, - 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 } 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 { - return a.State == "firing" && sent > 0 && hasAct && act.EscalationPolicyID != "" +// reachable — чи БУЛО КУДИ слати цей алерт: або знайшлись канали, або +// вони знайшлись би, якби не тиха година. +// +// Друга половина — не дрібниця. Доти драбина не взводилась, коли перше +// сповіщення притлумила тиха година, а взводять лише НОВИЙ алерт — тобто +// аварія о 22:01 не отримувала драбини вже ніколи, до кінця свого життя. +// Тиха година мовчки роззброювала саме той механізм, який існує на +// випадок, коли перше сповіщення не спрацювало. +func shouldArmEscalation(a store.Alert, act store.RuleAction, hasAct bool, reachable bool) bool { + return a.State == "firing" && reachable && hasAct && act.EscalationPolicyID != "" } // targets вирішує, у які канали піде цей алерт. @@ -168,7 +183,7 @@ func shouldArmEscalation(a store.Alert, act store.RuleAction, hasAct bool, sent // підключили Telegram і чекають, що працюватиме. func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool, 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 { 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"] { _ = n.st.RecordNotification(context.Background(), tenantID, a.ID, "", "throttled", "тиха година правила", "", nil) - return nil + // Каналів немає не тому, що їх не налаштували, — тому що + // зараз ніч. Різницю треба донести нагору: від неї залежить, + // чи взведеться драбина. + return nil, true } var out []store.Channel 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) } } - 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) } } - return out + return out, len(out) > 0 } seen := map[string]bool{} var out []store.Channel + quiet := false for _, r := range routes { if !r.Matches(a) { continue @@ -217,6 +236,10 @@ func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool, if severityRank[a.Severity] < severityRank["disaster"] { _ = n.st.RecordNotification(context.Background(), tenantID, a.ID, "", "throttled", "тиха година маршруту "+r.Name, "", nil) + // Маршрут підійшов, канали в нього є — мовчить лише ніч. + if len(r.ChannelIDs) > 0 { + quiet = true + } continue } } @@ -229,7 +252,7 @@ func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool, 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) { diff --git a/server/internal/alerting/notify_test.go b/server/internal/alerting/notify_test.go index accd0d3..2557d7a 100644 --- a/server/internal/alerting/notify_test.go +++ b/server/internal/alerting/notify_test.go @@ -129,7 +129,7 @@ func TestTargetsFallBackToAllChannelsWithoutRoutes(t *testing.T) { 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") 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"}} 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)) } - 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") } } @@ -188,13 +188,13 @@ func TestRuleChannelsOverrideRoutes(t *testing.T) { routes := []store.Route{{ID: "r1", Name: "усі", ChannelIDs: []string{"c1", "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" { 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") if len(got) != 2 { t.Fatalf("очікували обидва канали з маршруту, отримали %v", ids(got)) @@ -208,7 +208,7 @@ func TestRuleChannelsRespectDisabled(t *testing.T) { channels := []store.Channel{{ID: "c1", Enabled: false, MinSeverity: "info"}} 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") if len(got) != 0 { t.Fatalf("вимкнений канал отримав алерт: %v", ids(got)) diff --git a/server/internal/httpapi/alerts.go b/server/internal/httpapi/alerts.go index db6eef4..01a5722 100644 --- a/server/internal/httpapi/alerts.go +++ b/server/internal/httpapi/alerts.go @@ -679,6 +679,10 @@ func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Reque Steps []store.EscalationStep `json:"steps"` RepeatAfterMin int `json:"repeat_after_min"` MaxRepeats int `json:"max_repeats"` + // Відсутнє поле = false: та поведінка, що діяла досі. Оновлення + // не має тихо роззброїти нічні дзвінки в тих, у кого вони + // працюють, лише тому, що старий клієнт про поле не знає. + RespectQuietHours bool `json:"respect_quiet_hours"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON") @@ -720,6 +724,8 @@ func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Reque Steps: req.Steps, RepeatAfterMin: req.RepeatAfterMin, MaxRepeats: req.MaxRepeats, + + RespectQuietHours: req.RespectQuietHours, }) if err != nil { if errors.Is(err, store.ErrNotFound) { diff --git a/server/internal/store/alerts_escalation.go b/server/internal/store/alerts_escalation.go index 130ccf4..eb342b9 100644 --- a/server/internal/store/alerts_escalation.go +++ b/server/internal/store/alerts_escalation.go @@ -34,6 +34,17 @@ type EscalationPolicy struct { // 0 — не повторювати. RepeatAfterMin int `json:"repeat_after_min"` MaxRepeats int `json:"max_repeats"` + // Чи відкладати сходинки на час тихої години правила. + // + // false (типово) — драбина пробивається: тиха година стосується + // ПЕРШОГО сповіщення, а сенс драбини рівно в тому, щоб дійти, коли + // перше проґавили. true — «ці люди зараз не на зміні», і будити їх + // драбиною так само неправильно, як першим сповіщенням. + // + // Вибір не технічний і залежить від того, чи є в кабінету нічна + // зміна, — тому він тут, а не в нашому коді. Disaster пробивається + // за будь-якого значення. + RespectQuietHours bool `json:"respect_quiet_hours"` // Скільки правил уже посилаються на цю політику — щоб видалення не // було мовчазним вимкненням ескалації на десятку тригерів. RuleCount int `json:"rule_count"` @@ -157,6 +168,7 @@ func (s *Store) ListEscalationPolicies(ctx context.Context, tenantID string) ([] rows, err := tx.Query(ctx, ` SELECT p.id::text, p.name, COALESCE(p.description,''), p.steps::text, 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) FROM alr.escalation_policies p WHERE p.tenant_id = $1 @@ -170,7 +182,8 @@ func (s *Store) ListEscalationPolicies(ctx context.Context, tenantID string) ([] var p EscalationPolicy var steps string 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 } 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 == "" { return tx.QueryRow(ctx, ` INSERT INTO alr.escalation_policies - (tenant_id, name, description, steps, repeat_after_min, max_repeats) - VALUES ($1, $2, $3, $4::jsonb, $5, $6) + (tenant_id, name, description, steps, repeat_after_min, + max_repeats, respect_quiet_hours) + VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7) RETURNING id::text `, 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, ` UPDATE alr.escalation_policies 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 `, tenantID, id, p.Name, nullString(p.Description), string(steps), - repeat, p.MaxRepeats) + repeat, p.MaxRepeats, p.RespectQuietHours) if err != nil { 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 — скільки триває драбина, якщо ніхто не втручається. func escalationSpan(p EscalationPolicy, isEvent bool) time.Duration { if len(p.Steps) == 0 { @@ -348,6 +379,13 @@ type EscalationSnapshot struct { RepeatAfterMin int MaxRepeats int + // Тиха година правила, за яким піднято алерт, і чи зважає на неї ця + // драбина. Читаються разом зі сходинкою, бо рішення «дзвонити чи + // відкласти» ухвалюється в момент сходинки, а не в момент взведення: + // розклад могли переписати за ті години, що драбина йшла. + RespectQuietHours bool + Schedule *RouteSchedule + // Стан алерту на момент читання. Порожньо — алерту вже немає. AlertState string // Алерт у вигляді, придатному для тексту повідомлення. @@ -473,6 +511,32 @@ func PlanEscalation(s EscalationSnapshot, now time.Time) EscalationDecision { 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{ Action: EscFire, 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, COALESCE(p.name,''), COALESCE(p.steps::text,'[]'), 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.title,''), COALESCE(a.message,''), 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.escalation_policies p ON p.id = t.policy_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 `, limit, EscalationLease.String()) if err != nil { @@ -606,15 +673,22 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation var out []EscalationSnapshot for rows.Next() { var s EscalationSnapshot - var steps string + var steps, sched string if err := rows.Scan(&s.AlertID, &s.TenantID, &s.PolicyID, &s.IsEvent, &s.StepIdx, &s.RepeatIdx, &s.PassStart, &s.Deadline, &s.PolicyName, &steps, &s.RepeatAfterMin, &s.MaxRepeats, + &s.RespectQuietHours, &sched, &s.AlertState, &s.Alert.Severity, &s.Alert.Title, &s.Alert.Message, &s.Alert.DeviceID, &s.Alert.DeviceName, &s.Alert.RuleID, &s.Alert.SuppressedBy, &s.Alert.StartedAt); err != nil { 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 { // Політика з нечитабельними сходинками не має валити чергу: // решта драбин у кабінеті ні в чому не винна. @@ -714,14 +788,14 @@ func (s *Store) LogEscalationStep(ctx context.Context, snap EscalationSnapshot, // Відкладання пишеться ОДИН раз на сходинку, а не щоперевірки. // - // Заглушений алерт переглядається кожні EscalationRecheck, тобто - // приблизно 288 разів на добу, і драбина може чекати тижнями (стеля - // життя — до ~21 доби). Без цієї умови один алерт під вікном - // обслуговування лишав би тисячі однакових рядків, і журнал, у який - // заходять з питанням «чому мене не розбудили», перестав би бути - // придатним для читання саме тоді, коли він потрібен. Один рядок - // каже рівно те саме: цю сходинку відкладено через заглушення. - once := outcome == "suppressed" + // Відкладена сходинка (заглушення або тиха година) переглядається + // кожні EscalationRecheck, тобто близько 288 разів на добу, а драбина + // може чекати тижнями (стеля життя — до ~21 доби). Без цієї умови + // одна ніч під тихою годиною лишала б сотні однакових рядків, і + // журнал, у який заходять із питанням «чому мене не розбудили», + // ставав би нечитабельним рівно тоді, коли він потрібен. Один рядок + // каже те саме: цю сходинку відкладено, і ось чому. + once := outcome == "suppressed" || outcome == "quiet_hours" return s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error { _, err := tx.Exec(ctx, ` diff --git a/server/internal/store/alerts_escalation_quiet_test.go b/server/internal/store/alerts_escalation_quiet_test.go new file mode 100644 index 0000000..a5390fb --- /dev/null +++ b/server/internal/store/alerts_escalation_quiet_test.go @@ -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) + } +} diff --git a/server/migrations/0072_escalation_quiet_hours.sql b/server/migrations/0072_escalation_quiet_hours.sql new file mode 100644 index 0000000..1e5868e --- /dev/null +++ b/server/migrations/0072_escalation_quiet_hours.sql @@ -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 пробивається завжди.'; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6176fda..a9c2b10 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -987,6 +987,14 @@ export const api = { (r) => r.policies ?? [], ), + // respect_quiet_hours тут ОБОВ'ЯЗКОВЕ, а не `?: boolean`. + // + // PUT замінює драбину цілком, а відсутнє поле сервер читає як false. + // Необов'язкове поле означало б, що форма може мовчки не надіслати + // його — і драбина, якій увімкнули повагу до тихої години, при + // будь-якій наступній правці назви тихо почала б дзвонити вночі. + // Ми вже наступали на це з `enabled`, коли сервер домислював true. + // Обов'язковість перекладає цю помилку з ночі до збірки. saveEscalationPolicy: ( id: string | null, p: { @@ -995,6 +1003,7 @@ export const api = { steps: EscalationStep[] repeat_after_min: number max_repeats: number + respect_quiet_hours: boolean }, ) => request<{ id: string }>( diff --git a/web/src/pages/EscalationsPage.tsx b/web/src/pages/EscalationsPage.tsx index f63cbd2..29a577b 100644 --- a/web/src/pages/EscalationsPage.tsx +++ b/web/src/pages/EscalationsPage.tsx @@ -164,6 +164,34 @@ export function EscalationsPage() { ), }, + { + // Обидва стани — словами, і жоден не прочерк. + // + // Прочерк на «пробивається» читався б як «не + // налаштовано», а це найгучніший з двох станів: саме + // він дзвонить о третій ночі. Без цього стовпчика + // різницю між драбинами видно лише відкривши кожну — + // тобто рівно тоді, коли вже пізно. + key: 'quiet', + header: 'Тиха година', + hideOnMobile: true, + cell: (p) => + p.respect_quiet_hours ? ( + + відкладає сходинки + + ) : ( + + пробивається + + ), + }, { key: 'rules', header: 'Тригерів', @@ -281,6 +309,11 @@ function PolicyForm({ }) const [repeatAfter, setRepeatAfter] = useState(String(policy?.repeat_after_min ?? 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 [err, setErr] = useState(null) @@ -327,6 +360,11 @@ function PolicyForm({ steps, repeat_after_min: Number(repeatAfter) || 0, max_repeats: Number(maxRepeats) || 0, + // Надсилаємо завжди, обома значеннями. Пропущене поле сервер + // читає як false, тож «не слати, коли вимкнено» виглядало б + // однаково — рівно до дня, коли типове значення на сервері + // зміниться, і форма почне мовчки означати не те, що показує. + respect_quiet_hours: respectQuiet, }) await onSaved() } catch (e) { @@ -460,6 +498,32 @@ function PolicyForm({

+
+
Тиха година
+ +
+ {err ?? problem}
diff --git a/web/src/test/escalationchannels.test.tsx b/web/src/test/escalationchannels.test.tsx index 8c37031..bcbc08e 100644 --- a/web/src/test/escalationchannels.test.tsx +++ b/web/src/test/escalationchannels.test.tsx @@ -91,8 +91,12 @@ describe('сходинка, що посилається на видалений 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: 'Зберегти' })) let put: Call | undefined diff --git a/web/src/test/escalationquiet.test.tsx b/web/src/test/escalationquiet.test.tsx new file mode 100644 index 0000000..c2b7e38 --- /dev/null +++ b/web/src/test/escalationquiet.test.tsx @@ -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() + 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() + 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() + 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() + 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() + + 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() + + await screen.findByText('Стара') + expect(screen.getByText('пробивається')).toBeTruthy() + }) +}) diff --git a/web/src/types.ts b/web/src/types.ts index da65e2a..3e55d20 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -816,6 +816,18 @@ export interface EscalationPolicy { /** 0 — не повторювати драбину. */ repeat_after_min: number max_repeats: number + /** + * Чи відкладати сходинки на час тихої години правила. + * + * false (типово) — драбина пробивається крізь тиху годину: тиха + * година стримує ПЕРШЕ сповіщення, а драбина йде саме тоді, коли на + * перше ніхто не відповів. true — «цих людей зараз немає на зміні», + * і сходинка чекає до кінця тихої години. + * + * Не обов'язкове поле: сервер вважає відсутнє за false, і драбини, + * збережені до появи прапорця, читаються без нього. + */ + respect_quiet_hours?: boolean /** Скільки тригерів на неї посилаються — щоб видалення не було тихим. */ rule_count: number }