Кнопка під сповіщенням відповідала «ваш Telegram не прив'язано» і не давала виходу. У базі нуль прив'язок і нуль кодів за весь час. Сторінка профілю існує, але пункту меню не мала, а єдиний вхід — ім'я користувача в шапці — малювався за умовою «є ім'я або пошта», тоді як /me віддавало лише пошту. В облікового запису власника, який заводить установник і який входить ІМЕНЕМ, вона порожня. Тобто в типовій інсталяції входу в профіль не було взагалі. * /me віддає username (тип Me на фронтенді його вже вимагав); * вхід у профіль малюється завжди для людини; * пункт меню «Обліковий запис → Мій профіль», perm став необов'язковим; * текст бота називає те, що видно на екрані; * сторінка каналів показує стан прив'язки біля telegram-каналу. Плюс 0073: оренда сходинки ескалації отримала lease_token. Партія переростає 2-хвилинну оренду, і другий інстанс доставляв ту саму сходинку паралельно з першим. Тепер запис проходить лише за збігу токена; при розбіжності не відбувається нічого, сходинка лишається належною. 65 міграцій, усе зелене проти справжньої бази. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
921 lines
48 KiB
Go
921 lines
48 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Політики
|
||
// ---------------------------------------------------------------------
|
||
|
||
// EscalationStep — одна сходинка драбини.
|
||
//
|
||
// AfterMin рахується від ПОЧАТКУ алерту, а не від попередньої сходинки.
|
||
// Людина проектує чергування абсолютними числами («через 15 хвилин —
|
||
// другий інженер, через 45 — керівник»), і відносні проміжки змушували б
|
||
// перераховувати всю драбину щоразу, коли посередині додається сходинка.
|
||
type EscalationStep struct {
|
||
AfterMin int `json:"after_min"`
|
||
ChannelIDs []string `json:"channel_ids"`
|
||
}
|
||
|
||
// EscalationPolicy — драбина цілком.
|
||
type EscalationPolicy struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description,omitempty"`
|
||
Steps []EscalationStep `json:"steps"`
|
||
// Через скільки хвилин після вичерпання драбини почати її спочатку.
|
||
// 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"`
|
||
}
|
||
|
||
// Стелі драбини. Живуть тут, а не в HTTP-шарі, бо ту саму перевірку
|
||
// робить і збереження політики, і CHECK у 0066: три різні числа в трьох
|
||
// місцях розходяться на першій же правці.
|
||
const (
|
||
MaxEscalationSteps = 10
|
||
MaxEscalationRepeats = 10
|
||
// Доба — стеля проміжку сходинки. Більше означає «розбудити
|
||
// завтра», а це вже не ескалація, а нагадування.
|
||
MaxEscalationAfterMin = 24 * 60
|
||
)
|
||
|
||
// ValidateEscalationSteps відмовляє в драбині, яка не робитиме того, що
|
||
// про неї думає людина.
|
||
//
|
||
// Головна перевірка тут — перша сходинка НЕ на нульовій хвилині.
|
||
// Сходинка «через 0 хвилин» пішла б одночасно зі звичайним сповіщенням
|
||
// про той самий алерт: людина отримала б два однакові повідомлення й
|
||
// вирішила, що система заїкається. Ескалація починається там, де
|
||
// закінчилось мовчання, тобто строго пізніше.
|
||
func ValidateEscalationSteps(steps []EscalationStep) error {
|
||
if len(steps) == 0 {
|
||
return fmt.Errorf("%w: драбина без жодної сходинки нікого не розбудить — "+
|
||
"додайте принаймні одну", ErrInvalid)
|
||
}
|
||
if len(steps) > MaxEscalationSteps {
|
||
return fmt.Errorf("%w: сходинок не більше %d: довша драбина — це вже розсилка "+
|
||
"на всю компанію з інтервалом", ErrInvalid, MaxEscalationSteps)
|
||
}
|
||
prev := 0
|
||
for i, s := range steps {
|
||
if s.AfterMin <= 0 {
|
||
return fmt.Errorf("%w: сходинка %d стоїть на %d хв — перша ескалація має бути "+
|
||
"пізніше за саме сповіщення, інакше вона його просто продублює",
|
||
ErrInvalid, i+1, s.AfterMin)
|
||
}
|
||
if s.AfterMin > MaxEscalationAfterMin {
|
||
return fmt.Errorf("%w: сходинка %d аж через %d хв — це вже нагадування, "+
|
||
"а не ескалація (стеля %d хв)", ErrInvalid, i+1, s.AfterMin, MaxEscalationAfterMin)
|
||
}
|
||
if i > 0 && s.AfterMin <= prev {
|
||
return fmt.Errorf("%w: сходинка %d (%d хв) не пізніша за попередню (%d хв) — "+
|
||
"драбина має вести вгору", ErrInvalid, i+1, s.AfterMin, prev)
|
||
}
|
||
if len(s.ChannelIDs) == 0 {
|
||
return fmt.Errorf("%w: сходинка %d не має жодного каналу — вона мовчатиме, "+
|
||
"а драбина рахуватиме її пройденою", ErrInvalid, i+1)
|
||
}
|
||
prev = s.AfterMin
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ValidateStepChannels відмовляє в драбині, сходинка якої посилається на
|
||
// канал, якого в цьому кабінеті немає.
|
||
//
|
||
// Перевірка окремо від ValidateEscalationSteps, бо вона єдина потребує
|
||
// бази: решта драбини перевіряється як текст, а «чи є такий канал» —
|
||
// лише запитом. Розділення дозволяє тримати першу половину чистою й
|
||
// перевіреною без Postgres.
|
||
//
|
||
// Ціна відсутності цієї перевірки — рівно та сама мовчазна обіцянка,
|
||
// заради якої й написана вся 0066. Сходинка з неіснуючим UUID
|
||
// виглядає в переліку налаштованою, движок не знаходить для неї жодного
|
||
// каналу, списує її й пише в журнал «нікуди не пішло» — але читає той
|
||
// журнал уже той, хто прийшов розбиратися вранці, а не той, кого мали
|
||
// розбудити вночі.
|
||
//
|
||
// known — ідентифікатори каналів ЦЬОГО кабінету. Саме тому перевірка
|
||
// закриває й підстановку чужого UUID: перелік читається під RLS у тій
|
||
// самій транзакції, що й запис.
|
||
func ValidateStepChannels(steps []EscalationStep, known map[string]bool) error {
|
||
for i, s := range steps {
|
||
for _, id := range s.ChannelIDs {
|
||
if known[id] {
|
||
continue
|
||
}
|
||
// Ідентифікатор у тексті лишаємо навмисно: у формі канали
|
||
// обираються галочками, тож людина, яка це побачила,
|
||
// надсилає драбину не з форми — і їй потрібно знати, який
|
||
// саме рядок не прийнято.
|
||
return fmt.Errorf("%w: сходинка %d посилається на канал %s, якого немає в цьому "+
|
||
"кабінеті — оберіть канал зі списку на сторінці «Канали»", ErrInvalid, i+1, id)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// tenantChannelIDs — ідентифікатори каналів кабінету, як їх бачить ця
|
||
// транзакція.
|
||
//
|
||
// Читається саме в транзакції запису, а не окремим викликом до неї:
|
||
// інакше між перевіркою й записом лишалась би щілина, в якій канал
|
||
// встигає зникнути.
|
||
func tenantChannelIDs(ctx context.Context, tx pgx.Tx, tenantID string) (map[string]bool, error) {
|
||
rows, err := tx.Query(ctx,
|
||
`SELECT id::text FROM alr.channels WHERE tenant_id = $1`, tenantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
known := map[string]bool{}
|
||
for rows.Next() {
|
||
var id string
|
||
if err := rows.Scan(&id); err != nil {
|
||
return nil, err
|
||
}
|
||
known[id] = true
|
||
}
|
||
return known, rows.Err()
|
||
}
|
||
|
||
// ListEscalationPolicies читає політики кабінету.
|
||
func (s *Store) ListEscalationPolicies(ctx context.Context, tenantID string) ([]EscalationPolicy, error) {
|
||
var out []EscalationPolicy
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
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
|
||
ORDER BY p.name
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var p EscalationPolicy
|
||
var steps string
|
||
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &steps,
|
||
&p.RepeatAfterMin, &p.MaxRepeats, &p.RespectQuietHours,
|
||
&p.RuleCount); err != nil {
|
||
return err
|
||
}
|
||
if err := json.Unmarshal([]byte(steps), &p.Steps); err != nil {
|
||
return fmt.Errorf("політика %s: сходинки: %w", p.Name, err)
|
||
}
|
||
if p.Steps == nil {
|
||
p.Steps = []EscalationStep{}
|
||
}
|
||
out = append(out, p)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// SaveEscalationPolicy створює або замінює політику цілком.
|
||
//
|
||
// Цілком, а не полями: форма показує повну драбину, і часткове
|
||
// оновлення дало б комбінацію сходинок, якої людина не бачила.
|
||
func (s *Store) SaveEscalationPolicy(ctx context.Context, tenantID, id string, p EscalationPolicy) (string, error) {
|
||
steps, err := json.Marshal(p.Steps)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
var repeat any
|
||
if p.RepeatAfterMin > 0 {
|
||
repeat = p.RepeatAfterMin
|
||
}
|
||
|
||
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
// Обидві перевірки тут, а не лише в HTTP: драбина — це список
|
||
// людей, яких будять уночі, і єдине місце, де він може бути
|
||
// перевірений раз і назавжди, — це запис у базу. Перевірка,
|
||
// продубльована в кожному обробнику, розходиться на першому ж
|
||
// новому шляху запису (імпорт, шаблон, API-токен).
|
||
if err := ValidateEscalationSteps(p.Steps); err != nil {
|
||
return err
|
||
}
|
||
known, err := tenantChannelIDs(ctx, tx, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := ValidateStepChannels(p.Steps, known); err != nil {
|
||
return err
|
||
}
|
||
if id == "" {
|
||
return tx.QueryRow(ctx, `
|
||
INSERT INTO alr.escalation_policies
|
||
(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, 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,
|
||
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, p.RespectQuietHours)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if ct.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
return id, err
|
||
}
|
||
|
||
// DeleteEscalationPolicy прибирає політику.
|
||
//
|
||
// Правила, що на неї посилались, лишаються без ескалації (ON DELETE SET
|
||
// NULL у 0066), а живі драбини зупиняє движок із причиною «політику
|
||
// видалено». Мовчазного продовження за старою копією немає навмисно:
|
||
// драбина, якої вже немає у формі, але яка ще будить людей, — найгірший
|
||
// із можливих станів.
|
||
func (s *Store) DeleteEscalationPolicy(ctx context.Context, tenantID, id string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
ct, err := tx.Exec(ctx,
|
||
`DELETE FROM alr.escalation_policies WHERE tenant_id = $1 AND id = $2`, tenantID, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if ct.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Взведення драбини
|
||
// ---------------------------------------------------------------------
|
||
|
||
// EscalationGrace — запас до жорсткої стелі життя драбини.
|
||
//
|
||
// Стеля потрібна через заглушення: заглушена сходинка не витрачається,
|
||
// а відкладається (див. PlanEscalation), і без стелі відкладання ходило
|
||
// б по колу місяцями на алерті, який ніхто не закриє. Доба запасу
|
||
// означає «драбину, яку цілу добу не давали пройти, вже нема сенсу
|
||
// проходити»: за добу або аварію розібрали, або вона перестала бути
|
||
// новиною.
|
||
const EscalationGrace = 24 * time.Hour
|
||
|
||
// ArmEscalation ставить драбину на бойовий звід.
|
||
//
|
||
// ON CONFLICT DO NOTHING — і це не оптимізація, а вимога. Повторна
|
||
// доставка того самого алерту трапляється (ретрай, другий інстанс,
|
||
// перезапуск між надсиланням і записом), і кожна з них інакше
|
||
// перезапускала б драбину з нуля: алерт висів би годинами, а «наступного»
|
||
// будили б щоп'ятнадцять хвилин заново.
|
||
func (s *Store) ArmEscalation(ctx context.Context, tenantID, alertID, policyID string,
|
||
isEvent bool, p EscalationPolicy, firstNotified time.Time) error {
|
||
|
||
if len(p.Steps) == 0 {
|
||
return nil
|
||
}
|
||
// Відлік — від першого сповіщення, а не від початку алерту: див.
|
||
// armEscalation. Для алерту, з якого щойно зняли заглушення, це
|
||
// різниця між «драбина попереду» і «драбина протухла ще у вікні
|
||
// обслуговування й висиплеться одним залпом».
|
||
passStart := firstNotified
|
||
next := passStart.Add(time.Duration(p.Steps[0].AfterMin) * time.Minute)
|
||
deadline := passStart.Add(escalationSpan(p, isEvent)).Add(EscalationGrace)
|
||
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO alr.alert_escalations
|
||
(alert_id, tenant_id, policy_id, is_event, step_idx, repeat_idx,
|
||
pass_start, next_at, deadline)
|
||
VALUES ($1, $2, $3, $4, 0, 0, $5, $6, $7)
|
||
ON CONFLICT (alert_id) DO NOTHING
|
||
`, alertID, tenantID, policyID, isEvent, passStart, next, deadline)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// 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 {
|
||
return 0
|
||
}
|
||
span := time.Duration(p.Steps[len(p.Steps)-1].AfterMin) * time.Minute
|
||
if !isEvent && p.RepeatAfterMin > 0 && p.MaxRepeats > 0 {
|
||
pass := time.Duration(p.RepeatAfterMin) * time.Minute
|
||
span += time.Duration(p.MaxRepeats) * (pass + span)
|
||
}
|
||
return span
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Рішення про одну сходинку
|
||
// ---------------------------------------------------------------------
|
||
|
||
// EscalationSnapshot — усе, що потрібно, щоб вирішити долю однієї
|
||
// сходинки. Читається з бази одним запитом і в один момент часу: стан
|
||
// алерту, прочитаний окремо від стану драбини, встиг би застаріти рівно
|
||
// між двома запитами — тобто саме тоді, коли алерт підтвердили.
|
||
type EscalationSnapshot struct {
|
||
AlertID string
|
||
TenantID string
|
||
PolicyID string
|
||
PolicyName string
|
||
IsEvent bool
|
||
|
||
StepIdx int
|
||
RepeatIdx int
|
||
PassStart time.Time
|
||
Deadline time.Time
|
||
|
||
// Токен оренди, під якою цю сходинку взято (0073).
|
||
//
|
||
// Не інформація, а перепустка: ApplyEscalation запише рішення лише
|
||
// тоді, коли в рядку лежить рівно цей токен. Знімок, чия оренда
|
||
// встигла спливти й дістатись іншому взяттю, стає недійсним — і
|
||
// саме тому обробка партії, довша за EscalationLease, більше не
|
||
// коштує другого дзвінка о третій ночі.
|
||
LeaseToken string
|
||
|
||
Steps []EscalationStep
|
||
RepeatAfterMin int
|
||
MaxRepeats int
|
||
|
||
// Тиха година правила, за яким піднято алерт, і чи зважає на неї ця
|
||
// драбина. Читаються разом зі сходинкою, бо рішення «дзвонити чи
|
||
// відкласти» ухвалюється в момент сходинки, а не в момент взведення:
|
||
// розклад могли переписати за ті години, що драбина йшла.
|
||
RespectQuietHours bool
|
||
Schedule *RouteSchedule
|
||
|
||
// Стан алерту на момент читання. Порожньо — алерту вже немає.
|
||
AlertState string
|
||
// Алерт у вигляді, придатному для тексту повідомлення.
|
||
Alert Alert
|
||
}
|
||
|
||
// EscalationAction — що робити з цією сходинкою.
|
||
type EscalationAction int
|
||
|
||
const (
|
||
// EscFire — доставити сходинку.
|
||
EscFire EscalationAction = iota
|
||
// EscDefer — не доставляти й не витрачати: перевірити пізніше.
|
||
EscDefer
|
||
// EscStop — драбина закінчилась, доставляти нічого.
|
||
EscStop
|
||
)
|
||
|
||
// EscalationDecision — рішення разом із новим станом драбини.
|
||
type EscalationDecision struct {
|
||
Action EscalationAction
|
||
// Код для журналу: sent | done | acked | closed | suppressed |
|
||
// deadline | no_policy.
|
||
Outcome string
|
||
Detail string
|
||
|
||
// Яку саме сходинку доставляємо (для EscFire).
|
||
StepIdx int
|
||
RepeatIdx int
|
||
ChannelIDs []string
|
||
|
||
// Новий стан. NextAt == nil означає «драбину зупинено».
|
||
NextStepIdx int
|
||
NextRepeatIdx int
|
||
NextPassStart time.Time
|
||
NextAt *time.Time
|
||
}
|
||
|
||
// EscalationRecheck — через скільки перевірити відкладену драбину.
|
||
//
|
||
// Порівняно з тіком движка це довго й навмисно: заглушений алерт не
|
||
// потребує уваги щопівхвилини, а кожна перевірка — це рядок у журналі
|
||
// сходинок.
|
||
const EscalationRecheck = 5 * time.Minute
|
||
|
||
// PlanEscalation вирішує долю однієї сходинки.
|
||
//
|
||
// Функція чиста, і це головне архітектурне рішення в усій ескалації.
|
||
// Причина проста: половина роботи ескалації — НЕ будити. «Сходинка
|
||
// спрацювала» перевіряється легко й доводить мало; «сходинка не
|
||
// спрацювала, бо алерт підтвердили / закрили / хост заглушено / вікно
|
||
// обслуговування / драбина протухла» — це п'ять різних гілок, кожна з
|
||
// яких коштує чийогось сну, і перевіряти їх треба без бази.
|
||
//
|
||
// Перевірка стану робиться ПЕРЕД КОЖНОЮ сходинкою, а не один раз на
|
||
// початку. Інакше драбина, взведена о 02:40, о 03:10 будила б людину
|
||
// через алерт, закритий о 02:45, — тобто ескалація воскрешала б мертве.
|
||
func PlanEscalation(s EscalationSnapshot, now time.Time) EscalationDecision {
|
||
stop := func(outcome, detail string) EscalationDecision {
|
||
return EscalationDecision{
|
||
Action: EscStop, Outcome: outcome, Detail: detail,
|
||
StepIdx: s.StepIdx, RepeatIdx: s.RepeatIdx,
|
||
NextStepIdx: s.StepIdx, NextRepeatIdx: s.RepeatIdx,
|
||
NextPassStart: s.PassStart,
|
||
}
|
||
}
|
||
|
||
switch s.AlertState {
|
||
case "", "resolved", "expired":
|
||
// Закритий алерт ескалації не потребує за визначенням. Окремо
|
||
// від 'acknowledged', бо це різні історії: тут проблеми більше
|
||
// немає, там нею зайнялись.
|
||
return stop("closed", "алерт закрито — далі будити нікого")
|
||
case "acknowledged":
|
||
// Рівно те, заради чого існує кнопка «Прийняти»: ack не гасить
|
||
// проблему, він зупиняє драбину. Без цього людина, яка вже
|
||
// дивиться на аварію, за 15 хвилин отримала б дзвінок від
|
||
// керівника про те, що вона й так чинить.
|
||
return stop("acked", "алерт підтверджено — драбину зупинено")
|
||
}
|
||
|
||
if len(s.Steps) == 0 || s.StepIdx < 0 || s.StepIdx >= len(s.Steps) {
|
||
// Політику видалили або переписали коротшою, поки драбина йшла.
|
||
// Мовчки добивати за старою копією не можна: драбини, якої вже
|
||
// немає у формі, ніхто не знайде, коли питатиме «звідки дзвінок».
|
||
return stop("no_policy", "політику ескалації видалено або скорочено")
|
||
}
|
||
|
||
if !now.Before(s.Deadline) {
|
||
return stop("deadline", "драбина протухла — стелю життя вичерпано")
|
||
}
|
||
|
||
if s.AlertState == "suppressed" {
|
||
// Вікно обслуговування й ручне заглушення зупиняють ескалацію
|
||
// так само, як звичайне сповіщення: обидва означають «не
|
||
// турбувати», і драбина не має бути винятком.
|
||
//
|
||
// Але сходинка при цьому НЕ витрачається. Різниця принципова:
|
||
// заглушення — це «не зараз», а не «проблеми немає». Списана
|
||
// сходинка означала б, що півгодинне вікно обслуговування тихо
|
||
// роззброює драбину до кінця життя алерту — тобто рівно та
|
||
// мовчазна відмова, від якої ескалація й рятує. Тому чекаємо,
|
||
// а стелю життя (Deadline) поставлено саме для того, щоб це
|
||
// чекання колись закінчилось.
|
||
next := now.Add(EscalationRecheck)
|
||
if !next.Before(s.Deadline) {
|
||
return stop("deadline", "заглушення пережило стелю життя драбини")
|
||
}
|
||
return EscalationDecision{
|
||
Action: EscDefer, Outcome: "suppressed",
|
||
Detail: "придушено (" + s.Alert.SuppressedBy + ") — сходинку відкладено",
|
||
StepIdx: s.StepIdx,
|
||
RepeatIdx: s.RepeatIdx,
|
||
NextStepIdx: s.StepIdx,
|
||
NextRepeatIdx: s.RepeatIdx,
|
||
NextPassStart: s.PassStart,
|
||
NextAt: &next,
|
||
}
|
||
}
|
||
|
||
if s.AlertState != "firing" {
|
||
// Невідомий стан. Мовчати безпечніше, ніж будити за здогадкою.
|
||
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",
|
||
StepIdx: s.StepIdx,
|
||
RepeatIdx: s.RepeatIdx,
|
||
ChannelIDs: s.Steps[s.StepIdx].ChannelIDs,
|
||
NextPassStart: s.PassStart,
|
||
}
|
||
|
||
// Є наступна сходинка в цьому проході.
|
||
if s.StepIdx+1 < len(s.Steps) {
|
||
cur := s.Steps[s.StepIdx].AfterMin
|
||
nxt := s.Steps[s.StepIdx+1].AfterMin
|
||
at := s.PassStart.Add(time.Duration(nxt) * time.Minute)
|
||
// Якщо сходинка спізнилась (процес стояв, драбина чекала кінця
|
||
// вікна обслуговування), наступна не має спрацювати негайно
|
||
// слідом: інакше після паузи вся драбина висиплеться в одну
|
||
// хвилину й розбудить одразу всіх.
|
||
gap := nxt - cur
|
||
if gap < 1 {
|
||
gap = 1
|
||
}
|
||
if floor := now.Add(time.Duration(gap) * time.Minute); at.Before(floor) {
|
||
at = floor
|
||
}
|
||
d.NextStepIdx = s.StepIdx + 1
|
||
d.NextRepeatIdx = s.RepeatIdx
|
||
d.NextAt = &at
|
||
return d
|
||
}
|
||
|
||
// Драбина вичерпана. Повтор — це ставка на те, що проблема ще
|
||
// триває, і зробити її можна лише там, де існування алерту саме по
|
||
// собі є доказом: метричний алерт живий рівно доти, доки виконується
|
||
// умова, і зникає сам, щойно вона перестала.
|
||
//
|
||
// Подієвий алерт (0058) такого доказу не дає. Рядок журналу стався
|
||
// один раз і «перестати ставатись» не може: алерт висить, поки його
|
||
// не закриє людина або строк auto_close. Повторювати за ним драбину
|
||
// означало б будити всю зміну по колу через один нічний блимок
|
||
// порту, який давно припинився. Тому подієвий алерт проходить
|
||
// драбину рівно раз — і на цьому ескалація закінчується.
|
||
if !s.IsEvent && s.RepeatAfterMin > 0 && s.RepeatIdx+1 <= s.MaxRepeats {
|
||
at := now.Add(time.Duration(s.RepeatAfterMin) * time.Minute)
|
||
if !at.Before(s.Deadline) {
|
||
d.Outcome = "done"
|
||
d.Detail = "драбину пройдено; повтор не вміщається у стелю життя"
|
||
d.NextStepIdx, d.NextRepeatIdx = s.StepIdx, s.RepeatIdx
|
||
return d
|
||
}
|
||
d.NextStepIdx = 0
|
||
d.NextRepeatIdx = s.RepeatIdx + 1
|
||
// Відлік нового проходу зсуваємо так, щоб перша сходинка
|
||
// припала рівно на «через repeat_after_min», а не на
|
||
// «repeat_after_min + after_min першої сходинки».
|
||
d.NextPassStart = at.Add(-time.Duration(s.Steps[0].AfterMin) * time.Minute)
|
||
d.NextAt = &at
|
||
return d
|
||
}
|
||
|
||
d.Outcome = "done"
|
||
if s.IsEvent {
|
||
d.Detail = "драбину пройдено; подієвий алерт не повторюється"
|
||
} else {
|
||
d.Detail = "драбину пройдено повністю"
|
||
}
|
||
d.NextStepIdx, d.NextRepeatIdx = s.StepIdx, s.RepeatIdx
|
||
return d
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Читання й запис стану
|
||
// ---------------------------------------------------------------------
|
||
|
||
// EscalationLease — на скільки сходинка вважається взятою в роботу.
|
||
//
|
||
// Взяття, рішення й запис — три кроки, і між ними процес може впасти.
|
||
// Оренда закриває найгіршу з двох дір: доки вона не спливла, другий
|
||
// інстанс тієї самої сходинки не візьме, тож дубля не буде. Друга діра
|
||
// (падіння між записом і надсиланням) коштує однієї недоставленої
|
||
// сходинки — це той самий свідомий вибір, що вже зроблено для черги
|
||
// подієвих алертів у 0058: «спробували» не дорівнює «доставили», і
|
||
// краще не надіслати, ніж надіслати вдруге о третій ночі.
|
||
//
|
||
// Строк лишається коротким навмисно, хоч партія буває довшою за нього.
|
||
// Подовжити його «щоб вистачало» неможливо: сотня сходинок, кожна з
|
||
// власним мережевим таймаутом, переросте будь-яке число, а довга
|
||
// оренда робить гірше тому єдиному випадку, заради якого вона й
|
||
// існує, — процесу, що впав одразу після взяття. Розрив «партія довша
|
||
// за оренду» закриває не строк, а токен оренди (0073): володіння
|
||
// звіряється при записі, тож перебрана сходинка вже не подвоюється.
|
||
const EscalationLease = 2 * time.Minute
|
||
|
||
// TakeDueEscalations забирає сходинки, час яких настав.
|
||
//
|
||
// Наскрізно по всіх кабінетах і робочим пулом — так само, як черга
|
||
// подієвих алертів: движок один на інсталяцію й крутиться під
|
||
// advisory-блокуванням.
|
||
//
|
||
// Разом зі строком оренди рядок отримує ТОКЕН цього взяття
|
||
// (core.new_id() у SET обчислюється для кожного рядка окремо). Строк
|
||
// каже лише «зайнято до», і після його спливання рядок дістається
|
||
// іншому взяттю — а перше про це не дізнається й піде доставляти. Токен
|
||
// перетворює строк на володіння: ApplyEscalation запише рішення лише
|
||
// під тим токеном, що лежить у рядку зараз.
|
||
func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]EscalationSnapshot, error) {
|
||
if limit <= 0 || limit > 500 {
|
||
limit = 100
|
||
}
|
||
rows, err := s.bg.Query(ctx, `
|
||
WITH due AS (
|
||
SELECT alert_id FROM alr.alert_escalations
|
||
WHERE stopped_at IS NULL
|
||
AND next_at IS NOT NULL AND next_at <= now()
|
||
AND (leased_until IS NULL OR leased_until <= now())
|
||
ORDER BY next_at
|
||
LIMIT $1
|
||
FOR UPDATE SKIP LOCKED
|
||
), taken AS (
|
||
UPDATE alr.alert_escalations e
|
||
SET leased_until = now() + $2::interval,
|
||
lease_token = core.new_id()
|
||
FROM due d WHERE e.alert_id = d.alert_id
|
||
RETURNING e.alert_id, e.tenant_id, e.policy_id, e.is_event,
|
||
e.step_idx, e.repeat_idx, e.pass_start, e.deadline,
|
||
e.lease_token
|
||
)
|
||
SELECT t.alert_id::text, t.tenant_id::text, COALESCE(t.policy_id::text,''),
|
||
t.is_event, t.step_idx, t.repeat_idx, t.pass_start, t.deadline,
|
||
t.lease_token::text,
|
||
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,''),
|
||
COALESCE(a.rule_id::text,''), COALESCE(a.suppressed_by,''),
|
||
COALESCE(a.started_at, t.pass_start)
|
||
FROM taken t
|
||
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 {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []EscalationSnapshot
|
||
for rows.Next() {
|
||
var s EscalationSnapshot
|
||
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.LeaseToken,
|
||
&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 {
|
||
// Політика з нечитабельними сходинками не має валити чергу:
|
||
// решта драбин у кабінеті ні в чому не винна.
|
||
s.Steps = nil
|
||
}
|
||
s.Alert.ID = s.AlertID
|
||
s.Alert.TenantID = s.TenantID
|
||
s.Alert.State = s.AlertState
|
||
out = append(out, s)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ApplyEscalation просуває стан драбини й повертає, чи вдалось.
|
||
//
|
||
// Стан записується ДО надсилання — з тієї ж причини, що й у
|
||
// RecordNotification: падіння між записом і надсиланням лишає
|
||
// непройдену сходинку, а зворотний порядок лишав би драбину на місці, і
|
||
// після підйому вона надіслала б те саме вдруге. Краще не надіслати, ніж
|
||
// надіслати двічі о третій ночі.
|
||
//
|
||
// А от ЖУРНАЛ пишеться після доставки (LogEscalationStep), і це окреме
|
||
// рішення. Журнал існує рівно для відповіді на «чому мене розбудили» та
|
||
// «чому не розбудили», тож рядок «надіслано» на сходинці, якій не
|
||
// знайшлось жодного каналу, гірший за відсутній: він перетворює
|
||
// доказ на брехню. Ціна — рідкісний випадок падіння між просуванням і
|
||
// записом: сходинка лишиться без рядка. Це видно (step_idx більший за
|
||
// кількість рядків) і це чесно.
|
||
//
|
||
// ДВІ УМОВИ ПРО СТАН, і кожна закриває свій бік того самого розриву.
|
||
// Між тим, як сходинку взяли в чергу, і тим, як до неї дійшли руки,
|
||
// минають хвилини: партія обробляється послідовно, кожна доставка має
|
||
// власний таймаут. За цей час алерт міг перестати потребувати дзвінка.
|
||
//
|
||
// stopped_at IS NULL — людина натиснула «Прийняти» (або закрила алерт
|
||
// руками): ці шляхи ставлять stopped_at самі. Без умови UPDATE зняв би
|
||
// його й воскресив зупинену драбину — розбудив би саме того, хто
|
||
// щойно сказав «я цим займаюсь».
|
||
//
|
||
// оренда все ще НАША (lease_token, 0073) — третя умова, і вона про
|
||
// інше: не про алерт, а про право писати. Партія буває довшою за
|
||
// EscalationLease, і тоді рядок дістається наступному взяттю ще до
|
||
// того, як руки дійшли до нашої сходинки. Без звірки токена обидва
|
||
// доставили б ту саму сходинку, і в журналі стояло б два «надіслано»
|
||
// на одну — тобто саме той подвійний дзвінок о третій ночі, від якого
|
||
// весь цей порядок і побудовано. Токен звіряється завжди, а не лише
|
||
// для EscFire: запис зупинки чи відкладання від чужого імені так само
|
||
// зсунув би next_at під ногами того, хто зараз працює.
|
||
//
|
||
// алерт усе ще 'firing' (лише для сходинки, що має спрацювати) —
|
||
// решта шляхів гасіння рядка драбини НЕ чіпають: вимкнення чи
|
||
// видалення правила (resolveRuleAlerts), відновлення метрики
|
||
// (ResolveMissing), гасіння простроченого подієвого. Вони переводять
|
||
// алерт у 'resolved', і драбина зупиниться на НАСТУПНІЙ сходинці — а
|
||
// та, що вже в партії, без цієї умови подзвонить за погашеним
|
||
// алертом. Умова стоїть тут, а не в кожному з тих шляхів, саме тому
|
||
// що їх багато й з часом побільшає: перевіряти стан у момент дії
|
||
// надійніше, ніж пам'ятати про драбину в кожному новому місці.
|
||
//
|
||
// Якщо жодного рядка не оновлено, наступний такт візьме цю сходинку
|
||
// знову, PlanEscalation побачить 'resolved' і зупинить драбину штатно —
|
||
// тобто вона не застрягне. Те саме стосується й розбіжного токена:
|
||
// сходинка лишається належною (next_at не зрушено), її доводить до
|
||
// розв'язку той, чий токен зараз у рядку, а якщо не дійде й він —
|
||
// оренда спливе за EscalationLease, і сходинку переберуть заново.
|
||
func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d EscalationDecision) (bool, error) {
|
||
var next any
|
||
if d.NextAt != nil {
|
||
next = *d.NextAt
|
||
}
|
||
fired := d.Action == EscFire
|
||
|
||
applied := false
|
||
err := s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE alr.alert_escalations
|
||
SET step_idx = $2,
|
||
repeat_idx = $3,
|
||
pass_start = $4,
|
||
next_at = $5,
|
||
leased_until = NULL,
|
||
lease_token = NULL,
|
||
last_step_at = CASE WHEN $6 THEN now() ELSE last_step_at END,
|
||
stopped_at = CASE WHEN $5::timestamptz IS NULL THEN now() ELSE NULL END,
|
||
stop_reason = CASE WHEN $5::timestamptz IS NULL THEN $7 ELSE NULL END
|
||
WHERE alert_id = $1
|
||
AND stopped_at IS NULL
|
||
-- Знімок без токена (він міг лишитись від процесу, що
|
||
-- пережив накат 0073) до запису не допускається: NULL = NULL
|
||
-- хибне, тож рядків не буде. Це правильний бік помилки —
|
||
-- сходинку переберуть, а не подвоять.
|
||
AND lease_token = $8::uuid
|
||
AND (NOT $6 OR EXISTS (
|
||
SELECT 1 FROM alr.alerts a
|
||
WHERE a.id = alr.alert_escalations.alert_id
|
||
AND a.state = 'firing'))
|
||
`, snap.AlertID, d.NextStepIdx, d.NextRepeatIdx, d.NextPassStart,
|
||
next, fired, d.Outcome, nullUUID(snap.LeaseToken))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
applied = tag.RowsAffected() > 0
|
||
return nil
|
||
})
|
||
return applied, err
|
||
}
|
||
|
||
// LogEscalationStep записує в журнал те, що СПРАВДІ сталося зі сходинкою.
|
||
//
|
||
// Викликається після доставки, тому outcome тут може відрізнятись від
|
||
// того, що планувалось: сходинка, чиї канали видалили, вимкнули або
|
||
// підняли їм поріг серйозності, отримує 'no_channels', а не 'sent'.
|
||
// Саме заради цієї різниці журнал і винесено з ApplyEscalation.
|
||
func (s *Store) LogEscalationStep(ctx context.Context, snap EscalationSnapshot,
|
||
d EscalationDecision, outcome, detail string) error {
|
||
|
||
// Відкладання пишеться ОДИН раз на сходинку, а не щоперевірки.
|
||
//
|
||
// Відкладена сходинка (заглушення або тиха година) переглядається
|
||
// кожні EscalationRecheck, тобто близько 288 разів на добу, а драбина
|
||
// може чекати тижнями (стеля життя — до ~21 доби). Без цієї умови
|
||
// одна ніч під тихою годиною лишала б сотні однакових рядків, і
|
||
// журнал, у який заходять із питанням «чому мене не розбудили»,
|
||
// ставав би нечитабельним рівно тоді, коли він потрібен. Один рядок
|
||
// каже те саме: цю сходинку відкладено, і ось чому.
|
||
once := outcome == "suppressed" || outcome == "quiet_hours"
|
||
|
||
return s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error {
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO alr.escalation_steps
|
||
(tenant_id, alert_id, policy_id, step_idx, repeat_idx, outcome, detail)
|
||
SELECT $1, $2, $3, $4, $5, $6, $7
|
||
WHERE NOT $8 OR NOT EXISTS (
|
||
SELECT 1 FROM alr.escalation_steps
|
||
WHERE alert_id = $2 AND step_idx = $4
|
||
AND repeat_idx = $5 AND outcome = $6)
|
||
`, snap.TenantID, snap.AlertID, nullUUID(snap.PolicyID),
|
||
d.StepIdx, d.RepeatIdx, outcome, nullString(detail), once)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// AlertEscalation — стан драбини для картки алерту.
|
||
//
|
||
// Без цього ескалація перетворюється на невидиму магію: людину підняли
|
||
// о 03:10, а на екрані нічого не пояснює, звідки взявся дзвінок. Тут
|
||
// видно і те, скільки сходинок пройдено, і коли буде наступна, і — якщо
|
||
// драбину зупинено — чому саме.
|
||
type AlertEscalation struct {
|
||
PolicyName string `json:"policy_name"`
|
||
// Скільки сходинок цього проходу вже доставлено.
|
||
Step int `json:"step"`
|
||
// Скільки їх усього в драбині.
|
||
Total int `json:"total"`
|
||
Repeat int `json:"repeat"`
|
||
// Коли наступна. Порожньо — драбина зупинена або вичерпана.
|
||
NextAt *time.Time `json:"next_at,omitempty"`
|
||
StoppedAt *time.Time `json:"stopped_at,omitempty"`
|
||
StopReason string `json:"stop_reason,omitempty"`
|
||
}
|
||
|
||
// PurgeEscalationLog прибирає журнал сходинок за строком.
|
||
//
|
||
// Той самий строк, що й у alr.notifications (0007). Таблиця не
|
||
// гіпертаблиця й політики ретеншену TimescaleDB не має, тож прибирає
|
||
// її прибиральник алертів.
|
||
func (s *Store) PurgeEscalationLog(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||
tag, err := s.bg.Exec(ctx,
|
||
`DELETE FROM alr.escalation_steps WHERE ts < now() - $1::interval`,
|
||
olderThan.String())
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return tag.RowsAffected(), nil
|
||
}
|
||
|
||
// stopEscalationTx зупиняє драбину в межах уже відкритої транзакції.
|
||
//
|
||
// Викликається з підтвердження й ручного закриття алерту. Це НЕ той
|
||
// механізм, який гарантує зупинку: гарантує її перевірка стану перед
|
||
// кожною сходинкою в PlanEscalation, і саме там зупиняються драбини
|
||
// алертів, закритих шляхами, до яких цей код не дотягується (гасіння
|
||
// прострочених подієвих, закриття різницею множин, вимкнення правила).
|
||
// Тут — лише щоб картка алерту сказала «ескалацію зупинено» одразу, а
|
||
// не за півхвилини, коли настане час наступної сходинки.
|
||
func stopEscalationTx(ctx context.Context, tx pgx.Tx, tenantID, alertID, reason, detail string) error {
|
||
// Одним оператором, щоб журнал не міг розійтися зі станом: окремий
|
||
// SELECT після UPDATE записав би рядок і тоді, коли драбини вже не
|
||
// було, — тобто вигадав би подію.
|
||
_, err := tx.Exec(ctx, `
|
||
WITH stopped AS (
|
||
UPDATE alr.alert_escalations
|
||
SET next_at = NULL, leased_until = NULL, lease_token = NULL,
|
||
stopped_at = now(), stop_reason = $3
|
||
WHERE tenant_id = $1 AND alert_id = $2 AND stopped_at IS NULL
|
||
RETURNING policy_id, step_idx, repeat_idx
|
||
)
|
||
INSERT INTO alr.escalation_steps
|
||
(tenant_id, alert_id, policy_id, step_idx, repeat_idx, outcome, detail)
|
||
SELECT $1, $2, policy_id, step_idx, repeat_idx, $3, $4 FROM stopped
|
||
`, tenantID, alertID, reason, nullString(detail))
|
||
return err
|
||
}
|