Етап 9: алерти й сповіщення

Система вміла малювати мапу, але мовчала, коли щось падало. Схема alr.*
лежала готовою з Етапу 1 і повністю порожньою.

Движок:
- обчислює правила з icmp / interface / metric / no_data і не тримає
  стану між тіками: вікно for_seconds — це запит по часу до TSDB, тож
  перезапуск нічого не збиває
- дві семантики вікна: без agg умова має триматися всі виміри (антифлап),
  з agg порівнюється агрегат
- кореляція за топологією: причина аварії — той, у кого лишився живий
  сусід; хто оточений мертвими, той наслідок. Заради цього й будувалась
  topo.links: інакше падіння маршрутизатора дає сорок сповіщень
- вікна обслуговування, ручне заглушення зі стелею в тиждень
- advisory-блокування на тік: кілька API за балансувальником безпечні

Доставка:
- Telegram, webhook, SMTP; токени в core.secrets тим самим кільцем, що
  й паролі від обладнання
- тенант без маршрутів отримує все в усі придатні канали — підключили
  Telegram, має працювати
- тиха година не глушить disaster
- вебхуки на внутрішні адреси заблоковано; для self-hosted знімається
  прапорцем процесу, бо там ця мережа своя

UI: індикатор у шапці, панель зі списком, ack/mute/close, окремий фільтр
придушених, спільна шина подій замість другого WebSocket.

Знайдено живою роботою й виправлено:
- вимкнене або видалене правило лишало алерти сиротами назавжди
- перехід у suppressed не публікував події — UI дізнавався лише після
  перезавантаження сторінки
- кнопки дій на телефоні були 26 px

20 нових тестів, go vet і tsc чисто. Живий прогін: поріг посередині
розділив два справжні пристрої, 5 доставок на 5 подій без повторів.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-15 11:09:19 +03:00
parent a4faf31fcd
commit 215bf8c4db
23 changed files with 4125 additions and 8 deletions

View file

@ -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` движок пропускає:
вони обробляються подіями, а не опитуванням.

View file

@ -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 (керування зондом)** — дешевий і робить онбординг можливим.

View file

@ -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) ще немає.
- **Спільне редагування** обмежене оптимістичним блокуванням: одночасний драг двома

View file

@ -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=<hex|base64>[,...] — потрібні для каналів сповіщень")
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(),

View file

@ -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

View file

@ -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=

View file

@ -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)
}
}
}
}

View file

@ -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 }

View file

@ -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())
}
}
}

View file

@ -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=<hex|base64>[,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
}

View file

@ -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")
}

View file

@ -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))

View file

@ -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
}

View file

@ -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:0008: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
})
}

View file

@ -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("різні правила — різні ключі")
}
}

View file

@ -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 }

View file

@ -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
}

View file

@ -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() {
<span className="hidden xl:block">
<Legend />
</span>
{canSeeAlerts && (
<AlertBadge counts={alerts.counts} onClick={() => setAlertsOpen((v) => !v)} />
)}
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
{live.state && (
<span className="hidden tabular-nums text-slate-500 sm:inline">
@ -233,6 +241,19 @@ function Workspace() {
</div>
)}
</main>
{alertsOpen && canSeeAlerts && (
<AlertsPanel
live={alerts}
onClose={() => setAlertsOpen(false)}
// Клік по алерту веде до пристрою на мапі: інакше зв'язок
// між рядком у списку й вузлом доводиться шукати очима.
onFocusDevice={(deviceID) => {
const n = live.state?.nodes.find((x) => x.device_id === deviceID)
if (n) setSelected(n.id)
}}
/>
)}
</div>
{/* Інспектор вузла: збоку на десктопі, нижнім аркушем на телефоні. */}

View file

@ -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<Alert>(`/api/v1/alerts/${id}/ack`, {
method: 'POST',
body: JSON.stringify({ comment }),
}),
closeAlert: (id: string) =>
request<void>(`/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<void>(`/api/v1/alert-rules/${id}`, {
method: 'PATCH',
body: JSON.stringify({ enabled }),
}),
}

View file

@ -8,6 +8,34 @@ interface Options {
onState: (state: ConnectionState) => void
}
/**
* Спільна шина подій.
*
* З'єднання одне на вкладку, а слухачів у нього кілька: мапа, панель
* алертів і далі буде більше. Другий WebSocket заради алертів коштував
* би ще одного з'єднання й ще одного циклу перепідключень на кожен
* відкритий NOC-екран при тому, що сервер шле обом однаковий потік.
*/
type Listener = (msg: WsMessage) => void
const listeners = new Set<Listener>()
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 {
// Пошкоджене повідомлення не має рвати з'єднання.
}

View file

