diff --git a/HISTORY.md b/HISTORY.md index bc8c80b..5c7a756 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -682,3 +682,125 @@ HTTP (лише CLI), не можна змінити роль собі, не мо Сторінки керування командою в UI ще немає — є API й методи клієнта (`team`, `roles`, `addMember`, `setRole`, `removeMember`). Додати користувача поки можна лише CLI або запитом. + +--- + +## 2026-08-15 — Етап 9: алерти й сповіщення + +Система вміла малювати мапу, але мовчала, коли щось падало. Схема +(`alr.*`) лежала готовою з Етапу 1 і повністю порожньою. Тепер вона +працює. + +### Створено + +- `server/internal/store/alerts.go` — правила, обчислення умов + (icmp / interface / metric / no_data), побудова предикатів селектора. +- `server/internal/store/alerts_state.go` — життєвий цикл алерту, + придушення, граф топології й визначення першопричини. +- `server/internal/store/alerts_query.go` — списки, ack, mute, CRUD правил. +- `server/internal/store/alerts_channels.go` — канали, маршрути, тихі + години, журнал доставки. +- `server/internal/alerting/engine.go` — цикл обчислення під + advisory-блокуванням. +- `server/internal/alerting/notify.go` — Telegram, webhook, SMTP, + шаблони повідомлень, SSRF-захист. +- `server/internal/httpapi/alerts.go` — 12 ендпоїнтів. +- `web/src/hooks/useAlerts.ts`, `components/AlertsPanel.tsx`, шина подій + у `api/ws.ts`. +- 20 нових тестів (11 у `store`, 9 в `alerting`). + +### Прийняті рішення + +**Движок не має стану між тіками.** Вікно `for_seconds` — це запит по +часу до TSDB, а не лічильник у пам'яті. Тому перезапуск процесу нічого +не збиває, а два процеси дали б однаковий результат. Лічильник у +пам'яті довелося б і зберігати, і відновлювати, і синхронізувати між +екземплярами — три способи розійтися з реальністю замість нуля. + +**Дві семантики вікна.** Без `agg` умова має триматися **всі** виміри +вікна — це і є антифлап: одна втрачена відповідь не будить людину. З +`agg` порівнюється агрегат. Обидві потрібні: «недоступний три хвилини +поспіль» і «середнє завантаження за 5 хв вище 85%» — різні питання. + +**Список метрик і операторів закритий.** Значення з `condition` +потрапляє в текст запиту (агрегатну функцію не підставиш параметром), +тому все, що йде в SQL, проходить whitelist, а все інше — лише +параметром. Послаблення тут — це SQL-ін'єкція через JSON у таблиці +правил. + +**Кореляція за межею зони недоступності.** Причина аварії — той, у кого +лишився живий сусід; хто оточений мертвими — наслідок. Це те, заради +чого будувалась `topo.links`: інакше падіння маршрутизатора дає сорок +сповіщень, серед яких губиться єдине потрібне. Пристрій без зв'язків +завжди лишається причиною — топологія про нього нічого не знає, і +списати його на чужу аварію було б вигадкою. + +**Дедуплікація індексом, а не перевіркою в коді.** Частковий унікальний +індекс `alerts_active_dedup_uniq` робить другий алерт на ту саму +проблему неможливим навіть якщо два движки якимось чином працюють +одночасно. + +**Сповіщення шле лише щойно піднятий і не придушений алерт.** +Продовження вже відомої проблеми не є новиною. Перевірено прогоном: за +три тіки кількість сповіщень не зросла. + +**Тенант без маршрутів отримує все в усі придатні канали.** Підключили +Telegram — має працювати. Вимагати ще й маршрут означало б мовчати саме +там, де налаштування щойно зроблене й здається повним. + +**Тиха година не глушить `disaster`.** Сенс чергування в тому, щоб +його підняли. + +**SSRF-захист із виходом для self-hosted.** Адресу вебхука задає +користувач тенанта, а запит іде з сервера — у SaaS це класичний вектор. +Але в self-hosted вебхук майже завжди веде саме всередину, у +корпоративний Mattermost. Тому заборона знімається прапорцем процесу, +і рішення приймає адміністратор сервера, а не користувач тенанта. + +**Стеля ручного заглушення — тиждень.** Безстрокове «не турбувати» — +найпоширеніший спосіб тихо вимкнути моніторинг назавжди. + +### Знайдено роботою з живою системою (три справжні помилки) + +**Вимкнене або видалене правило лишало свої алерти висіти вічно.** +`alr.alerts.rule_id` має `ON DELETE SET NULL`, а вимкнене правило +випадає з вибірки движка — в обох випадках алерти ставали сиротами, +яких нікому закрити. Виправлено закриттям алертів у тій самій +транзакції, до видалення правила. + +**Перехід у «придушено» не публікував події.** Людина глушила пристрій, +движок за 10 секунд переводив алерт у `suppressed`, а UI про це не +дізнавався до перезавантаження сторінки. Причина: подія публікувалась +лише для нових і закритих алертів. Додано `alert.updated` і читання +попереднього стану через CTE у тому ж знімку, що й UPSERT — інакше +відрізнити «стан змінився» від «проблема триває» неможливо. + +**Кнопки дій на телефоні були 26 px.** Виміряно в мобільному вигляді: +для «Прийняти» і «Заглушити», які натискають пальцем уночі, це +промахи. Піднято до 38 px на вузьких екранах, на десктопі щільність +збережено. + +### Перевірено наживо + +Проти справжніх даних (2 пристрої, 240 ICMP-вимірів за 10 хв): + +- Правило з порогом 0.5 мс підняло алерт на `gateway` (0.98 мс) і **не** + підняло на `snmp-host` (0.08 мс) — поріг рівно посередині. +- `no_data` і «помилки на порту» мовчали, як і мали. +- Вебхук дійшов: 5 доставок на 5 подій, жодного повтору за багато тіків. +- Заглушення пристрою придушило рівно його алерт, сусідній лишився + активним. +- Вимкнення правила закрило його алерти й не зачепило чужий `ack`. +- Глядач отримує 403 на ack і 200 на читання. +- У браузері: індикатор пульсує червоним, панель відкривається, ack + оновлює список наживо, придушений алерт іде під окремий фільтр. +- Мобільний вигляд 375 px: панель на весь екран, горизонтального скролу + немає. + +### Чого свідомо не робив + +Ескалацій (`alr.escalation_policies`) і повторних сповіщень немає — +алерт сповіщає один раз. Кнопки Ack/Mute у Telegram відмальовуються, але +приймача `callback_data` не написано. Web Push відкладено до Етапу 10 +разом із PWA. Правила з джерел `syslog`/`trap`/`ncm` движок пропускає: +вони обробляються подіями, а не опитуванням. diff --git a/ROADMAP.md b/ROADMAP.md index 7636fd7..7234a9b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,12 +22,12 @@ API віддає готове полотно з живими статусами, |------------|-------|------------| | Мапа, топологія, телеметрія | ✅ | ✅ | | Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ | -| **Користувачі, ролі, вхід** | ✅ | ❌ | +| **Користувачі, ролі, вхід** | ✅ | ✅ | | **Шаблони опитування** | ❌ | ❌ | | **NCM (збір конфігів)** | ✅ | ⚠️ half | | **Керування зондом із UI** | ✅ | ⚠️ транспорт є | -| **Алерти й сповіщення** | ✅ | ❌ | -| **Мобільна адаптивність, PWA** | — | ❌ | +| **Алерти й сповіщення** | ✅ | ✅ | +| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає | | Дашборди, NOC TV | ✅ | ❌ | | Білінг, ліцензії | ✅ | ❌ | @@ -207,7 +207,17 @@ DELETE /api/v1/agents/{id} --- -## Етап 9. Алерти й сповіщення +## Етап 9. Алерти й сповіщення — ✅ зроблено 2026-08-15 + +> Реалізовано: движок правил (icmp/interface/metric/no_data), антифлап +> вікном, кореляція за топологією, вікна обслуговування й ручне +> заглушення, маршрути з тихими годинами, доставка в Telegram/webhook/ +> SMTP, панель алертів у UI. Подробиці — [HISTORY.md](HISTORY.md), +> контракт — [server/API.md](server/API.md). +> +> **Відкладено з цього етапу:** ескалації й повторні сповіщення, +> приймач кнопок Telegram (`callback_data`), Web Push (іде з Етапом 10), +> правила з джерел `syslog`/`trap`/`ncm`/`compliance`. **Те, без чого це не моніторинг.** Система малює мапу, але мовчить, коли щось падає. Схема готова з Етапу 1 (`alr.rules`, `alerts`, `channels`, `routes`, @@ -256,7 +266,7 @@ Diff* — усе з ТЗ. ## Порядок і чому саме такий 1. ~~**Етап 5 (користувачі)** — без входу продукт не можна віддати нікому.~~ ✅ -2. **Етап 9 (алерти)** — без сповіщень це не моніторинг. Наступний у черзі. +2. ~~**Етап 9 (алерти)** — без сповіщень це не моніторинг.~~ ✅ 3. **Етап 6 (шаблони)** — знімає потребу дописувати Go під кожен вендор; що раніше, то менше захардкодженого коду доведеться викидати. 4. **Етап 8 (керування зондом)** — дешевий і робить онбординг можливим. diff --git a/server/API.md b/server/API.md index e710932..d107e1b 100644 --- a/server/API.md +++ b/server/API.md @@ -77,6 +77,18 @@ JWT — ні. | `POST` | `/api/v1/maps/{id}/undo` | відкотити останню зміну полотна | | `GET` | `/api/v1/devices` | інвентар | | `GET` | `/api/v1/agents` | зонди, версії, самометрики | +| `GET` | `/api/v1/alerts` | активні алерти + лічильники | +| `POST` | `/api/v1/alerts/{id}/ack` | підтвердити (`alerts:ack`) | +| `POST` | `/api/v1/alerts/{id}/close` | закрити вручну (`alerts:ack`) | +| `POST` | `/api/v1/mutes` | заглушити пристрій (`alerts:ack`) | +| `GET` | `/api/v1/alert-rules` | правила з лічильником активних | +| `POST` | `/api/v1/alert-rules` | створити правило (`alerts:write`) | +| `PATCH` | `/api/v1/alert-rules/{id}` | увімкнути/вимкнути (`alerts:write`) | +| `DELETE` | `/api/v1/alert-rules/{id}` | видалити правило (`alerts:write`) | +| `GET` | `/api/v1/channels` | канали доставки (без секретів) | +| `POST` | `/api/v1/channels` | створити канал (`alerts:write`) | +| `POST` | `/api/v1/channels/{id}/test` | пробне повідомлення (`alerts:write`) | +| `DELETE` | `/api/v1/channels/{id}` | видалити канал (`alerts:write`) | | `GET` | `/api/v1/ws` | WebSocket: події та завантаження каналів | ### `POST /api/v1/auth/login` — вхід @@ -389,6 +401,114 @@ Sec-WebSocket-Protocol: netpulse.token.<токен> знімків в історії: 2 ``` +## Алерти + +Движок правил живе всередині `netpulse-api` і обчислюється раз на +`-alert-interval` (за замовчуванням 30 с). Кілька екземплярів API за +балансувальником безпечні: тік бере `pg_try_advisory_lock`, тож правила +рахує рівно один — дедуплікацію алертів захищає індекс, а от сповіщення +пішли б у кількох копіях. + +Движок не тримає стану між тіками. Вікно `for_seconds` — це запит по +часу до TSDB, а не лічильник у пам'яті, тому перезапуск процесу нічого +не збиває. + +### Умови правил + +```jsonc +// Усі виміри вікна мають задовольняти умову — це і є антифлап. +{"metric": "loss_pct", "op": ">", "value": 20} + +// Агрегат за вікно порівнюється один раз. +{"metric": "rtt_avg_ms", "op": ">", "value": 150, "agg": "avg"} + +// Даних немає взагалі — моніторинг, що мовчить про мертвий зонд, +// показує зелену мапу мертвої мережі. +{"metric": "no_data"} + +// Довільна серія з ts.samples. +{"metric_key": "cpu.util", "op": ">", "value": 85, "agg": "avg"} +``` + +`op`: `> >= < <= == !=`. `agg`: `avg min max sum count last`. +Метрики: для `icmp` — `rtt_avg_ms`, `rtt_min_ms`, `rtt_max_ms`, +`jitter_ms`, `loss_pct`, `reachable`; для `interface` — `in_bps`, +`out_bps`, `in_pps`, `out_pps`, `util_in_pct`, `util_out_pct`, +`in_errors`, `out_errors`, `in_discards`, `out_discards`, `oper_up`. + +Списки закриті навмисно: значення з `condition` потрапляє в текст +запиту, і будь-яке послаблення тут перетворюється на SQL-ін'єкцію через +JSON у таблиці правил. + +`selector` обмежує область: `device_ids`, `group_ids`, `site_ids`, +`kinds`, `vendors`, `tags`. Порожній означає «до всього» — найчастіший +випадок, і вимагати для нього переліку означало б ламати правило щоразу, +коли додається пристрій. + +### Кореляція за топологією + +Правило з `depends_on_topology` (типово увімкнено) не піднімає алерти на +пристроях, що стоять за іншим недоступним пристроєм. Причина аварії — на +межі зони недоступності: у кого лишився хоч один живий сусід, той упав +сам; хто оточений виключно мертвими — наслідок. + +Пристрій без жодного відомого зв'язку завжди лишається причиною: +топологія про нього нічого не знає, тож списати його на чужу аварію +було б вигадкою. Повністю мертвий острів алертує весь — краще зайвий +шум, ніж мовчазне ковтання цілого сегмента. + +### Придушення + +- **Вікна обслуговування** (`alr.maintenance_windows`) — за розкладом; + вікно без селектора накриває весь тенант. +- **Ручне заглушення** (`alr.mutes`) — кнопка в UI, стеля 7 днів. + Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути + моніторинг назавжди. + +Придушений алерт лишається видимим у списку (окремим фільтром), але не +надсилає сповіщень. + +### Канали й маршрути + +Канал зберігає несекретну частину в `config`, а токен — у +`core.secrets`, зашифрований тим самим кільцем, що й паролі від +обладнання. Перелік каналів секрети не розшифровує взагалі. + +Тенант **без жодного маршруту** отримує сповіщення в усі придатні +канали: підключили Telegram — має працювати без додаткових налаштувань. +Маршрути з'являються тоді, коли треба розділити потоки. + +Тихі години маршруту глушать усе, крім `disaster`: сенс чергування в +тому, щоб його підняли. + +Вебхуки на внутрішні адреси заблоковані (SSRF), бо адресу задає +користувач тенанта, а запит іде з сервера. У self-hosted це знімається +прапорцем `-allow-private-webhooks` — там внутрішня мережа належить тому +самому, хто налаштовує вебхук. + +### Живий прогін алертів + +``` +1) вхід власника 200 +2) активні алерти 200, 1 алерт: gateway 0.98 мс > 0.5 + snmp-host (0.08 мс) не спрацював — поріг посередині +3) правила 200, 3 правила, «no_data» і «помилки на порту» мовчать +4) канал webhook створено 201 +5) перевірка каналу 200, ok=true — повідомлення дійшло +6) нове правило 201 +7) після тіку 3 алерти, сповіщень по 1 +8) підтвердження 200, стан=acknowledged +9) глядач пробує підтвердити 403 +10) глядач читає алерти 200 + +дедуплікація: за три тіки алертів 3→3, сповіщень 2→2 +вимкнення правила його алерти закрито, чужий ack не зачеплено +повернення правила алерти піднялись знову +заглушення пристрою рівно його алерт → suppressed (mute) +видалення правила його алерти закрито, сиріт не лишилось +доставок за прогін 5 на 5 подій — жодного повтору +``` + ### Живий прогін входу ``` @@ -410,7 +530,14 @@ Sec-WebSocket-Protocol: netpulse.token.<токен> ## Чого ще немає - **Історія метрик**: `GET /api/v1/metrics` для графіків із `ts.samples_5m` не написано. -- **Алерти**: `alr.alerts` не віддаються, хоча схема готова. +- **Ескалації**: `alr.escalation_policies` і повторні сповіщення не реалізовані — + сповіщення надсилається один раз при піднятті алерту. +- **Web Push і Telegram-кнопки**: `alr.push_subscriptions` порожня, а `callback_data` + кнопок Ack/Mute у Telegram нікуди не приходить — бот-приймач не написано. +- **Правила з джерел `syslog`, `trap`, `ncm`, `compliance`**: движок їх свідомо + пропускає, бо вони обробляються подіями, а не опитуванням. +- **Редагування правил**: `PATCH` міняє лише `enabled`; умову правила змінюють + перестворенням. - **Завантаження підкладок**: `map_backgrounds.storage_key` можна задати патчем, але самого прийому файлів (S3/MinIO) ще немає. - **Спільне редагування** обмежене оптимістичним блокуванням: одночасний драг двома diff --git a/server/cmd/netpulse-api/main.go b/server/cmd/netpulse-api/main.go index 242156e..ff9be25 100644 --- a/server/cmd/netpulse-api/main.go +++ b/server/cmd/netpulse-api/main.go @@ -17,7 +17,9 @@ import ( "syscall" "time" + "github.com/netpulse/netpulse/server/internal/alerting" "github.com/netpulse/netpulse/server/internal/auth" + "github.com/netpulse/netpulse/server/internal/crypto" "github.com/netpulse/netpulse/server/internal/httpapi" "github.com/netpulse/netpulse/server/internal/store" ) @@ -42,6 +44,15 @@ func run() error { "секрет підпису токенів доступу, щонайменше 32 байти") pruneAge = flag.Duration("event-retention", 24*time.Hour, "скільки тримати доставлені події в core.event_outbox") + keysFlag = flag.String("dek", os.Getenv("NETPULSE_DEK"), + "ключі шифрування: key_id=[,...] — потрібні для каналів сповіщень") + alertEvery = flag.Duration("alert-interval", 30*time.Second, + "як часто обчислювати правила алертів; 0 — не запускати движок") + alertKeep = flag.Duration("alert-retention", 7*24*time.Hour, + "скільки тримати закриті алерти до переносу в історію") + privateHooks = flag.Bool("allow-private-webhooks", + os.Getenv("NETPULSE_ALLOW_PRIVATE_WEBHOOKS") == "1", + "дозволити вебхуки на внутрішні адреси — для self-hosted інсталяцій") ) flag.Parse() @@ -70,11 +81,27 @@ func run() error { } defer st.Close() + ring, err := crypto.ParseKeyring(*keysFlag) + if err != nil { + return fmt.Errorf("-dek: %w", err) + } + api := httpapi.New(st, signer, log) go api.Hub().Run(ctx) go pruneLoop(ctx, st, log, *pruneAge) + // Движок алертів живе тут, а не в netpulse-server, бо саме цей + // процес уже читає БД для UI і має ключі для каналів доставки. + // Одночасний запуск кількох екземплярів безпечний: тік бере + // advisory-блокування, тож обчислює завжди рівно один. + if *alertEvery > 0 { + eng := alerting.New(st, ring, log, *alertEvery, *privateHooks) + api = api.WithNotifications(ring, eng.Notifier()) + go eng.Run(ctx) + go eng.RunHousekeeping(ctx, *alertKeep) + } + srv := &http.Server{ Addr: *listen, Handler: api.Handler(), diff --git a/server/go.mod b/server/go.mod index 43a5c55..37d14cb 100644 --- a/server/go.mod +++ b/server/go.mod @@ -6,6 +6,8 @@ require ( github.com/coder/websocket v1.8.15 github.com/jackc/pgx/v5 v5.7.6 github.com/netpulse/netpulse/gen/go v0.0.0 + golang.org/x/crypto v0.51.0 + golang.org/x/term v0.43.0 google.golang.org/grpc v1.83.0 google.golang.org/protobuf v1.36.12 ) @@ -14,7 +16,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/server/go.sum b/server/go.sum index 50c7f87..883c6f3 100644 --- a/server/go.sum +++ b/server/go.sum @@ -50,6 +50,8 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= diff --git a/server/internal/alerting/engine.go b/server/internal/alerting/engine.go new file mode 100644 index 0000000..525a138 --- /dev/null +++ b/server/internal/alerting/engine.go @@ -0,0 +1,319 @@ +// Package alerting — движок правил і доставка сповіщень. +// +// Движок навмисно не тримає стану між тіками. Усе, що потрібно для +// рішення «піднімати чи ні», обчислюється з даних у TSDB: вікно +// for_seconds — це запит по часу, а не лічильник у пам'яті. Тому +// перезапуск процесу нічого не збиває, а два процеси, що випадково +// працюють одночасно, дадуть однаковий результат. +package alerting + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/netpulse/netpulse/server/internal/crypto" + "github.com/netpulse/netpulse/server/internal/store" +) + +// Порядок серйозності — той самий, що в alr.severity. +var severityRank = map[string]int{ + "info": 0, "warning": 1, "average": 2, "high": 3, "disaster": 4, +} + +type Engine struct { + st *store.Store + ring *crypto.Keyring + log *slog.Logger + notifier *Notifier + + interval time.Duration +} + +func New(st *store.Store, ring *crypto.Keyring, log *slog.Logger, + interval time.Duration, allowPrivateHooks bool) *Engine { + + if interval <= 0 { + interval = 30 * time.Second + } + return &Engine{ + st: st, + ring: ring, + log: log.With("component", "alerting"), + notifier: NewNotifier(st, log, allowPrivateHooks), + interval: interval, + } +} + +// advisoryLockKey — довільна стала; важливо лише, щоб її не займав +// ніхто інший у цій же БД. +const advisoryLockKey = 0x6e70_616c // "npal" + +// Run крутить цикл обчислення до скасування контексту. +// +// Перед кожним тіком береться advisory-блокування Postgres. Кілька +// серверів за балансувальником — норма, але правила має обчислювати +// рівно один: дедуплікація алертів захищена індексом, а от сповіщення +// пішли б у кількох копіях, і людина отримала б три однакові +// повідомлення про одну аварію. +func (e *Engine) Run(ctx context.Context) { + t := time.NewTicker(e.interval) + defer t.Stop() + + e.log.Info("движок алертів запущено", "інтервал", e.interval) + + for { + select { + case <-ctx.Done(): + e.log.Info("движок алертів зупинено") + return + case <-t.C: + start := time.Now() + n, err := e.tick(ctx) + if err != nil { + e.log.Error("тік алертів", "помилка", err) + continue + } + if n > 0 { + e.log.Debug("тік алертів", "правил", n, "тривалість", time.Since(start)) + } + } + } +} + +func (e *Engine) tick(ctx context.Context) (int, error) { + conn, err := e.st.Pool().Acquire(ctx) + if err != nil { + return 0, err + } + defer conn.Release() + + var got bool + if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`, int64(advisoryLockKey)).Scan(&got); err != nil { + return 0, err + } + if !got { + // Обчислює інший інстанс — це штатний стан, не помилка. + return 0, nil + } + defer func() { + _, _ = conn.Exec(context.WithoutCancel(ctx), + `SELECT pg_advisory_unlock($1)`, int64(advisoryLockKey)) + }() + + rules, err := e.st.ActiveRules(ctx) + if err != nil { + return 0, fmt.Errorf("читання правил: %w", err) + } + if len(rules) == 0 { + return 0, nil + } + + // Правила групуються за тенантом, бо топологія, вікна обслуговування + // й канали читаються раз на тенант, а не раз на правило. + byTenant := map[string][]store.Rule{} + for _, r := range rules { + byTenant[r.TenantID] = append(byTenant[r.TenantID], r) + } + + for tenantID, rs := range byTenant { + if err := e.tickTenant(ctx, tenantID, rs); err != nil { + // Зламаний тенант не має зупиняти решту: у SaaS це означало б, + // що одне криве правило одного клієнта гасить моніторинг усім. + e.log.Error("тік тенанта", "tenant", tenantID, "помилка", err) + } + } + return len(rules), nil +} + +func (e *Engine) tickTenant(ctx context.Context, tenantID string, rules []store.Rule) error { + sup, err := e.st.LoadSuppression(ctx, tenantID) + if err != nil { + return fmt.Errorf("вікна обслуговування: %w", err) + } + + // Топологія потрібна лише якщо хоч одне правило її враховує. + var roots map[string]string + for _, r := range rules { + if r.DependsOnTopology { + g, err := e.st.LoadTopology(ctx, tenantID) + if err != nil { + return fmt.Errorf("топологія: %w", err) + } + roots = g.RootCause() + break + } + } + + var fired, resolved, changed []store.Alert + + for _, r := range rules { + cands, err := e.st.EvaluateRule(ctx, r) + if err != nil { + e.log.Error("обчислення правила", "правило", r.Name, "помилка", err) + continue + } + + keep := make([]string, 0, len(cands)) + for _, c := range cands { + key := c.DedupKey(r.ID) + keep = append(keep, key) + + reason := sup.For(c.DeviceID, r.ID) + if reason == "" && r.DependsOnTopology { + if _, collateral := roots[c.DeviceID]; collateral { + reason = "topology" + } + } + + a := buildAlert(r, c, key, reason) + f, err := e.st.RaiseAlert(ctx, tenantID, a) + if err != nil { + e.log.Error("підняття алерту", "правило", r.Name, "помилка", err) + continue + } + switch { + case f.IsNew: + fired = append(fired, f.Alert) + case f.StateChanged(): + // Наприклад, заглушення пристрою: проблема та сама, але + // на екрані алерт має піти в «придушені» без чекання на + // наступне перезавантаження сторінки. + changed = append(changed, f.Alert) + } + } + + gone, err := e.st.ResolveMissing(ctx, tenantID, r.ID, keep) + if err != nil { + e.log.Error("закриття алертів", "правило", r.Name, "помилка", err) + continue + } + resolved = append(resolved, gone...) + } + + e.publish(ctx, tenantID, fired, resolved, changed) + + // Сповіщаються лише щойно підняті й не придушені: продовження вже + // відомої проблеми не є новиною, а придушене — за визначенням те, + // про що просили не турбувати. + var notify []store.Alert + for _, a := range fired { + if a.State == "firing" { + notify = append(notify, a) + } + } + if len(notify) > 0 { + e.notifier.Dispatch(ctx, tenantID, notify, e.ring) + } + return nil +} + +func buildAlert(r store.Rule, c store.Candidate, dedupKey, suppressedBy string) store.Alert { + name := c.DeviceName + if name == "" { + name = c.DeviceID + } + title := fmt.Sprintf("%s: %s", name, r.Name) + if c.IfName != "" { + title = fmt.Sprintf("%s [%s]: %s", name, c.IfName, r.Name) + } + + msg := describe(r, c) + value := c.Value + threshold := r.Condition.Value + + meta, _ := json.Marshal(map[string]any{ + "metric": r.Condition.Metric, + "op": r.Condition.Op, + "agg": r.Condition.Agg, + "samples": c.Samples, + "for": r.ForSeconds, + }) + + return store.Alert{ + RuleID: r.ID, + RuleName: r.Name, + DeviceID: c.DeviceID, + DeviceName: c.DeviceName, + InterfaceID: c.InterfaceID, + Severity: r.Severity, + Title: title, + Message: msg, + DedupKey: dedupKey, + Value: &value, + Threshold: &threshold, + Context: meta, + SuppressedBy: suppressedBy, + } +} + +// describe робить із умови людський текст. +// +// Повідомлення читає людина о третій ночі з телефона: «loss_pct > 20» +// там марне, а «втрати 100% при порозі 20% протягом 3 хв» — ні. +func describe(r store.Rule, c store.Candidate) string { + if r.Condition.Metric == "no_data" { + return fmt.Sprintf("даних немає понад %s (останні — %s)", + humanDur(r.ForSeconds), c.LastTS.Format("15:04:05")) + } + scope := "усі виміри" + if r.Condition.Agg != "" { + scope = r.Condition.Agg + " за" + } + return fmt.Sprintf("%s %s %s %g (поточне %.3g, %d вимірів за %s)", + r.Condition.Metric, scope, r.Condition.Op, r.Condition.Value, + c.Value, c.Samples, humanDur(r.ForSeconds)) +} + +func humanDur(sec int) string { + d := time.Duration(sec) * time.Second + switch { + case d >= time.Hour: + return fmt.Sprintf("%.0f год", d.Hours()) + case d >= time.Minute: + return fmt.Sprintf("%.0f хв", d.Minutes()) + default: + return fmt.Sprintf("%d с", sec) + } +} + +// publish кладе події в outbox, щоб мапа й список алертів оновились без +// перезавантаження сторінки. +func (e *Engine) publish(ctx context.Context, tenantID string, fired, resolved, changed []store.Alert) { + emit := func(topic string, as []store.Alert) { + for _, a := range as { + if err := e.st.PublishEvent(ctx, tenantID, topic, map[string]any{ + "alert_id": a.ID, "device_id": a.DeviceID, "severity": a.Severity, + "title": a.Title, "state": a.State, "suppressed_by": a.SuppressedBy, + }); err != nil { + e.log.Error("подія "+topic, "помилка", err) + } + } + } + emit("alert.fired", fired) + emit("alert.resolved", resolved) + emit("alert.updated", changed) +} + +// RunHousekeeping переносить закриті алерти в історію. +func (e *Engine) RunHousekeeping(ctx context.Context, keepResolved time.Duration) { + t := time.NewTicker(15 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + n, err := e.st.ArchiveResolved(ctx, keepResolved) + if err != nil { + e.log.Error("архівація алертів", "помилка", err) + continue + } + if n > 0 { + e.log.Info("алерти заархівовано", "рядків", n) + } + } + } +} diff --git a/server/internal/alerting/notify.go b/server/internal/alerting/notify.go new file mode 100644 index 0000000..2883400 --- /dev/null +++ b/server/internal/alerting/notify.go @@ -0,0 +1,460 @@ +package alerting + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net" + "net/http" + "net/smtp" + "net/url" + "strings" + "text/template" + "time" + + "github.com/netpulse/netpulse/server/internal/crypto" + "github.com/netpulse/netpulse/server/internal/store" +) + +// Notifier доставляє алерти в канали. +type Notifier struct { + st *store.Store + log *slog.Logger + hc *http.Client + + // allowPrivate вимикає перевірку на внутрішні адреси. Для SaaS вона + // обов'язкова, а от у self-hosted інсталяції вебхук майже завжди + // веде саме всередину — у корпоративний Mattermost чи хелпдеск. + allowPrivate bool +} + +func NewNotifier(st *store.Store, log *slog.Logger, allowPrivate bool) *Notifier { + return &Notifier{ + st: st, + log: log.With("component", "notify"), + hc: &http.Client{ + // Таймаут обов'язковий: канал, який не відповідає, інакше + // зупинив би доставку решті — а це рівно та ситуація, коли + // сповіщення потрібні найбільше. + Timeout: 15 * time.Second, + }, + allowPrivate: allowPrivate, + } +} + +// Dispatch розсилає партію алертів. +func (n *Notifier) Dispatch(ctx context.Context, tenantID string, alerts []store.Alert, ring *crypto.Keyring) { + channels, err := n.st.LoadChannels(ctx, tenantID, ring) + if err != nil { + n.log.Error("читання каналів", "tenant", tenantID, "помилка", err) + return + } + if len(channels) == 0 { + return + } + + byID := make(map[string]store.Channel, len(channels)) + for _, c := range channels { + byID[c.ID] = c + } + + routes, err := n.st.LoadRoutes(ctx, tenantID) + if err != nil { + n.log.Error("читання маршрутів", "tenant", tenantID, "помилка", err) + return + } + + now := time.Now() + for _, a := range alerts { + for _, c := range n.targets(a, routes, channels, byID, now, tenantID) { + n.send(ctx, tenantID, a, c) + } + } +} + +// targets вирішує, у які канали піде цей алерт. +func (n *Notifier) targets(a store.Alert, routes []store.Route, all []store.Channel, + byID map[string]store.Channel, now time.Time, tenantID string) []store.Channel { + + allow := func(c store.Channel) bool { + return c.Enabled && severityRank[a.Severity] >= severityRank[c.MinSeverity] + } + + // Тенант без жодного маршруту — найчастіший випадок: підключили + // Telegram і чекають, що працюватиме. Вимагати ще й маршрут означало б + // мовчати саме там, де налаштування щойно зроблене й здається повним. + if len(routes) == 0 { + var out []store.Channel + for _, c := range all { + if allow(c) { + out = append(out, c) + } + } + return out + } + + seen := map[string]bool{} + var out []store.Channel + for _, r := range routes { + if !r.Matches(a) { + continue + } + if r.Schedule.IsQuiet(now) { + // Тиха година глушить лише те, що можна відкласти. Disaster + // будить завжди: сенс чергування в тому, щоб його підняли. + if severityRank[a.Severity] < severityRank["disaster"] { + _ = n.st.RecordNotification(context.Background(), tenantID, a.ID, "", + "throttled", "тиха година маршруту "+r.Name, "", nil) + continue + } + } + for _, id := range r.ChannelIDs { + c, ok := byID[id] + if !ok || seen[id] || !allow(c) { + continue + } + seen[id] = true + out = append(out, c) + } + } + return out +} + +func (n *Notifier) send(ctx context.Context, tenantID string, a store.Alert, c store.Channel) { + text := renderMessage(a, c) + + var extID string + var err error + switch c.Kind { + case "telegram": + extID, err = n.sendTelegram(ctx, c, a, text) + case "webhook", "slack", "discord", "mattermost": + err = n.sendWebhook(ctx, c, a, text) + case "email": + err = n.sendEmail(c, a, text) + default: + err = fmt.Errorf("канал %q поки не реалізовано", c.Kind) + } + + status, errMsg := "sent", "" + if err != nil { + status, errMsg = "failed", err.Error() + n.log.Error("доставка", "канал", c.Name, "вид", c.Kind, "помилка", err) + } + + if rerr := n.st.RecordNotification(ctx, tenantID, a.ID, c.ID, status, errMsg, extID, + map[string]any{"text": text}); rerr != nil { + n.log.Error("журнал доставки", "помилка", rerr) + } +} + +// --------------------------------------------------------------------- +// Формат повідомлення +// --------------------------------------------------------------------- + +var defaultTemplate = template.Must(template.New("alert").Parse( + `{{.Icon}} {{.Severity}}: {{.Title}} +{{.Message}} +{{if .DeviceName}}Пристрій: {{.DeviceName}} +{{end}}Час: {{.Started}}`)) + +type msgData struct { + Icon string + Severity string + Title string + Message string + DeviceName string + Started string + Value string + Threshold string +} + +var severityIcon = map[string]string{ + "info": "ℹ️", "warning": "⚠️", "average": "🟠", "high": "🔴", "disaster": "🚨", +} + +func renderMessage(a store.Alert, c store.Channel) string { + d := msgData{ + Icon: severityIcon[a.Severity], + Severity: strings.ToUpper(a.Severity), + Title: a.Title, + Message: a.Message, + DeviceName: a.DeviceName, + Started: a.StartedAt.Format("2006-01-02 15:04:05"), + } + if a.Value != nil { + d.Value = fmt.Sprintf("%.3g", *a.Value) + } + if a.Threshold != nil { + d.Threshold = fmt.Sprintf("%g", *a.Threshold) + } + + tmpl := defaultTemplate + if c.Template != "" { + // Крива шаблонка каналу не має ковтати сповіщення: якщо вона не + // компілюється або не виконується, іде стандартний текст. + if t, err := template.New("custom").Parse(c.Template); err == nil { + tmpl = t + } + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, d); err != nil { + var fb bytes.Buffer + _ = defaultTemplate.Execute(&fb, d) + return fb.String() + } + return buf.String() +} + +// --------------------------------------------------------------------- +// Транспорти +// --------------------------------------------------------------------- + +type telegramConfig struct { + ChatID json.Number `json:"chat_id"` + ThreadID int `json:"thread_id"` +} + +func (n *Notifier) sendTelegram(ctx context.Context, c store.Channel, a store.Alert, text string) (string, error) { + if c.Secret == "" { + return "", fmt.Errorf("канал %s: не задано токен бота", c.Name) + } + var cfg telegramConfig + if err := json.Unmarshal(c.Config, &cfg); err != nil { + return "", fmt.Errorf("канал %s: config: %w", c.Name, err) + } + if cfg.ChatID.String() == "" { + return "", fmt.Errorf("канал %s: не задано chat_id", c.Name) + } + + body := map[string]any{ + "chat_id": cfg.ChatID, + "text": text, + "parse_mode": "HTML", + } + if cfg.ThreadID != 0 { + body["message_thread_id"] = cfg.ThreadID + } + // Кнопки ведуть на веб: підтвердити алерт із телефона — головний + // сценарій чергового, і зайвий перехід у браузер тут коштує хвилин. + body["reply_markup"] = map[string]any{ + "inline_keyboard": [][]map[string]string{{ + {"text": "Підтвердити", "callback_data": "ack:" + a.ID}, + {"text": "Заглушити 1 год", "callback_data": "mute:" + a.DeviceID}, + }}, + } + + payload, err := json.Marshal(body) + if err != nil { + return "", err + } + + // Токен іде в шляху URL — так вимагає Bot API. Тому він ніколи не + // потрапляє в журнал: логуються лише ім'я каналу й помилка. + endpoint := "https://api.telegram.org/bot" + c.Secret + "/sendMessage" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + res, err := n.hc.Do(req) + if err != nil { + return "", scrubToken(err, c.Secret) + } + defer res.Body.Close() + + var out struct { + OK bool `json:"ok"` + Description string `json:"description"` + Result struct { + MessageID int64 `json:"message_id"` + } `json:"result"` + } + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + return "", fmt.Errorf("telegram: відповідь %d нерозбірлива", res.StatusCode) + } + if !out.OK { + return "", fmt.Errorf("telegram: %s", out.Description) + } + return fmt.Sprint(out.Result.MessageID), nil +} + +// scrubToken прибирає токен із тексту помилки: http.Client вкладає в +// неї повний URL, а той містить токен бота. +func scrubToken(err error, token string) error { + if token == "" { + return err + } + return fmt.Errorf("%s", strings.ReplaceAll(err.Error(), token, "«токен»")) +} + +type webhookConfig struct { + URL string `json:"url"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` + // Формат тіла: "netpulse" (повний алерт) або "text" ({"text": "..."}), + // який розуміють Slack, Mattermost і Discord. + Format string `json:"format"` +} + +func (n *Notifier) sendWebhook(ctx context.Context, c store.Channel, a store.Alert, text string) error { + var cfg webhookConfig + if err := json.Unmarshal(c.Config, &cfg); err != nil { + return fmt.Errorf("канал %s: config: %w", c.Name, err) + } + if err := validateHookURL(cfg.URL, n.allowPrivate); err != nil { + return fmt.Errorf("канал %s: %w", c.Name, err) + } + + var payload []byte + var err error + if cfg.Format == "netpulse" { + payload, err = json.Marshal(map[string]any{"text": text, "alert": a}) + } else { + payload, err = json.Marshal(map[string]any{"text": text}) + } + if err != nil { + return err + } + + method := cfg.Method + if method == "" { + method = http.MethodPost + } + req, err := http.NewRequestWithContext(ctx, method, cfg.URL, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + for k, v := range cfg.Headers { + req.Header.Set(k, v) + } + if c.Secret != "" { + req.Header.Set("Authorization", "Bearer "+c.Secret) + } + + res, err := n.hc.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode >= 300 { + return fmt.Errorf("webhook: код %d", res.StatusCode) + } + return nil +} + +// validateHookURL не пускає сповіщення на внутрішні адреси. +// +// Адресу вебхука задає користувач тенанта, а запит іде з сервера. Без +// цієї перевірки будь-хто зі своїм акаунтом змусив би NetPulse стукати +// у внутрішню мережу хостера — класичний SSRF, і мовчазне сповіщення +// про алерт тут ідеальний носій. +// +// У self-hosted інсталяції перевірка знімається прапорцем: там внутрішня +// мережа належить тому самому, хто налаштовує вебхук, і заборона просто +// заважала б. Рішення свідомо приймає адміністратор сервера, а не +// користувач тенанта. +func validateHookURL(raw string, allowPrivate bool) error { + if raw == "" { + return fmt.Errorf("не задано url") + } + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("некоректний url") + } + if u.Scheme != "https" && u.Scheme != "http" { + return fmt.Errorf("дозволені лише http і https") + } + if allowPrivate { + return nil + } + + host := u.Hostname() + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("не вдалося розв'язати %s", host) + } + for _, ip := range ips { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsUnspecified() || ip.IsMulticast() { + return fmt.Errorf("адреса %s веде у внутрішню мережу", ip) + } + } + return nil +} + +type emailConfig struct { + Host string `json:"host"` + Port int `json:"port"` + From string `json:"from"` + To []string `json:"to"` + User string `json:"user"` +} + +func (n *Notifier) sendEmail(c store.Channel, a store.Alert, text string) error { + var cfg emailConfig + if err := json.Unmarshal(c.Config, &cfg); err != nil { + return fmt.Errorf("канал %s: config: %w", c.Name, err) + } + if cfg.Host == "" || cfg.From == "" || len(cfg.To) == 0 { + return fmt.Errorf("канал %s: потрібні host, from і to", c.Name) + } + if cfg.Port == 0 { + cfg.Port = 587 + } + + msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: [%s] %s\r\n"+ + "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", + cfg.From, strings.Join(cfg.To, ", "), + strings.ToUpper(a.Severity), a.Title, text) + + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + var auth smtp.Auth + if cfg.User != "" && c.Secret != "" { + auth = smtp.PlainAuth("", cfg.User, c.Secret, cfg.Host) + } + return smtp.SendMail(addr, auth, cfg.From, cfg.To, []byte(msg)) +} + +// TestChannel надсилає пробне повідомлення — кнопка «перевірити» в UI. +// +// Без неї єдиний спосіб дізнатися, що chat_id помилковий, — дочекатися +// справжньої аварії й не отримати про неї сповіщення. +func (n *Notifier) TestChannel(ctx context.Context, tenantID string, c store.Channel) error { + a := store.Alert{ + Severity: "info", + Title: "Перевірка каналу " + c.Name, + Message: "Якщо ви це бачите — канал налаштовано правильно.", + StartedAt: time.Now(), + DeviceName: "netpulse", + } + text := renderMessage(a, c) + + var err error + switch c.Kind { + case "telegram": + _, err = n.sendTelegram(ctx, c, a, text) + case "webhook", "slack", "discord", "mattermost": + err = n.sendWebhook(ctx, c, a, text) + case "email": + err = n.sendEmail(c, a, text) + default: + err = fmt.Errorf("канал %q поки не реалізовано", c.Kind) + } + + status, errMsg := "sent", "" + if err != nil { + status, errMsg = "failed", err.Error() + } + _ = n.st.RecordNotification(ctx, tenantID, "", c.ID, status, errMsg, "", + map[string]any{"test": true}) + return err +} + +// Notifier доступний ззовні для кнопки «перевірити» в API. +func (e *Engine) Notifier() *Notifier { return e.notifier } diff --git a/server/internal/alerting/notify_test.go b/server/internal/alerting/notify_test.go new file mode 100644 index 0000000..0788127 --- /dev/null +++ b/server/internal/alerting/notify_test.go @@ -0,0 +1,172 @@ +package alerting + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// SaaS-режим: адреса вебхука приходить від користувача тенанта, а запит +// іде з сервера — внутрішні адреси мають відхилятися. +func TestWebhookRejectsInternalTargets(t *testing.T) { + bad := []string{ + "http://127.0.0.1:8080/hook", + "http://localhost/hook", + "http://10.1.2.3/hook", + "http://192.168.1.1/hook", + "http://169.254.169.254/latest/meta-data/", // метадані хмари + "http://[::1]/hook", + } + for _, u := range bad { + if err := validateHookURL(u, false); err == nil { + t.Errorf("%s: очікували відмову, отримали дозвіл", u) + } + } +} + +func TestWebhookRejectsNonHTTPSchemes(t *testing.T) { + for _, u := range []string{"file:///etc/passwd", "gopher://x/", "ftp://x/"} { + // Схема перевіряється до прапорця: навіть self-hosted не має + // приводу слати сповіщення у file://. + if err := validateHookURL(u, true); err == nil { + t.Errorf("%s: очікували відмову", u) + } + } + if err := validateHookURL("", false); err == nil { + t.Error("порожній url має відхилятися") + } +} + +// Self-hosted: внутрішня мережа належить тому ж, хто налаштовує вебхук. +func TestWebhookAllowsInternalWhenExplicitlyPermitted(t *testing.T) { + if err := validateHookURL("http://192.168.1.50/hook", true); err != nil { + t.Errorf("із дозволом внутрішні адреси мають проходити: %v", err) + } +} + +// --------------------------------------------------------------------- + +func TestRenderMessageUsesDefaultTemplate(t *testing.T) { + v := 98.5 + a := store.Alert{ + Severity: "high", + Title: "sw1: Завантаження порту", + Message: "util_out_pct усі виміри > 90", + DeviceName: "sw1", + Value: &v, + StartedAt: time.Date(2026, 8, 15, 10, 30, 0, 0, time.UTC), + } + + got := renderMessage(a, store.Channel{}) + for _, want := range []string{"HIGH", "sw1: Завантаження порту", "2026-08-15 10:30:00"} { + if !strings.Contains(got, want) { + t.Errorf("у повідомленні немає %q:\n%s", want, got) + } + } +} + +// Крива шаблонка каналу не має ковтати сповіщення — це рівно той +// випадок, коли мовчання найдорожче. +func TestRenderMessageFallsBackOnBrokenTemplate(t *testing.T) { + a := store.Alert{Severity: "warning", Title: "тест", StartedAt: time.Now()} + + broken := store.Channel{Template: "{{.Nope"} + if got := renderMessage(a, broken); !strings.Contains(got, "тест") { + t.Errorf("очікували стандартний текст, отримали %q", got) + } + + // Компілюється, але звертається до неіснуючого поля — падає вже + // при виконанні. + runtimeBroken := store.Channel{Template: "{{.Missing.Deep}}"} + if got := renderMessage(a, runtimeBroken); !strings.Contains(got, "тест") { + t.Errorf("очікували стандартний текст, отримали %q", got) + } +} + +func TestRenderMessageHonoursCustomTemplate(t *testing.T) { + a := store.Alert{Severity: "info", Title: "заголовок", StartedAt: time.Now()} + c := store.Channel{Template: "[{{.Severity}}] {{.Title}}"} + + if got := renderMessage(a, c); got != "[INFO] заголовок" { + t.Errorf("отримали %q", got) + } +} + +// Токен бота лежить у шляху URL, тому http.Client вкладає його в текст +// помилки. У журнал він потрапити не має. +func TestScrubTokenRemovesSecretFromError(t *testing.T) { + token := "123456:AAF-secret-token" + err := scrubToken( + &urlError{msg: `Post "https://api.telegram.org/bot` + token + `/sendMessage": timeout`}, + token) + + if strings.Contains(err.Error(), token) { + t.Fatalf("токен лишився в помилці: %s", err) + } + if !strings.Contains(err.Error(), "«токен»") { + t.Errorf("очікували заміну на «токен», отримали %s", err) + } +} + +type urlError struct{ msg string } + +func (e *urlError) Error() string { return e.msg } + +// Тенант без маршрутів має отримувати сповіщення в усі придатні канали: +// підключили Telegram — воно має працювати без додаткових налаштувань. +func TestTargetsFallBackToAllChannelsWithoutRoutes(t *testing.T) { + n := &Notifier{} + channels := []store.Channel{ + {ID: "c1", Enabled: true, MinSeverity: "warning"}, + {ID: "c2", Enabled: true, MinSeverity: "disaster"}, + {ID: "c3", Enabled: false, MinSeverity: "info"}, + } + byID := map[string]store.Channel{} + for _, c := range channels { + byID[c.ID] = c + } + + got := n.targets(store.Alert{Severity: "high"}, nil, channels, byID, time.Now(), "t1") + + if len(got) != 1 || got[0].ID != "c1" { + t.Fatalf("очікували лише c1, отримали %v", ids(got)) + } +} + +func TestTargetsRespectChannelSeverityFloor(t *testing.T) { + n := &Notifier{} + 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"}, nil, channels, byID, time.Now(), "t1"); len(got) != 0 { + t.Fatalf("warning не має проходити поріг high, отримали %v", ids(got)) + } + if got := n.targets(store.Alert{Severity: "disaster"}, nil, channels, byID, time.Now(), "t1"); len(got) != 1 { + t.Fatal("disaster має проходити поріг high") + } +} + +func ids(cs []store.Channel) []string { + out := make([]string, 0, len(cs)) + for _, c := range cs { + out = append(out, c.ID) + } + return out +} + +// Конфіг Telegram приймає chat_id і числом, і рядком: у групах він +// від'ємний і великий, і людина копіює його з різних джерел по-різному. +func TestTelegramChatIDAcceptsBothForms(t *testing.T) { + for _, raw := range []string{`{"chat_id":-1001234567890}`, `{"chat_id":"-1001234567890"}`} { + var cfg telegramConfig + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("%s: %v", raw, err) + } + if cfg.ChatID.String() != "-1001234567890" { + t.Errorf("%s: отримали %q", raw, cfg.ChatID.String()) + } + } +} diff --git a/server/internal/crypto/secrets.go b/server/internal/crypto/secrets.go index dce90ec..8ab7da6 100644 --- a/server/internal/crypto/secrets.go +++ b/server/internal/crypto/secrets.go @@ -10,8 +10,11 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "encoding/base64" + "encoding/hex" "errors" "fmt" + "strings" "sync" ) @@ -171,3 +174,38 @@ func GenerateKey() ([]byte, error) { } return key, nil } + +// ParseKeyring розбирає "key_id=[,key_id2=...]". +// +// Той самий формат читають обидва процеси — і AgentService, і REST: +// секрети в БД спільні, тому й кільце має збиратися однаково. Порожній +// спец дає nil без помилки: не кожному процесу потрібні секрети. +func ParseKeyring(spec string) (*Keyring, error) { + if strings.TrimSpace(spec) == "" { + return nil, nil + } + + ring := NewKeyring() + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, raw, ok := strings.Cut(part, "=") + if !ok { + return nil, fmt.Errorf("некоректний запис ключа: %q", part) + } + + key, err := hex.DecodeString(raw) + if err != nil { + key, err = base64.StdEncoding.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("ключ %q: не hex і не base64", id) + } + } + if err := ring.Add(id, key); err != nil { + return nil, fmt.Errorf("ключ %q: %w", id, err) + } + } + return ring, nil +} diff --git a/server/internal/httpapi/alerts.go b/server/internal/httpapi/alerts.go new file mode 100644 index 0000000..1384490 --- /dev/null +++ b/server/internal/httpapi/alerts.go @@ -0,0 +1,455 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// maxMute — стеля ручного заглушення. +// +// Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути +// моніторинг назавжди: людина глушить на час аварії й забуває. Тиждень +// достатньо для будь-яких робіт і замало, щоб забути назовсім. +const maxMute = 7 * 24 * time.Hour + +func (s *Server) handleListAlerts(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:read") { + return + } + + f := store.AlertFilter{ + MinSeverity: r.URL.Query().Get("min_severity"), + DeviceID: r.URL.Query().Get("device_id"), + } + if v := r.URL.Query().Get("state"); v != "" { + f.States = strings.Split(v, ",") + } + if v := r.URL.Query().Get("limit"); v != "" { + f.Limit, _ = strconv.Atoi(v) + } + + alerts, err := s.store.ListAlerts(r.Context(), p.TenantID, f) + if err != nil { + s.log.Error("перелік алертів", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + if alerts == nil { + alerts = []store.Alert{} + } + + counts, err := s.store.CountAlerts(r.Context(), p.TenantID) + if err != nil { + s.log.Error("лічильники алертів", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"alerts": alerts, "counts": counts}) +} + +func (s *Server) handleAckAlert(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:ack") { + return + } + + var req struct { + Comment string `json:"comment"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + a, err := s.store.AckAlert(r.Context(), p.TenantID, r.PathValue("id"), p.UserID, req.Comment) + if errors.Is(err, store.ErrAlertNotFound) { + writeError(w, http.StatusNotFound, "not_found", "алерт не знайдено або вже закритий") + return + } + if err != nil { + if isBadUUID(err) { + writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор") + return + } + s.log.Error("підтвердження алерту", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusOK, a) +} + +func (s *Server) handleCloseAlert(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:ack") { + return + } + + err := s.store.CloseAlert(r.Context(), p.TenantID, r.PathValue("id")) + if errors.Is(err, store.ErrAlertNotFound) { + writeError(w, http.StatusNotFound, "not_found", "алерт не знайдено або вже закритий") + return + } + if err != nil { + if isBadUUID(err) { + writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор") + return + } + s.log.Error("закриття алерту", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleMuteDevice(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:ack") { + return + } + + var req struct { + DeviceID string `json:"device_id"` + Minutes int `json:"minutes"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON") + return + } + if req.DeviceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "потрібен device_id") + return + } + if req.Minutes <= 0 { + req.Minutes = 60 + } + + until := time.Now().Add(time.Duration(req.Minutes) * time.Minute) + if max := time.Now().Add(maxMute); until.After(max) { + until = max + } + + if err := s.store.MuteDevice(r.Context(), p.TenantID, req.DeviceID, p.UserID, req.Reason, until); err != nil { + if isBadUUID(err) { + writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор пристрою") + return + } + s.log.Error("заглушення пристрою", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusOK, map[string]any{"until": until}) +} + +// --------------------------------------------------------------------- +// Правила +// --------------------------------------------------------------------- + +func (s *Server) handleListAlertRules(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:read") { + return + } + rules, err := s.store.ListRules(r.Context(), p.TenantID) + if err != nil { + s.log.Error("перелік правил", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + if rules == nil { + rules = []map[string]any{} + } + writeJSON(w, http.StatusOK, map[string]any{"rules": rules}) +} + +// validSources і validSeverities дублюють ENUM у БД навмисно: помилка +// в назві джерела має давати 400 з зрозумілим текстом, а не 500 від +// невдалого приведення типу в глибині запиту. +var validSources = map[string]bool{ + "metric": true, "icmp": true, "interface": true, "link": true, + "syslog": true, "trap": true, "ncm": true, "agent": true, "compliance": true, +} + +var validSeverities = map[string]bool{ + "info": true, "warning": true, "average": true, "high": true, "disaster": true, +} + +func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:write") { + return + } + + var req struct { + Name string `json:"name"` + Description string `json:"description"` + Source string `json:"source"` + Severity string `json:"severity"` + Selector json.RawMessage `json:"selector"` + Condition json.RawMessage `json:"condition"` + ForSeconds int `json:"for_seconds"` + DependsOnTopology *bool `json:"depends_on_topology"` + Enabled *bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON") + return + } + + if strings.TrimSpace(req.Name) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва правила") + return + } + if !validSources[req.Source] { + writeError(w, http.StatusBadRequest, "bad_source", "невідоме джерело правила") + return + } + if req.Severity == "" { + req.Severity = "warning" + } + if !validSeverities[req.Severity] { + writeError(w, http.StatusBadRequest, "bad_severity", "невідома серйозність") + return + } + if len(req.Condition) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "потрібна умова") + return + } + if req.ForSeconds <= 0 { + req.ForSeconds = 60 + } + + in := store.RuleInput{ + Name: req.Name, + Description: req.Description, + Source: req.Source, + Severity: req.Severity, + Selector: jsonOr(req.Selector, "{}"), + Condition: string(req.Condition), + ForSeconds: req.ForSeconds, + DependsOnTopology: req.DependsOnTopology == nil || *req.DependsOnTopology, + Enabled: req.Enabled == nil || *req.Enabled, + } + + id, err := s.store.CreateRule(r.Context(), p.TenantID, p.UserID, in) + if err != nil { + if isUniqueViolation(err) { + writeError(w, http.StatusConflict, "duplicate", "правило з такою назвою вже є") + return + } + s.log.Error("створення правила", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"id": id}) +} + +func (s *Server) handlePatchAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:write") { + return + } + var req struct { + Enabled *bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Enabled == nil { + writeError(w, http.StatusBadRequest, "bad_request", "очікується поле enabled") + return + } + + err := s.store.SetRuleEnabled(r.Context(), p.TenantID, r.PathValue("id"), *req.Enabled) + if errors.Is(err, store.ErrAlertNotFound) { + writeError(w, http.StatusNotFound, "not_found", "правило не знайдено") + return + } + if err != nil { + if isBadUUID(err) { + writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор") + return + } + s.log.Error("зміна правила", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleDeleteAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:write") { + return + } + err := s.store.DeleteRule(r.Context(), p.TenantID, r.PathValue("id")) + if errors.Is(err, store.ErrAlertNotFound) { + writeError(w, http.StatusNotFound, "not_found", "правило не знайдено") + return + } + if err != nil { + if isBadUUID(err) { + writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор") + return + } + s.log.Error("видалення правила", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------- +// Канали +// --------------------------------------------------------------------- + +func (s *Server) handleListChannels(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:read") { + return + } + // Секрети не розшифровуються: у переліку вони не потрібні, а + // найнадійніший спосіб не віддати токен назовні — не діставати його + // з бази взагалі. + channels, err := s.store.LoadChannels(r.Context(), p.TenantID, nil) + if err != nil { + s.log.Error("перелік каналів", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + if channels == nil { + channels = []store.Channel{} + } + writeJSON(w, http.StatusOK, map[string]any{"channels": channels}) +} + +var validChannelKinds = map[string]bool{ + "email": true, "telegram": true, "webhook": true, "slack": true, + "discord": true, "mattermost": true, +} + +func (s *Server) handleCreateChannel(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:write") { + return + } + if s.keyring == nil { + writeError(w, http.StatusServiceUnavailable, "no_keyring", + "сервер запущено без ключа шифрування") + return + } + + var req struct { + Kind string `json:"kind"` + Name string `json:"name"` + Config json.RawMessage `json:"config"` + Template string `json:"template"` + MinSeverity string `json:"min_severity"` + Secret string `json:"secret"` + Enabled *bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON") + return + } + if strings.TrimSpace(req.Name) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва каналу") + return + } + if !validChannelKinds[req.Kind] { + writeError(w, http.StatusBadRequest, "bad_kind", "невідомий вид каналу") + return + } + if req.MinSeverity == "" { + req.MinSeverity = "warning" + } + if !validSeverities[req.MinSeverity] { + writeError(w, http.StatusBadRequest, "bad_severity", "невідома серйозність") + return + } + + id, err := s.store.CreateChannel(r.Context(), p.TenantID, store.ChannelInput{ + Kind: req.Kind, + Name: req.Name, + Config: jsonOr(req.Config, "{}"), + Template: req.Template, + MinSeverity: req.MinSeverity, + Secret: req.Secret, + Enabled: req.Enabled == nil || *req.Enabled, + }, s.keyring) + if err != nil { + if isUniqueViolation(err) { + writeError(w, http.StatusConflict, "duplicate", "канал із такою назвою вже є") + return + } + s.log.Error("створення каналу", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"id": id}) +} + +func (s *Server) handleDeleteChannel(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:write") { + return + } + err := s.store.DeleteChannel(r.Context(), p.TenantID, r.PathValue("id")) + if errors.Is(err, store.ErrAlertNotFound) { + writeError(w, http.StatusNotFound, "not_found", "канал не знайдено") + return + } + if err != nil { + if isBadUUID(err) { + writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор") + return + } + s.log.Error("видалення каналу", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleTestChannel надсилає пробне повідомлення. +func (s *Server) handleTestChannel(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "alerts:write") { + return + } + if s.notifier == nil || s.keyring == nil { + writeError(w, http.StatusServiceUnavailable, "no_notifier", + "доставку сповіщень не налаштовано в цьому процесі") + return + } + + channels, err := s.store.LoadChannels(r.Context(), p.TenantID, s.keyring) + if err != nil { + s.log.Error("читання каналів", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + + id := r.PathValue("id") + for _, c := range channels { + if c.ID != id { + continue + } + if err := s.notifier.TestChannel(r.Context(), p.TenantID, c); err != nil { + // Помилка каналу — це відповідь на питання «чи працює», а не + // збій сервера: 200 з описом кращий за 500 без нього. + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + writeError(w, http.StatusNotFound, "not_found", "канал не знайдено") +} + +// --------------------------------------------------------------------- + +func jsonOr(raw json.RawMessage, def string) string { + if len(raw) == 0 { + return def + } + return string(raw) +} + +func isBadUUID(err error) bool { + return err != nil && strings.Contains(err.Error(), "invalid input syntax for type uuid") +} + +func isUniqueViolation(err error) bool { + return err != nil && strings.Contains(err.Error(), "SQLSTATE 23505") +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index eb81219..4eed879 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -13,7 +13,9 @@ import ( "strings" "time" + "github.com/netpulse/netpulse/server/internal/alerting" "github.com/netpulse/netpulse/server/internal/auth" + "github.com/netpulse/netpulse/server/internal/crypto" "github.com/netpulse/netpulse/server/internal/store" ) @@ -22,6 +24,11 @@ type Server struct { log *slog.Logger hub *Hub signer *auth.Signer + + // Потрібні лише для каналів сповіщень: без них решта API працює, + // а /api/v1/channels відповідає 503 із поясненням. + keyring *crypto.Keyring + notifier *alerting.Notifier } // New створює сервер. signer може бути nil лише в тестах, які не @@ -35,6 +42,16 @@ func New(st *store.Store, signer *auth.Signer, log *slog.Logger) *Server { return s } +// WithNotifications вмикає керування каналами доставки. +// +// Виділено в окремий метод, а не в аргументи New: більшість тестів і +// значна частина інсталяцій (кіоск, читальний інстанс) каналами не +// керують, і вимагати від них ключ шифрування було б безпідставно. +func (s *Server) WithNotifications(ring *crypto.Keyring, n *alerting.Notifier) *Server { + s.keyring, s.notifier = ring, n + return s +} + // Hub — доступ до трансляції для зовнішнього коду (тести, метрики). func (s *Server) Hub() *Hub { return s.hub } @@ -72,6 +89,21 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices)) mux.Handle("GET /api/v1/agents", s.authenticated(s.handleListAgents)) + mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts)) + mux.Handle("POST /api/v1/alerts/{id}/ack", s.authenticated(s.handleAckAlert)) + mux.Handle("POST /api/v1/alerts/{id}/close", s.authenticated(s.handleCloseAlert)) + mux.Handle("POST /api/v1/mutes", s.authenticated(s.handleMuteDevice)) + + mux.Handle("GET /api/v1/alert-rules", s.authenticated(s.handleListAlertRules)) + mux.Handle("POST /api/v1/alert-rules", s.authenticated(s.handleCreateAlertRule)) + mux.Handle("PATCH /api/v1/alert-rules/{id}", s.authenticated(s.handlePatchAlertRule)) + mux.Handle("DELETE /api/v1/alert-rules/{id}", s.authenticated(s.handleDeleteAlertRule)) + + mux.Handle("GET /api/v1/channels", s.authenticated(s.handleListChannels)) + mux.Handle("POST /api/v1/channels", s.authenticated(s.handleCreateChannel)) + mux.Handle("DELETE /api/v1/channels/{id}", s.authenticated(s.handleDeleteChannel)) + mux.Handle("POST /api/v1/channels/{id}/test", s.authenticated(s.handleTestChannel)) + // WebSocket теж під автентифікацією: браузер шле токен у // заголовку через підпротокол — див. ws.go. mux.Handle("GET /api/v1/ws", s.authenticated(s.handleWS)) diff --git a/server/internal/store/alerts.go b/server/internal/store/alerts.go new file mode 100644 index 0000000..2092c3b --- /dev/null +++ b/server/internal/store/alerts.go @@ -0,0 +1,492 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// Rule — правило з alr.rules у вигляді, придатному для обчислення. +type Rule struct { + ID string + TenantID string + Name string + Description string + Source string + Severity string + Selector Selector + Condition Condition + ForSeconds int + DependsOnTopology bool +} + +// Selector — до чого застосовується правило. Порожній означає «до всього»: +// це найчастіший випадок («ping усіх»), і вимагати для нього явного +// переліку означало б ламати правило щоразу, коли додається пристрій. +type Selector struct { + DeviceIDs []string `json:"device_ids"` + GroupIDs []string `json:"group_ids"` + SiteIDs []string `json:"site_ids"` + Kinds []string `json:"kinds"` + Vendors []string `json:"vendors"` + Tags map[string]string `json:"tags"` +} + +// Condition — умова спрацювання. +// +// {"metric":"loss_pct","op":">","value":20} — усі семпли вікна +// {"metric":"rtt_avg_ms","op":">","value":150,"agg":"avg"} — середнє за вікно +// {"metric":"no_data"} — даних немає взагалі +// +// Без `agg` умова має триматися ВСІ семпли вікна `for_seconds`. Це і є +// антифлап: одна втрачена відповідь не піднімає алерт, а вікно вимірюється +// в даних, а не в стані движка, тому перезапуск нічого не збиває. +type Condition struct { + Metric string `json:"metric"` + Op string `json:"op"` + Value float64 `json:"value"` + Agg string `json:"agg"` + MetricKey string `json:"metric_key"` // для source=metric: ts.series.metric_key +} + +// Candidate — об'єкт, який щойно задовольнив умову правила. +type Candidate struct { + DeviceID string + DeviceName string + InterfaceID string + IfName string + Value float64 + Samples int + LastTS time.Time +} + +// DedupKey — ключ, за яким алерт вважається тим самим. +// +// Він же primary key дедуплікації в БД, тому має бути стабільним між +// тіками: інакше кожне обчислення відкривало б новий алерт і слало +// сповіщення заново. +func (c Candidate) DedupKey(ruleID string) string { + if c.InterfaceID != "" { + return ruleID + ":if:" + c.InterfaceID + } + return ruleID + ":dev:" + c.DeviceID +} + +// --------------------------------------------------------------------- +// Правила +// --------------------------------------------------------------------- + +// ActiveRules читає всі увімкнені правила всіх тенантів. +// +// Движок один на інсталяцію, тому вибірка навмисно наскрізна: тримати +// по горутині на тенант означало б платити з'єднанням до БД за кожного +// клієнта, у якого може не бути жодного правила. +func (s *Store) ActiveRules(ctx context.Context) ([]Rule, error) { + rows, err := s.pool.Query(ctx, ` + SELECT r.id::text, r.tenant_id::text, r.name, COALESCE(r.description,''), + r.source::text, r.severity::text, + r.selector::text, r.condition::text, + r.for_seconds, r.depends_on_topology + FROM alr.rules r + JOIN core.tenants t ON t.id = r.tenant_id + WHERE r.enabled AND t.status NOT IN ('suspended','cancelled') + ORDER BY r.tenant_id, r.name + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Rule + for rows.Next() { + var r Rule + var sel, cond string + if err := rows.Scan(&r.ID, &r.TenantID, &r.Name, &r.Description, + &r.Source, &r.Severity, &sel, &cond, + &r.ForSeconds, &r.DependsOnTopology); err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(sel), &r.Selector); err != nil { + return nil, fmt.Errorf("правило %s: selector: %w", r.Name, err) + } + if err := json.Unmarshal([]byte(cond), &r.Condition); err != nil { + return nil, fmt.Errorf("правило %s: condition: %w", r.Name, err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------- +// Обчислення умов +// --------------------------------------------------------------------- + +// Колонки, дозволені в умовах. Список закритий навмисно: значення з +// condition потрапляє в текст запиту, і будь-яке послаблення тут +// перетворюється на SQL-ін'єкцію через JSON у таблиці правил. +var icmpMetrics = map[string]string{ + "rtt_avg_ms": "rtt_avg_ms", + "rtt_min_ms": "rtt_min_ms", + "rtt_max_ms": "rtt_max_ms", + "jitter_ms": "jitter_ms", + "loss_pct": "loss_pct", + "reachable": "(CASE WHEN reachable THEN 1 ELSE 0 END)", +} + +var ifMetrics = map[string]string{ + "in_bps": "in_bps", + "out_bps": "out_bps", + "in_pps": "in_pps", + "out_pps": "out_pps", + "util_in_pct": "util_in_pct", + "util_out_pct": "util_out_pct", + "in_errors": "in_errors", + "out_errors": "out_errors", + "in_discards": "in_discards", + "out_discards": "out_discards", + "oper_up": "(CASE WHEN oper_up THEN 1 ELSE 0 END)", +} + +var sqlOps = map[string]string{ + ">": ">", ">=": ">=", "<": "<", "<=": "<=", + "==": "=", "=": "=", "!=": "<>", "<>": "<>", +} + +var sqlAggs = map[string]string{ + "avg": "avg(%s)", "min": "min(%s)", "max": "max(%s)", + "sum": "sum(%s)", "count": "count(%s)", + "last": "(array_agg(%s ORDER BY ts DESC))[1]", +} + +// EvaluateRule повертає об'єкти, для яких умова правила виконується. +func (s *Store) EvaluateRule(ctx context.Context, r Rule) ([]Candidate, error) { + if r.Condition.Metric == "no_data" { + return s.evalNoData(ctx, r) + } + + switch r.Source { + case "icmp": + return s.evalSamples(ctx, r, icmpMetrics, `ts.icmp_samples s`, "", false) + case "interface": + return s.evalSamples(ctx, r, ifMetrics, `ts.if_counters s`, "", true) + case "metric": + return s.evalSeries(ctx, r) + default: + // Джерела syslog/trap/ncm/compliance обробляються не опитуванням, + // а подіями — цей движок їх свідомо не чіпає. + return nil, nil + } +} + +// evalSamples — спільний шлях для icmp і interface: обидва лежать у +// гіпертаблицях із колонкою device_id і однаковою семантикою вікна. +func (s *Store) evalSamples(ctx context.Context, r Rule, allowed map[string]string, + from, extraJoin string, perInterface bool) ([]Candidate, error) { + + col, ok := allowed[r.Condition.Metric] + if !ok { + return nil, fmt.Errorf("правило %s: невідома метрика %q для джерела %s", + r.Name, r.Condition.Metric, r.Source) + } + op, ok := sqlOps[r.Condition.Op] + if !ok { + return nil, fmt.Errorf("правило %s: невідомий оператор %q", r.Name, r.Condition.Op) + } + + a := &args{} + tenant := a.add(r.TenantID) + window := a.add(fmt.Sprintf("%d seconds", maxInt(r.ForSeconds, 1))) + threshold := a.add(r.Condition.Value) + + where, err := s.selectorSQL(r.Selector, a, "s.device_id") + if err != nil { + return nil, err + } + + // Дві семантики вікна. З agg — агрегат порівнюється один раз; + // без agg — умова має триматися кожен семпл вікна. + var having, valueExpr string + if r.Condition.Agg != "" { + tmpl, ok := sqlAggs[r.Condition.Agg] + if !ok { + return nil, fmt.Errorf("правило %s: невідома агрегація %q", r.Name, r.Condition.Agg) + } + valueExpr = fmt.Sprintf(tmpl, col) + having = fmt.Sprintf("%s %s %s", valueExpr, op, threshold) + } else { + valueExpr = fmt.Sprintf("avg(%s)", col) + having = fmt.Sprintf("bool_and(%s %s %s)", col, op, threshold) + } + + group, sel, join := "s.device_id", "s.device_id::text, ''::text", "" + if perInterface { + group = "s.device_id, s.interface_id" + sel = "s.device_id::text, s.interface_id::text" + join = "JOIN inv.interfaces i ON i.id = s.interface_id AND i.monitored" + } + + q := fmt.Sprintf(` + SELECT %s, %s AS val, count(*)::int AS n, max(s.ts) AS last_ts + FROM %s + JOIN inv.devices d ON d.id = s.device_id AND d.deleted_at IS NULL AND d.enabled + %s %s + WHERE s.tenant_id = %s + AND s.ts >= now() - %s::interval + %s + GROUP BY %s + HAVING %s + `, sel, valueExpr, from, join, extraJoin, tenant, window, where, group, having) + + return s.scanCandidates(ctx, r.TenantID, q, a.vals) +} + +// evalSeries — довільні метрики з ts.samples через ts.series. +func (s *Store) evalSeries(ctx context.Context, r Rule) ([]Candidate, error) { + if r.Condition.MetricKey == "" { + return nil, fmt.Errorf("правило %s: для джерела metric потрібен metric_key", r.Name) + } + op, ok := sqlOps[r.Condition.Op] + if !ok { + return nil, fmt.Errorf("правило %s: невідомий оператор %q", r.Name, r.Condition.Op) + } + + a := &args{} + tenant := a.add(r.TenantID) + window := a.add(fmt.Sprintf("%d seconds", maxInt(r.ForSeconds, 1))) + threshold := a.add(r.Condition.Value) + key := a.add(r.Condition.MetricKey) + + where, err := s.selectorSQL(r.Selector, a, "se.device_id") + if err != nil { + return nil, err + } + + var having, valueExpr string + if r.Condition.Agg != "" { + tmpl, ok := sqlAggs[r.Condition.Agg] + if !ok { + return nil, fmt.Errorf("правило %s: невідома агрегація %q", r.Name, r.Condition.Agg) + } + valueExpr = fmt.Sprintf(tmpl, "s.value") + having = fmt.Sprintf("%s %s %s", valueExpr, op, threshold) + } else { + valueExpr = "avg(s.value)" + having = fmt.Sprintf("bool_and(s.value %s %s)", op, threshold) + } + + q := fmt.Sprintf(` + SELECT se.device_id::text, COALESCE(se.interface_id::text, ''), + %s AS val, count(*)::int AS n, max(s.ts) AS last_ts + FROM ts.samples s + JOIN ts.series se ON se.id = s.series_id + JOIN inv.devices d ON d.id = se.device_id AND d.deleted_at IS NULL AND d.enabled + WHERE se.tenant_id = %s + AND se.metric_key = %s + AND s.ts >= now() - %s::interval + %s + GROUP BY se.device_id, se.interface_id + HAVING %s + `, valueExpr, tenant, key, window, where, having) + + return s.scanCandidates(ctx, r.TenantID, q, a.vals) +} + +// evalNoData шукає протилежне решті движка: пристрої, від яких даних +// НЕ надходить. +// +// Це не додаткова зручність. Моніторинг, який мовчить, коли замовк +// зонд, показує зелену мапу мертвої мережі — стан, гірший за будь-яку +// хибну тривогу. +func (s *Store) evalNoData(ctx context.Context, r Rule) ([]Candidate, error) { + a := &args{} + tenant := a.add(r.TenantID) + window := a.add(fmt.Sprintf("%d seconds", maxInt(r.ForSeconds, 1))) + + where, err := s.selectorSQL(r.Selector, a, "d.id") + if err != nil { + return nil, err + } + + // Пристрої, які колись відповідали (last_seen_at не NULL), але + // замовкли. Ті, що не відповідали ніколи, — це помилка налаштування, + // а не збій, і вони не мають будити людину вночі. + q := fmt.Sprintf(` + SELECT d.id::text, ''::text, 0::double precision, 0::int, + COALESCE(d.last_seen_at, now()) + FROM inv.devices d + WHERE d.tenant_id = %s + AND d.deleted_at IS NULL AND d.enabled + AND d.last_seen_at IS NOT NULL + AND d.last_seen_at < now() - %s::interval + %s + `, tenant, window, where) + + return s.scanCandidates(ctx, r.TenantID, q, a.vals) +} + +func (s *Store) scanCandidates(ctx context.Context, tenantID, q string, vals []any) ([]Candidate, error) { + var out []Candidate + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, q, vals...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var c Candidate + if err := rows.Scan(&c.DeviceID, &c.InterfaceID, &c.Value, &c.Samples, &c.LastTS); err != nil { + return err + } + out = append(out, c) + } + return rows.Err() + }) + if err != nil { + return nil, err + } + return s.fillNames(ctx, tenantID, out) +} + +// fillNames добирає імена одним запитом. +// +// Ім'я потрібне в заголовку алерту, а заголовок пишеться один раз при +// створенні: якщо взяти його з JOIN у гарячому запиті обчислення, той +// платитиме за це на кожному тіку для всіх пристроїв, а не лише для +// тих кількох, що справді спрацювали. +func (s *Store) fillNames(ctx context.Context, tenantID string, cs []Candidate) ([]Candidate, error) { + if len(cs) == 0 { + return cs, nil + } + devIDs := make([]string, 0, len(cs)) + ifIDs := make([]string, 0, len(cs)) + for _, c := range cs { + devIDs = append(devIDs, c.DeviceID) + if c.InterfaceID != "" { + ifIDs = append(ifIDs, c.InterfaceID) + } + } + + devNames := map[string]string{} + ifNames := map[string]string{} + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, + `SELECT id::text, name FROM inv.devices WHERE tenant_id = $1 AND id = ANY($2::uuid[])`, + tenantID, devIDs) + if err != nil { + return err + } + for rows.Next() { + var id, name string + if err := rows.Scan(&id, &name); err != nil { + rows.Close() + return err + } + devNames[id] = name + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + if len(ifIDs) == 0 { + return nil + } + rows, err = tx.Query(ctx, + `SELECT id::text, name FROM inv.interfaces WHERE tenant_id = $1 AND id = ANY($2::uuid[])`, + tenantID, ifIDs) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id, name string + if err := rows.Scan(&id, &name); err != nil { + return err + } + ifNames[id] = name + } + return rows.Err() + }) + if err != nil { + return nil, err + } + + for i := range cs { + cs[i].DeviceName = devNames[cs[i].DeviceID] + cs[i].IfName = ifNames[cs[i].InterfaceID] + } + return cs, nil +} + +// --------------------------------------------------------------------- +// Побудова предикатів +// --------------------------------------------------------------------- + +// args нумерує плейсхолдери. Значення з правил ніколи не вставляються +// в текст запиту — лише через параметри; у текст іде тільки те, що +// пройшло whitelist вище. +type args struct{ vals []any } + +func (a *args) add(v any) string { + a.vals = append(a.vals, v) + return fmt.Sprintf("$%d", len(a.vals)) +} + +func (s *Store) selectorSQL(sel Selector, a *args, deviceCol string) (string, error) { + var parts []string + + if len(sel.DeviceIDs) > 0 { + parts = append(parts, fmt.Sprintf("%s = ANY(%s::uuid[])", deviceCol, a.add(sel.DeviceIDs))) + } + if len(sel.SiteIDs) > 0 { + parts = append(parts, fmt.Sprintf("d.site_id = ANY(%s::uuid[])", a.add(sel.SiteIDs))) + } + if len(sel.Kinds) > 0 { + parts = append(parts, fmt.Sprintf("d.kind::text = ANY(%s::text[])", a.add(sel.Kinds))) + } + if len(sel.Vendors) > 0 { + parts = append(parts, fmt.Sprintf("d.vendor = ANY(%s::text[])", a.add(sel.Vendors))) + } + if len(sel.GroupIDs) > 0 { + parts = append(parts, fmt.Sprintf(`EXISTS ( + SELECT 1 FROM inv.device_group_members m + WHERE m.device_id = d.id AND m.group_id = ANY(%s::uuid[]))`, + a.add(sel.GroupIDs))) + } + // Ключі сортуються, щоб текст запиту не залежав від порядку обходу + // мапи: інакше одне й те саме правило породжувало б різні запити й + // щоразу промахувалось повз кеш підготовлених виразів. + for _, k := range sortedKeys(sel.Tags) { + parts = append(parts, fmt.Sprintf(`EXISTS ( + SELECT 1 FROM inv.device_tags dt JOIN inv.tags t ON t.id = dt.tag_id + WHERE dt.device_id = d.id AND t.key = %s AND t.value = %s)`, + a.add(k), a.add(sel.Tags[k]))) + } + + if len(parts) == 0 { + return "", nil + } + return "AND " + strings.Join(parts, "\n AND "), nil +} + +func sortedKeys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/server/internal/store/alerts_channels.go b/server/internal/store/alerts_channels.go new file mode 100644 index 0000000..365ed8e --- /dev/null +++ b/server/internal/store/alerts_channels.go @@ -0,0 +1,348 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/netpulse/netpulse/server/internal/crypto" +) + +// Channel — куди слати. Секрет уже розшифрований: движок доставки не +// має доступу до БД секретів і не має його потребувати. +type Channel struct { + ID string `json:"id"` + TenantID string `json:"-"` + Kind string `json:"kind"` + Name string `json:"name"` + Config json.RawMessage `json:"config"` + Template string `json:"template,omitempty"` + MinSeverity string `json:"min_severity"` + Enabled bool `json:"enabled"` + Secret string `json:"-"` + HasSecret bool `json:"has_secret"` +} + +// Route — правило маршрутизації алерту в канали. +type Route struct { + ID string + Name string + Priority int + Matcher RouteMatcher + ChannelIDs []string + Schedule *RouteSchedule +} + +type RouteMatcher struct { + SeverityGte string `json:"severity_gte"` + RuleIDs []string `json:"rule_ids"` + SiteIDs []string `json:"site_ids"` + DeviceIDs []string `json:"device_ids"` +} + +// RouteSchedule — тихі години. +type RouteSchedule struct { + TZ string `json:"tz"` + Quiet []struct { + Days []int `json:"days"` // 0=неділя, як у time.Weekday + From string `json:"from"` // "22:00" + To string `json:"to"` + } `json:"quiet"` +} + +// IsQuiet каже, чи момент t потрапляє в тиху годину. +// +// Інтервал через північ («22:00–08:00») — не окремий випадок, а норма +// для чергувань, тому обробляється явно: без цього нічні сповіщення +// тихими годинами не глушились би взагалі. +func (s *RouteSchedule) IsQuiet(t time.Time) bool { + if s == nil || len(s.Quiet) == 0 { + return false + } + loc := time.UTC + if s.TZ != "" { + if l, err := time.LoadLocation(s.TZ); err == nil { + loc = l + } + } + lt := t.In(loc) + mins := lt.Hour()*60 + lt.Minute() + day := int(lt.Weekday()) + + for _, q := range s.Quiet { + if len(q.Days) > 0 && !containsInt(q.Days, day) { + continue + } + from, ok1 := parseHM(q.From) + to, ok2 := parseHM(q.To) + if !ok1 || !ok2 { + continue + } + if from <= to { + if mins >= from && mins < to { + return true + } + } else if mins >= from || mins < to { + return true + } + } + return false +} + +func parseHM(s string) (int, bool) { + var h, m int + if _, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil { + return 0, false + } + if h < 0 || h > 23 || m < 0 || m > 59 { + return 0, false + } + return h*60 + m, true +} + +func containsInt(xs []int, v int) bool { + for _, x := range xs { + if x == v { + return true + } + } + return false +} + +// LoadChannels читає канали тенанта й розшифровує їхні секрети. +func (s *Store) LoadChannels(ctx context.Context, tenantID string, ring *crypto.Keyring) ([]Channel, error) { + var out []Channel + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT c.id::text, c.kind::text, c.name, c.config::text, + COALESCE(c.template,''), c.min_severity::text, c.enabled, + s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'') + FROM alr.channels c + LEFT JOIN core.secrets s ON s.id = c.secret_id + WHERE c.tenant_id = $1 + ORDER BY c.name + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var c Channel + var cfg string + var keyID, aad *string + var nonce, ct, tag []byte + + if err := rows.Scan(&c.ID, &c.Kind, &c.Name, &cfg, &c.Template, + &c.MinSeverity, &c.Enabled, &keyID, &nonce, &ct, &tag, &aad); err != nil { + return err + } + c.TenantID = tenantID + c.Config = json.RawMessage(cfg) + + if keyID != nil && ring != nil { + plain, err := ring.Decrypt(&crypto.Secret{ + KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag, + }, derefStr(aad)) + if err != nil { + // Канал із нечитабельним секретом не має валити + // доставку решти: одна зіпсована інтеграція гірша + // за мовчання лише для себе самої. + return fmt.Errorf("канал %s: розшифровка секрету: %w", c.Name, err) + } + c.Secret = string(plain) + c.HasSecret = true + } + out = append(out, c) + } + return rows.Err() + }) + return out, err +} + +func derefStr(p *string) string { + if p == nil { + return "" + } + return *p +} + +// LoadRoutes читає маршрути тенанта в порядку пріоритету. +func (s *Store) LoadRoutes(ctx context.Context, tenantID string) ([]Route, error) { + var out []Route + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT id::text, name, priority, matcher::text, + channel_ids::text[], COALESCE(schedule::text,'') + FROM alr.routes + WHERE tenant_id = $1 AND enabled + ORDER BY priority, name + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var r Route + var matcher, sched string + if err := rows.Scan(&r.ID, &r.Name, &r.Priority, &matcher, + &r.ChannelIDs, &sched); err != nil { + return err + } + if err := json.Unmarshal([]byte(matcher), &r.Matcher); err != nil { + return fmt.Errorf("маршрут %s: matcher: %w", r.Name, err) + } + if sched != "" { + var sc RouteSchedule + if err := json.Unmarshal([]byte(sched), &sc); err == nil { + r.Schedule = &sc + } + } + out = append(out, r) + } + return rows.Err() + }) + return out, err +} + +// Matches — чи цей маршрут бере цей алерт. +func (r Route) Matches(a Alert) bool { + if r.Matcher.SeverityGte != "" { + if severityRank[a.Severity] < severityRank[r.Matcher.SeverityGte] { + return false + } + } + if len(r.Matcher.RuleIDs) > 0 && !containsStr(r.Matcher.RuleIDs, a.RuleID) { + return false + } + if len(r.Matcher.DeviceIDs) > 0 && !containsStr(r.Matcher.DeviceIDs, a.DeviceID) { + return false + } + return true +} + +func containsStr(xs []string, v string) bool { + for _, x := range xs { + if x == v { + return true + } + } + return false +} + +// RecordNotification пише спробу доставки в журнал і рахує сповіщення +// на алерті. +// +// Журнал ведеться до відправлення, а не після: інакше падіння процесу +// між HTTP-запитом і записом лишало б слід «не надсилали» на вже +// доставленому повідомленні, і ретрай слав би дубль. +func (s *Store) RecordNotification(ctx context.Context, tenantID, alertID, channelID, + status, errMsg, externalID string, payload any) error { + + data, _ := json.Marshal(payload) + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, ` + INSERT INTO alr.notifications + (tenant_id, alert_id, channel_id, status, error, external_id, payload) + VALUES ($1, $2, $3, $4::alr.delivery_status, $5, $6, $7::jsonb) + `, tenantID, nullUUID(alertID), nullUUID(channelID), status, + nullString(errMsg), nullString(externalID), string(data)); err != nil { + return err + } + if status != "sent" || alertID == "" { + return nil + } + _, err := tx.Exec(ctx, ` + UPDATE alr.alerts SET notify_count = notify_count + 1 + WHERE tenant_id = $1 AND id = $2 + `, tenantID, alertID) + return err + }) +} + +// --------------------------------------------------------------------- +// CRUD каналів для UI +// --------------------------------------------------------------------- + +type ChannelInput struct { + Kind string + Name string + Config string + Template string + MinSeverity string + Secret string + Enabled bool +} + +// CreateChannel зберігає канал; секрет шифрується тим самим кільцем, що +// й паролі від обладнання — окремого сховища для нього немає навмисно. +func (s *Store) CreateChannel(ctx context.Context, tenantID string, in ChannelInput, ring *crypto.Keyring) (string, error) { + var id string + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var secretID any + if in.Secret != "" { + if ring == nil { + return errors.New("сервер запущено без ключа шифрування — зберегти секрет каналу ніяк") + } + aad := tenantID + "|alr.channel" + sec, err := ring.Encrypt([]byte(in.Secret), aad) + if err != nil { + return err + } + var sid string + if err := tx.QueryRow(ctx, ` + INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad) + VALUES ($1, 'api_token', $2, $3, $4, $5, $6) + RETURNING id::text + `, tenantID, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad).Scan(&sid); err != nil { + return err + } + secretID = sid + } + + return tx.QueryRow(ctx, ` + INSERT INTO alr.channels + (tenant_id, kind, name, config, secret_id, template, min_severity, enabled) + VALUES ($1, $2::alr.channel_kind, $3, $4::jsonb, $5, $6, $7::alr.severity, $8) + RETURNING id::text + `, tenantID, in.Kind, in.Name, in.Config, secretID, + nullString(in.Template), in.MinSeverity, in.Enabled).Scan(&id) + }) + return id, err +} + +func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Секрет видаляється разом із каналом: залишений «на всякий + // випадок» токен у core.secrets — це чинний доступ, про який + // уже ніхто не пам'ятає. + // + // Лічильник беремо з видалення каналу, а не секрету: канал без + // секрету (webhook без підпису) дав би 0 рядків у другому + // операторі, і відповідь була б «немає такого» на успішне + // видалення. + var deleted int + err := tx.QueryRow(ctx, ` + WITH gone AS ( + DELETE FROM alr.channels + WHERE tenant_id = $1 AND id = $2 + RETURNING secret_id + ), dropped AS ( + DELETE FROM core.secrets + WHERE id IN (SELECT secret_id FROM gone WHERE secret_id IS NOT NULL) + ) + SELECT count(*)::int FROM gone + `, tenantID, channelID).Scan(&deleted) + if err != nil { + return err + } + if deleted == 0 { + return ErrAlertNotFound + } + return nil + }) +} diff --git a/server/internal/store/alerts_logic_test.go b/server/internal/store/alerts_logic_test.go new file mode 100644 index 0000000..043c0b2 --- /dev/null +++ b/server/internal/store/alerts_logic_test.go @@ -0,0 +1,211 @@ +package store + +import ( + "testing" + "time" +) + +// graph будує двонаправлений граф зі списку ребер. +func graph(down []string, edges ...[2]string) TopologyGraph { + g := TopologyGraph{Neighbors: map[string][]string{}, Down: map[string]bool{}} + mark := func(id string) { + if _, ok := g.Down[id]; !ok { + g.Down[id] = false + } + if _, ok := g.Neighbors[id]; !ok { + g.Neighbors[id] = nil + } + } + for _, e := range edges { + mark(e[0]) + mark(e[1]) + g.Neighbors[e[0]] = append(g.Neighbors[e[0]], e[1]) + g.Neighbors[e[1]] = append(g.Neighbors[e[1]], e[0]) + } + for _, d := range down { + mark(d) + g.Down[d] = true + } + return g +} + +// Головний сценарій, заради якого будувалася topo.links: упав +// маршрутизатор — сорок пристроїв за ним не мають дати сорок алертів. +func TestRootCauseCollapsesOutageBehindRouter(t *testing.T) { + g := graph( + []string{"router", "sw1", "sw2", "host1", "host2"}, + [2]string{"core", "router"}, + [2]string{"router", "sw1"}, + [2]string{"router", "sw2"}, + [2]string{"sw1", "host1"}, + [2]string{"sw2", "host2"}, + ) + + roots := g.RootCause() + + if _, ok := roots["router"]; ok { + t.Fatal("маршрутизатор має бути причиною, а не наслідком") + } + for _, id := range []string{"sw1", "sw2", "host1", "host2"} { + if roots[id] != "router" { + t.Errorf("%s: корінь %q, очікували router", id, roots[id]) + } + } +} + +// Пристрій, у якого лишився живий сусід, впав сам. +func TestRootCauseKeepsDeviceWithLiveNeighbour(t *testing.T) { + g := graph( + []string{"host1"}, + [2]string{"sw1", "host1"}, + ) + if roots := g.RootCause(); len(roots) != 0 { + t.Fatalf("очікували жодного наслідку, отримали %v", roots) + } +} + +// Пристрій без жодного відомого зв'язку не можна списати на чужу +// аварію: топологія про нього нічого не знає. +func TestRootCauseIsolatedDeviceStaysRoot(t *testing.T) { + g := graph([]string{"lonely"}) + g.Neighbors["lonely"] = nil + + if roots := g.RootCause(); len(roots) != 0 { + t.Fatalf("одинокий пристрій має лишитись причиною, отримали %v", roots) + } +} + +// Повністю мертвий острів: причини не видно, тож алертують усі — +// краще зайвий шум, ніж мовчазне ковтання цілого сегмента. +func TestRootCauseFullyDownIslandAlertsAll(t *testing.T) { + g := graph( + []string{"a", "b"}, + [2]string{"a", "b"}, + ) + if roots := g.RootCause(); len(roots) != 0 { + t.Fatalf("острів без живих сусідів має алертувати весь, отримали %v", roots) + } +} + +// Ланцюг: корінь передається вглиб, а не переписується сусідом. +func TestRootCausePropagatesAlongChain(t *testing.T) { + g := graph( + []string{"r", "a", "b", "c"}, + [2]string{"up", "r"}, + [2]string{"r", "a"}, + [2]string{"a", "b"}, + [2]string{"b", "c"}, + ) + roots := g.RootCause() + for _, id := range []string{"a", "b", "c"} { + if roots[id] != "r" { + t.Errorf("%s: корінь %q, очікували r", id, roots[id]) + } + } +} + +// --------------------------------------------------------------------- + +func atTime(t *testing.T, s string) time.Time { + t.Helper() + v, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatal(err) + } + return v +} + +// Тихі години через північ — норма для чергувань, а не окремий випадок. +func TestQuietHoursSpanMidnight(t *testing.T) { + sc := &RouteSchedule{TZ: "UTC"} + sc.Quiet = append(sc.Quiet, struct { + Days []int `json:"days"` + From string `json:"from"` + To string `json:"to"` + }{From: "22:00", To: "08:00"}) + + cases := []struct { + ts string + want bool + }{ + {"2026-08-15T23:30:00Z", true}, + {"2026-08-15T03:00:00Z", true}, + {"2026-08-15T07:59:00Z", true}, + {"2026-08-15T08:00:00Z", false}, + {"2026-08-15T12:00:00Z", false}, + {"2026-08-15T21:59:00Z", false}, + } + for _, c := range cases { + if got := sc.IsQuiet(atTime(t, c.ts)); got != c.want { + t.Errorf("%s: отримали %v, очікували %v", c.ts, got, c.want) + } + } +} + +func TestQuietHoursRespectDays(t *testing.T) { + sc := &RouteSchedule{TZ: "UTC"} + // 2026-08-15 — субота (6), 2026-08-17 — понеділок (1). + sc.Quiet = append(sc.Quiet, struct { + Days []int `json:"days"` + From string `json:"from"` + To string `json:"to"` + }{Days: []int{6, 0}, From: "00:00", To: "23:59"}) + + if !sc.IsQuiet(atTime(t, "2026-08-15T12:00:00Z")) { + t.Error("субота має бути тихою") + } + if sc.IsQuiet(atTime(t, "2026-08-17T12:00:00Z")) { + t.Error("понеділок тихим не є") + } +} + +func TestNilScheduleIsNeverQuiet(t *testing.T) { + var sc *RouteSchedule + if sc.IsQuiet(time.Now()) { + t.Error("відсутній розклад не має глушити нічого") + } +} + +// --------------------------------------------------------------------- + +func TestRouteMatchesSeverityFloor(t *testing.T) { + r := Route{Matcher: RouteMatcher{SeverityGte: "high"}} + + if r.Matches(Alert{Severity: "warning"}) { + t.Error("warning не має проходити поріг high") + } + if !r.Matches(Alert{Severity: "high"}) { + t.Error("high має проходити поріг high") + } + if !r.Matches(Alert{Severity: "disaster"}) { + t.Error("disaster має проходити поріг high") + } +} + +func TestRouteMatchesDeviceList(t *testing.T) { + r := Route{Matcher: RouteMatcher{DeviceIDs: []string{"d1", "d2"}}} + + if !r.Matches(Alert{Severity: "warning", DeviceID: "d2"}) { + t.Error("пристрій зі списку має проходити") + } + if r.Matches(Alert{Severity: "warning", DeviceID: "d9"}) { + t.Error("пристрій поза списком не має проходити") + } +} + +// Ключ дедуплікації має бути стабільним і розрізняти рівні об'єкта: +// інакше алерт про інтерфейс затирав би алерт про сам пристрій. +func TestDedupKeySeparatesDeviceAndInterface(t *testing.T) { + dev := Candidate{DeviceID: "d1"} + iface := Candidate{DeviceID: "d1", InterfaceID: "i1"} + + if dev.DedupKey("r1") == iface.DedupKey("r1") { + t.Fatal("ключі пристрою й інтерфейсу мають відрізнятися") + } + if dev.DedupKey("r1") != dev.DedupKey("r1") { + t.Fatal("ключ має бути стабільним") + } + if dev.DedupKey("r1") == dev.DedupKey("r2") { + t.Fatal("різні правила — різні ключі") + } +} diff --git a/server/internal/store/alerts_query.go b/server/internal/store/alerts_query.go new file mode 100644 index 0000000..49b088d --- /dev/null +++ b/server/internal/store/alerts_query.go @@ -0,0 +1,338 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +// ErrNotFound — об'єкта немає або він належить іншому тенанту. +// Обидва випадки навмисно нерозрізненні: відповідь «не ваш» сама по +// собі підтверджує, що такий id існує. +var ErrAlertNotFound = errors.New("алерт не знайдено") + +// AlertFilter — параметри вибірки для списку. +type AlertFilter struct { + States []string + MinSeverity string + DeviceID string + Limit int +} + +// severityRank повторює порядок ENUM alr.severity. Тримати його в Go +// потрібно, бо порівняння «не нижче за warning» у SQL інакше вимагало б +// приведення до тексту й втрати індексу. +var severityRank = map[string]int{ + "info": 0, "warning": 1, "average": 2, "high": 3, "disaster": 4, +} + +// ListAlerts повертає активні алерти тенанта. +func (s *Store) ListAlerts(ctx context.Context, tenantID string, f AlertFilter) ([]Alert, error) { + if f.Limit <= 0 || f.Limit > 500 { + f.Limit = 200 + } + states := f.States + if len(states) == 0 { + states = []string{"firing", "acknowledged", "suppressed"} + } + + var out []Alert + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT a.id::text, COALESCE(a.rule_id::text,''), COALESCE(r.name,''), + COALESCE(a.device_id::text,''), COALESCE(d.name,''), + COALESCE(a.interface_id::text,''), + a.severity::text, a.state::text, a.title, COALESCE(a.message,''), + a.dedup_key, a.value, a.threshold, a.context::text, + COALESCE(a.root_alert_id::text,''), COALESCE(a.suppressed_by,''), + a.started_at, a.acked_at, COALESCE(u.email::text,''), + COALESCE(a.ack_comment,''), a.resolved_at, a.last_seen_at, a.notify_count + FROM alr.alerts a + LEFT JOIN alr.rules r ON r.id = a.rule_id + LEFT JOIN inv.devices d ON d.id = a.device_id + LEFT JOIN core.users u ON u.id = a.acked_by + WHERE a.tenant_id = $1 + AND a.state::text = ANY($2::text[]) + AND ($3::uuid IS NULL OR a.device_id = $3::uuid) + ORDER BY + CASE a.severity + WHEN 'disaster' THEN 0 WHEN 'high' THEN 1 WHEN 'average' THEN 2 + WHEN 'warning' THEN 3 ELSE 4 END, + a.started_at DESC + LIMIT $4 + `, tenantID, states, nullUUID(f.DeviceID), f.Limit) + if err != nil { + return err + } + defer rows.Close() + + minRank := -1 + if f.MinSeverity != "" { + if r, ok := severityRank[f.MinSeverity]; ok { + minRank = r + } + } + + for rows.Next() { + var a Alert + var ctxJSON string + if err := rows.Scan(&a.ID, &a.RuleID, &a.RuleName, &a.DeviceID, &a.DeviceName, + &a.InterfaceID, &a.Severity, &a.State, &a.Title, &a.Message, + &a.DedupKey, &a.Value, &a.Threshold, &ctxJSON, + &a.RootAlertID, &a.SuppressedBy, &a.StartedAt, &a.AckedAt, + &a.AckedByEmail, &a.AckComment, &a.ResolvedAt, &a.LastSeenAt, + &a.NotifyCount); err != nil { + return err + } + if minRank >= 0 && severityRank[a.Severity] < minRank { + continue + } + a.Context = []byte(ctxJSON) + out = append(out, a) + } + return rows.Err() + }) + return out, err +} + +// AlertCounts — зведення для шапки UI. +type AlertCounts struct { + Firing int `json:"firing"` + Acknowledged int `json:"acknowledged"` + Suppressed int `json:"suppressed"` + Disaster int `json:"disaster"` + High int `json:"high"` +} + +func (s *Store) CountAlerts(ctx context.Context, tenantID string) (AlertCounts, error) { + var c AlertCounts + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT + count(*) FILTER (WHERE state = 'firing'), + count(*) FILTER (WHERE state = 'acknowledged'), + count(*) FILTER (WHERE state = 'suppressed'), + count(*) FILTER (WHERE severity = 'disaster' AND state = 'firing'), + count(*) FILTER (WHERE severity = 'high' AND state = 'firing') + FROM alr.alerts + WHERE tenant_id = $1 AND state IN ('firing','acknowledged','suppressed') + `, tenantID).Scan(&c.Firing, &c.Acknowledged, &c.Suppressed, &c.Disaster, &c.High) + }) + return c, err +} + +// AckAlert підтверджує алерт. +// +// Підтвердження не гасить проблему — воно зупиняє ескалацію й показує +// решті команди, що хтось уже взявся. Тому стан лишається активним, а +// не переходить у resolved. +func (s *Store) AckAlert(ctx context.Context, tenantID, alertID, userID, comment string) (Alert, error) { + var a Alert + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + err := tx.QueryRow(ctx, ` + UPDATE alr.alerts + SET state = 'acknowledged', acked_at = now(), acked_by = $3, ack_comment = $4 + WHERE tenant_id = $1 AND id = $2 AND state IN ('firing','suppressed') + RETURNING id::text, title, severity::text, state::text, + COALESCE(device_id::text,''), started_at, acked_at, last_seen_at + `, tenantID, alertID, nullUUID(userID), nullString(comment)). + Scan(&a.ID, &a.Title, &a.Severity, &a.State, &a.DeviceID, + &a.StartedAt, &a.AckedAt, &a.LastSeenAt) + if errors.Is(err, pgx.ErrNoRows) { + return ErrAlertNotFound + } + if err != nil { + return err + } + + _, err = tx.Exec(ctx, ` + INSERT INTO core.event_outbox (tenant_id, topic, payload) + VALUES ($1, 'alert.acked', jsonb_build_object('alert_id', $2::text)) + `, tenantID, a.ID) + return err + }) + return a, err +} + +// CloseAlert закриває алерт руками. +// +// Потрібне для правил, чия умова більше ніколи не стане хибною сама +// (разова подія на кшталт «конфіг змінено»): без ручного закриття такий +// алерт висів би на екрані вічно. +func (s *Store) CloseAlert(ctx context.Context, tenantID, alertID string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, ` + UPDATE alr.alerts + SET state = 'resolved', resolved_at = now() + WHERE tenant_id = $1 AND id = $2 + AND state IN ('firing','acknowledged','suppressed') + `, tenantID, alertID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrAlertNotFound + } + _, err = tx.Exec(ctx, ` + INSERT INTO core.event_outbox (tenant_id, topic, payload) + VALUES ($1, 'alert.resolved', jsonb_build_object('alert_id', $2::text)) + `, tenantID, alertID) + return err + }) +} + +// MuteDevice глушить пристрій на визначений час. +func (s *Store) MuteDevice(ctx context.Context, tenantID, deviceID, userID, reason string, until time.Time) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + INSERT INTO alr.mutes (tenant_id, device_id, until, reason, created_by) + VALUES ($1, $2, $3, $4, $5) + `, tenantID, deviceID, until, nullString(reason), nullUUID(userID)) + return err + }) +} + +// --------------------------------------------------------------------- +// Правила: CRUD для UI +// --------------------------------------------------------------------- + +type RuleInput struct { + Name string + Description string + Source string + Severity string + Selector string + Condition string + ForSeconds int + DependsOnTopology bool + Enabled bool +} + +func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]any, error) { + var out []map[string]any + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT r.id::text, r.name, COALESCE(r.description,''), r.source::text, + r.severity::text, r.selector::text, r.condition::text, + r.for_seconds, r.depends_on_topology, r.enabled, + (SELECT count(*) FROM alr.alerts a + WHERE a.rule_id = r.id AND a.state IN ('firing','acknowledged')) + FROM alr.rules r + WHERE r.tenant_id = $1 + ORDER BY r.name + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id, name, desc, source, sev, sel, cond string + var forSec, active int + var topo, enabled bool + if err := rows.Scan(&id, &name, &desc, &source, &sev, &sel, &cond, + &forSec, &topo, &enabled, &active); err != nil { + return err + } + out = append(out, map[string]any{ + "id": id, "name": name, "description": desc, "source": source, + "severity": sev, "selector": rawJSON(sel), "condition": rawJSON(cond), + "for_seconds": forSec, "depends_on_topology": topo, + "enabled": enabled, "active_alerts": active, + }) + } + return rows.Err() + }) + return out, err +} + +func (s *Store) CreateRule(ctx context.Context, tenantID, userID string, in RuleInput) (string, error) { + var id string + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + INSERT INTO alr.rules + (tenant_id, name, description, source, severity, selector, condition, + for_seconds, depends_on_topology, enabled, created_by) + VALUES ($1,$2,$3,$4::alr.rule_source,$5::alr.severity,$6::jsonb,$7::jsonb, + $8,$9,$10,$11) + RETURNING id::text + `, tenantID, in.Name, nullString(in.Description), in.Source, in.Severity, + in.Selector, in.Condition, in.ForSeconds, in.DependsOnTopology, + in.Enabled, nullUUID(userID)).Scan(&id) + }) + return id, err +} + +func (s *Store) DeleteRule(ctx context.Context, tenantID, ruleID string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Спершу закриваємо алерти, і лише потім саме правило. + // + // Порядок принциповий: alr.alerts.rule_id має ON DELETE SET NULL, + // тож після видалення правила його алерти лишаються без жодного + // зв'язку — движок їх більше не бачить і закрити не може, а на + // екрані вони висять вічно. + if err := resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило видалено"); err != nil { + return err + } + tag, err := tx.Exec(ctx, + `DELETE FROM alr.rules WHERE tenant_id = $1 AND id = $2`, tenantID, ruleID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrAlertNotFound + } + return nil + }) +} + +func (s *Store) SetRuleEnabled(ctx context.Context, tenantID, ruleID string, enabled bool) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, ` + UPDATE alr.rules SET enabled = $3, updated_at = now() + WHERE tenant_id = $1 AND id = $2 + `, tenantID, ruleID, enabled) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrAlertNotFound + } + // Вимкнене правило випадає з вибірки движка, тому закрити свої + // алерти вже не зможе — це треба зробити тут і зараз. + if !enabled { + return resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило вимкнено") + } + return nil + }) +} + +// resolveRuleAlerts закриває всі активні алерти правила й повідомляє UI. +func resolveRuleAlerts(ctx context.Context, tx pgx.Tx, tenantID, ruleID, reason string) error { + _, err := tx.Exec(ctx, ` + WITH closed AS ( + UPDATE alr.alerts + SET state = 'resolved', resolved_at = now() + WHERE tenant_id = $1 AND rule_id = $2 + AND state IN ('firing','acknowledged','suppressed') + RETURNING id + ) + INSERT INTO core.event_outbox (tenant_id, topic, payload) + SELECT $1, 'alert.resolved', + jsonb_build_object('alert_id', id::text, 'reason', $3::text) + FROM closed + `, tenantID, ruleID, reason) + return err +} + +func rawJSON(s string) any { + if s == "" { + return nil + } + return jsonRaw(s) +} + +type jsonRaw string + +func (j jsonRaw) MarshalJSON() ([]byte, error) { return []byte(j), nil } diff --git a/server/internal/store/alerts_state.go b/server/internal/store/alerts_state.go new file mode 100644 index 0000000..a66953a --- /dev/null +++ b/server/internal/store/alerts_state.go @@ -0,0 +1,446 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Alert — рядок alr.alerts у вигляді, придатному і для доставки, і для UI. +type Alert struct { + ID string `json:"id"` + RuleID string `json:"rule_id,omitempty"` + RuleName string `json:"rule_name,omitempty"` + DeviceID string `json:"device_id,omitempty"` + DeviceName string `json:"device_name,omitempty"` + InterfaceID string `json:"interface_id,omitempty"` + Severity string `json:"severity"` + State string `json:"state"` + Title string `json:"title"` + Message string `json:"message,omitempty"` + DedupKey string `json:"dedup_key"` + Value *float64 `json:"value,omitempty"` + Threshold *float64 `json:"threshold,omitempty"` + Context json.RawMessage `json:"context,omitempty"` + RootAlertID string `json:"root_alert_id,omitempty"` + SuppressedBy string `json:"suppressed_by,omitempty"` + StartedAt time.Time `json:"started_at"` + AckedAt *time.Time `json:"acked_at,omitempty"` + AckedByEmail string `json:"acked_by_email,omitempty"` + AckComment string `json:"ack_comment,omitempty"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` + LastSeenAt time.Time `json:"last_seen_at"` + NotifyCount int `json:"notify_count"` +} + +// FiredAlert — результат підняття. +type FiredAlert struct { + Alert + IsNew bool + // PrevState — стан до цього тіку; порожній, якщо алерт щойно + // створено. Потрібен, щоб відрізнити «нічого не змінилось» від + // переходу firing↔suppressed: другий треба показати в UI, перший — + // ні. + PrevState string +} + +// StateChanged — чи змінився стан наявного алерту. +func (f FiredAlert) StateChanged() bool { + return !f.IsNew && f.PrevState != "" && f.PrevState != f.State +} + +// RaiseAlert створює або продовжує алерт. +// +// Дедуплікація робиться індексом у БД, а не перевіркою в коді: два +// движки, що з якоїсь причини працюють одночасно, інакше створили б два +// алерти на ту саму проблему й розбудили б людину двічі. +func (s *Store) RaiseAlert(ctx context.Context, tenantID string, a Alert) (FiredAlert, error) { + var out FiredAlert + ctxJSON := "{}" + if len(a.Context) > 0 { + ctxJSON = string(a.Context) + } + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var acked *time.Time + // prev читається в тому ж знімку, що й UPSERT, тому бачить стан + // ДО оновлення. Без цього перехід firing→suppressed нічим не + // відрізнявся б від чергового підтвердження живої проблеми, і UI + // не дізнався б, що алерт замовк. + return tx.QueryRow(ctx, ` + WITH prev AS ( + SELECT id, state::text AS old_state + FROM alr.alerts + WHERE tenant_id = $1 AND dedup_key = $8 + AND state IN ('firing','acknowledged','suppressed') + ), ups AS ( + INSERT INTO alr.alerts + (tenant_id, rule_id, device_id, interface_id, severity, state, + title, message, dedup_key, value, threshold, context, + suppressed_by, started_at, last_seen_at) + VALUES ($1, $2, $3, $4, $5::alr.severity, + CASE WHEN $12::text IS NULL THEN 'firing' ELSE 'suppressed' END::alr.alert_state, + $6, $7, $8, $9, $10, $11::jsonb, $12, now(), now()) + ON CONFLICT (tenant_id, dedup_key) + WHERE state IN ('firing','acknowledged','suppressed') + DO UPDATE SET + last_seen_at = now(), + value = EXCLUDED.value, + -- Підтверджений людиною алерт не повертається у firing + -- від того, що проблема все ще триває: ack означає + -- «я знаю», а не «полагоджено». + state = CASE + WHEN alr.alerts.state = 'acknowledged' THEN 'acknowledged' + WHEN $12::text IS NULL THEN 'firing' + ELSE 'suppressed' END::alr.alert_state, + suppressed_by = $12, + severity = EXCLUDED.severity + RETURNING id, state::text AS new_state, started_at, last_seen_at, + notify_count, acked_at, (xmax = 0) AS inserted + ) + SELECT ups.id::text, ups.new_state, ups.started_at, ups.last_seen_at, + ups.notify_count, ups.acked_at, ups.inserted, + COALESCE(prev.old_state, '') + FROM ups LEFT JOIN prev ON prev.id = ups.id + `, tenantID, nullUUID(a.RuleID), nullUUID(a.DeviceID), nullUUID(a.InterfaceID), + a.Severity, a.Title, nullString(a.Message), a.DedupKey, + a.Value, a.Threshold, ctxJSON, nullString(a.SuppressedBy), + ).Scan(&out.ID, &out.State, &out.StartedAt, &out.LastSeenAt, + &out.NotifyCount, &acked, &out.IsNew, &out.PrevState) + }) + if err != nil { + return out, fmt.Errorf("підняття алерту %s: %w", a.DedupKey, err) + } + + out.RuleID, out.DeviceID, out.InterfaceID = a.RuleID, a.DeviceID, a.InterfaceID + out.DeviceName, out.Severity, out.Title = a.DeviceName, a.Severity, a.Title + out.Message, out.DedupKey = a.Message, a.DedupKey + out.Value, out.Threshold, out.SuppressedBy = a.Value, a.Threshold, a.SuppressedBy + return out, nil +} + +// ResolveMissing закриває алерти правила, яких більше немає серед +// кандидатів. +// +// Закриття робиться саме різницею множин, а не окремою recovery-умовою: +// якщо алерт піднявся тому, що умова виконалась, він має зникнути рівно +// тоді, коли вона перестала. Окрема умова зняття — це другий шанс +// помилитись і залишити на екрані алерт про давно полагоджене. +func (s *Store) ResolveMissing(ctx context.Context, tenantID, ruleID string, keep []string) ([]Alert, error) { + var out []Alert + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + UPDATE alr.alerts + SET state = 'resolved', resolved_at = now() + WHERE tenant_id = $1 + AND rule_id = $2 + AND state IN ('firing','acknowledged','suppressed') + AND NOT (dedup_key = ANY($3::text[])) + RETURNING id::text, dedup_key, title, severity::text, + COALESCE(device_id::text,''), started_at, resolved_at + `, tenantID, ruleID, keep) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var a Alert + if err := rows.Scan(&a.ID, &a.DedupKey, &a.Title, &a.Severity, + &a.DeviceID, &a.StartedAt, &a.ResolvedAt); err != nil { + return err + } + a.State = "resolved" + out = append(out, a) + } + return rows.Err() + }) + return out, err +} + +// ArchiveResolved переносить закриті алерти в гіпертаблицю історії. +// +// Активна таблиця має лишатися маленькою: за неї щосекунди чіпляється +// і мапа, і список алертів, і рахунок непідтверджених. Історія за рік +// у тій самій таблиці зробила б кожен із цих запитів дорожчим без +// жодної користі. +func (s *Store) ArchiveResolved(ctx context.Context, olderThan time.Duration) (int64, error) { + tag, err := s.pool.Exec(ctx, ` + WITH moved AS ( + DELETE FROM alr.alerts + WHERE state IN ('resolved','expired') + AND resolved_at IS NOT NULL + AND resolved_at < now() - $1::interval + RETURNING * + ) + INSERT INTO alr.alerts_history + (ts, id, tenant_id, rule_id, device_id, severity, title, + started_at, resolved_at, duration_sec, acked_by, context) + SELECT COALESCE(resolved_at, now()), id, tenant_id, rule_id, device_id, + severity, title, started_at, resolved_at, + GREATEST(0, EXTRACT(EPOCH FROM (COALESCE(resolved_at, now()) - started_at))::int), + acked_by, context + FROM moved + ON CONFLICT (ts, id) DO NOTHING + `, olderThan.String()) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// --------------------------------------------------------------------- +// Придушення +// --------------------------------------------------------------------- + +// Suppression — чому саме алерт не має турбувати людину. +type Suppression struct { + Devices map[string]string // device_id → 'maintenance' | 'mute' + Rules map[string]string // rule_id → те саме + All string // вікно на весь тенант +} + +func (s Suppression) For(deviceID, ruleID string) string { + if s.All != "" { + return s.All + } + if v, ok := s.Devices[deviceID]; ok { + return v + } + if v, ok := s.Rules[ruleID]; ok { + return v + } + return "" +} + +// LoadSuppression збирає діючі вікна обслуговування й ручні заглушення. +// +// Читається раз на тік і на весь тенант: перевіряти кожен алерт окремим +// запитом означало б лінійно множити навантаження на БД саме в той +// момент, коли впала половина мережі й алертів найбільше. +func (s *Store) LoadSuppression(ctx context.Context, tenantID string) (Suppression, error) { + sup := Suppression{Devices: map[string]string{}, Rules: map[string]string{}} + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT selector::text + FROM alr.maintenance_windows + WHERE tenant_id = $1 AND period @> now() AND suppress_notifications + `, tenantID) + if err != nil { + return err + } + var selectors []string + for rows.Next() { + var sel string + if err := rows.Scan(&sel); err != nil { + rows.Close() + return err + } + selectors = append(selectors, sel) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + for _, raw := range selectors { + var sel Selector + if err := json.Unmarshal([]byte(raw), &sel); err != nil { + continue + } + // Вікно без селектора накриває весь тенант — так виглядає + // планова робота «вимикаємо все на 30 хвилин». + if len(sel.DeviceIDs) == 0 && len(sel.GroupIDs) == 0 && + len(sel.SiteIDs) == 0 && len(sel.Tags) == 0 { + sup.All = "maintenance" + continue + } + ids, err := s.resolveSelectorDevices(ctx, tx, tenantID, sel) + if err != nil { + return err + } + for _, id := range ids { + sup.Devices[id] = "maintenance" + } + } + + rows, err = tx.Query(ctx, ` + SELECT COALESCE(device_id::text,''), COALESCE(rule_id::text,'') + FROM alr.mutes + WHERE tenant_id = $1 AND until > now() + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var dev, rule string + if err := rows.Scan(&dev, &rule); err != nil { + return err + } + if dev != "" { + sup.Devices[dev] = "mute" + } + if rule != "" { + sup.Rules[rule] = "mute" + } + } + return rows.Err() + }) + return sup, err +} + +func (s *Store) resolveSelectorDevices(ctx context.Context, tx pgx.Tx, tenantID string, sel Selector) ([]string, error) { + a := &args{} + tenant := a.add(tenantID) + where, err := s.selectorSQL(sel, a, "d.id") + if err != nil { + return nil, err + } + q := fmt.Sprintf(` + SELECT d.id::text FROM inv.devices d + WHERE d.tenant_id = %s AND d.deleted_at IS NULL %s + `, tenant, where) + + rows, err := tx.Query(ctx, q, a.vals...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------- +// Кореляція за топологією +// --------------------------------------------------------------------- + +// TopologyGraph — сусідство пристроїв і їхній поточний стан. +type TopologyGraph struct { + Neighbors map[string][]string + Down map[string]bool +} + +// LoadTopology читає граф зв'язків тенанта разом зі станом вузлів. +func (s *Store) LoadTopology(ctx context.Context, tenantID string) (TopologyGraph, error) { + g := TopologyGraph{Neighbors: map[string][]string{}, Down: map[string]bool{}} + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT id::text, status::text + FROM inv.devices + WHERE tenant_id = $1 AND deleted_at IS NULL AND enabled + `, tenantID) + if err != nil { + return err + } + for rows.Next() { + var id, st string + if err := rows.Scan(&id, &st); err != nil { + rows.Close() + return err + } + g.Down[id] = st == "down" + if _, ok := g.Neighbors[id]; !ok { + g.Neighbors[id] = nil + } + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + rows, err = tx.Query(ctx, ` + SELECT a_device_id::text, b_device_id::text + FROM topo.links + WHERE tenant_id = $1 + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var a, b string + if err := rows.Scan(&a, &b); err != nil { + return err + } + g.Neighbors[a] = append(g.Neighbors[a], b) + g.Neighbors[b] = append(g.Neighbors[b], a) + } + return rows.Err() + }) + return g, err +} + +// RootCause визначає, які з недоступних пристроїв є причиною, а які — +// наслідком. +// +// Ідея проста й перевіряється очима на мапі: причина аварії — на межі +// зони недоступності. Пристрій, у якого лишився хоч один живий сусід, +// впав сам; пристрій, оточений виключно мертвими, з великою ймовірністю +// просто стоїть за тим, що впало. +// +// Саме заради цього будувалась topo.links: без кореляції падіння +// маршрутизатора дає сорок сповіщень про сорок «несправних» пристроїв, +// і серед них губиться єдине потрібне. +// +// Повертає device_id → device_id кореневого пристрою. Пристрої-причини +// у мапі відсутні. +func (g TopologyGraph) RootCause() map[string]string { + collateral := map[string]string{} + + isRoot := func(id string) bool { + neigh := g.Neighbors[id] + // Пристрій без жодного відомого зв'язку не можна списати на + // чужу аварію: топологія про нього нічого не знає, тож єдина + // чесна відповідь — він упав сам. + if len(neigh) == 0 { + return true + } + for _, n := range neigh { + if !g.Down[n] { + return true + } + } + return false + } + + // Пошук у ширину від межі: кожен «глибокий» вузол дістає корінь + // того фронту, через який до нього найкоротший шлях. + var queue []string + seen := map[string]bool{} + for id, down := range g.Down { + if down && isRoot(id) { + queue = append(queue, id) + seen[id] = true + } + } + + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + root := cur + if r, ok := collateral[cur]; ok { + root = r + } + for _, n := range g.Neighbors[cur] { + if !g.Down[n] || seen[n] { + continue + } + seen[n] = true + collateral[n] = root + queue = append(queue, n) + } + } + return collateral +} diff --git a/web/src/App.tsx b/web/src/App.tsx index cff24a8..ba08708 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,8 +2,10 @@ import { useEffect, useMemo, useState, useSyncExternalStore } from 'react' import { api, staticTokenPresent } from './api/client' import { session } from './api/session' import { useLiveMap } from './hooks/useLiveMap' +import { useAlerts } from './hooks/useAlerts' import { LoginPage } from './components/LoginPage' import { MapCanvas } from './components/MapCanvas' +import { AlertBadge, AlertsPanel } from './components/AlertsPanel' import type { AgentSummary, MapSummary } from './types' export default function App() { @@ -46,9 +48,12 @@ function Workspace() { // Мобільний сайдбар відкривається поверх полотна: на 375 px ширини // постійна колонка з'їдала б дві третини карти. const [sidebarOpen, setSidebarOpen] = useState(false) + const [alertsOpen, setAlertsOpen] = useState(false) const live = useLiveMap(mapID) + const alerts = useAlerts() const canWrite = session.can('maps:write') + const canSeeAlerts = session.can('alerts:read') useEffect(() => { api @@ -135,6 +140,9 @@ function Workspace() { + {canSeeAlerts && ( + setAlertsOpen((v) => !v)} /> + )} {live.state && ( @@ -233,6 +241,19 @@ function Workspace() { )} + + {alertsOpen && canSeeAlerts && ( + setAlertsOpen(false)} + // Клік по алерту веде до пристрою на мапі: інакше зв'язок + // між рядком у списку й вузлом доводиться шукати очима. + onFocusDevice={(deviceID) => { + const n = live.state?.nodes.find((x) => x.device_id === deviceID) + if (n) setSelected(n.id) + }} + /> + )} {/* Інспектор вузла: збоку на десктопі, нижнім аркушем на телефоні. */} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index eebb151..a611f43 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,6 +1,9 @@ import { meFromLogin, session } from './session' import type { AgentSummary, + Alert, + AlertCounts, + AlertRule, BuildResult, DeviceSummary, LoginResponse, @@ -190,4 +193,36 @@ export const api = { listAgents: () => request<{ agents: AgentSummary[] }>('/api/v1/agents').then((r) => r.agents ?? []), + + // --- алерти --- + + listAlerts: () => + request<{ alerts: Alert[]; counts: AlertCounts }>('/api/v1/alerts').then((r) => ({ + alerts: r.alerts ?? [], + counts: r.counts ?? { firing: 0, acknowledged: 0, suppressed: 0, disaster: 0, high: 0 }, + })), + + ackAlert: (id: string, comment = '') => + request(`/api/v1/alerts/${id}/ack`, { + method: 'POST', + body: JSON.stringify({ comment }), + }), + + closeAlert: (id: string) => + request(`/api/v1/alerts/${id}/close`, { method: 'POST' }), + + muteDevice: (deviceID: string, minutes: number, reason = '') => + request<{ until: string }>('/api/v1/mutes', { + method: 'POST', + body: JSON.stringify({ device_id: deviceID, minutes, reason }), + }), + + listAlertRules: () => + request<{ rules: AlertRule[] }>('/api/v1/alert-rules').then((r) => r.rules ?? []), + + setRuleEnabled: (id: string, enabled: boolean) => + request(`/api/v1/alert-rules/${id}`, { + method: 'PATCH', + body: JSON.stringify({ enabled }), + }), } diff --git a/web/src/api/ws.ts b/web/src/api/ws.ts index d852f11..98036a4 100644 --- a/web/src/api/ws.ts +++ b/web/src/api/ws.ts @@ -8,6 +8,34 @@ interface Options { onState: (state: ConnectionState) => void } +/** + * Спільна шина подій. + * + * З'єднання одне на вкладку, а слухачів у нього кілька: мапа, панель + * алертів і далі буде більше. Другий WebSocket заради алертів коштував + * би ще одного з'єднання й ще одного циклу перепідключень на кожен + * відкритий NOC-екран — при тому, що сервер шле обом однаковий потік. + */ +type Listener = (msg: WsMessage) => void + +const listeners = new Set() + +export const liveEvents = { + subscribe(fn: Listener): () => void { + listeners.add(fn) + return () => listeners.delete(fn) + }, + emit(msg: WsMessage) { + for (const fn of listeners) { + try { + fn(msg) + } catch { + // Слухач, що впав, не має глушити решту. + } + } + }, +} + /** * Живе з'єднання з сервером. * @@ -47,7 +75,9 @@ export class LiveConnection { ws.onmessage = (ev) => { try { - this.opts.onMessage(JSON.parse(ev.data) as WsMessage) + const msg = JSON.parse(ev.data) as WsMessage + this.opts.onMessage(msg) + liveEvents.emit(msg) } catch { // Пошкоджене повідомлення не має рвати з'єднання. } diff --git a/web/src/components/AlertsPanel.tsx b/web/src/components/AlertsPanel.tsx new file mode 100644 index 0000000..ed7d733 --- /dev/null +++ b/web/src/components/AlertsPanel.tsx @@ -0,0 +1,250 @@ +import { useState } from 'react' +import { session } from '../api/session' +import type { Alert, Severity } from '../types' +import type { LiveAlerts } from '../hooks/useAlerts' + +const severityStyle: Record = { + disaster: { dot: 'bg-red-500', text: 'text-red-300', label: 'аварія' }, + high: { dot: 'bg-red-400', text: 'text-red-300', label: 'висока' }, + average: { dot: 'bg-orange-400', text: 'text-orange-300', label: 'середня' }, + warning: { dot: 'bg-amber-400', text: 'text-amber-300', label: 'увага' }, + info: { dot: 'bg-sky-400', text: 'text-sky-300', label: 'інфо' }, +} + +const suppressLabel: Record = { + maintenance: 'вікно обслуговування', + mute: 'заглушено вручну', + topology: 'наслідок іншої аварії', + dependency: 'залежність', +} + +/** + * Панель алертів. + * + * На десктопі — колонка праворуч, на телефоні — аркуш на весь екран. + * Проміжного варіанта немає навмисно: список алертів або читають + * уважно, або не читають узагалі, і вузька смужка збоку на 375 px + * гарантує друге. + */ +export function AlertsPanel({ + live, + onClose, + onFocusDevice, +}: { + live: LiveAlerts + onClose: () => void + onFocusDevice?: (deviceID: string) => void +}) { + const canAck = session.can('alerts:ack') + const [showSuppressed, setShowSuppressed] = useState(false) + + const visible = showSuppressed + ? live.alerts + : live.alerts.filter((a) => a.state !== 'suppressed') + const hidden = live.alerts.length - visible.length + + return ( + + ) +} + +function AlertRow({ + alert: a, + canAck, + onAck, + onClose, + onMute, + onFocus, +}: { + alert: Alert + canAck: boolean + onAck: () => void + onClose: () => void + onMute?: () => void + onFocus?: () => void +}) { + const s = severityStyle[a.severity] ?? severityStyle.info + const acked = a.state === 'acknowledged' + + return ( +
  • +
    + +
    +
    + {s.label} + {ago(a.started_at)} + {acked && ( + + ✓ прийнято + + )} +
    + + + + {a.message &&

    {a.message}

    } + + {a.suppressed_by && ( +

    + придушено: {suppressLabel[a.suppressed_by] ?? a.suppressed_by} +

    + )} + + {canAck && ( +
    + {!acked && ( + + Прийняти + + )} + {onMute && ( + + Заглушити 1 год + + )} + + Закрити + +
    + )} +
    +
    +
  • + ) +} + +function SmallButton({ + children, + onClick, + title, +}: { + children: React.ReactNode + onClick: () => void + title?: string +}) { + // На телефоні ці кнопки натискають пальцем і зазвичай уночі: 26 px, + // яких вистачало мишею, тут перетворюються на промахи. На десктопі + // висота повертається до щільної — список має лишатись оглядним. + return ( + + ) +} + +/** Індикатор у шапці. Мовчить, коли алертів немає. */ +export function AlertBadge({ + counts, + onClick, +}: { + counts: { firing: number; acknowledged: number; disaster: number; high: number } + onClick: () => void +}) { + const total = counts.firing + counts.acknowledged + const critical = counts.disaster + counts.high + + return ( + + ) +} + +function ago(iso: string): string { + const sec = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000)) + if (sec < 60) return `${sec} с` + if (sec < 3600) return `${Math.round(sec / 60)} хв` + if (sec < 86400) return `${Math.round(sec / 3600)} год` + return `${Math.round(sec / 86400)} дн` +} diff --git a/web/src/hooks/useAlerts.ts b/web/src/hooks/useAlerts.ts new file mode 100644 index 0000000..c98c7e1 --- /dev/null +++ b/web/src/hooks/useAlerts.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { api } from '../api/client' +import { liveEvents } from '../api/ws' +import { session } from '../api/session' +import type { Alert, AlertCounts } from '../types' + +const emptyCounts: AlertCounts = { + firing: 0, + acknowledged: 0, + suppressed: 0, + disaster: 0, + high: 0, +} + +export interface LiveAlerts { + alerts: Alert[] + counts: AlertCounts + loading: boolean + error: string | null + reload: () => Promise + ack: (id: string) => Promise + close: (id: string) => Promise + mute: (deviceID: string, minutes: number) => Promise +} + +/** + * Тримає список активних алертів. + * + * Подія по WebSocket не несе всього алерту — лише те, що щось + * змінилося. Список перечитується цілком: він короткий (десятки рядків), + * а склеювати його з дельт означало б розходитись зі станом сервера + * рівно в момент аварії, коли подій найбільше. + */ +export function useAlerts(): LiveAlerts { + const [alerts, setAlerts] = useState([]) + const [counts, setCounts] = useState(emptyCounts) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const canRead = session.can('alerts:read') + + const reload = useCallback(async () => { + if (!canRead) { + setLoading(false) + return + } + try { + const r = await api.listAlerts() + setAlerts(r.alerts) + setCounts(r.counts) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [canRead]) + + useEffect(() => { + void reload() + }, [reload]) + + // Злиття сплеску подій в одне перечитування. + // + // Падіння комутатора піднімає десятки алертів за один тік движка, і + // кожен приходить окремим повідомленням. Без затримки це десятки + // однакових запитів підряд саме тоді, коли сервер найзавантаженіший. + const timer = useRef(null) + useEffect(() => { + if (!canRead) return + return liveEvents.subscribe((msg) => { + if (!msg.type?.startsWith('alert.')) return + if (timer.current) window.clearTimeout(timer.current) + timer.current = window.setTimeout(() => void reload(), 400) + }) + }, [reload, canRead]) + + useEffect(() => () => { + if (timer.current) window.clearTimeout(timer.current) + }, []) + + const ack = useCallback( + async (id: string) => { + await api.ackAlert(id) + await reload() + }, + [reload], + ) + + const close = useCallback( + async (id: string) => { + await api.closeAlert(id) + await reload() + }, + [reload], + ) + + const mute = useCallback( + async (deviceID: string, minutes: number) => { + await api.muteDevice(deviceID, minutes) + await reload() + }, + [reload], + ) + + return { alerts, counts, loading, error, reload, ack, close, mute } +} diff --git a/web/src/types.ts b/web/src/types.ts index b61e527..3046909 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -214,6 +214,61 @@ export interface BuildResult { revision: number } +// --------------------------------------------------------------------- +// Алерти +// --------------------------------------------------------------------- + +export type Severity = 'info' | 'warning' | 'average' | 'high' | 'disaster' +export type AlertState = 'firing' | 'acknowledged' | 'suppressed' | 'resolved' | 'expired' + +export interface Alert { + id: string + rule_id?: string + rule_name?: string + device_id?: string + device_name?: string + interface_id?: string + severity: Severity + state: AlertState + title: string + message?: string + dedup_key: string + value?: number + threshold?: number + root_alert_id?: string + /** 'maintenance' | 'mute' | 'topology' — чому не турбуємо. */ + suppressed_by?: string + started_at: string + acked_at?: string + acked_by_email?: string + ack_comment?: string + resolved_at?: string + last_seen_at: string + notify_count: number +} + +export interface AlertCounts { + firing: number + acknowledged: number + suppressed: number + disaster: number + high: number +} + +export interface AlertRule { + id: string + name: string + description?: string + source: string + severity: Severity + selector: unknown + condition: unknown + for_seconds: number + depends_on_topology: boolean + enabled: boolean + active_alerts: number +} + // --------------------------------------------------------------------- // Повідомлення WebSocket // --------------------------------------------------------------------- @@ -237,3 +292,22 @@ export type WsMessage = links: { link_id: string; status: DeviceStatus; util_pct?: number }[] } | { type: 'map.updated'; map_id: string; revision: number } + | { + type: 'alert.fired' + payload: { + alert_id: string + device_id?: string + severity: Severity + title: string + state: AlertState + suppressed_by?: string + } + } + | { type: 'alert.resolved'; payload: { alert_id: string; title?: string; reason?: string } } + | { type: 'alert.acked'; payload: { alert_id: string } } + // Проблема та сама, змінився лише стан — наприклад, пристрій заглушили + // або відкрилось вікно обслуговування. + | { + type: 'alert.updated' + payload: { alert_id: string; state: AlertState; suppressed_by?: string } + }