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