@ -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<Severity, { dot: string; text: string; label: string }> = {
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<string, string> = {
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 (
<aside
className="fixed inset-0 z-30 flex flex-col border-slate-800 bg-slate-900
md:absolute md:inset-y-0 md:left-auto md:right-0 md:w-96 md:border-l"
>
<header className="flex shrink-0 items-center gap-2 border-b border-slate-800 px-3 py-2">
<h2 className="text-sm font-semibold text-slate-100">Алерти</h2>
<span className="text-xs tabular-nums text-slate-500">
{live.counts.firing} активних · {live.counts.acknowledged} підтверджено
</span>
<button
className="ml-auto rounded px-2 py-1 text-slate-500 hover:bg-slate-800 hover:text-slate-300"
onClick={onClose}
aria-label="Закрити панель алертів"
>
</button>
</header>
{live.error && (
<div className="shrink-0 border-b border-amber-900/50 bg-amber-950/60 px-3 py-1.5 text-xs text-amber-200">
{live.error}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
{live.loading && (
<p className="px-3 py-6 text-center text-xs text-slate-600">Завантаження</p>
)}
{!live.loading && visible.length === 0 && (
<p className="px-3 py-8 text-center text-xs text-slate-600">
{live.alerts.length === 0 ? 'Активних алертів немає' : 'Усі активні — придушені'}
</p>
)}
<ul className="divide-y divide-slate-800">
{visible.map((a) => (
<AlertRow
key={a.id}
alert={a}
canAck={canAck}
onAck={() => void live.ack(a.id)}
onClose={() => void live.close(a.id)}
onMute={a.device_id ? () => void live.mute(a.device_id!, 60) : undefined}
onFocus={
a.device_id && onFocusDevice ? () => onFocusDevice(a.device_id!) : undefined
}
/>
))}
</ul>
</div>
{hidden > 0 && (
<button
className="shrink-0 border-t border-slate-800 px-3 py-2 text-left text-xs text-slate-500
hover:bg-slate-800/50"
onClick={() => setShowSuppressed((v) => !v)}
>
{showSuppressed ? 'Сховати придушені' : `Показати придушені (${hidden})`}
</button>
)}
</aside>
)
}
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 (
<li className={`px-3 py-2.5 ${a.state === 'suppressed' ? 'opacity-50' : ''}`}>
<div className="flex items-start gap-2">
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className={`text-[10px] uppercase tracking-wide ${s.text}`}>{s.label}</span>
<span className="text-[10px] tabular-nums text-slate-600">{ago(a.started_at)}</span>
{acked && (
<span className="text-[10px] text-emerald-500" title={a.acked_by_email}>
прийнято
</span>
)}
</div>
<button
className="block w-full truncate text-left text-sm font-medium text-slate-100
hover:text-sky-300 disabled:hover:text-slate-100"
onClick={onFocus}
disabled={!onFocus}
title={a.title}
>
{a.title}
</button>
{a.message && <p className="mt-0.5 text-xs leading-snug text-slate-400">{a.message}</p>}
{a.suppressed_by && (
<p className="mt-1 text-[11px] text-slate-500">
придушено: {suppressLabel[a.suppressed_by] ?? a.suppressed_by}
</p>
)}
{canAck && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{!acked && (
<SmallButton onClick={onAck} title="Зупинити ескалацію — я вже дивлюсь">
Прийняти
</SmallButton>
)}
{onMute && (
<SmallButton onClick={onMute} title="Не турбувати цим пристроєм годину">
Заглушити 1 год
</SmallButton>
)}
<SmallButton onClick={onClose} title="Закрити вручну">
Закрити
</SmallButton>
</div>
)}
</div>
</div>
</li>
)
}
function SmallButton({
children,
onClick,
title,
}: {
children: React.ReactNode
onClick: () => void
title?: string
}) {
// На телефоні ці кнопки натискають пальцем і зазвичай уночі: 26 px,
// яких вистачало мишею, тут перетворюються на промахи. На десктопі
// висота повертається до щільної — список має лишатись оглядним.
return (
<button
className="min-h-[38px] rounded border border-slate-700 bg-slate-800 px-3 py-2 text-xs
text-slate-300 hover:bg-slate-700
md:min-h-0 md:px-2 md:py-1 md:text-[11px]"
onClick={onClick}
title={title}
>
{children}
</button>
)
}
/** Індикатор у шапці. Мовчить, коли алертів немає. */
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 (
<button
className={`flex items-center gap-1.5 rounded border px-2 py-1 text-xs ${
critical > 0
? 'animate-pulse border-red-800 bg-red-950/60 text-red-200'
: counts.firing > 0
? 'border-amber-800 bg-amber-950/50 text-amber-200'
: 'border-slate-700 text-slate-400 hover:bg-slate-800'
}`}
onClick={onClick}
title={
total === 0
? 'Алертів немає'
: `${counts.firing} активних, ${counts.acknowledged} підтверджено`
}
>
<span>🔔</span>
<span className="tabular-nums">{total}</span>
</button>
)
}
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)} дн`
}

107
web/src/hooks/useAlerts.ts Normal file
View file

@ -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<void>
ack: (id: string) => Promise<void>
close: (id: string) => Promise<void>
mute: (deviceID: string, minutes: number) => Promise<void>
}
/**
* Тримає список активних алертів.
*
* Подія по WebSocket не несе всього алерту лише те, що щось
* змінилося. Список перечитується цілком: він короткий (десятки рядків),
* а склеювати його з дельт означало б розходитись зі станом сервера
* рівно в момент аварії, коли подій найбільше.
*/
export function useAlerts(): LiveAlerts {
const [alerts, setAlerts] = useState<Alert[]>([])
const [counts, setCounts] = useState<AlertCounts>(emptyCounts)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<number | null>(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 }
}

View file

@ -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 }
}