Тиха година й драбина: вада окремо, вибір окремо
Асиметрія: аварія о 21:59 ескалювала всю ніч, о 22:01 не ескалювала ніколи. Дві хвилини різниці — протилежні наслідки, причому гірший (повна тиша) виглядав як тиша справна. ВАДА. targets() повертав порожньо в тиху годину, а взведення читало це як «немає куди слати». Взводять лише новий алерт, тож драбина не з'являлась уже ніколи: тиха година вимикала механізм саме тоді, коли перше сповіщення не спрацювало. Тепер targets() розрізняє «каналів немає» і «канали є, просто зараз ніч». ВИБІР. 0072 додає respect_quiet_hours на драбину: false (типово, як діяло) — драбина пробивається; true — сходинка відкладається до ранку і НЕ витрачається. Залежить від того, чи є в кабінету нічна зміна — це вирішує кабінет. disaster пробивається за будь-якого значення, як і в targets(). Відлік драбини — від першого сповіщення, а не від started_at: інакше для розглушеного алерту вона протухла б ще у вікні. І сам прогін проти бази брехав: dbtest.sh котив схему готовим образом (старі міграції), а тести брав із нового дерева. Тепер міграції з того ж дерева. 64 міграції, усе зелене. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
954be1d643
commit
ec4b2cd54b
15 changed files with 691 additions and 53 deletions
55
HISTORY.md
55
HISTORY.md
|
|
@ -7557,3 +7557,58 @@ NULL і валить запис. Правило без власних канал
|
|||
подвійне сповіщення (`ApplyEscalation` не звіряє оренду токеном);
|
||||
`LoadChannels` падає цілком через один нерозшифровний секрет, і кабінет
|
||||
лишається без ескалацій до стелі життя драбини.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-28 — Тиха година й драбина: обидві відповіді правильні, тому це вибір
|
||||
|
||||
Асиметрія, яку неможливо було пояснити людині, що її переживе:
|
||||
|
||||
```
|
||||
аварія о 21:59 — сповіщення пішло, драбина взвелась і дзвонить усю ніч
|
||||
аварія о 22:01 — сповіщення притлумила тиха година, каналів «не
|
||||
знайшлось», драбина не взвелась — і не взведеться
|
||||
НІКОЛИ, бо взводять лише новий алерт
|
||||
```
|
||||
|
||||
Дві хвилини різниці — протилежні наслідки, причому гірший (повна тиша на
|
||||
аварії) виглядає як тиша справна.
|
||||
|
||||
**Половина перша — вада, і її просто виправлено.** Тиха година
|
||||
роззброювала драбину назавжди, бо `targets()` повертав порожньо, а
|
||||
`shouldArmEscalation` бачив у цьому «немає куди слати». Тепер `targets()`
|
||||
розрізняє «каналів немає» і «канали є, просто зараз ніч», і драбина
|
||||
взводиться в другому випадку. Механізм, що існує на випадок «перше
|
||||
сповіщення не спрацювало», більше не вимикається саме тоді, коли перше
|
||||
сповіщення не спрацювало.
|
||||
|
||||
**Половина друга — не вада, а вибір, і тому це прапорець.** Міграція 0072
|
||||
додає `alr.escalation_policies.respect_quiet_hours`:
|
||||
|
||||
* `false` (типово, поведінка що діяла) — драбина пробивається. Тиха
|
||||
година стосується ПЕРШОГО сповіщення, а сенс драбини рівно в тому, щоб
|
||||
дійти, коли перше проґавили. Так працює чергування.
|
||||
* `true` — сходинка відкладається до кінця тихої години, як під
|
||||
заглушенням, і НЕ витрачається: інакше одна ніч тихо роззброювала б
|
||||
драбину до кінця життя алерту.
|
||||
|
||||
Правильна відповідь залежить від того, чи є в кабінету нічна зміна — це
|
||||
вирішує кабінет, не ми. Типове значення зберігає наявну поведінку:
|
||||
оновлення не має тихо роззброїти нічні дзвінки в тих, у кого вони
|
||||
працюють.
|
||||
|
||||
`disaster` пробивається за будь-якого значення — виняток не новий,
|
||||
`targets()` уже пропускає його крізь тиху годину маршруту й правила. Одне
|
||||
слово в двох місцях мусить означати одне.
|
||||
|
||||
Відлік драбини ведеться від ПЕРШОГО СПОВІЩЕННЯ, а не від `started_at`:
|
||||
для алерту, з якого щойно зняли заглушення, це різниця між «драбина
|
||||
попереду» і «драбина протухла ще у вікні й висиплеться одним залпом».
|
||||
|
||||
### Прогін проти бази знову збрехав — і це виправлено в самому прогоні
|
||||
|
||||
`dbtest.sh` на стенді котив схему готовим ОБРАЗОМ, а тести брав із нового
|
||||
дерева. Образ зібрано з розгорнутого коду, тож він знав лише старі
|
||||
міграції: падіння виглядало як «немає стовпця respect_quiet_hours», хоча
|
||||
стовпець у гілці є. Тепер міграції котяться з того ж дерева, що й тести
|
||||
(`go run ./cmd/netpulse-migrate`). Перевірено: 64 міграції, усе зелене.
|
||||
|
|
|
|||
|
|
@ -90,6 +90,11 @@ cleanup() {
|
|||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Міграції котяться З ЦЬОГО Ж ДЕРЕВА (go run), а не готовим образом — і
|
||||
# це не дрібниця. Образ зібрано з розгорнутого коду, тож він знає лише
|
||||
# старі міграції, і тести нового коду йшли б проти старої схеми: падіння
|
||||
# виглядало б як «немає такого стовпця», хоч стовпець у гілці є. Один
|
||||
# такий прогін уже збрехав саме так.
|
||||
echo "== накат міграцій"
|
||||
( cd "$SRC/server" && NETPULSE_DSN="$NETPULSE_TEST_DSN" go run ./cmd/netpulse-migrate ) || exit 1
|
||||
|
||||
|
|
|
|||
|
|
@ -1198,7 +1198,8 @@ JSON у таблиці правил.
|
|||
{"after_min": 45, "channel_ids": ["…керівник зміни"]}
|
||||
],
|
||||
"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`) проходять драбину
|
||||
один раз, без повторів. Повтор — це ставка на те, що проблема триває, а
|
||||
її можна робити лише там, де існування алерту саме по собі є доказом:
|
||||
|
|
|
|||
|
|
@ -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("драбина взведена попри те, що перше сповіщення нікуди не пішло")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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, `
|
||||
|
|
|
|||
122
server/internal/store/alerts_escalation_quiet_test.go
Normal file
122
server/internal/store/alerts_escalation_quiet_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Тиха година 22:00–08:00 за Києвом, щодня.
|
||||
func nightSchedule() *RouteSchedule {
|
||||
var s RouteSchedule
|
||||
s.TZ = "Europe/Kyiv"
|
||||
s.Quiet = append(s.Quiet, struct {
|
||||
Days []int `json:"days"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}{From: "22:00", To: "08:00"})
|
||||
return &s
|
||||
}
|
||||
|
||||
func quietSnap(respect bool, severity string) EscalationSnapshot {
|
||||
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||
start := time.Date(2026, 8, 28, 21, 0, 0, 0, kyiv)
|
||||
return EscalationSnapshot{
|
||||
AlertID: "a1",
|
||||
TenantID: "t1",
|
||||
PolicyID: "p1",
|
||||
AlertState: "firing",
|
||||
StepIdx: 0,
|
||||
PassStart: start,
|
||||
Deadline: start.Add(48 * time.Hour),
|
||||
Steps: []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"c1"}}},
|
||||
RespectQuietHours: respect,
|
||||
Schedule: nightSchedule(),
|
||||
Alert: Alert{Severity: severity, State: "firing"},
|
||||
}
|
||||
}
|
||||
|
||||
// Кабінет сказав «драбина вночі мовчить» — сходинка чекає ранку.
|
||||
//
|
||||
// І головне: вона НЕ витрачається. Списана сходинка означала б, що одна
|
||||
// ніч тихо роззброює драбину до кінця життя алерту, тобто рівно та
|
||||
// мовчазна відмова, від якої ескалація й рятує.
|
||||
func TestQuietHoursDeferStepWhenRespected(t *testing.T) {
|
||||
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
|
||||
|
||||
d := PlanEscalation(quietSnap(true, "high"), night)
|
||||
if d.Action != EscDefer {
|
||||
t.Fatalf("сходинка мала відкластись, а не %v (%s)", d.Action, d.Outcome)
|
||||
}
|
||||
if d.Outcome != "quiet_hours" {
|
||||
t.Errorf("причина відкладання нечитабельна: %q", d.Outcome)
|
||||
}
|
||||
if d.NextStepIdx != 0 {
|
||||
t.Errorf("сходинку витрачено: наступна %d замість 0", d.NextStepIdx)
|
||||
}
|
||||
if d.NextAt == nil || !d.NextAt.After(night) {
|
||||
t.Error("немає часу повторної перевірки — драбина застрягне")
|
||||
}
|
||||
}
|
||||
|
||||
// Той самий кабінет удень: тиха година скінчилась, драбина йде.
|
||||
func TestQuietHoursDoNotHoldByDay(t *testing.T) {
|
||||
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||
day := time.Date(2026, 8, 28, 11, 0, 0, 0, kyiv)
|
||||
|
||||
if d := PlanEscalation(quietSnap(true, "high"), day); d.Action != EscFire {
|
||||
t.Fatalf("удень сходинка мала спрацювати: %v/%s", d.Action, d.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// Кабінет НЕ просив зважати на тиху годину — драбина пробивається.
|
||||
//
|
||||
// Це типове значення й поведінка, що діяла досі: оновлення не має тихо
|
||||
// роззброїти нічні дзвінки в тих, у кого вони працюють.
|
||||
func TestQuietHoursIgnoredByDefault(t *testing.T) {
|
||||
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
|
||||
|
||||
if d := PlanEscalation(quietSnap(false, "high"), night); d.Action != EscFire {
|
||||
t.Fatalf("драбина без прапорця мала пробитись: %v/%s", d.Action, d.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// Disaster будить попри все — так само, як у першому сповіщенні.
|
||||
//
|
||||
// Одне слово в двох місцях мусить означати одне: targets() уже пропускає
|
||||
// disaster крізь тиху годину маршруту й правила.
|
||||
func TestDisasterBreaksThroughQuietHours(t *testing.T) {
|
||||
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
|
||||
|
||||
if d := PlanEscalation(quietSnap(true, "disaster"), night); d.Action != EscFire {
|
||||
t.Fatalf("disaster мав пробитись крізь тиху годину: %v/%s", d.Action, d.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// Драбина, яку ніч не пускала аж до стелі життя, зупиняється — а не
|
||||
// відкладається вічно.
|
||||
func TestQuietHoursCannotOutliveDeadline(t *testing.T) {
|
||||
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
|
||||
|
||||
s := quietSnap(true, "high")
|
||||
s.Deadline = night.Add(time.Minute)
|
||||
d := PlanEscalation(s, night)
|
||||
if d.Action != EscStop || d.Outcome != "deadline" {
|
||||
t.Fatalf("драбина мала протухнути, а не %v/%s", d.Action, d.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// Розкладу немає — тиха година не може тримати те, чого не описано.
|
||||
func TestNoScheduleNoHold(t *testing.T) {
|
||||
kyiv, _ := time.LoadLocation("Europe/Kyiv")
|
||||
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
|
||||
|
||||
s := quietSnap(true, "high")
|
||||
s.Schedule = nil
|
||||
if d := PlanEscalation(s, night); d.Action != EscFire {
|
||||
t.Fatalf("без розкладу сходинка мала спрацювати: %v/%s", d.Action, d.Outcome)
|
||||
}
|
||||
}
|
||||
54
server/migrations/0072_escalation_quiet_hours.sql
Normal file
54
server/migrations/0072_escalation_quiet_hours.sql
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0072_escalation_quiet_hours.sql
|
||||
-- Що тиха година правила означає для драбини ескалації.
|
||||
--
|
||||
-- ЩО БУЛО НЕ ТАК
|
||||
--
|
||||
-- Тиха година діяла на перше сповіщення й не діяла на драбину взагалі.
|
||||
-- Це давало асиметрію, яку неможливо пояснити людині, що її переживе:
|
||||
--
|
||||
-- аварія о 21:59 — перше сповіщення пішло, драбина взвелась, і вона
|
||||
-- дзвонить усю ніч, бо про тиху годину не знає;
|
||||
-- аварія о 22:01 — перше сповіщення притлумлено тихою годиною, тож
|
||||
-- каналів «не знайшлось», тож драбина не взвелась —
|
||||
-- і не взведеться вже НІКОЛИ, бо взводять лише
|
||||
-- новий алерт.
|
||||
--
|
||||
-- Дві хвилини різниці — і протилежні наслідки, причому гірший із них
|
||||
-- (повна тиша на аварії) виглядає як тиша справна.
|
||||
--
|
||||
-- ЧОМУ ЦЕ ПРАПОРЕЦЬ, А НЕ ОБРАНА НАМИ ПОВЕДІНКА
|
||||
--
|
||||
-- Обидві відповіді правильні, і вибір між ними — не технічний:
|
||||
--
|
||||
-- * «драбина не зважає на тиху годину» — тиха година стосується
|
||||
-- ПЕРШОГО сповіщення, а сенс драбини рівно в тому, щоб пробитись,
|
||||
-- коли перше проґавили. Так працює чергування: тебе не будять по
|
||||
-- дрібницях, але якщо ніхто не відповів — будять.
|
||||
--
|
||||
-- * «драбина відкладає сходинку до кінця тихої години» — тиха година
|
||||
-- означає «ці люди зараз не на зміні», і будити їх драбиною так
|
||||
-- само неправильно, як першим сповіщенням.
|
||||
--
|
||||
-- Правильна відповідь залежить від того, чи є в кабінету нічна зміна, а
|
||||
-- це вирішує кабінет, а не ми. Тому прапорець на драбині — там же, де
|
||||
-- людина вирішує, кого й через скільки будити.
|
||||
--
|
||||
-- Типове значення false — та поведінка, яка вже діє для взведених
|
||||
-- драбин. Оновлення не має тихо роззброїти нічні дзвінки в тих, у кого
|
||||
-- вони працюють.
|
||||
--
|
||||
-- DISASTER ПРОБИВАЄТЬСЯ ЗАВЖДИ
|
||||
--
|
||||
-- Виняток не новий: targets() уже пропускає disaster крізь тиху годину
|
||||
-- маршруту й правила з тим самим міркуванням — сенс чергування в тому,
|
||||
-- щоб найважче підняли. Драбина мусить поводитись так само, інакше одне
|
||||
-- і те саме слово в двох місцях означало б різне.
|
||||
-- =====================================================================
|
||||
|
||||
ALTER TABLE alr.escalation_policies
|
||||
ADD COLUMN IF NOT EXISTS respect_quiet_hours boolean NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN alr.escalation_policies.respect_quiet_hours IS
|
||||
'Чи відкладати сходинки на час тихої години правила. false — драбина '
|
||||
'пробивається крізь тиху годину (типово). disaster пробивається завжди.';
|
||||
|
|
@ -987,6 +987,14 @@ export const api = {
|
|||
(r) => r.policies ?? [],
|
||||
),
|
||||
|
||||
// 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 }>(
|
||||
|
|
|
|||
|
|
@ -164,6 +164,34 @@ export function EscalationsPage() {
|
|||
<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',
|
||||
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<string | null>(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({
|
|||
</p>
|
||||
</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>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
196
web/src/test/escalationquiet.test.tsx
Normal file
196
web/src/test/escalationquiet.test.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { EscalationsPage } from '../pages/EscalationsPage'
|
||||
import { session } from '../api/session'
|
||||
import { fetchRouter, res, type Call } from './support'
|
||||
|
||||
/**
|
||||
* Прапорець драбини «зважати на тиху годину».
|
||||
*
|
||||
* ЧОГО ЦЕЙ ФАЙЛ БОЇТЬСЯ. Прапорець вирішує, чи подзвонить драбина о
|
||||
* третій ночі, а форма замінює драбину цілком (PUT). Тому кожен спосіб
|
||||
* НЕ надіслати поле коштує чийогось сну, і всі вони мовчазні:
|
||||
*
|
||||
* * форма не шле поле взагалі — сервер читає відсутнє як false, і
|
||||
* кабінет, який свідомо вимкнув нічні дзвінки, отримує їх назад
|
||||
* після того, як хтось правив у драбині назву;
|
||||
* * форма шле поле лише коли воно true — те саме, тільки помітно
|
||||
* стане в день, коли типове значення на сервері зміниться;
|
||||
* * форма відкриває наявну драбину з незаповненим станом — галочка
|
||||
* не стоїть, людина зберігає, тиша зникає.
|
||||
*
|
||||
* Ми вже наступали на це з `enabled`, коли форма поля не слала, а
|
||||
* сервер домислював true. Тому перевіряється не «галочка є», а те, ЩО
|
||||
* САМЕ їде в тілі запиту — обома значеннями.
|
||||
*
|
||||
* Форма підставних відповідей звірена з
|
||||
* `server/internal/httpapi/alerts.go` (`handleListEscalationPolicies`,
|
||||
* `handleSaveEscalationPolicy`) і зі `store.EscalationPolicy`.
|
||||
*/
|
||||
|
||||
const duty = {
|
||||
id: 'ch-duty',
|
||||
kind: 'telegram',
|
||||
name: 'Черговий',
|
||||
config: {},
|
||||
min_severity: 'warning',
|
||||
enabled: true,
|
||||
has_secret: true,
|
||||
}
|
||||
|
||||
/** Драбина, якій нічну тишу ввімкнули свідомо. */
|
||||
const quiet = {
|
||||
id: 'p-quiet',
|
||||
name: 'Денна зміна',
|
||||
description: '',
|
||||
steps: [{ after_min: 15, channel_ids: ['ch-duty'] }],
|
||||
repeat_after_min: 0,
|
||||
max_repeats: 0,
|
||||
respect_quiet_hours: true,
|
||||
rule_count: 1,
|
||||
}
|
||||
|
||||
/** Драбина зі звичною поведінкою: пробивається крізь тиху годину. */
|
||||
const loud = {
|
||||
...quiet,
|
||||
id: 'p-loud',
|
||||
name: 'Нічне чергування',
|
||||
respect_quiet_hours: false,
|
||||
}
|
||||
|
||||
/** Тіло PUT/POST у тому вигляді, у якому його бачить сервер. */
|
||||
type SavedBody = { name: string; respect_quiet_hours?: boolean }
|
||||
|
||||
function saved(calls: Call[]): SavedBody {
|
||||
const c = calls.find((x) => x.method === 'PUT' || x.method === 'POST')
|
||||
if (!c) throw new Error('форма не надіслала збереження')
|
||||
return c.body as SavedBody
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('WebSocket', class {})
|
||||
session.set('tok', {
|
||||
userID: 'u-me',
|
||||
username: 'me',
|
||||
tenantID: 't-1',
|
||||
permissions: ['alerts:read', 'alerts:write'],
|
||||
})
|
||||
})
|
||||
|
||||
describe('форма драбини надсилає прапорець явно', () => {
|
||||
it('нова драбина їде з false, навіть якщо галочки не торкались', async () => {
|
||||
const srv = fetchRouter({
|
||||
'GET /api/v1/escalation-policies': { policies: [] },
|
||||
'GET /api/v1/channels': { channels: [duty] },
|
||||
'POST /api/v1/escalation-policies': () => res(200, { id: 'p-new' }),
|
||||
})
|
||||
|
||||
render(<EscalationsPage />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '+ Драбина' }))
|
||||
await screen.findByText('Нова драбина')
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Нічне чергування/), {
|
||||
target: { value: 'Нова' },
|
||||
})
|
||||
fireEvent.click(screen.getAllByRole('checkbox')[0]) // канал сходинці
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Створити' }))
|
||||
|
||||
await waitFor(() => expect(saved(srv.calls)).toBeTruthy())
|
||||
// Саме false, а не undefined: сервер домислить відсутнє поле сам, і
|
||||
// сьогодні домислить так само — але тоді форма означала б не те, що
|
||||
// показує, і залежала б від чужого типового значення.
|
||||
expect(saved(srv.calls).respect_quiet_hours).toBe(false)
|
||||
})
|
||||
|
||||
it('увімкнена галочка їде з true', async () => {
|
||||
const srv = fetchRouter({
|
||||
'GET /api/v1/escalation-policies': { policies: [loud] },
|
||||
'GET /api/v1/channels': { channels: [duty] },
|
||||
'PUT /api/v1/escalation-policies/p-loud': () => res(200, { id: 'p-loud' }),
|
||||
})
|
||||
|
||||
render(<EscalationsPage />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Змінити' }))
|
||||
await screen.findByText('Драбина: Нічне чергування')
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /тихої години/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Зберегти' }))
|
||||
|
||||
await waitFor(() => expect(saved(srv.calls)).toBeTruthy())
|
||||
expect(saved(srv.calls).respect_quiet_hours).toBe(true)
|
||||
})
|
||||
|
||||
it('редагування чужого поля не роззброює вже ввімкнену тишу', async () => {
|
||||
const srv = fetchRouter({
|
||||
'GET /api/v1/escalation-policies': { policies: [quiet] },
|
||||
'GET /api/v1/channels': { channels: [duty] },
|
||||
'PUT /api/v1/escalation-policies/p-quiet': () => res(200, { id: 'p-quiet' }),
|
||||
})
|
||||
|
||||
render(<EscalationsPage />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Змінити' }))
|
||||
await screen.findByText('Драбина: Денна зміна')
|
||||
|
||||
// Галочка має стояти ще до того, як людина щось натисне: інакше
|
||||
// збереження мовчки поверне нічні дзвінки.
|
||||
expect((screen.getByRole('checkbox', { name: /тихої години/ }) as HTMLInputElement).checked).toBe(
|
||||
true,
|
||||
)
|
||||
|
||||
// Правимо те, що до тихої години стосунку не має.
|
||||
fireEvent.change(screen.getByPlaceholderText(/Нічне чергування/), {
|
||||
target: { value: 'Денна зміна (2)' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Зберегти' }))
|
||||
|
||||
await waitFor(() => expect(saved(srv.calls)).toBeTruthy())
|
||||
expect(saved(srv.calls).name).toBe('Денна зміна (2)')
|
||||
expect(saved(srv.calls).respect_quiet_hours).toBe(true)
|
||||
})
|
||||
|
||||
it('форма попереджає, що disaster проходить попри перемикач', async () => {
|
||||
fetchRouter({
|
||||
'GET /api/v1/escalation-policies': { policies: [] },
|
||||
'GET /api/v1/channels': { channels: [duty] },
|
||||
})
|
||||
|
||||
render(<EscalationsPage />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '+ Драбина' }))
|
||||
await screen.findByText('Нова драбина')
|
||||
|
||||
// Без цього рядка людина вважає, що вмикає повну тишу, — і дізнається
|
||||
// протилежне з дзвінка.
|
||||
expect(screen.getByText('disaster')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('перелік драбин', () => {
|
||||
it('показує обидва стани словами, не відкриваючи кожну', async () => {
|
||||
fetchRouter({
|
||||
'GET /api/v1/escalation-policies': { policies: [quiet, loud] },
|
||||
'GET /api/v1/channels': { channels: [duty] },
|
||||
})
|
||||
|
||||
render(<EscalationsPage />)
|
||||
|
||||
await screen.findByText('Денна зміна')
|
||||
expect(screen.getByText('відкладає сходинки')).toBeTruthy()
|
||||
// Прочерк тут читався б як «не налаштовано», а це найгучніший стан.
|
||||
expect(screen.getByText('пробивається')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('драбина, збережена до появи поля, читається як «пробивається»', async () => {
|
||||
const old = { ...loud, id: 'p-old', name: 'Стара' }
|
||||
delete (old as { respect_quiet_hours?: boolean }).respect_quiet_hours
|
||||
|
||||
fetchRouter({
|
||||
'GET /api/v1/escalation-policies': { policies: [old] },
|
||||
'GET /api/v1/channels': { channels: [duty] },
|
||||
})
|
||||
|
||||
render(<EscalationsPage />)
|
||||
|
||||
await screen.findByText('Стара')
|
||||
expect(screen.getByText('пробивається')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
@ -816,6 +816,18 @@ export interface EscalationPolicy {
|
|||
/** 0 — не повторювати драбину. */
|
||||
repeat_after_min: number
|
||||
max_repeats: number
|
||||
/**
|
||||
* Чи відкладати сходинки на час тихої години правила.
|
||||
*
|
||||
* false (типово) — драбина пробивається крізь тиху годину: тиха
|
||||
* година стримує ПЕРШЕ сповіщення, а драбина йде саме тоді, коли на
|
||||
* перше ніхто не відповів. true — «цих людей зараз немає на зміні»,
|
||||
* і сходинка чекає до кінця тихої години.
|
||||
*
|
||||
* Не обов'язкове поле: сервер вважає відсутнє за false, і драбини,
|
||||
* збережені до появи прапорця, читаються без нього.
|
||||
*/
|
||||
respect_quiet_hours?: boolean
|
||||
/** Скільки тригерів на неї посилаються — щоб видалення не було тихим. */
|
||||
rule_count: number
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue