Етап 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:
parent
a4faf31fcd
commit
215bf8c4db
23 changed files with 4125 additions and 8 deletions
122
HISTORY.md
122
HISTORY.md
|
|
@ -682,3 +682,125 @@ HTTP (лише CLI), не можна змінити роль собі, не мо
|
|||
Сторінки керування командою в UI ще немає — є API й методи клієнта
|
||||
(`team`, `roles`, `addMember`, `setRole`, `removeMember`). Додати користувача
|
||||
поки можна лише CLI або запитом.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-15 — Етап 9: алерти й сповіщення
|
||||
|
||||
Система вміла малювати мапу, але мовчала, коли щось падало. Схема
|
||||
(`alr.*`) лежала готовою з Етапу 1 і повністю порожньою. Тепер вона
|
||||
працює.
|
||||
|
||||
### Створено
|
||||
|
||||
- `server/internal/store/alerts.go` — правила, обчислення умов
|
||||
(icmp / interface / metric / no_data), побудова предикатів селектора.
|
||||
- `server/internal/store/alerts_state.go` — життєвий цикл алерту,
|
||||
придушення, граф топології й визначення першопричини.
|
||||
- `server/internal/store/alerts_query.go` — списки, ack, mute, CRUD правил.
|
||||
- `server/internal/store/alerts_channels.go` — канали, маршрути, тихі
|
||||
години, журнал доставки.
|
||||
- `server/internal/alerting/engine.go` — цикл обчислення під
|
||||
advisory-блокуванням.
|
||||
- `server/internal/alerting/notify.go` — Telegram, webhook, SMTP,
|
||||
шаблони повідомлень, SSRF-захист.
|
||||
- `server/internal/httpapi/alerts.go` — 12 ендпоїнтів.
|
||||
- `web/src/hooks/useAlerts.ts`, `components/AlertsPanel.tsx`, шина подій
|
||||
у `api/ws.ts`.
|
||||
- 20 нових тестів (11 у `store`, 9 в `alerting`).
|
||||
|
||||
### Прийняті рішення
|
||||
|
||||
**Движок не має стану між тіками.** Вікно `for_seconds` — це запит по
|
||||
часу до TSDB, а не лічильник у пам'яті. Тому перезапуск процесу нічого
|
||||
не збиває, а два процеси дали б однаковий результат. Лічильник у
|
||||
пам'яті довелося б і зберігати, і відновлювати, і синхронізувати між
|
||||
екземплярами — три способи розійтися з реальністю замість нуля.
|
||||
|
||||
**Дві семантики вікна.** Без `agg` умова має триматися **всі** виміри
|
||||
вікна — це і є антифлап: одна втрачена відповідь не будить людину. З
|
||||
`agg` порівнюється агрегат. Обидві потрібні: «недоступний три хвилини
|
||||
поспіль» і «середнє завантаження за 5 хв вище 85%» — різні питання.
|
||||
|
||||
**Список метрик і операторів закритий.** Значення з `condition`
|
||||
потрапляє в текст запиту (агрегатну функцію не підставиш параметром),
|
||||
тому все, що йде в SQL, проходить whitelist, а все інше — лише
|
||||
параметром. Послаблення тут — це SQL-ін'єкція через JSON у таблиці
|
||||
правил.
|
||||
|
||||
**Кореляція за межею зони недоступності.** Причина аварії — той, у кого
|
||||
лишився живий сусід; хто оточений мертвими — наслідок. Це те, заради
|
||||
чого будувалась `topo.links`: інакше падіння маршрутизатора дає сорок
|
||||
сповіщень, серед яких губиться єдине потрібне. Пристрій без зв'язків
|
||||
завжди лишається причиною — топологія про нього нічого не знає, і
|
||||
списати його на чужу аварію було б вигадкою.
|
||||
|
||||
**Дедуплікація індексом, а не перевіркою в коді.** Частковий унікальний
|
||||
індекс `alerts_active_dedup_uniq` робить другий алерт на ту саму
|
||||
проблему неможливим навіть якщо два движки якимось чином працюють
|
||||
одночасно.
|
||||
|
||||
**Сповіщення шле лише щойно піднятий і не придушений алерт.**
|
||||
Продовження вже відомої проблеми не є новиною. Перевірено прогоном: за
|
||||
три тіки кількість сповіщень не зросла.
|
||||
|
||||
**Тенант без маршрутів отримує все в усі придатні канали.** Підключили
|
||||
Telegram — має працювати. Вимагати ще й маршрут означало б мовчати саме
|
||||
там, де налаштування щойно зроблене й здається повним.
|
||||
|
||||
**Тиха година не глушить `disaster`.** Сенс чергування в тому, щоб
|
||||
його підняли.
|
||||
|
||||
**SSRF-захист із виходом для self-hosted.** Адресу вебхука задає
|
||||
користувач тенанта, а запит іде з сервера — у SaaS це класичний вектор.
|
||||
Але в self-hosted вебхук майже завжди веде саме всередину, у
|
||||
корпоративний Mattermost. Тому заборона знімається прапорцем процесу,
|
||||
і рішення приймає адміністратор сервера, а не користувач тенанта.
|
||||
|
||||
**Стеля ручного заглушення — тиждень.** Безстрокове «не турбувати» —
|
||||
найпоширеніший спосіб тихо вимкнути моніторинг назавжди.
|
||||
|
||||
### Знайдено роботою з живою системою (три справжні помилки)
|
||||
|
||||
**Вимкнене або видалене правило лишало свої алерти висіти вічно.**
|
||||
`alr.alerts.rule_id` має `ON DELETE SET NULL`, а вимкнене правило
|
||||
випадає з вибірки движка — в обох випадках алерти ставали сиротами,
|
||||
яких нікому закрити. Виправлено закриттям алертів у тій самій
|
||||
транзакції, до видалення правила.
|
||||
|
||||
**Перехід у «придушено» не публікував події.** Людина глушила пристрій,
|
||||
движок за 10 секунд переводив алерт у `suppressed`, а UI про це не
|
||||
дізнавався до перезавантаження сторінки. Причина: подія публікувалась
|
||||
лише для нових і закритих алертів. Додано `alert.updated` і читання
|
||||
попереднього стану через CTE у тому ж знімку, що й UPSERT — інакше
|
||||
відрізнити «стан змінився» від «проблема триває» неможливо.
|
||||
|
||||
**Кнопки дій на телефоні були 26 px.** Виміряно в мобільному вигляді:
|
||||
для «Прийняти» і «Заглушити», які натискають пальцем уночі, це
|
||||
промахи. Піднято до 38 px на вузьких екранах, на десктопі щільність
|
||||
збережено.
|
||||
|
||||
### Перевірено наживо
|
||||
|
||||
Проти справжніх даних (2 пристрої, 240 ICMP-вимірів за 10 хв):
|
||||
|
||||
- Правило з порогом 0.5 мс підняло алерт на `gateway` (0.98 мс) і **не**
|
||||
підняло на `snmp-host` (0.08 мс) — поріг рівно посередині.
|
||||
- `no_data` і «помилки на порту» мовчали, як і мали.
|
||||
- Вебхук дійшов: 5 доставок на 5 подій, жодного повтору за багато тіків.
|
||||
- Заглушення пристрою придушило рівно його алерт, сусідній лишився
|
||||
активним.
|
||||
- Вимкнення правила закрило його алерти й не зачепило чужий `ack`.
|
||||
- Глядач отримує 403 на ack і 200 на читання.
|
||||
- У браузері: індикатор пульсує червоним, панель відкривається, ack
|
||||
оновлює список наживо, придушений алерт іде під окремий фільтр.
|
||||
- Мобільний вигляд 375 px: панель на весь екран, горизонтального скролу
|
||||
немає.
|
||||
|
||||
### Чого свідомо не робив
|
||||
|
||||
Ескалацій (`alr.escalation_policies`) і повторних сповіщень немає —
|
||||
алерт сповіщає один раз. Кнопки Ack/Mute у Telegram відмальовуються, але
|
||||
приймача `callback_data` не написано. Web Push відкладено до Етапу 10
|
||||
разом із PWA. Правила з джерел `syslog`/`trap`/`ncm` движок пропускає:
|
||||
вони обробляються подіями, а не опитуванням.
|
||||
|
|
|
|||
20
ROADMAP.md
20
ROADMAP.md
|
|
@ -22,12 +22,12 @@ API віддає готове полотно з живими статусами,
|
|||
|------------|-------|------------|
|
||||
| Мапа, топологія, телеметрія | ✅ | ✅ |
|
||||
| Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ |
|
||||
| **Користувачі, ролі, вхід** | ✅ | ❌ |
|
||||
| **Користувачі, ролі, вхід** | ✅ | ✅ |
|
||||
| **Шаблони опитування** | ❌ | ❌ |
|
||||
| **NCM (збір конфігів)** | ✅ | ⚠️ half |
|
||||
| **Керування зондом із UI** | ✅ | ⚠️ транспорт є |
|
||||
| **Алерти й сповіщення** | ✅ | ❌ |
|
||||
| **Мобільна адаптивність, PWA** | — | ❌ |
|
||||
| **Алерти й сповіщення** | ✅ | ✅ |
|
||||
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |
|
||||
| Дашборди, NOC TV | ✅ | ❌ |
|
||||
| Білінг, ліцензії | ✅ | ❌ |
|
||||
|
||||
|
|
@ -207,7 +207,17 @@ DELETE /api/v1/agents/{id}
|
|||
|
||||
---
|
||||
|
||||
## Етап 9. Алерти й сповіщення
|
||||
## Етап 9. Алерти й сповіщення — ✅ зроблено 2026-08-15
|
||||
|
||||
> Реалізовано: движок правил (icmp/interface/metric/no_data), антифлап
|
||||
> вікном, кореляція за топологією, вікна обслуговування й ручне
|
||||
> заглушення, маршрути з тихими годинами, доставка в Telegram/webhook/
|
||||
> SMTP, панель алертів у UI. Подробиці — [HISTORY.md](HISTORY.md),
|
||||
> контракт — [server/API.md](server/API.md).
|
||||
>
|
||||
> **Відкладено з цього етапу:** ескалації й повторні сповіщення,
|
||||
> приймач кнопок Telegram (`callback_data`), Web Push (іде з Етапом 10),
|
||||
> правила з джерел `syslog`/`trap`/`ncm`/`compliance`.
|
||||
|
||||
**Те, без чого це не моніторинг.** Система малює мапу, але мовчить, коли щось
|
||||
падає. Схема готова з Етапу 1 (`alr.rules`, `alerts`, `channels`, `routes`,
|
||||
|
|
@ -256,7 +266,7 @@ Diff* — усе з ТЗ.
|
|||
## Порядок і чому саме такий
|
||||
|
||||
1. ~~**Етап 5 (користувачі)** — без входу продукт не можна віддати нікому.~~ ✅
|
||||
2. **Етап 9 (алерти)** — без сповіщень це не моніторинг. Наступний у черзі.
|
||||
2. ~~**Етап 9 (алерти)** — без сповіщень це не моніторинг.~~ ✅
|
||||
3. **Етап 6 (шаблони)** — знімає потребу дописувати Go під кожен вендор; що
|
||||
раніше, то менше захардкодженого коду доведеться викидати.
|
||||
4. **Етап 8 (керування зондом)** — дешевий і робить онбординг можливим.
|
||||
|
|
|
|||
129
server/API.md
129
server/API.md
|
|
@ -77,6 +77,18 @@ JWT — ні.
|
|||
| `POST` | `/api/v1/maps/{id}/undo` | відкотити останню зміну полотна |
|
||||
| `GET` | `/api/v1/devices` | інвентар |
|
||||
| `GET` | `/api/v1/agents` | зонди, версії, самометрики |
|
||||
| `GET` | `/api/v1/alerts` | активні алерти + лічильники |
|
||||
| `POST` | `/api/v1/alerts/{id}/ack` | підтвердити (`alerts:ack`) |
|
||||
| `POST` | `/api/v1/alerts/{id}/close` | закрити вручну (`alerts:ack`) |
|
||||
| `POST` | `/api/v1/mutes` | заглушити пристрій (`alerts:ack`) |
|
||||
| `GET` | `/api/v1/alert-rules` | правила з лічильником активних |
|
||||
| `POST` | `/api/v1/alert-rules` | створити правило (`alerts:write`) |
|
||||
| `PATCH` | `/api/v1/alert-rules/{id}` | увімкнути/вимкнути (`alerts:write`) |
|
||||
| `DELETE` | `/api/v1/alert-rules/{id}` | видалити правило (`alerts:write`) |
|
||||
| `GET` | `/api/v1/channels` | канали доставки (без секретів) |
|
||||
| `POST` | `/api/v1/channels` | створити канал (`alerts:write`) |
|
||||
| `POST` | `/api/v1/channels/{id}/test` | пробне повідомлення (`alerts:write`) |
|
||||
| `DELETE` | `/api/v1/channels/{id}` | видалити канал (`alerts:write`) |
|
||||
| `GET` | `/api/v1/ws` | WebSocket: події та завантаження каналів |
|
||||
|
||||
### `POST /api/v1/auth/login` — вхід
|
||||
|
|
@ -389,6 +401,114 @@ Sec-WebSocket-Protocol: netpulse.token.<токен>
|
|||
знімків в історії: 2
|
||||
```
|
||||
|
||||
## Алерти
|
||||
|
||||
Движок правил живе всередині `netpulse-api` і обчислюється раз на
|
||||
`-alert-interval` (за замовчуванням 30 с). Кілька екземплярів API за
|
||||
балансувальником безпечні: тік бере `pg_try_advisory_lock`, тож правила
|
||||
рахує рівно один — дедуплікацію алертів захищає індекс, а от сповіщення
|
||||
пішли б у кількох копіях.
|
||||
|
||||
Движок не тримає стану між тіками. Вікно `for_seconds` — це запит по
|
||||
часу до TSDB, а не лічильник у пам'яті, тому перезапуск процесу нічого
|
||||
не збиває.
|
||||
|
||||
### Умови правил
|
||||
|
||||
```jsonc
|
||||
// Усі виміри вікна мають задовольняти умову — це і є антифлап.
|
||||
{"metric": "loss_pct", "op": ">", "value": 20}
|
||||
|
||||
// Агрегат за вікно порівнюється один раз.
|
||||
{"metric": "rtt_avg_ms", "op": ">", "value": 150, "agg": "avg"}
|
||||
|
||||
// Даних немає взагалі — моніторинг, що мовчить про мертвий зонд,
|
||||
// показує зелену мапу мертвої мережі.
|
||||
{"metric": "no_data"}
|
||||
|
||||
// Довільна серія з ts.samples.
|
||||
{"metric_key": "cpu.util", "op": ">", "value": 85, "agg": "avg"}
|
||||
```
|
||||
|
||||
`op`: `> >= < <= == !=`. `agg`: `avg min max sum count last`.
|
||||
Метрики: для `icmp` — `rtt_avg_ms`, `rtt_min_ms`, `rtt_max_ms`,
|
||||
`jitter_ms`, `loss_pct`, `reachable`; для `interface` — `in_bps`,
|
||||
`out_bps`, `in_pps`, `out_pps`, `util_in_pct`, `util_out_pct`,
|
||||
`in_errors`, `out_errors`, `in_discards`, `out_discards`, `oper_up`.
|
||||
|
||||
Списки закриті навмисно: значення з `condition` потрапляє в текст
|
||||
запиту, і будь-яке послаблення тут перетворюється на SQL-ін'єкцію через
|
||||
JSON у таблиці правил.
|
||||
|
||||
`selector` обмежує область: `device_ids`, `group_ids`, `site_ids`,
|
||||
`kinds`, `vendors`, `tags`. Порожній означає «до всього» — найчастіший
|
||||
випадок, і вимагати для нього переліку означало б ламати правило щоразу,
|
||||
коли додається пристрій.
|
||||
|
||||
### Кореляція за топологією
|
||||
|
||||
Правило з `depends_on_topology` (типово увімкнено) не піднімає алерти на
|
||||
пристроях, що стоять за іншим недоступним пристроєм. Причина аварії — на
|
||||
межі зони недоступності: у кого лишився хоч один живий сусід, той упав
|
||||
сам; хто оточений виключно мертвими — наслідок.
|
||||
|
||||
Пристрій без жодного відомого зв'язку завжди лишається причиною:
|
||||
топологія про нього нічого не знає, тож списати його на чужу аварію
|
||||
було б вигадкою. Повністю мертвий острів алертує весь — краще зайвий
|
||||
шум, ніж мовчазне ковтання цілого сегмента.
|
||||
|
||||
### Придушення
|
||||
|
||||
- **Вікна обслуговування** (`alr.maintenance_windows`) — за розкладом;
|
||||
вікно без селектора накриває весь тенант.
|
||||
- **Ручне заглушення** (`alr.mutes`) — кнопка в UI, стеля 7 днів.
|
||||
Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути
|
||||
моніторинг назавжди.
|
||||
|
||||
Придушений алерт лишається видимим у списку (окремим фільтром), але не
|
||||
надсилає сповіщень.
|
||||
|
||||
### Канали й маршрути
|
||||
|
||||
Канал зберігає несекретну частину в `config`, а токен — у
|
||||
`core.secrets`, зашифрований тим самим кільцем, що й паролі від
|
||||
обладнання. Перелік каналів секрети не розшифровує взагалі.
|
||||
|
||||
Тенант **без жодного маршруту** отримує сповіщення в усі придатні
|
||||
канали: підключили Telegram — має працювати без додаткових налаштувань.
|
||||
Маршрути з'являються тоді, коли треба розділити потоки.
|
||||
|
||||
Тихі години маршруту глушать усе, крім `disaster`: сенс чергування в
|
||||
тому, щоб його підняли.
|
||||
|
||||
Вебхуки на внутрішні адреси заблоковані (SSRF), бо адресу задає
|
||||
користувач тенанта, а запит іде з сервера. У self-hosted це знімається
|
||||
прапорцем `-allow-private-webhooks` — там внутрішня мережа належить тому
|
||||
самому, хто налаштовує вебхук.
|
||||
|
||||
### Живий прогін алертів
|
||||
|
||||
```
|
||||
1) вхід власника 200
|
||||
2) активні алерти 200, 1 алерт: gateway 0.98 мс > 0.5
|
||||
snmp-host (0.08 мс) не спрацював — поріг посередині
|
||||
3) правила 200, 3 правила, «no_data» і «помилки на порту» мовчать
|
||||
4) канал webhook створено 201
|
||||
5) перевірка каналу 200, ok=true — повідомлення дійшло
|
||||
6) нове правило 201
|
||||
7) після тіку 3 алерти, сповіщень по 1
|
||||
8) підтвердження 200, стан=acknowledged
|
||||
9) глядач пробує підтвердити 403
|
||||
10) глядач читає алерти 200
|
||||
|
||||
дедуплікація: за три тіки алертів 3→3, сповіщень 2→2
|
||||
вимкнення правила його алерти закрито, чужий ack не зачеплено
|
||||
повернення правила алерти піднялись знову
|
||||
заглушення пристрою рівно його алерт → suppressed (mute)
|
||||
видалення правила його алерти закрито, сиріт не лишилось
|
||||
доставок за прогін 5 на 5 подій — жодного повтору
|
||||
```
|
||||
|
||||
### Живий прогін входу
|
||||
|
||||
```
|
||||
|
|
@ -410,7 +530,14 @@ Sec-WebSocket-Protocol: netpulse.token.<токен>
|
|||
## Чого ще немає
|
||||
|
||||
- **Історія метрик**: `GET /api/v1/metrics` для графіків із `ts.samples_5m` не написано.
|
||||
- **Алерти**: `alr.alerts` не віддаються, хоча схема готова.
|
||||
- **Ескалації**: `alr.escalation_policies` і повторні сповіщення не реалізовані —
|
||||
сповіщення надсилається один раз при піднятті алерту.
|
||||
- **Web Push і Telegram-кнопки**: `alr.push_subscriptions` порожня, а `callback_data`
|
||||
кнопок Ack/Mute у Telegram нікуди не приходить — бот-приймач не написано.
|
||||
- **Правила з джерел `syslog`, `trap`, `ncm`, `compliance`**: движок їх свідомо
|
||||
пропускає, бо вони обробляються подіями, а не опитуванням.
|
||||
- **Редагування правил**: `PATCH` міняє лише `enabled`; умову правила змінюють
|
||||
перестворенням.
|
||||
- **Завантаження підкладок**: `map_backgrounds.storage_key` можна задати патчем, але
|
||||
самого прийому файлів (S3/MinIO) ще немає.
|
||||
- **Спільне редагування** обмежене оптимістичним блокуванням: одночасний драг двома
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
319
server/internal/alerting/engine.go
Normal file
319
server/internal/alerting/engine.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
460
server/internal/alerting/notify.go
Normal file
460
server/internal/alerting/notify.go
Normal 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 }
|
||||
172
server/internal/alerting/notify_test.go
Normal file
172
server/internal/alerting/notify_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
455
server/internal/httpapi/alerts.go
Normal file
455
server/internal/httpapi/alerts.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
492
server/internal/store/alerts.go
Normal file
492
server/internal/store/alerts.go
Normal 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
|
||||
}
|
||||
348
server/internal/store/alerts_channels.go
Normal file
348
server/internal/store/alerts_channels.go
Normal 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:00–08:00») — не окремий випадок, а норма
|
||||
// для чергувань, тому обробляється явно: без цього нічні сповіщення
|
||||
// тихими годинами не глушились би взагалі.
|
||||
func (s *RouteSchedule) IsQuiet(t time.Time) bool {
|
||||
if s == nil || len(s.Quiet) == 0 {
|
||||
return false
|
||||
}
|
||||
loc := time.UTC
|
||||
if s.TZ != "" {
|
||||
if l, err := time.LoadLocation(s.TZ); err == nil {
|
||||
loc = l
|
||||
}
|
||||
}
|
||||
lt := t.In(loc)
|
||||
mins := lt.Hour()*60 + lt.Minute()
|
||||
day := int(lt.Weekday())
|
||||
|
||||
for _, q := range s.Quiet {
|
||||
if len(q.Days) > 0 && !containsInt(q.Days, day) {
|
||||
continue
|
||||
}
|
||||
from, ok1 := parseHM(q.From)
|
||||
to, ok2 := parseHM(q.To)
|
||||
if !ok1 || !ok2 {
|
||||
continue
|
||||
}
|
||||
if from <= to {
|
||||
if mins >= from && mins < to {
|
||||
return true
|
||||
}
|
||||
} else if mins >= from || mins < to {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseHM(s string) (int, bool) {
|
||||
var h, m int
|
||||
if _, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return 0, false
|
||||
}
|
||||
return h*60 + m, true
|
||||
}
|
||||
|
||||
func containsInt(xs []int, v int) bool {
|
||||
for _, x := range xs {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LoadChannels читає канали тенанта й розшифровує їхні секрети.
|
||||
func (s *Store) LoadChannels(ctx context.Context, tenantID string, ring *crypto.Keyring) ([]Channel, error) {
|
||||
var out []Channel
|
||||
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT c.id::text, c.kind::text, c.name, c.config::text,
|
||||
COALESCE(c.template,''), c.min_severity::text, c.enabled,
|
||||
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
|
||||
FROM alr.channels c
|
||||
LEFT JOIN core.secrets s ON s.id = c.secret_id
|
||||
WHERE c.tenant_id = $1
|
||||
ORDER BY c.name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var c Channel
|
||||
var cfg string
|
||||
var keyID, aad *string
|
||||
var nonce, ct, tag []byte
|
||||
|
||||
if err := rows.Scan(&c.ID, &c.Kind, &c.Name, &cfg, &c.Template,
|
||||
&c.MinSeverity, &c.Enabled, &keyID, &nonce, &ct, &tag, &aad); err != nil {
|
||||
return err
|
||||
}
|
||||
c.TenantID = tenantID
|
||||
c.Config = json.RawMessage(cfg)
|
||||
|
||||
if keyID != nil && ring != nil {
|
||||
plain, err := ring.Decrypt(&crypto.Secret{
|
||||
KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
|
||||
}, derefStr(aad))
|
||||
if err != nil {
|
||||
// Канал із нечитабельним секретом не має валити
|
||||
// доставку решти: одна зіпсована інтеграція гірша
|
||||
// за мовчання лише для себе самої.
|
||||
return fmt.Errorf("канал %s: розшифровка секрету: %w", c.Name, err)
|
||||
}
|
||||
c.Secret = string(plain)
|
||||
c.HasSecret = true
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func derefStr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// LoadRoutes читає маршрути тенанта в порядку пріоритету.
|
||||
func (s *Store) LoadRoutes(ctx context.Context, tenantID string) ([]Route, error) {
|
||||
var out []Route
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id::text, name, priority, matcher::text,
|
||||
channel_ids::text[], COALESCE(schedule::text,'')
|
||||
FROM alr.routes
|
||||
WHERE tenant_id = $1 AND enabled
|
||||
ORDER BY priority, name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var r Route
|
||||
var matcher, sched string
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.Priority, &matcher,
|
||||
&r.ChannelIDs, &sched); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(matcher), &r.Matcher); err != nil {
|
||||
return fmt.Errorf("маршрут %s: matcher: %w", r.Name, err)
|
||||
}
|
||||
if sched != "" {
|
||||
var sc RouteSchedule
|
||||
if err := json.Unmarshal([]byte(sched), &sc); err == nil {
|
||||
r.Schedule = &sc
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Matches — чи цей маршрут бере цей алерт.
|
||||
func (r Route) Matches(a Alert) bool {
|
||||
if r.Matcher.SeverityGte != "" {
|
||||
if severityRank[a.Severity] < severityRank[r.Matcher.SeverityGte] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(r.Matcher.RuleIDs) > 0 && !containsStr(r.Matcher.RuleIDs, a.RuleID) {
|
||||
return false
|
||||
}
|
||||
if len(r.Matcher.DeviceIDs) > 0 && !containsStr(r.Matcher.DeviceIDs, a.DeviceID) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsStr(xs []string, v string) bool {
|
||||
for _, x := range xs {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RecordNotification пише спробу доставки в журнал і рахує сповіщення
|
||||
// на алерті.
|
||||
//
|
||||
// Журнал ведеться до відправлення, а не після: інакше падіння процесу
|
||||
// між HTTP-запитом і записом лишало б слід «не надсилали» на вже
|
||||
// доставленому повідомленні, і ретрай слав би дубль.
|
||||
func (s *Store) RecordNotification(ctx context.Context, tenantID, alertID, channelID,
|
||||
status, errMsg, externalID string, payload any) error {
|
||||
|
||||
data, _ := json.Marshal(payload)
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO alr.notifications
|
||||
(tenant_id, alert_id, channel_id, status, error, external_id, payload)
|
||||
VALUES ($1, $2, $3, $4::alr.delivery_status, $5, $6, $7::jsonb)
|
||||
`, tenantID, nullUUID(alertID), nullUUID(channelID), status,
|
||||
nullString(errMsg), nullString(externalID), string(data)); err != nil {
|
||||
return err
|
||||
}
|
||||
if status != "sent" || alertID == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE alr.alerts SET notify_count = notify_count + 1
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
`, tenantID, alertID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// CRUD каналів для UI
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type ChannelInput struct {
|
||||
Kind string
|
||||
Name string
|
||||
Config string
|
||||
Template string
|
||||
MinSeverity string
|
||||
Secret string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// CreateChannel зберігає канал; секрет шифрується тим самим кільцем, що
|
||||
// й паролі від обладнання — окремого сховища для нього немає навмисно.
|
||||
func (s *Store) CreateChannel(ctx context.Context, tenantID string, in ChannelInput, ring *crypto.Keyring) (string, error) {
|
||||
var id string
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
var secretID any
|
||||
if in.Secret != "" {
|
||||
if ring == nil {
|
||||
return errors.New("сервер запущено без ключа шифрування — зберегти секрет каналу ніяк")
|
||||
}
|
||||
aad := tenantID + "|alr.channel"
|
||||
sec, err := ring.Encrypt([]byte(in.Secret), aad)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var sid string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
|
||||
VALUES ($1, 'api_token', $2, $3, $4, $5, $6)
|
||||
RETURNING id::text
|
||||
`, tenantID, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad).Scan(&sid); err != nil {
|
||||
return err
|
||||
}
|
||||
secretID = sid
|
||||
}
|
||||
|
||||
return tx.QueryRow(ctx, `
|
||||
INSERT INTO alr.channels
|
||||
(tenant_id, kind, name, config, secret_id, template, min_severity, enabled)
|
||||
VALUES ($1, $2::alr.channel_kind, $3, $4::jsonb, $5, $6, $7::alr.severity, $8)
|
||||
RETURNING id::text
|
||||
`, tenantID, in.Kind, in.Name, in.Config, secretID,
|
||||
nullString(in.Template), in.MinSeverity, in.Enabled).Scan(&id)
|
||||
})
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
// Секрет видаляється разом із каналом: залишений «на всякий
|
||||
// випадок» токен у core.secrets — це чинний доступ, про який
|
||||
// уже ніхто не пам'ятає.
|
||||
//
|
||||
// Лічильник беремо з видалення каналу, а не секрету: канал без
|
||||
// секрету (webhook без підпису) дав би 0 рядків у другому
|
||||
// операторі, і відповідь була б «немає такого» на успішне
|
||||
// видалення.
|
||||
var deleted int
|
||||
err := tx.QueryRow(ctx, `
|
||||
WITH gone AS (
|
||||
DELETE FROM alr.channels
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
RETURNING secret_id
|
||||
), dropped AS (
|
||||
DELETE FROM core.secrets
|
||||
WHERE id IN (SELECT secret_id FROM gone WHERE secret_id IS NOT NULL)
|
||||
)
|
||||
SELECT count(*)::int FROM gone
|
||||
`, tenantID, channelID).Scan(&deleted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted == 0 {
|
||||
return ErrAlertNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
211
server/internal/store/alerts_logic_test.go
Normal file
211
server/internal/store/alerts_logic_test.go
Normal 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("різні правила — різні ключі")
|
||||
}
|
||||
}
|
||||
338
server/internal/store/alerts_query.go
Normal file
338
server/internal/store/alerts_query.go
Normal 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 }
|
||||
446
server/internal/store/alerts_state.go
Normal file
446
server/internal/store/alerts_state.go
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
{/* Інспектор вузла: збоку на десктопі, нижнім аркушем на телефоні. */}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
// Пошкоджене повідомлення не має рвати з'єднання.
|
||||
}
|
||||
|
|
|
|||
250
web/src/components/AlertsPanel.tsx
Normal file
250
web/src/components/AlertsPanel.tsx
Normal 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
107
web/src/hooks/useAlerts.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue