Історія метрик, шаблони будь-яких перевірок, маршрути правил

Метрики збиралися в ts.samples і не показувалися ніде — побачити
зібране можна було лише через psql. Додано GET /devices/{id}/series і
/metrics: джерело (сирі дані, 5m, 1h) обирається за потрібним кроком,
бакетизація в БД, пропуск у даних лишається пропуском, а не лінією
через діру.

Знайдено живим прогоном: зонд працює рівно годину. CredentialTTL —
година, Credentials() свідомо не віддає прострочені (щоб не блокувати
облікові записи на пристроях), а поновлення не просив ніхто:
CredentialRequest є в контракті з Етапу 2, сервер його обробляє, агент
не надсилає. Будь-яка інсталяція припиняла збирати SNMP через годину
після старту й мовчала про це.

Шаблон описує перевірки будь-якого типу, не лише OID. Пачкою в один PDU
збираються тільки snmp.get; решта — елемент на чек, слід у
core.checks.template_item_key. Вбудований шаблон «Доступність (ICMP)».
Імпорт/експорт глобальний і поштучний, свій формат замість Zabbix-YAML.

Спільний розклад бекапів із перевизначенням на хості: прапорець
follows_default, а не порівняння значень — власний розклад може
випадково збігтися зі спільним.

Правило саме каже, куди йде його алерт: канали, тихі години, групи
хостів, повідомлення про відновлення. Канали правила перекривають
маршрути повністю.

Доступи до обладнання отримали свою сторінку: SSH-паролі й
SNMP-community заводяться, змінюються й видаляються з вебу. Секрет
назовні не повертається ніколи.

Дрібниці за скаргами: відступи в картках шаблонів, українська множина,
ручний ввід інтервалу опитування, підтвердження видалення з описом
наслідків замість «Ви впевнені?», помітні кнопки видалення замість
сірого ✕ у кутку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-24 17:21:50 +03:00
parent 2de1894fd5
commit cd8d4c62ed
38 changed files with 3450 additions and 309 deletions

View file

@ -1471,3 +1471,142 @@ Mikrotik, де індекс фіксований.
успадковує інший). Тригерів усередині шаблону — поки правила алертів
заводяться окремо. `snmp.walk` як тип елемента: таблиці з динамічним
індексом (CPU по ядрах, диски, сенсори) шаблон описати не вміє.
---
## Історія метрик і те, що знайшлося дорогою
Система збирала телеметрію в `ts.samples` і не показувала її ніде: щоб
побачити зібране, треба було йти в `psql`. Шаблони, зроблені на минулому
кроці, збирали метрики в нікуди.
**Джерело обирається за кроком.** Читати сирі точки за місяць — це
мільйони рядків заради трьохсот пікселів; брати годинні бакети на вікні
в п'ять хвилин — це графік з однієї точки. Роллапи `ts.samples_5m` і
`ts.samples_1h` уже існували з Етапу 1, лишалось ними скористатися.
**Крок рахується з бажаної кількості точок, а не приходить ззовні.**
Інакше вузьке вікно з дрібним кроком повернуло б десятки тисяч точок, з
яких екран покаже сотні.
**Пропуск і нуль — різні речі.** Значення точки — вказівник: лінія,
проведена через діру в даних, каже «все було добре», хоча насправді
нічого не відомо. Це найгірший різновид брехні в моніторингі.
**`series_id` приходить від клієнта, тож належність перевіряється явно.**
Таблиці `ts.*` не під RLS (несумісно зі стисненням), і без цієї
перевірки чужий ідентифікатор віддав би чужі дані.
### Знайдено живим прогоном: зонд працює рівно годину
Метрики SNMP замовкли о 12:05 — рівно через годину після того, як зонд
отримав доступи. У журналі — жодного слова.
`CredentialTTL` дорівнює годині, і `Credentials()` свідомо не віддає
прострочені: інакше зонд довбав би комутатори старим паролем і
заблокував обліковий запис. Правильне рішення. Але **поновлення не
просив ніхто**: повідомлення `CredentialRequest` є в контракті з Етапу 2,
сервер його обробляє, агент — не надсилає. Тобто будь-яка інсталяція
припиняла збирати SNMP через годину після старту й мовчала про це.
Тепер зонд просить нову пачку за десять хвилин до кінця терміну, не
частіше ніж раз на хвилину. Причина в запиті розрізняє `expiring` і
`expired` — за журналом видно, чи встигли.
---
## Шаблон перестав бути «набором OID»
Шаблон описував лише `snmp.get`. Пінг заводився руками — і це змушувало
пам'ятати, що саме шаблон покриває, а що ні.
Тепер елемент має `params` (те саме, що лягає в `core.checks.params`), а
`oid` і `metric_key` стали необов'язковими. Пачкою в один PDU
збираються тільки OID: два пінги з різними параметрами — це просто два
пінги. Для негрупованих типів елемент відповідає окремому чеку, і його
слід у `core.checks.template_item_key` дозволяє впізнати рядок.
**Ключ елемента, а не його id.** Збереження шаблону перезаписує елементи
цілком, тож id живуть недовго, а ключ стабільний за визначенням.
Вбудований шаблон «Доступність (ICMP)» — той, який чіпляють першим.
### Обмін шаблонами
Експорт: усі одним файлом, окремий шаблон кнопкою на картці, і ще один
вхід — просто з форми редагування. Імпорт: глобальний (файл або
вставлений текст) і локальний, що замінює вміст відкритого шаблону.
Формат свій. Zabbix-YAML описує елемент ключем виду `snmp.get[...]`, з
препроцесингом, value maps і тригерами — нічого з цього тут немає, і
вдавати сумісність означало б мовчки втрачати половину імпортованого.
Ідентифікатори з документа прибираються: на іншому стенді вони нічого не
значать, а лишені створюють ілюзію, що імпорт «відновить те саме».
---
## Спільний розклад бекапів
Розклад існував лише поштучно: щоб бекапити сто пристроїв, треба було
сто разів відкрити форму.
Спільний розклад заводить політику кожному придатному хосту з
`follows_default = true`. Хост, якому задали власний розклад, прапорець
втрачає.
**Прапорець, а не порівняння значень.** Власний розклад може випадково
збігтися зі спільним, і тоді зміна спільного мовчки потягла б за собою
хост, який навмисно налаштували окремо.
**Вимкнення спільного зупиняє лише тих, хто йому слідує.** Форма показує
`following_count` і `custom_count`: без цих двох чисел вона не каже
головного — кого саме зачепить зміна.
---
## Куди йде алерт
Правило вміло сказати «за яких умов», але не «кому». Маршрути
(`alr.routes`) вирішують інше завдання — спільну політику на всі правила
разом, і для звичайного «це правило важливе, шліть черговому в Telegram»
вони заважкі.
Порядок вирішення: канали правила → маршрути тенанта → усі придатні
канали.
**Канали правила перекривають маршрути повністю.** Інакше «шліть це
черговому» перетворювалося б на «шліть це черговому і ще туди, куди
вирішить спільна політика» — тобто на щось, чого людина не просила.
Разом із каналами правило отримало тихі години (той самий формат, що в
маршрутах — щоб не заводити другий діалект того самого поняття), вибір
груп хостів і перемикач повідомлень про відновлення.
---
## Дрібниці, які насправді не дрібниці
**Відступи в картках шаблонів.** `Card` навмисно без внутрішнього
відступу — на інших сторінках його діти самі малюють `px-4 py-2.5` і
роздільники на всю ширину. На сторінці шаблонів картка — звичайний блок
тексту, і клас загубився. Текст стояв упритул до рамки.
**Українська множина.** «1 метрик» — дрібниця, з якої складається
враження, що інтерфейс писали не для людей. `plural(n, one, few, many)`
з правилом 1114.
**Інтервал опитування вводиться руками.** Список сам по собі не годиться:
рано чи пізно комусь потрібні 45 секунд, і відсутність такої можливості
робить продукт «майже підходящим». Ручний ввід сам по собі теж не
годиться: у дев'яти випадках із десяти значення є в списку.
**Підтвердження показує наслідки, а не питає «Ви впевнені?».** «Видалити
групу?» і «Видалити групу? 34 хости втратять межі доступу» — різні
питання, і людина відповідає на них по-різному. Вбудований `confirm()`
другого не вміє, тому свій діалог.
**Кнопки видалення стали кнопками.** Сірий `✕` у кутку рядка формально
існував і фактично не знаходився — користувач повідомив, що видалення
користувачів «немає».

View file

@ -102,6 +102,9 @@ POST /api/v1/roles кастомна роль із набором
> звірка планів зонда, REST і редактор у вебі, чотири вбудовані шаблони.
> Перевірено наскрізно на живому net-snmp.
>
> **Додано 2026-08-24:** шаблон описує перевірки будь-якого типу (не лише
> OID), імпорт/експорт глобальний і поштучний, ручні інтервали.
>
> **Лишилось:** автопризначення за `sysObjectID`, прототипи шаблонів,
> `snmp.walk` як тип елемента (таблиці з динамічним індексом), тригери
> всередині шаблону.

View file

@ -0,0 +1,73 @@
package session
import (
"context"
"testing"
"time"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Комплект доступів живе годину. Якщо його не поновлювати, зонд через
// цю годину замовкає: Credentials() свідомо не віддає прострочені, і
// кожна перевірка падає з «немає креденшелів».
//
// Перевіряємо саме рішення, а не мережу: підсовуємо пачку, що от-от
// протухне, і чекаємо на запит поновлення.
func TestSessionAsksToRenewExpiringCredentials(t *testing.T) {
s := &Session{}
out := make(chan *npv1.ControlUp, 4)
ctx := context.Background()
// Свіжа пачка — просити нічого.
s.applyCredentials(&npv1.CredentialBundle{
ExpiresAt: timestamppb.New(time.Now().Add(time.Hour)),
})
s.maybeRenewCredentials(ctx, out)
if len(out) != 0 {
t.Fatal("попросили поновлення на свіжій пачці")
}
// Лишилось менше запасу — час просити.
s.applyCredentials(&npv1.CredentialBundle{
ExpiresAt: timestamppb.New(time.Now().Add(credRenewBefore / 2)),
})
s.maybeRenewCredentials(ctx, out)
if len(out) != 1 {
t.Fatalf("очікували один запит, отримали %d", len(out))
}
msg := <-out
req := msg.GetCredentialRequest()
if req == nil {
t.Fatal("надіслано не CredentialRequest")
}
if req.Reason != "expiring" {
t.Fatalf("причина %q", req.Reason)
}
// Повторний тік одразу після запиту не має додати другого.
s.maybeRenewCredentials(ctx, out)
if len(out) != 0 {
t.Fatal("запит продубльовано, антиспам не спрацював")
}
// Уже прострочена пачка — причина інша.
s.applyCredentials(&npv1.CredentialBundle{
ExpiresAt: timestamppb.New(time.Now().Add(-time.Minute)),
})
s.maybeRenewCredentials(ctx, out)
if len(out) != 1 {
t.Fatalf("очікували запит на простроченій пачці, отримали %d", len(out))
}
if r := (<-out).GetCredentialRequest(); r == nil || r.Reason != "expired" {
t.Fatalf("причина %v", r)
}
// Пачка без TTL — поновлювати нічого.
s.applyCredentials(&npv1.CredentialBundle{})
s.maybeRenewCredentials(ctx, out)
if len(out) != 0 {
t.Fatal("попросили поновлення для пачки без терміну")
}
}

View file

@ -70,6 +70,8 @@ type Session struct {
credMu sync.RWMutex
creds map[string][]*npv1.Credential
credExpiry time.Time
// Коли востаннє просили поновлення — щоб не питати щотіку.
credAsked time.Time
devMu sync.RWMutex
devices map[string]*npv1.DeviceTarget
@ -390,6 +392,7 @@ func (s *Session) heartbeatLoop(ctx context.Context, out chan<- *npv1.ControlUp,
s.enqueue(ctx, out, &npv1.ControlUp{
Payload: &npv1.ControlUp_Heartbeat{Heartbeat: hb},
})
s.maybeRenewCredentials(ctx, out)
}
}
}
@ -510,6 +513,53 @@ func (s *Session) applyDirective(d *npv1.Directive) (stop bool) {
return false
}
// credRenewBefore — за скільки до кінця терміну просити нові.
//
// Комплект живе годину, heartbeat ходить раз на 30 секунд: десяти
// хвилин вистачає на кількадесят спроб навіть на поганому каналі.
const credRenewBefore = 10 * time.Minute
// credAskEvery — не частіше, ніж раз на хвилину.
//
// Якщо сервер мовчить, питати щотіку означає засипати його однаковими
// запитами саме тоді, коли йому й так погано.
const credAskEvery = time.Minute
// maybeRenewCredentials просить нову пачку доступів до того, як стара
// протухне.
//
// Без цього зонд працює рівно годину: `Credentials()` свідомо не віддає
// прострочені, щоб не блокувати облікові записи на пристроях, — і після
// цього кожна перевірка падає з «немає креденшелів», доки хтось не
// перезапустить зв'язок. Знайдено живим прогоном: SNMP замовк через
// годину після підключення, а в журналі не було ані слова про помилку.
func (s *Session) maybeRenewCredentials(ctx context.Context, out chan<- *npv1.ControlUp) {
s.credMu.Lock()
expiry, asked := s.credExpiry, s.credAsked
now := time.Now()
// Нульовий термін — комплект без TTL, поновлювати нічого.
if expiry.IsZero() || now.Add(credRenewBefore).Before(expiry) {
s.credMu.Unlock()
return
}
if !asked.IsZero() && now.Sub(asked) < credAskEvery {
s.credMu.Unlock()
return
}
s.credAsked = now
reason := "expiring"
if now.After(expiry) {
reason = "expired"
}
s.credMu.Unlock()
s.enqueue(ctx, out, &npv1.ControlUp{
Payload: &npv1.ControlUp_CredentialRequest{
CredentialRequest: &npv1.CredentialRequest{Reason: reason},
},
})
}
func (s *Session) applyCredentials(b *npv1.CredentialBundle) {
s.credMu.Lock()
defer s.credMu.Unlock()
@ -523,6 +573,8 @@ func (s *Session) applyCredentials(b *npv1.CredentialBundle) {
} else {
s.credExpiry = time.Time{}
}
// Пачка прийшла — наступний запит рахується від нуля.
s.credAsked = time.Time{}
}
// ---------------------------------------------------------------------

View file

@ -0,0 +1,58 @@
-- =====================================================================
-- NetPulse :: 0016_templates_any_check.sql
-- Шаблон перестає бути «набором OID» і стає набором перевірок будь-якого
-- типу: пінг, HTTP, SNMP.
--
-- Причина проста: «як опитувати цей клас пристроїв» — це одне рішення, і
-- ділити його на «пінг заводиться руками, а SNMP шаблоном» означає
-- змусити людину пам'ятати, що саме шаблон покриває, а що ні.
-- =====================================================================
-- Параметри перевірки для типів, які не описуються одним OID:
-- {"count":3,"packet_size":56} для icmp.ping, {"url":"…"} для http.status.
ALTER TABLE tpl.items
ADD COLUMN params jsonb NOT NULL DEFAULT '{}'::jsonb;
-- OID і ключ метрики більше не обов'язкові: у пінга немає ані того, ані
-- іншого — його метрики фіксовані самим типом перевірки.
ALTER TABLE tpl.items ALTER COLUMN oid DROP NOT NULL;
ALTER TABLE tpl.items ALTER COLUMN metric_key DROP NOT NULL;
-- ---------------------------------------------------------------------
-- Один чек на елемент для негрупованих типів
-- ---------------------------------------------------------------------
-- Пачкою в один PDU збираються лише OID (snmp.get). Два пінги з різними
-- параметрами в одному чеку не існують, тому для решти типів елемент
-- шаблону відповідає окремому чеку — і його треба вміти впізнати.
--
-- Ключ елемента, а не його id: SaveTemplate перезаписує елементи цілком,
-- тож id живуть недовго, а ключ стабільний за визначенням.
ALTER TABLE core.checks
ADD COLUMN template_item_key text NOT NULL DEFAULT '';
DROP INDEX IF EXISTS core.checks_template_uniq;
CREATE UNIQUE INDEX checks_template_uniq
ON core.checks (device_id, template_id, check_type, interval_sec, template_item_key)
WHERE template_id IS NOT NULL;
-- ---------------------------------------------------------------------
-- Вбудований шаблон доступності
--
-- Пінг — єдина перевірка, яка працює будь-де без налаштування, тому
-- саме він має бути тим шаблоном, який чіпляють першим.
-- ---------------------------------------------------------------------
INSERT INTO tpl.templates (id, tenant_id, key, name, description, vendor, is_builtin) VALUES
('00000000-0000-0000-0000-0000000000c5'::uuid, NULL, 'icmp-basic',
'Доступність (ICMP)',
'Пінг раз на 30 секунд: час відгуку, втрати, джитер. Працює будь-де без доступів і є основою для правил недоступності.',
NULL, true)
ON CONFLICT DO NOTHING;
INSERT INTO tpl.items
(template_id, key, name, check_type, oid, metric_key, unit, scale, interval_sec, params)
VALUES
('00000000-0000-0000-0000-0000000000c5', 'ping', 'Пінг',
'icmp.ping', NULL, NULL, 'ms', 1, 30, '{"count":3,"packet_size":56}'::jsonb)
ON CONFLICT DO NOTHING;

View file

@ -0,0 +1,36 @@
-- =====================================================================
-- NetPulse :: 0017_backup_defaults.sql
-- Глобальний розклад збору конфігів плюс перевизначення на хості.
--
-- Досі розклад існував лише поштучно: щоб бекапити сто пристроїв, треба
-- було сто разів відкрити форму. Глобальне правило — це те, чим воно й
-- має бути: значення за замовчуванням, від якого хост може відступити.
-- =====================================================================
CREATE TABLE ncm.backup_defaults (
tenant_id uuid PRIMARY KEY REFERENCES core.tenants(id) ON DELETE CASCADE,
enabled boolean NOT NULL DEFAULT false,
cron text NOT NULL DEFAULT '0 3 * * *',
-- Профіль і доступ теж можуть бути спільними: у більшості мереж
-- обладнання однорідне, і задавати їх поштучно — марна робота.
profile_id uuid REFERENCES ncm.profiles(id) ON DELETE SET NULL,
credential_id uuid REFERENCES inv.credentials(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Чи слідує хост глобальному розкладу.
--
-- Прапорець, а не порівняння значень: розклад хоста може випадково
-- збігтися з глобальним, і тоді зміна глобального мовчки потягла б за
-- собою хост, який навмисно налаштували окремо.
ALTER TABLE ncm.device_policies
ADD COLUMN follows_default boolean NOT NULL DEFAULT false;
ALTER TABLE ncm.backup_defaults ENABLE ROW LEVEL SECURITY;
ALTER TABLE ncm.backup_defaults FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ncm.backup_defaults
USING (tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant());
GRANT SELECT, INSERT, UPDATE, DELETE ON ncm.backup_defaults
TO netpulse_app, netpulse_worker;

View file

@ -0,0 +1,32 @@
-- =====================================================================
-- NetPulse :: 0018_rule_actions.sql
-- Правило саме каже, куди йде його алерт.
--
-- Досі маршрутизація жила тільки в alr.routes — окремій сутності з
-- власним фільтром. Це працює для великих інсталяцій, де маршрути
-- будують один раз, і не працює для звичайного випадку «це правило
-- важливе, шліть його черговому в Telegram»: людина створює правило й
-- очікує тут же сказати, кому воно телефонує.
--
-- Маршрути лишаються: вони вирішують інше завдання — спільну політику
-- на всі правила разом.
-- =====================================================================
-- Порожній масив = «як вирішать маршрути». Саме порожній, а не NULL:
-- відсутність вибору і свідомо порожній вибір тут — те саме, і два
-- способи це записати лише плодили б розбіжність.
ALTER TABLE alr.rules
ADD COLUMN channel_ids uuid[] NOT NULL DEFAULT '{}';
-- Тихі години правила: {"tz":"Europe/Kyiv","quiet":[{"days":[6,0],"from":"22:00","to":"08:00"}]}
-- Той самий формат, що в alr.routes.schedule — щоб не заводити другий
-- діалект того самого поняття.
ALTER TABLE alr.rules
ADD COLUMN notify_schedule jsonb;
-- Чи слати повідомлення про зняття алерту.
--
-- Типово так: «впало» без «піднялося» змушує людину йти перевіряти
-- руками, і цим повідомлення про аварію знецінює саме себе.
ALTER TABLE alr.rules
ADD COLUMN notify_on_resolve boolean NOT NULL DEFAULT true;

View file

@ -527,6 +527,17 @@ Sec-WebSocket-Protocol: netpulse.token.<токен>
}
```
**Шаблон описує перевірки будь-якого типу, не лише OID.** `snmp.get`
адресується OID-ом і збирається в пачку; `icmp.ping`, `http.status` і
`snmp.if` описуються полем `params` — тим самим, що лягає в
`core.checks.params`. Ділити на «пінг заводиться руками, а SNMP
шаблоном» означало б змусити людину пам'ятати, що саме шаблон покриває.
Для негрупованих типів елемент відповідає окремому чеку, і його сліду в
`core.checks.template_item_key` вистачає, щоб упізнати рядок. Ключ
елемента, а не його id: збереження шаблону перезаписує елементи цілком,
тож id живуть недовго.
**Елементи замінюються цілком, а не додаються.** Форма редагує шаблон як
один документ, і «прибрати метрику» має бути таким самим звичайним
рухом, як «додати». Крапку на початку OID сервер дописує сам: у
@ -555,6 +566,25 @@ Sec-WebSocket-Protocol: netpulse.token.<токен>
`SET NULL`: перевірка без шаблону, який її створив, нікому не належить —
вона б просто тихо опитувала пристрій вічно.
#### Обмін
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/api/v1/templates/export` | документ із шаблонами (`?ids=a,b` — вибрані) |
| `POST` | `/api/v1/templates/import` | залити документ |
Формат свій, не Zabbix-YAML: там елемент описується ключем виду
`snmp.get[...]`, до нього чіпляються препроцесинг, value maps і тригери —
нічого з цього тут поки немає, і вдавати сумісність означало б мовчки
втрачати половину імпортованого.
`update_existing` вирішує долю збігів за ключем: без нього наявний
шаблон іде в `skipped`. Мовчазне перезаписування — найшвидший спосіб
втратити локальні правки. Вбудований шаблон не чіпається ніколи: він
спільний для всіх тенантів.
Відповідь — три списки ключів: `created`, `updated`, `skipped`.
#### Як зміна доїжджає до зонда
Перевірки міняє REST-процес, а живу сесію зонда тримає AgentService —
@ -578,6 +608,27 @@ SNMP-community, паролі SSH і Telnet живуть в `inv.credentials`,
зашифровані тим самим кільцем, що й секрети каналів. Прив'язка до хоста —
`credential_ids` у тілі хоста. Без доступу працює лише `icmp.ping`.
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/api/v1/credentials` | перелік + `usage`: скільки хостів кожним користується |
| `POST` | `/api/v1/credentials` | створити |
| `PATCH` | `/api/v1/credentials/{id}` | змінити (порожній `secret` — лишити пароль) |
| `DELETE` | `/api/v1/credentials/{id}` | видалити разом із секретом |
**Секрет ніколи не повертається назовні.** Розшифрувати пароль заради
показу означає віддати його туди, звідки він уже не повернеться, тому
форма редагування показує порожнє поле: змінити пароль можна,
підглянути — ні. Порожній `secret` у `PATCH` означає «лишити як є».
Протокол доступу не змінюється після створення: зміна `snmp_v2c` на
`ssh` перетворює запис на інший об'єкт, і чесніше завести новий.
**Комплект доступів живе годину** (`CredentialTTL`). Зонд просить новий
за десять хвилин до кінця терміну і не частіше, ніж раз на хвилину.
Без цього поновлення зонд працював рівно годину: `Credentials()` свідомо
не віддає прострочені, щоб не блокувати облікові записи на пристроях, —
і після цього кожна перевірка падала з «немає креденшелів».
## Профілі збору конфігу
`ncm.profiles` описує, як зняти конфіг із конкретної платформи: які
@ -632,6 +683,29 @@ LOCKED`: два екземпляри AgentService не надішлють одн
### Розклад
Розклад буває спільний і поштучний.
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/api/v1/ncm/backup-defaults` | спільний розклад тенанта |
| `PUT` | `/api/v1/ncm/backup-defaults` | змінити спільний |
Спільний розклад заводить політику кожному придатному хосту з
`follows_default = true` і протягує на них свій cron. Хост, якому задали
власний розклад через `PUT /devices/{id}/backup-policy`, прапорець
втрачає — і зміни спільного його більше не чіпають. Це не порівняння
значень, а саме прапорець: власний розклад може випадково збігтися зі
спільним, і тоді зміна спільного мовчки потягла б за собою хост, який
навмисно налаштували окремо.
`apply_to_all` повертає під спільний розклад усіх, включно з тими, хто
має власний. Руйнівно, тому окремим прапорцем, а не побічним ефектом
збереження. Відповідь містить `following_count` і `custom_count` — без
цих двох чисел форма не каже головного: кого саме зачепить зміна.
Вимкнення спільного розкладу зупиняє лише тих, хто йому слідує. Хост із
власним розкладом налаштували свідомо.
`PUT /api/v1/devices/{id}/backup-policy` приймає:
```json
@ -833,6 +907,34 @@ JSON у таблиці правил.
Придушений алерт лишається видимим у списку (окремим фільтром), але не
надсилає сповіщень.
### Куди йде алерт
Порядок вирішення: **канали самого правила → маршрути тенанта → усі
придатні канали.** Кожен наступний крок — це відповідь на «а якщо нічого
не налаштовано», і останній навмисно не мовчить.
Правило приймає в тілі:
```jsonc
{
"channel_ids": ["…"], // порожньо — за маршрутами тенанта
"notify_on_resolve": true, // «впало» без «піднялося» знецінює саме себе
"notify_schedule": {"tz": "Europe/Kyiv",
"quiet": [{"from": "23:00", "to": "07:00"}]},
"selector": {"group_ids": ["…"]} // порожньо — усі хости
}
```
**Канали правила перекривають маршрути повністю.** Інакше «шліть це
черговому» перетворювалося б на «шліть це черговому і ще туди, куди
вирішить спільна політика».
Тиха година правила глушить усе, крім `disaster` — те саме правило, що
в маршрутах. Вимкнений канал не отримує алерт навіть тоді, коли правило
назвало його явно: вимкнення — це рішення про канал, а не про правило.
`PUT /api/v1/alert-rules/{id}` замінює правило цілком.
### Канали й маршрути
Канал зберігає несекретну частину в `config`, а токен — у

View file

@ -66,22 +66,58 @@ func (n *Notifier) Dispatch(ctx context.Context, tenantID string, alerts []store
return
}
// Маршрутизація самих правил. Помилка тут не має зупиняти доставку:
// краще розіслати за загальними маршрутами, ніж не розіслати нічого.
actions, err := n.st.LoadRuleActions(ctx, tenantID)
if err != nil {
n.log.Error("читання дій правил", "tenant", tenantID, "помилка", err)
actions = map[string]store.RuleAction{}
}
now := time.Now()
for _, a := range alerts {
for _, c := range n.targets(a, routes, channels, byID, now, tenantID) {
act, hasAct := actions[a.RuleID]
if hasAct && a.State == "resolved" && !act.NotifyOnResolve {
continue
}
for _, c := range n.targets(a, act, hasAct, 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,
//
// Порядок вирішення: канали самого правила → маршрути тенанта → усі
// придатні канали. Кожен наступний крок — це відповідь на «а якщо
// нічого не налаштовано», і останній із них навмисно не мовчить:
// підключили Telegram і чекають, що працюватиме.
func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool,
routes []store.Route, all []store.Channel,
byID map[string]store.Channel, now time.Time, tenantID string) []store.Channel {
allow := func(c store.Channel) bool {
return c.Enabled && severityRank[a.Severity] >= severityRank[c.MinSeverity]
}
// Правило сказало явно — маршрути не питаємо. Інакше «шліть це
// черговому» перетворювалося б на «шліть це черговому і ще туди,
// куди вирішить спільна політика».
if hasAct && len(act.ChannelIDs) > 0 {
if act.Schedule.IsQuiet(now) && severityRank[a.Severity] < severityRank["disaster"] {
_ = n.st.RecordNotification(context.Background(), tenantID, a.ID, "",
"throttled", "тиха година правила", "", nil)
return nil
}
var out []store.Channel
for _, id := range act.ChannelIDs {
if c, ok := byID[id]; ok && allow(c) {
out = append(out, c)
}
}
return out
}
// Тенант без жодного маршруту — найчастіший випадок: підключили
// Telegram і чекають, що працюватиме. Вимагати ще й маршрут означало б
// мовчати саме там, де налаштування щойно зроблене й здається повним.

View file

@ -129,7 +129,8 @@ func TestTargetsFallBackToAllChannelsWithoutRoutes(t *testing.T) {
byID[c.ID] = c
}
got := n.targets(store.Alert{Severity: "high"}, nil, channels, byID, time.Now(), "t1")
got := n.targets(store.Alert{Severity: "high"}, store.RuleAction{}, false,
nil, channels, byID, time.Now(), "t1")
if len(got) != 1 || got[0].ID != "c1" {
t.Fatalf("очікували лише c1, отримали %v", ids(got))
@ -141,10 +142,10 @@ func TestTargetsRespectChannelSeverityFloor(t *testing.T) {
channels := []store.Channel{{ID: "c1", Enabled: true, MinSeverity: "high"}}
byID := map[string]store.Channel{"c1": channels[0]}
if got := n.targets(store.Alert{Severity: "warning"}, nil, channels, byID, time.Now(), "t1"); len(got) != 0 {
if got := n.targets(store.Alert{Severity: "warning"}, store.RuleAction{}, false, nil, channels, byID, time.Now(), "t1"); len(got) != 0 {
t.Fatalf("warning не має проходити поріг high, отримали %v", ids(got))
}
if got := n.targets(store.Alert{Severity: "disaster"}, nil, channels, byID, time.Now(), "t1"); len(got) != 1 {
if got := n.targets(store.Alert{Severity: "disaster"}, store.RuleAction{}, false, nil, channels, byID, time.Now(), "t1"); len(got) != 1 {
t.Fatal("disaster має проходити поріг high")
}
}
@ -170,3 +171,46 @@ func TestTelegramChatIDAcceptsBothForms(t *testing.T) {
}
}
}
// Канали, задані самим правилом, перекривають загальні маршрути.
//
// Інакше «шліть це черговому» перетворювалося б на «шліть це черговому
// і ще туди, куди вирішить спільна політика» — тобто на щось, чого
// людина не просила.
func TestRuleChannelsOverrideRoutes(t *testing.T) {
n := &Notifier{}
channels := []store.Channel{
{ID: "c1", Enabled: true, MinSeverity: "info"},
{ID: "c2", Enabled: true, MinSeverity: "info"},
}
byID := map[string]store.Channel{"c1": channels[0], "c2": channels[1]}
routes := []store.Route{{ID: "r1", Name: "усі", ChannelIDs: []string{"c1", "c2"}}}
act := store.RuleAction{ChannelIDs: []string{"c2"}}
got := n.targets(store.Alert{Severity: "high"}, act, true, routes, channels, byID, time.Now(), "t1")
if len(got) != 1 || got[0].ID != "c2" {
t.Fatalf("очікували лише c2, отримали %v", ids(got))
}
// Правило без власних каналів лишає рішення маршрутам.
got = n.targets(store.Alert{Severity: "high"}, store.RuleAction{}, true,
routes, channels, byID, time.Now(), "t1")
if len(got) != 2 {
t.Fatalf("очікували обидва канали з маршруту, отримали %v", ids(got))
}
}
// Вимкнений канал не отримує алерт навіть тоді, коли правило назвало
// його явно: вимкнення — це рішення про канал, а не про правило.
func TestRuleChannelsRespectDisabled(t *testing.T) {
n := &Notifier{}
channels := []store.Channel{{ID: "c1", Enabled: false, MinSeverity: "info"}}
byID := map[string]store.Channel{"c1": channels[0]}
got := n.targets(store.Alert{Severity: "disaster"},
store.RuleAction{ChannelIDs: []string{"c1"}}, true, nil, channels, byID, time.Now(), "t1")
if len(got) != 0 {
t.Fatalf("вимкнений канал отримав алерт: %v", ids(got))
}
}

View file

@ -195,6 +195,10 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
ForSeconds int `json:"for_seconds"`
DependsOnTopology *bool `json:"depends_on_topology"`
Enabled *bool `json:"enabled"`
// Куди слати. Порожньо — за загальними маршрутами тенанта.
ChannelIDs []string `json:"channel_ids"`
NotifySchedule json.RawMessage `json:"notify_schedule"`
NotifyOnResolve *bool `json:"notify_on_resolve"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
@ -234,6 +238,36 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
ForSeconds: req.ForSeconds,
DependsOnTopology: req.DependsOnTopology == nil || *req.DependsOnTopology,
Enabled: req.Enabled == nil || *req.Enabled,
ChannelIDs: req.ChannelIDs,
NotifySchedule: string(req.NotifySchedule),
NotifyOnResolve: req.NotifyOnResolve == nil || *req.NotifyOnResolve,
}
if in.ChannelIDs == nil {
in.ChannelIDs = []string{}
}
if in.NotifySchedule == "null" {
in.NotifySchedule = ""
}
// Оновлення наявного правила приходить сюди ж: форма однакова, і
// розділяти її на дві гілки на клієнті означало б розійтися в
// перевірках рівно там, де вони найпотрібніші.
if id := r.PathValue("id"); id != "" {
if err := s.store.UpdateRule(r.Context(), p.TenantID, id, in); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
return
}
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "правило з такою назвою вже є")
return
}
s.log.Error("оновлення правила", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id})
return
}
id, err := s.store.CreateRule(r.Context(), p.TenantID, p.UserID, in)

View file

@ -129,7 +129,14 @@ func (s *Server) handleListCredentials(w http.ResponseWriter, r *http.Request, p
if creds == nil {
creds = []store.Credential{}
}
writeJSON(w, http.StatusOK, map[string]any{"credentials": creds})
// Скільки хостів користується кожним доступом — щоб попередження
// перед видаленням казало щось конкретне, а не «Ви впевнені?».
usage, err := s.store.CredentialUsage(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік доступів", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"credentials": creds, "usage": usage})
}
var validCredProtos = map[string]bool{
@ -381,6 +388,8 @@ func (s *Server) handleSetBackupPolicy(w http.ResponseWriter, r *http.Request, p
Enabled *bool `json:"enabled"`
Cron string `json:"cron"`
OnSyslog *bool `json:"on_syslog"`
// true — повернути хост під глобальний розклад.
FollowsDefault *bool `json:"follows_default"`
}
if !decodeBody(w, r, &in) {
return
@ -403,6 +412,10 @@ func (s *Server) handleSetBackupPolicy(w http.ResponseWriter, r *http.Request, p
Enabled: in.Enabled == nil || *in.Enabled,
Cron: in.Cron,
OnSyslog: in.OnSyslog != nil && *in.OnSyslog,
// Збереження форми хоста типово означає «в мене свій розклад».
// Інакше найближча зміна глобального мовчки затерла б те, що
// людина щойно налаштувала руками.
FollowsDefault: in.FollowsDefault != nil && *in.FollowsDefault,
})
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
@ -430,3 +443,119 @@ func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request, p *P
}
writeJSON(w, http.StatusOK, map[string]any{"profiles": list})
}
// ---------------------------------------------------------------------
// Глобальний розклад бекапів
// ---------------------------------------------------------------------
func (s *Server) handleGetBackupDefaults(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
d, err := s.store.GetBackupDefaults(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "глобальний розклад", err)
return
}
writeJSON(w, http.StatusOK, d)
}
func (s *Server) handleSetBackupDefaults(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:write") {
return
}
var in struct {
Enabled bool `json:"enabled"`
Cron string `json:"cron"`
ProfileID string `json:"profile_id"`
CredentialID string `json:"credential_id"`
// Підпорядкувати глобальному розкладу навіть ті хости, які
// налаштували окремо. Руйнівно, тому окремим прапорцем.
ApplyToAll bool `json:"apply_to_all"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Cron) == "" {
in.Cron = "0 3 * * *"
}
if _, err := cronx.Parse(in.Cron); err != nil {
writeError(w, http.StatusBadRequest, "bad_cron", "розклад: "+err.Error())
return
}
if err := s.store.SetBackupDefaults(r.Context(), p.TenantID, store.BackupDefaults{
Enabled: in.Enabled,
Cron: in.Cron,
ProfileID: in.ProfileID,
CredentialID: in.CredentialID,
}, in.ApplyToAll); err != nil {
s.writeStoreError(w, "глобальний розклад", err)
return
}
d, err := s.store.GetBackupDefaults(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "глобальний розклад", err)
return
}
writeJSON(w, http.StatusOK, d)
}
func (s *Server) handleUpdateCredential(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in struct {
Name string `json:"name"`
Username string `json:"username"`
Port int `json:"port"`
Secret string `json:"secret"`
Options json.RawMessage `json:"options"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва доступу")
return
}
if in.Secret != "" && s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування — зберегти пароль ніяк")
return
}
err := s.store.UpdateCredential(r.Context(), p.TenantID, r.PathValue("id"),
strings.TrimSpace(in.Name), in.Username, in.Port, in.Secret,
jsonOr(in.Options, "{}"), s.keyring)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "доступ не знайдено")
return
}
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "доступ із такою назвою вже є")
return
}
s.writeStoreError(w, "оновлення доступу", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteCredential(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
err := s.store.DeleteCredential(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "доступ не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "видалення доступу", err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,150 @@
package httpapi
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
func (s *Server) handleListSeries(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanRead(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
list, err := s.store.ListSeries(r.Context(), p.TenantID, deviceID)
if err != nil {
s.writeStoreError(w, "метрики хоста", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"series": list})
}
// handleQueryMetrics — точки для графіка.
//
// Вікно задається або парою from/to, або відносним `range` (`6h`, `7d`).
// Відносне зручніше й чесніше для типового випадку «останні шість
// годин»: абсолютні межі, пораховані в браузері, розповзаються з
// серверним часом рівно настільки, наскільки розходяться годинники.
func (s *Server) handleQueryMetrics(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanRead(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
q := r.URL.Query()
var ids []int64
for _, raw := range strings.Split(q.Get("series"), ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_series", "ідентифікатор ряду має бути числом")
return
}
ids = append(ids, id)
}
if len(ids) == 0 {
writeError(w, http.StatusBadRequest, "bad_series", "потрібен хоча б один ряд")
return
}
if len(ids) > 20 {
writeError(w, http.StatusBadRequest, "too_many",
"понад 20 ліній на одному графіку не читаються")
return
}
mq := store.MetricQuery{DeviceID: deviceID, SeriesIDs: ids}
if v := q.Get("range"); v != "" {
d, err := parseRange(v)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_range", err.Error())
return
}
mq.To = time.Now()
mq.From = mq.To.Add(-d)
} else {
if v := q.Get("from"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_from", "from має бути в форматі RFC3339")
return
}
mq.From = t
}
if v := q.Get("to"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_to", "to має бути в форматі RFC3339")
return
}
mq.To = t
}
}
if v := q.Get("points"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
mq.Points = n
}
}
res, err := s.store.QueryMetrics(r.Context(), p.TenantID, mq)
if err != nil {
s.writeStoreError(w, "історія метрик", err)
return
}
writeJSON(w, http.StatusOK, res)
}
// parseRange розбирає відносне вікно: 30m, 6h, 7d.
//
// Свій розбір, а не time.ParseDuration, бо той не знає «d», а доба —
// найчастіше вживане вікно після годин. Стеля в рік: далі роллапи
// однаково зачищає retention.
func parseRange(v string) (time.Duration, error) {
if len(v) < 2 {
return 0, errBadRange
}
n, err := strconv.Atoi(v[:len(v)-1])
if err != nil || n <= 0 {
return 0, errBadRange
}
var unit time.Duration
switch v[len(v)-1] {
case 'm':
unit = time.Minute
case 'h':
unit = time.Hour
case 'd':
unit = 24 * time.Hour
default:
return 0, errBadRange
}
d := time.Duration(n) * unit
if d > 365*24*time.Hour {
return 0, errBadRange
}
return d, nil
}
var errBadRange = errRange{}
type errRange struct{}
func (errRange) Error() string {
return "вікно задається як 30m, 6h або 7d, максимум 365d"
}

View file

@ -99,12 +99,19 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleGetBackupPolicy))
mux.Handle("PUT /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleSetBackupPolicy))
mux.Handle("GET /api/v1/ncm-profiles", s.authenticated(s.handleListProfiles))
mux.Handle("GET /api/v1/ncm/backup-defaults", s.authenticated(s.handleGetBackupDefaults))
mux.Handle("PUT /api/v1/ncm/backup-defaults", s.authenticated(s.handleSetBackupDefaults))
mux.Handle("GET /api/v1/check-types", s.authenticated(s.handleListCheckTypes))
mux.Handle("GET /api/v1/devices/{id}/checks", s.authenticated(s.handleListDeviceChecks))
mux.Handle("PUT /api/v1/devices/{id}/checks", s.authenticated(s.handleSetDeviceChecks))
mux.Handle("GET /api/v1/devices/{id}/series", s.authenticated(s.handleListSeries))
mux.Handle("GET /api/v1/devices/{id}/metrics", s.authenticated(s.handleQueryMetrics))
mux.Handle("GET /api/v1/templates", s.authenticated(s.handleListTemplates))
mux.Handle("POST /api/v1/templates", s.authenticated(s.handleSaveTemplate))
mux.Handle("GET /api/v1/templates/export", s.authenticated(s.handleExportTemplates))
mux.Handle("POST /api/v1/templates/import", s.authenticated(s.handleImportTemplates))
mux.Handle("GET /api/v1/templates/{id}", s.authenticated(s.handleGetTemplate))
mux.Handle("PUT /api/v1/templates/{id}", s.authenticated(s.handleSaveTemplate))
mux.Handle("DELETE /api/v1/templates/{id}", s.authenticated(s.handleDeleteTemplate))
@ -113,6 +120,8 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/credentials", s.authenticated(s.handleListCredentials))
mux.Handle("POST /api/v1/credentials", s.authenticated(s.handleCreateCredential))
mux.Handle("PATCH /api/v1/credentials/{id}", s.authenticated(s.handleUpdateCredential))
mux.Handle("DELETE /api/v1/credentials/{id}", s.authenticated(s.handleDeleteCredential))
mux.Handle("GET /api/v1/device-groups", s.authenticated(s.handleListDeviceGroups))
mux.Handle("POST /api/v1/device-groups", s.authenticated(s.handleCreateDeviceGroup))
@ -131,6 +140,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/alert-rules", s.authenticated(s.handleListAlertRules))
mux.Handle("POST /api/v1/alert-rules", s.authenticated(s.handleCreateAlertRule))
mux.Handle("PUT /api/v1/alert-rules/{id}", 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))

View file

@ -3,8 +3,10 @@ package httpapi
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
@ -52,15 +54,16 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P
Description string `json:"description"`
Vendor string `json:"vendor"`
Items []struct {
Key string `json:"key"`
Name string `json:"name"`
CheckType string `json:"check_type"`
OID string `json:"oid"`
MetricKey string `json:"metric_key"`
Unit string `json:"unit"`
Scale float64 `json:"scale"`
IntervalSec int `json:"interval_sec"`
Enabled *bool `json:"enabled"`
Key string `json:"key"`
Name string `json:"name"`
CheckType string `json:"check_type"`
OID string `json:"oid"`
MetricKey string `json:"metric_key"`
Unit string `json:"unit"`
Scale float64 `json:"scale"`
IntervalSec int `json:"interval_sec"`
Enabled *bool `json:"enabled"`
Params json.RawMessage `json:"params"`
} `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
@ -83,18 +86,36 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P
Vendor: strings.TrimSpace(in.Vendor),
}
for _, i := range in.Items {
checkType := i.CheckType
if checkType == "" {
checkType = "snmp.get"
}
oid := strings.TrimSpace(i.OID)
metric := strings.TrimSpace(i.MetricKey)
if oid == "" || metric == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"кожна метрика потребує OID і ключ метрики")
return
// OID і ключ метрики потрібні лише там, де перевірка справді
// адресується OID-ом. У пінга немає ані того, ані іншого — його
// метрики визначає сам тип перевірки.
if checkType == "snmp.get" {
if oid == "" || metric == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"метрика SNMP потребує OID і ключ метрики")
return
}
// Крапка на початку — те, як OID пишуть у документації
// вендорів і як його чекає gosnmp. Дописати її самим
// дешевше, ніж відмовляти людині через символ, який нічого
// не означає.
if !strings.HasPrefix(oid, ".") {
oid = "." + oid
}
}
// Крапка на початку — те, як OID пишуть у документації вендорів
// і як його чекає gosnmp. Дописати її самим дешевше, ніж
// відмовляти людині через символ, який нічого не означає.
if !strings.HasPrefix(oid, ".") {
oid = "." + oid
if i.IntervalSec < 5 || i.IntervalSec > 86400 {
if i.IntervalSec != 0 {
writeError(w, http.StatusBadRequest, "bad_interval",
"інтервал задається в межах від 5 секунд до доби")
return
}
}
enabled := true
if i.Enabled != nil {
@ -103,13 +124,14 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P
t.Items = append(t.Items, store.TemplateItem{
Key: strings.TrimSpace(i.Key),
Name: strings.TrimSpace(i.Name),
CheckType: i.CheckType,
CheckType: checkType,
OID: oid,
MetricKey: metric,
Unit: strings.TrimSpace(i.Unit),
Scale: i.Scale,
IntervalSec: i.IntervalSec,
Enabled: enabled,
Params: i.Params,
})
}
@ -193,3 +215,61 @@ func (s *Server) handleSetDeviceTemplates(w http.ResponseWriter, r *http.Request
}
writeJSON(w, http.StatusOK, map[string]any{"template_ids": ids})
}
// ---------------------------------------------------------------------
// Обмін
// ---------------------------------------------------------------------
// handleExportTemplates віддає документ як файл.
//
// `?ids=a,b` — вибрані, без параметра — усі. Content-Disposition
// обов'язковий: без нього браузер покаже JSON у вкладці, а людина
// прийшла по файл.
func (s *Server) handleExportTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
var ids []string
for _, v := range strings.Split(r.URL.Query().Get("ids"), ",") {
if v = strings.TrimSpace(v); v != "" {
ids = append(ids, v)
}
}
doc, err := s.store.ExportTemplates(r.Context(), p.TenantID, ids)
if err != nil {
s.writeStoreError(w, "експорт шаблонів", err)
return
}
name := fmt.Sprintf("netpulse-templates-%s.json", time.Now().Format("2006-01-02"))
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
writeJSON(w, http.StatusOK, doc)
}
func (s *Server) handleImportTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in struct {
store.TemplateDoc
// Типово вимкнено: мовчазне перезаписування — найшвидший спосіб
// втратити локальні правки.
UpdateExisting bool `json:"update_existing"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json",
"не вдалося прочитати документ: "+err.Error())
return
}
if len(in.Templates) == 0 {
writeError(w, http.StatusBadRequest, "empty", "у документі немає жодного шаблону")
return
}
res, err := s.store.ImportTemplates(r.Context(), p.TenantID, in.TemplateDoc, in.UpdateExisting)
if err != nil {
writeError(w, http.StatusBadRequest, "import_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, res)
}

View file

@ -351,3 +351,50 @@ func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) e
return nil
})
}
// RuleAction — куди й коли шле саме це правило.
//
// Zabbix розводить «тригер» і «дію над тригером» на дві сутності;
// у переважній більшості випадків це одна думка, розірвана надвоє. Тут
// дія живе в самому правилі, а alr.routes лишаються для спільної
// політики на всі правила разом.
type RuleAction struct {
ChannelIDs []string
Schedule *RouteSchedule
NotifyOnResolve bool
}
// LoadRuleActions читає маршрутизацію всіх увімкнених правил тенанта.
//
// Одним запитом на партію алертів, а не по правилу на алерт: під час
// масової аварії партія — це сотні алертів на десяток правил.
func (s *Store) LoadRuleActions(ctx context.Context, tenantID string) (map[string]RuleAction, error) {
out := map[string]RuleAction{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT id::text, channel_ids::text[],
COALESCE(notify_schedule::text,''), notify_on_resolve
FROM alr.rules WHERE tenant_id = $1
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id, sched string
var a RuleAction
if err := rows.Scan(&id, &a.ChannelIDs, &sched, &a.NotifyOnResolve); err != nil {
return err
}
if sched != "" {
var sc RouteSchedule
if err := json.Unmarshal([]byte(sched), &sc); err == nil {
a.Schedule = &sc
}
}
out[id] = a
}
return rows.Err()
})
return out, err
}

View file

@ -217,6 +217,10 @@ type RuleInput struct {
ForSeconds int
DependsOnTopology bool
Enabled bool
// Куди слати. Порожньо — за загальними маршрутами тенанта.
ChannelIDs []string
NotifySchedule string
NotifyOnResolve bool
}
func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]any, error) {
@ -226,6 +230,8 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an
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,
r.channel_ids::text[], COALESCE(r.notify_schedule::text,''),
r.notify_on_resolve,
(SELECT count(*) FROM alr.alerts a
WHERE a.rule_id = r.id AND a.state IN ('firing','acknowledged'))
FROM alr.rules r
@ -237,18 +243,25 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an
}
defer rows.Close()
for rows.Next() {
var id, name, desc, source, sev, sel, cond string
var id, name, desc, source, sev, sel, cond, sched string
var forSec, active int
var topo, enabled bool
var topo, enabled, onResolve bool
var channelIDs []string
if err := rows.Scan(&id, &name, &desc, &source, &sev, &sel, &cond,
&forSec, &topo, &enabled, &active); err != nil {
&forSec, &topo, &enabled, &channelIDs, &sched, &onResolve,
&active); err != nil {
return err
}
if channelIDs == nil {
channelIDs = []string{}
}
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,
"channel_ids": channelIDs, "notify_on_resolve": onResolve,
"notify_schedule": rawJSON(sched),
})
}
return rows.Err()
@ -262,13 +275,15 @@ func (s *Store) CreateRule(ctx context.Context, tenantID, userID string, in Rule
return tx.QueryRow(ctx, `
INSERT INTO alr.rules
(tenant_id, name, description, source, severity, selector, condition,
for_seconds, depends_on_topology, enabled, created_by)
for_seconds, depends_on_topology, enabled, created_by,
channel_ids, notify_schedule, notify_on_resolve)
VALUES ($1,$2,$3,$4::alr.rule_source,$5::alr.severity,$6::jsonb,$7::jsonb,
$8,$9,$10,$11)
$8,$9,$10,$11,$12::uuid[],NULLIF($13,'')::jsonb,$14)
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)
in.Enabled, nullUUID(userID), in.ChannelIDs, in.NotifySchedule,
in.NotifyOnResolve).Scan(&id)
})
return id, err
}
@ -345,3 +360,34 @@ func rawJSON(s string) any {
type jsonRaw string
func (j jsonRaw) MarshalJSON() ([]byte, error) { return []byte(j), nil }
// UpdateRule замінює правило цілком.
//
// Цілком, а не полями: форма показує повний стан правила, і часткові
// оновлення дали б спосіб отримати комбінацію, якої людина не бачила.
func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in RuleInput) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx, `
UPDATE alr.rules SET
name = $3, description = $4, source = $5::alr.rule_source,
severity = $6::alr.severity, selector = $7::jsonb,
condition = $8::jsonb, for_seconds = $9,
depends_on_topology = $10, enabled = $11,
channel_ids = $12::uuid[],
notify_schedule = NULLIF($13,'')::jsonb,
notify_on_resolve = $14,
updated_at = now()
WHERE id = $1 AND tenant_id = $2
`, ruleID, tenantID, in.Name, nullString(in.Description), in.Source,
in.Severity, in.Selector, in.Condition, in.ForSeconds,
in.DependsOnTopology, in.Enabled, in.ChannelIDs, in.NotifySchedule,
in.NotifyOnResolve)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}

View file

@ -358,3 +358,156 @@ func (s *Store) DeviceCredentialsOf(ctx context.Context, tenantID string) (map[s
})
return out, err
}
// UpdateCredential змінює доступ.
//
// Порожній secret означає «пароль лишити як є»: форма ніколи не показує
// збережений секрет (розшифрувати його для показу — це віддати його
// туди, звідки він уже не повернеться), тож надіслати незмінений вона не
// може навіть теоретично.
func (s *Store) UpdateCredential(ctx context.Context, tenantID, id, name, username string,
port int, secret string, options string, ring *crypto.Keyring) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var proto string
var oldSecret *string
if err := tx.QueryRow(ctx, `
SELECT proto::text, secret_id::text FROM inv.credentials
WHERE id = $1 AND tenant_id = $2
`, id, tenantID).Scan(&proto, &oldSecret); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
secretID := any(nil)
if oldSecret != nil {
secretID = *oldSecret
}
if secret != "" {
if ring == nil {
return errors.New("сервер запущено без ключа шифрування")
}
aad := tenantID + "|inv.credential"
sec, err := ring.Encrypt([]byte(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, $2::core.secret_kind, $3, $4, $5, $6, $7)
RETURNING id::text
`, tenantID, secretKind(proto), sec.KeyID, sec.Nonce, sec.Ciphertext,
sec.AuthTag, aad).Scan(&sid); err != nil {
return err
}
// Старий секрет прибираємо лише після того, як новий ліг:
// зворотний порядок на помилці шифрування лишив би доступ
// без пароля взагалі.
if oldSecret != nil {
if _, err := tx.Exec(ctx, `DELETE FROM core.secrets WHERE id = $1`, *oldSecret); err != nil {
return err
}
}
secretID = sid
}
opts := options
if opts == "" {
opts = "{}"
}
ct, err := tx.Exec(ctx, `
UPDATE inv.credentials
SET name = $3, username = NULLIF($4,''), port = NULLIF($5,0),
secret_id = $6, options = $7::jsonb
WHERE id = $1 AND tenant_id = $2
`, id, tenantID, name, username, port, secretID, opts)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// DeleteCredential прибирає доступ разом із секретом.
//
// Прив'язки до хостів зникають каскадом. Це саме те, що треба: доступ,
// якого немає, не має лишатись у списку хоста й створювати враження, що
// пристрій опитується.
func (s *Store) DeleteCredential(ctx context.Context, tenantID, id string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var secretID *string
if err := tx.QueryRow(ctx, `
SELECT secret_id::text FROM inv.credentials WHERE id = $1 AND tenant_id = $2
`, id, tenantID).Scan(&secretID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.Exec(ctx, `
DELETE FROM inv.credentials WHERE id = $1 AND tenant_id = $2
`, id, tenantID); err != nil {
return err
}
if secretID != nil {
_, err := tx.Exec(ctx, `DELETE FROM core.secrets WHERE id = $1`, *secretID)
return err
}
return nil
})
}
// CredentialUsage — скільки хостів користується доступом.
//
// Потрібне попередженню перед видаленням: «доступ використовують 34
// хости» — це інформація, а «Ви впевнені?» — ні.
func (s *Store) CredentialUsage(ctx context.Context, tenantID string) (map[string]int, error) {
out := map[string]int{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT c.id::text, count(dc.device_id)
FROM inv.credentials c
LEFT JOIN inv.device_credentials dc ON dc.credential_id = c.id
WHERE c.tenant_id = $1
GROUP BY c.id
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
var n int
if err := rows.Scan(&id, &n); err != nil {
return err
}
out[id] = n
}
return rows.Err()
})
return out, err
}
// secretKind — під яким видом секрет лягає в core.secrets.
//
// Community SNMPv2c — це не пароль, але шифрується так само: у дампі БД
// вона дає рівно той самий доступ до обладнання.
func secretKind(proto string) string {
switch proto {
case "snmp_v2c":
return "api_token"
case "ssh":
return "ssh_password"
case "telnet":
return "telnet"
default:
return "snmp_v3"
}
}

View file

@ -0,0 +1,282 @@
package store
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// SeriesInfo — один ряд метрики, який можна намалювати.
//
// Ключа метрики недостатньо, щоб назвати ряд: на одному хості
// `if.in_bps` існує стільки разів, скільки в нього портів. Тому назва
// збирається з ключа плюс порт або мітки.
type SeriesInfo struct {
ID int64 `json:"id"`
MetricKey string `json:"metric_key"`
Unit string `json:"unit,omitempty"`
InterfaceID string `json:"interface_id,omitempty"`
InterfaceName string `json:"interface_name,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
LastValue *float64 `json:"last_value,omitempty"`
LastAt *time.Time `json:"last_at,omitempty"`
}
// ListSeries — які метрики взагалі є в цього хоста.
//
// Останнє значення береться тут же: список метрик без чисел змушував би
// клацнути кожну, щоб дізнатись, чи в ній щось є.
func (s *Store) ListSeries(ctx context.Context, tenantID, deviceID string) ([]SeriesInfo, error) {
out := []SeriesInfo{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT s.id, s.metric_key, COALESCE(s.unit,''),
COALESCE(s.interface_id::text,''), COALESCE(i.name,''),
s.labels::text,
(SELECT value FROM ts.samples m
WHERE m.series_id = s.id ORDER BY m.ts DESC LIMIT 1),
(SELECT ts FROM ts.samples m
WHERE m.series_id = s.id ORDER BY m.ts DESC LIMIT 1)
FROM ts.series s
LEFT JOIN inv.interfaces i ON i.id = s.interface_id
WHERE s.tenant_id = $1 AND s.device_id = $2
ORDER BY s.metric_key, i.name NULLS FIRST
`, tenantID, deviceID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var si SeriesInfo
var labels string
if err := rows.Scan(&si.ID, &si.MetricKey, &si.Unit,
&si.InterfaceID, &si.InterfaceName, &labels,
&si.LastValue, &si.LastAt); err != nil {
return err
}
si.Labels = decodeLabels(labels)
out = append(out, si)
}
return rows.Err()
})
return out, err
}
// Point — одна точка графіка.
//
// Val — вказівник, бо пропуск і нуль — різні речі. Лінія, проведена
// через діру в даних, каже «все було добре», хоча насправді нічого не
// відомо; це найгірший різновид брехні в моніторингу.
type Point struct {
Ts time.Time `json:"ts"`
Val *float64 `json:"v"`
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
}
// MetricSeries — ряд разом із точками.
type MetricSeries struct {
ID int64 `json:"id"`
MetricKey string `json:"metric_key"`
Unit string `json:"unit,omitempty"`
InterfaceName string `json:"interface_name,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Points []Point `json:"points"`
}
// MetricQuery — що саме малюємо.
type MetricQuery struct {
DeviceID string
SeriesIDs []int64
From time.Time
To time.Time
// Скільки точок хоче намалювати клієнт. Крок рахується з нього, а
// не приходить ззовні: інакше вузьке вікно з дрібним кроком
// повернуло б десятки тисяч точок, з яких екран покаже сотні.
Points int
}
// MetricResult — відповідь на запит графіка.
type MetricResult struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
StepSec int `json:"step_sec"`
Source string `json:"source"`
Series []MetricSeries `json:"series"`
}
const (
maxPoints = 2000
defaultPoints = 300
)
// pickSource обирає найгрубіший роллап, крок якого ще вміщується у
// потрібний.
//
// Читати сирі дані за місяць — це мільйони рядків заради трьохсот
// пікселів. Але й брати годинні бакети на вікні в п'ять хвилин не
// можна: графік вийде з однієї точки.
func pickSource(step time.Duration) (table string, bucket time.Duration, name string) {
switch {
case step < 5*time.Minute:
return "", 0, "raw"
case step < time.Hour:
return "ts.samples_5m", 5 * time.Minute, "5m"
default:
return "ts.samples_1h", time.Hour, "1h"
}
}
// QueryMetrics віддає точки для графіка.
//
// Бакетизація робиться в БД (time_bucket), а не в Go: тягнути сирі
// точки, щоб усереднити їх у застосунку, означає передати по мережі те,
// що однаково буде викинуто.
func (s *Store) QueryMetrics(ctx context.Context, tenantID string, q MetricQuery) (*MetricResult, error) {
if q.To.IsZero() {
q.To = time.Now()
}
if q.From.IsZero() || !q.From.Before(q.To) {
q.From = q.To.Add(-6 * time.Hour)
}
if q.Points <= 0 {
q.Points = defaultPoints
}
if q.Points > maxPoints {
q.Points = maxPoints
}
if len(q.SeriesIDs) == 0 {
return &MetricResult{From: q.From, To: q.To, Series: []MetricSeries{}}, nil
}
span := q.To.Sub(q.From)
step := span / time.Duration(q.Points)
table, bucket, source := pickSource(step)
// Крок не може бути дрібнішим за бакет джерела: просити 10-секундні
// точки з п'ятихвилинного роллапу безглуздо — вийде та сама
// п'ятихвилинка, лише повторена тридцять разів.
if bucket > 0 && step < bucket {
step = bucket
}
if step < time.Second {
step = time.Second
}
var sql string
if table == "" {
sql = `
SELECT s.series_id,
time_bucket($1::interval, s.ts) AS bucket,
avg(s.value), min(s.value), max(s.value)
FROM ts.samples s
WHERE s.series_id = ANY($2::bigint[]) AND s.ts >= $3 AND s.ts <= $4
GROUP BY 1, 2 ORDER BY 1, 2`
} else {
sql = fmt.Sprintf(`
SELECT s.series_id,
time_bucket($1::interval, s.bucket) AS bucket,
avg(s.avg_value), min(s.min_value), max(s.max_value)
FROM %s s
WHERE s.series_id = ANY($2::bigint[]) AND s.bucket >= $3 AND s.bucket <= $4
GROUP BY 1, 2 ORDER BY 1, 2`, table)
}
res := &MetricResult{
From: q.From,
To: q.To,
StepSec: int(step / time.Second),
Source: source,
Series: []MetricSeries{},
}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Метадані рядів читаємо під RLS і з явною перевіркою хоста:
// series_id приходить від клієнта, і без цієї перевірки чужий
// ідентифікатор віддав би чужі дані. Самі ts.* під RLS не
// стоять (несумісно зі стисненням), тож фільтр тут — єдиний.
meta := map[int64]*MetricSeries{}
order := []int64{}
rows, err := tx.Query(ctx, `
SELECT s.id, s.metric_key, COALESCE(s.unit,''),
COALESCE(i.name,''), s.labels::text
FROM ts.series s
LEFT JOIN inv.interfaces i ON i.id = s.interface_id
WHERE s.tenant_id = $1 AND s.device_id = $2 AND s.id = ANY($3::bigint[])
ORDER BY s.metric_key
`, tenantID, q.DeviceID, q.SeriesIDs)
if err != nil {
return err
}
for rows.Next() {
var ms MetricSeries
var labels string
if err := rows.Scan(&ms.ID, &ms.MetricKey, &ms.Unit,
&ms.InterfaceName, &labels); err != nil {
rows.Close()
return err
}
ms.Labels = decodeLabels(labels)
ms.Points = []Point{}
meta[ms.ID] = &ms
order = append(order, ms.ID)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(order) == 0 {
return nil
}
// Питаємо лише ті ряди, що пройшли перевірку належності.
prows, err := tx.Query(ctx, sql, step, order, q.From, q.To)
if err != nil {
return err
}
defer prows.Close()
for prows.Next() {
var id int64
var p Point
if err := prows.Scan(&id, &p.Ts, &p.Val, &p.Min, &p.Max); err != nil {
return err
}
if m := meta[id]; m != nil {
m.Points = append(m.Points, p)
}
}
if err := prows.Err(); err != nil {
return err
}
for _, id := range order {
res.Series = append(res.Series, *meta[id])
}
return nil
})
if err != nil {
return nil, err
}
return res, nil
}
// decodeLabels розбирає jsonb-мітки в мапу.
//
// Помилка розбору не має валити графік: мітки — це підпис до лінії, а
// не самі дані. Порожня мапа гірша за жодну лише на вигляд.
func decodeLabels(raw string) map[string]string {
if raw == "" || raw == "{}" {
return nil
}
var m map[string]string
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return nil
}
if len(m) == 0 {
return nil
}
return m
}

View file

@ -15,6 +15,9 @@ type BackupPolicy struct {
Enabled bool `json:"enabled"`
Cron string `json:"cron"`
OnSyslog bool `json:"on_syslog"`
// Чи слідує хост глобальному розкладу. Прапорець, а не порівняння
// значень: власний розклад може випадково збігтися з глобальним.
FollowsDefault bool `json:"follows_default"`
LastBackupAt *time.Time `json:"last_backup_at,omitempty"`
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
}
@ -82,11 +85,12 @@ func (s *Store) GetBackupPolicy(ctx context.Context, tenantID, deviceID string)
return tx.QueryRow(ctx, `
SELECT device_id::text, COALESCE(profile_id::text,''),
COALESCE(credential_id::text,''), enabled, cron, on_syslog,
last_backup_at, next_backup_at
follows_default, last_backup_at, next_backup_at
FROM ncm.device_policies
WHERE device_id = $1 AND tenant_id = $2
`, deviceID, tenantID).Scan(&p.DeviceID, &p.ProfileID, &p.CredentialID,
&p.Enabled, &p.Cron, &p.OnSyslog, &p.LastBackupAt, &p.NextBackupAt)
&p.Enabled, &p.Cron, &p.OnSyslog, &p.FollowsDefault,
&p.LastBackupAt, &p.NextBackupAt)
})
if err != nil {
if isNoRows(err) {
@ -118,18 +122,19 @@ func (s *Store) SetBackupPolicy(ctx context.Context, tenantID string, p BackupPo
_, err := tx.Exec(ctx, `
INSERT INTO ncm.device_policies
(device_id, tenant_id, profile_id, credential_id, enabled, cron,
on_syslog, next_backup_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL)
on_syslog, follows_default, next_backup_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL)
ON CONFLICT (device_id) DO UPDATE SET
profile_id = EXCLUDED.profile_id,
credential_id = EXCLUDED.credential_id,
enabled = EXCLUDED.enabled,
cron = EXCLUDED.cron,
on_syslog = EXCLUDED.on_syslog,
next_backup_at = NULL,
updated_at = now()
profile_id = EXCLUDED.profile_id,
credential_id = EXCLUDED.credential_id,
enabled = EXCLUDED.enabled,
cron = EXCLUDED.cron,
on_syslog = EXCLUDED.on_syslog,
follows_default = EXCLUDED.follows_default,
next_backup_at = NULL,
updated_at = now()
`, p.DeviceID, tenantID, nullUUID(p.ProfileID), nullUUID(p.CredentialID),
p.Enabled, p.Cron, p.OnSyslog)
p.Enabled, p.Cron, p.OnSyslog, p.FollowsDefault)
return err
})
}
@ -167,3 +172,107 @@ func (s *Store) ListProfiles(ctx context.Context, tenantID string) ([]map[string
func isNoRows(err error) bool {
return err != nil && err.Error() == pgx.ErrNoRows.Error()
}
// ---------------------------------------------------------------------
// Глобальний розклад
// ---------------------------------------------------------------------
// BackupDefaults — розклад за замовчуванням для всього тенанта.
type BackupDefaults struct {
Enabled bool `json:"enabled"`
Cron string `json:"cron"`
ProfileID string `json:"profile_id,omitempty"`
CredentialID string `json:"credential_id,omitempty"`
// Скільки хостів зараз слідує глобальному розкладу, а скільки має
// власний. Без цих двох чисел форма не каже головного: кого саме
// зачепить зміна.
FollowingCount int `json:"following_count"`
CustomCount int `json:"custom_count"`
}
// GetBackupDefaults читає глобальний розклад разом із лічильниками.
func (s *Store) GetBackupDefaults(ctx context.Context, tenantID string) (*BackupDefaults, error) {
d := &BackupDefaults{Cron: "0 3 * * *"}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx, `
SELECT enabled, cron, COALESCE(profile_id::text,''), COALESCE(credential_id::text,'')
FROM ncm.backup_defaults WHERE tenant_id = $1
`, tenantID).Scan(&d.Enabled, &d.Cron, &d.ProfileID, &d.CredentialID)
if err != nil && !isNoRows(err) {
return err
}
return tx.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE follows_default),
count(*) FILTER (WHERE NOT follows_default)
FROM ncm.device_policies WHERE tenant_id = $1
`, tenantID).Scan(&d.FollowingCount, &d.CustomCount)
})
return d, err
}
// SetBackupDefaults записує глобальний розклад і протягує його на всі
// хости, що йому слідують.
//
// applyToAll підпорядковує глобальному розкладу навіть ті хости, які
// налаштували окремо. Це руйнівна дія, тому вона окремим прапорцем, а не
// побічним ефектом збереження.
func (s *Store) SetBackupDefaults(ctx context.Context, tenantID string, d BackupDefaults, applyToAll bool) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `
INSERT INTO ncm.backup_defaults
(tenant_id, enabled, cron, profile_id, credential_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id) DO UPDATE SET
enabled = EXCLUDED.enabled, cron = EXCLUDED.cron,
profile_id = EXCLUDED.profile_id,
credential_id = EXCLUDED.credential_id,
updated_at = now()
`, tenantID, d.Enabled, d.Cron, nullUUID(d.ProfileID), nullUUID(d.CredentialID)); err != nil {
return err
}
if applyToAll {
if _, err := tx.Exec(ctx, `
UPDATE ncm.device_policies SET follows_default = true
WHERE tenant_id = $1
`, tenantID); err != nil {
return err
}
}
if !d.Enabled {
// Вимкнений глобальний розклад зупиняє лише тих, хто йому
// слідує. Хост із власним розкладом налаштували свідомо, і
// глобальний вимикач не має його чіпати.
_, err := tx.Exec(ctx, `
UPDATE ncm.device_policies
SET enabled = false, next_backup_at = NULL, updated_at = now()
WHERE tenant_id = $1 AND follows_default
`, tenantID)
return err
}
// Заводимо політику кожному придатному хосту, який її ще не має:
// глобальний розклад інакше не мав би де зберігати next_backup_at.
if _, err := tx.Exec(ctx, `
INSERT INTO ncm.device_policies
(device_id, tenant_id, enabled, cron, follows_default)
SELECT d.id, $1, true, $2, true
FROM inv.devices d
WHERE d.tenant_id = $1 AND d.enabled AND d.deleted_at IS NULL
ON CONFLICT (device_id) DO NOTHING
`, tenantID, d.Cron); err != nil {
return err
}
// next_backup_at обнуляємо: розклад міг стати частішим.
_, err := tx.Exec(ctx, `
UPDATE ncm.device_policies
SET enabled = true, cron = $2,
profile_id = $3, credential_id = $4,
next_backup_at = NULL, updated_at = now()
WHERE tenant_id = $1 AND follows_default
`, tenantID, d.Cron, nullUUID(d.ProfileID), nullUUID(d.CredentialID))
return err
})
}

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"sort"
"time"
"github.com/jackc/pgx/v5"
)
@ -29,18 +30,32 @@ type Template struct {
DeviceCount int `json:"device_count"`
}
// TemplateItem — одна метрика шаблону.
// TemplateItem — одна перевірка шаблону.
//
// Для snmp.get це «один OID = одна метрика». Для решти типів (пінг,
// HTTP) OID немає взагалі, а поведінку задає Params — те саме, що
// лежить у core.checks.params.
type TemplateItem struct {
ID string `json:"id,omitempty"`
Key string `json:"key"`
Name string `json:"name"`
CheckType string `json:"check_type"`
OID string `json:"oid"`
MetricKey string `json:"metric_key"`
Unit string `json:"unit"`
Scale float64 `json:"scale"`
IntervalSec int `json:"interval_sec"`
Enabled bool `json:"enabled"`
ID string `json:"id,omitempty"`
Key string `json:"key"`
Name string `json:"name"`
CheckType string `json:"check_type"`
OID string `json:"oid,omitempty"`
MetricKey string `json:"metric_key,omitempty"`
Unit string `json:"unit"`
Scale float64 `json:"scale"`
IntervalSec int `json:"interval_sec"`
Enabled bool `json:"enabled"`
Params json.RawMessage `json:"params,omitempty"`
}
// groupsByOID каже, чи збираються елементи цього типу в один чек.
//
// Пачкою ходять лише OID: агент питає їх однією PDU, і сотня окремих
// чеків замість однієї пачки — це сотня SNMP-сесій. Пінг у пачку не
// збирається: два пінги з різними параметрами — це просто два пінги.
func groupsByOID(checkType string) bool {
return checkType == "snmp.get"
}
// ---------------------------------------------------------------------
@ -109,8 +124,9 @@ func (s *Store) GetTemplate(ctx context.Context, tenantID, id string) (*Template
func templateItems(ctx context.Context, tx pgx.Tx, templateID string) ([]TemplateItem, error) {
rows, err := tx.Query(ctx, `
SELECT id::text, key, name, check_type, oid, metric_key, unit,
scale, interval_sec, enabled
SELECT id::text, key, name, check_type, COALESCE(oid,''),
COALESCE(metric_key,''), unit, scale, interval_sec, enabled,
params::text
FROM tpl.items WHERE template_id = $1 ORDER BY name
`, templateID)
if err != nil {
@ -120,10 +136,15 @@ func templateItems(ctx context.Context, tx pgx.Tx, templateID string) ([]Templat
items := []TemplateItem{}
for rows.Next() {
var i TemplateItem
var params string
if err := rows.Scan(&i.ID, &i.Key, &i.Name, &i.CheckType, &i.OID,
&i.MetricKey, &i.Unit, &i.Scale, &i.IntervalSec, &i.Enabled); err != nil {
&i.MetricKey, &i.Unit, &i.Scale, &i.IntervalSec, &i.Enabled,
&params); err != nil {
return nil, err
}
if params != "" && params != "{}" {
i.Params = json.RawMessage(params)
}
items = append(items, i)
}
return items, rows.Err()
@ -187,13 +208,17 @@ func (s *Store) SaveTemplate(ctx context.Context, tenantID string, t Template) (
if i.CheckType == "" {
i.CheckType = "snmp.get"
}
params := "{}"
if len(i.Params) > 0 {
params = string(i.Params)
}
if _, err := tx.Exec(ctx, `
INSERT INTO tpl.items
(template_id, key, name, check_type, oid, metric_key, unit,
scale, interval_sec, enabled)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
scale, interval_sec, enabled, params)
VALUES ($1,$2,$3,$4,NULLIF($5,''),NULLIF($6,''),$7,$8,$9,$10,$11::jsonb)
`, id, i.Key, i.Name, i.CheckType, i.OID, i.MetricKey, i.Unit,
i.Scale, i.IntervalSec, i.Enabled); err != nil {
i.Scale, i.IntervalSec, i.Enabled, params); err != nil {
return err
}
}
@ -314,18 +339,23 @@ type checkGroup struct {
templateID string
checkType string
intervalSec int
oids []oidSpec
// Порожній для згрупованого snmp.get, ключ елемента — для решти.
itemKey string
oids []oidSpec
params string
}
// ReconcileDeviceTemplates приводить чеки хоста у відповідність до його
// шаблонів.
//
// Елементи групуються за (шаблон, тип, інтервал) в один чек: агент
// Елементи snmp.get групуються за (шаблон, інтервал) в один чек: агент
// уміє питати список OID однією пачкою, і сотня окремих чеків замість
// однієї пачки — це сотня SNMP-сесій там, де досить кількох PDU.
// Інтервал у ключі групування, бо пачка ходить цілком: змішавши
// хвилинну метрику з п'ятихвилинною, ми або опитували б рідкісну надто
// часто, або часту — надто рідко.
//
// Решта типів (пінг, HTTP) групування не знає: елемент — це чек.
func (s *Store) ReconcileDeviceTemplates(ctx context.Context, tenantID, deviceID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return reconcileDevice(ctx, tx, tenantID, deviceID)
@ -364,12 +394,13 @@ func (s *Store) reconcileTemplate(ctx context.Context, tenantID, templateID stri
func reconcileDevice(ctx context.Context, tx pgx.Tx, tenantID, deviceID string) error {
rows, err := tx.Query(ctx, `
SELECT i.template_id::text, i.check_type, i.interval_sec,
i.oid, i.metric_key, i.unit, i.scale
SELECT i.template_id::text, i.check_type, i.interval_sec, i.key,
COALESCE(i.oid,''), COALESCE(i.metric_key,''), i.unit, i.scale,
i.params::text
FROM tpl.device_templates dt
JOIN tpl.items i ON i.template_id = dt.template_id
WHERE dt.device_id = $1 AND i.enabled
ORDER BY i.template_id, i.check_type, i.interval_sec, i.oid
ORDER BY i.template_id, i.check_type, i.interval_sec, i.key, i.oid
`, deviceID)
if err != nil {
return err
@ -377,19 +408,35 @@ func reconcileDevice(ctx context.Context, tx pgx.Tx, tenantID, deviceID string)
groups := map[string]*checkGroup{}
for rows.Next() {
var g checkGroup
var o oidSpec
if err := rows.Scan(&g.templateID, &g.checkType, &g.intervalSec,
&o.OID, &o.MetricKey, &o.Unit, &o.Scale); err != nil {
var (
templateID, checkType, itemKey, params string
intervalSec int
o oidSpec
)
if err := rows.Scan(&templateID, &checkType, &intervalSec, &itemKey,
&o.OID, &o.MetricKey, &o.Unit, &o.Scale, &params); err != nil {
rows.Close()
return err
}
k := fmt.Sprintf("%s|%s|%d", g.templateID, g.checkType, g.intervalSec)
g := &checkGroup{
templateID: templateID,
checkType: checkType,
intervalSec: intervalSec,
params: params,
}
if !groupsByOID(checkType) {
g.itemKey = itemKey
}
k := fmt.Sprintf("%s|%s|%d|%s", g.templateID, g.checkType, g.intervalSec, g.itemKey)
if groups[k] == nil {
g.oids = []oidSpec{}
groups[k] = &g
groups[k] = g
}
if groupsByOID(checkType) && o.OID != "" {
groups[k].oids = append(groups[k].oids, o)
}
groups[k].oids = append(groups[k].oids, o)
}
rows.Close()
if err := rows.Err(); err != nil {
@ -408,20 +455,36 @@ func reconcileDevice(ctx context.Context, tx pgx.Tx, tenantID, deviceID string)
kept := make([]string, 0, len(keys))
for _, k := range keys {
g := groups[k]
params, err := json.Marshal(map[string]any{"oids": g.oids})
if err != nil {
return err
var params string
if groupsByOID(g.checkType) {
if len(g.oids) == 0 {
continue
}
b, err := json.Marshal(map[string]any{"oids": g.oids})
if err != nil {
return err
}
params = string(b)
} else {
params = g.params
if params == "" {
params = "{}"
}
}
var id string
if err := tx.QueryRow(ctx, `
INSERT INTO core.checks
(tenant_id, device_id, template_id, check_type, params, interval_sec, enabled)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, true)
ON CONFLICT (device_id, template_id, check_type, interval_sec)
(tenant_id, device_id, template_id, template_item_key, check_type,
params, interval_sec, enabled)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, true)
ON CONFLICT (device_id, template_id, check_type, interval_sec, template_item_key)
WHERE template_id IS NOT NULL
DO UPDATE SET params = EXCLUDED.params, enabled = true, updated_at = now()
RETURNING id::text
`, tenantID, deviceID, g.templateID, g.checkType, string(params), g.intervalSec).Scan(&id); err != nil {
`, tenantID, deviceID, g.templateID, g.itemKey, g.checkType,
params, g.intervalSec).Scan(&id); err != nil {
return err
}
kept = append(kept, id)
@ -436,3 +499,150 @@ func reconcileDevice(ctx context.Context, tx pgx.Tx, tenantID, deviceID string)
`, deviceID, kept)
return err
}
// ---------------------------------------------------------------------
// Обмін шаблонами
// ---------------------------------------------------------------------
// TemplateDoc — документ обміну.
//
// Свій формат, а не Zabbix-YAML: там елемент описується ключем виду
// `snmp.get[...]`, до нього чіпляються препроцесинг, value maps і
// тригери — усього цього тут поки немає, і вдавати сумісність означало б
// мовчки втрачати половину імпортованого. Формат навмисно плаский і
// читається очима.
type TemplateDoc struct {
Format string `json:"format"`
Version int `json:"version"`
Exported time.Time `json:"exported_at"`
Templates []Template `json:"templates"`
}
const (
docFormat = "netpulse-templates"
docVersion = 1
)
// ExportTemplates збирає документ із заданих шаблонів.
//
// Порожній список — усі, включно з вбудованими: типовий сценарій
// «забрати все з одного стенду й перенести на інший».
func (s *Store) ExportTemplates(ctx context.Context, tenantID string, ids []string) (*TemplateDoc, error) {
doc := &TemplateDoc{
Format: docFormat,
Version: docVersion,
Exported: time.Now().UTC(),
Templates: []Template{},
}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var rows pgx.Rows
var err error
if len(ids) == 0 {
rows, err = tx.Query(ctx, `
SELECT id::text, key, name, COALESCE(description,''), COALESCE(vendor,'')
FROM tpl.templates ORDER BY name`)
} else {
rows, err = tx.Query(ctx, `
SELECT id::text, key, name, COALESCE(description,''), COALESCE(vendor,'')
FROM tpl.templates WHERE id = ANY($1::uuid[]) ORDER BY name`, ids)
}
if err != nil {
return err
}
var list []Template
for rows.Next() {
var t Template
if err := rows.Scan(&t.ID, &t.Key, &t.Name, &t.Description, &t.Vendor); err != nil {
rows.Close()
return err
}
list = append(list, t)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for i := range list {
items, err := templateItems(ctx, tx, list[i].ID)
if err != nil {
return err
}
// Ідентифікатори з документа прибираємо: на іншому стенді
// вони нічого не значать, а лишені — створюють ілюзію, що
// імпорт «відновить те саме».
list[i].ID = ""
for j := range items {
items[j].ID = ""
}
list[i].Items = items
doc.Templates = append(doc.Templates, list[i])
}
return nil
})
return doc, err
}
// ImportResult — що зробив імпорт.
type ImportResult struct {
Created []string `json:"created"`
Updated []string `json:"updated"`
Skipped []string `json:"skipped"`
}
// ImportTemplates заливає документ.
//
// updateExisting вирішує долю збігів за ключем: без нього наявний
// шаблон пропускається. Мовчазне перезаписування — найшвидший спосіб
// втратити локальні правки, тому типово вимкнене.
//
// Вбудований шаблон із таким самим ключем ніколи не чіпається: він
// спільний для всіх тенантів. Такий збіг завжди йде у Skipped.
func (s *Store) ImportTemplates(ctx context.Context, tenantID string, doc TemplateDoc, updateExisting bool) (*ImportResult, error) {
if doc.Format != "" && doc.Format != docFormat {
return nil, fmt.Errorf("чужий формат документа: %s", doc.Format)
}
res := &ImportResult{Created: []string{}, Updated: []string{}, Skipped: []string{}}
for _, t := range doc.Templates {
if t.Key == "" || t.Name == "" {
return nil, fmt.Errorf("шаблон без ключа або назви")
}
var existingID string
var builtin bool
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT id::text, is_builtin FROM tpl.templates
WHERE key = $1 AND (tenant_id IS NULL OR tenant_id = $2)
ORDER BY is_builtin DESC LIMIT 1
`, t.Key, tenantID).Scan(&existingID, &builtin)
})
if err != nil && !isNoRows(err) {
return nil, err
}
switch {
case existingID != "" && builtin:
res.Skipped = append(res.Skipped, t.Key)
continue
case existingID != "" && !updateExisting:
res.Skipped = append(res.Skipped, t.Key)
continue
case existingID != "":
t.ID = existingID
default:
t.ID = ""
}
if _, err := s.SaveTemplate(ctx, tenantID, t); err != nil {
return nil, fmt.Errorf("шаблон %s: %w", t.Key, err)
}
if existingID != "" {
res.Updated = append(res.Updated, t.Key)
} else {
res.Created = append(res.Created, t.Key)
}
}
return res, nil
}

View file

@ -8,6 +8,7 @@ import { MapPage } from './pages/MapPage'
import { DevicesPage } from './pages/DevicesPage'
import { GroupsPage } from './pages/GroupsPage'
import { TemplatesPage } from './pages/TemplatesPage'
import { CredentialsPage } from './pages/CredentialsPage'
import { ConfigsPage } from './pages/ConfigsPage'
import { AlertsPage } from './pages/AlertsPage'
import { RulesPage } from './pages/RulesPage'
@ -53,6 +54,7 @@ export default function App() {
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
<Route path="/groups" element={<Guard perm="devices:read" element={<GroupsPage />} />} />
<Route path="/credentials" element={<Guard perm="devices:read" element={<CredentialsPage />} />} />
<Route path="/templates" element={<Guard perm="devices:read" element={<TemplatesPage />} />} />
<Route path="/configs" element={<Guard perm="ncm:read" element={<ConfigsPage />} />} />
<Route path="/alerts" element={<Guard perm="alerts:read" element={<AlertsPage />} />} />

View file

@ -26,6 +26,7 @@ import type {
TeamMember,
UserGroup,
UndoResult,
BackupDefaults,
Template,
} from '../types'
@ -308,6 +309,8 @@ export const api = {
enabled: boolean
cron: string
on_syslog?: boolean
/** true — повернути хост під глобальний розклад. */
follows_default?: boolean
},
) => request<void>(`/api/v1/devices/${deviceID}/backup-policy`, {
method: 'PUT',
@ -320,6 +323,35 @@ export const api = {
listCredentials: () =>
request<{ credentials: Credential[] }>('/api/v1/credentials').then((r) => r.credentials ?? []),
/** Доступи разом із тим, скільки хостів кожним користується. */
listCredentialsWithUsage: () =>
request<{ credentials: Credential[]; usage: Record<string, number> }>(
'/api/v1/credentials',
).then((r) => ({ credentials: r.credentials ?? [], usage: r.usage ?? {} })),
updateCredential: (
id: string,
c: { name: string; username?: string; port?: number; secret?: string },
) => request<void>(`/api/v1/credentials/${id}`, { method: 'PATCH', body: JSON.stringify(c) }),
deleteCredential: (id: string) =>
request<void>(`/api/v1/credentials/${id}`, { method: 'DELETE' }),
// --- глобальний розклад бекапів ---
getBackupDefaults: () => request<BackupDefaults>('/api/v1/ncm/backup-defaults'),
setBackupDefaults: (d: {
enabled: boolean
cron: string
profile_id?: string
credential_id?: string
apply_to_all?: boolean
}) => request<BackupDefaults>('/api/v1/ncm/backup-defaults', {
method: 'PUT',
body: JSON.stringify(d),
}),
createCredential: (c: {
name: string
proto: string
@ -388,17 +420,23 @@ export const api = {
listAlertRules: () =>
request<{ rules: AlertRule[] }>('/api/v1/alert-rules').then((r) => r.rules ?? []),
createAlertRule: (rule: {
name: string
source: string
severity: string
condition: Record<string, unknown>
selector?: Record<string, unknown>
for_seconds: number
depends_on_topology: boolean
}) =>
request<{ id: string }>('/api/v1/alert-rules', {
method: 'POST',
saveAlertRule: (
id: string | null,
rule: {
name: string
source: string
severity: string
condition: Record<string, unknown>
selector?: Record<string, unknown>
for_seconds: number
depends_on_topology: boolean
channel_ids?: string[]
notify_schedule?: Record<string, unknown> | null
notify_on_resolve?: boolean
},
) =>
request<{ id: string }>(id ? `/api/v1/alert-rules/${id}` : '/api/v1/alert-rules', {
method: id ? 'PUT' : 'POST',
body: JSON.stringify(rule),
}),
@ -477,4 +515,31 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ template_ids: ids }),
}).then((r) => r.template_ids ?? []),
/**
* Експорт як завантажений файл.
*
* Через fetch, а не простим посиланням: ендпоїнт під токеном, а токен
* живе в замиканні модуля й у заголовку в URL його класти не можна.
*/
exportTemplates: async (ids?: string[]) => {
const qs = ids && ids.length > 0 ? `?ids=${ids.join(',')}` : ''
const doc = await request<unknown>(`/api/v1/templates/export${qs}`)
const blob = new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `netpulse-templates-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
},
importTemplates: (doc: unknown, updateExisting: boolean) =>
request<{ created: string[]; updated: string[]; skipped: string[] }>(
'/api/v1/templates/import',
{
method: 'POST',
body: JSON.stringify({ ...(doc as object), update_existing: updateExisting }),
},
),
}

View file

@ -20,6 +20,7 @@ const navItems: NavItem[] = [
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
{ to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' },
{ to: '/templates', label: 'Шаблони', icon: '📐', perm: 'devices:read' },
{ to: '/credentials', label: 'Доступи', icon: '🔑', perm: 'devices:read' },
{ to: '/configs', label: 'Конфіги', icon: '📄', perm: 'ncm:read' },
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
{ to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' },

View file

@ -0,0 +1,170 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Button, ErrorNote, Field, Modal, Toggle, inputClass, plural } from './ui'
import { CronPicker } from './CronPicker'
import type { BackupDefaults, Credential, NcmProfile } from '../types'
/**
* Глобальний розклад збору конфігів.
*
* Задає значення за замовчуванням для всіх хостів. Хост, якому задали
* власний розклад, з-під нього виходить і зміна глобального його вже
* не чіпає, доки цього не попросять окремо.
*/
export function BackupDefaultsForm({
credentials,
onClose,
onSaved,
}: {
credentials: Credential[]
onClose: () => void
onSaved: () => void
}) {
const [profiles, setProfiles] = useState<NcmProfile[]>([])
const [d, setD] = useState<BackupDefaults | null>(null)
const [enabled, setEnabled] = useState(false)
const [cron, setCron] = useState('0 3 * * *')
const [profileID, setProfileID] = useState('')
const [credID, setCredID] = useState('')
const [applyToAll, setApplyToAll] = useState(false)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
let alive = true
Promise.all([
api.listNcmProfiles().catch(() => [] as NcmProfile[]),
api.getBackupDefaults().catch(() => null),
])
.then(([pr, def]) => {
if (!alive) return
setProfiles(pr)
setD(def)
if (def) {
setEnabled(def.enabled)
setCron(def.cron)
setProfileID(def.profile_id ?? '')
setCredID(def.credential_id ?? '')
}
})
.finally(() => alive && setLoading(false))
return () => {
alive = false
}
}, [])
const cliCreds = credentials.filter((c) => c.proto === 'ssh' || c.proto === 'telnet')
return (
<Modal title="Розклад для всіх хостів" onClose={onClose}>
{loading ? (
<p className="py-6 text-center text-xs text-slate-600">Завантаження</p>
) : (
<div className="space-y-3">
<label className="flex items-center gap-2.5 text-sm text-slate-300">
<Toggle checked={enabled} onChange={setEnabled} />
Збирати конфіги за спільним розкладом
</label>
<CronPicker value={cron} onChange={setCron} disabled={!enabled} />
<Field label="Профіль" hint="Не задано — сервер підбере за виробником хоста">
<select
className={inputClass}
value={profileID}
disabled={!enabled}
onChange={(e) => setProfileID(e.target.value)}
>
<option value=""> автоматично </option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.key})
</option>
))}
</select>
</Field>
<Field
label="Доступ"
hint={
cliCreds.length === 0
? 'Немає жодного доступу ssh або telnet — без нього збір неможливий'
: 'Не задано — візьметься прив’язаний до хоста'
}
>
<select
className={inputClass}
value={credID}
disabled={!enabled}
onChange={(e) => setCredID(e.target.value)}
>
<option value=""> автоматично </option>
{cliCreds.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.proto})
</option>
))}
</select>
</Field>
{d && (
<p className="text-[11px] leading-relaxed text-slate-600">
Зараз спільному розкладу слідує{' '}
{plural(d.following_count, 'хост', 'хости', 'хостів')}, власний розклад мають{' '}
{plural(d.custom_count, 'хост', 'хости', 'хостів')}.
</p>
)}
{(d?.custom_count ?? 0) > 0 && (
<label className="flex items-start gap-2.5 rounded border border-amber-900/60 bg-amber-950/20 p-2.5">
<input
type="checkbox"
className="mt-0.5"
checked={applyToAll}
onChange={(e) => setApplyToAll(e.target.checked)}
/>
<span className="text-xs leading-snug text-amber-200">
Підпорядкувати всі хости
<span className="mt-0.5 block text-amber-200/70">
{plural(d?.custom_count ?? 0, 'хост', 'хости', 'хостів')} із власним розкладом
перейде на спільний. Їхні налаштування буде втрачено.
</span>
</span>
</label>
)}
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || (enabled && cron.trim() === '')}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await api.setBackupDefaults({
enabled,
cron: cron.trim(),
profile_id: profileID,
credential_id: credID,
apply_to_all: applyToAll,
})
onSaved()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Збереження…' : 'Зберегти'}
</Button>
</div>
</div>
)}
</Modal>
)
}

View file

@ -1,17 +1,9 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Button, ErrorNote, Field, Modal, Toggle, inputClass } from './ui'
import { CronPicker } from './CronPicker'
import type { BackupPolicy, Credential, DeviceSummary, NcmProfile } from '../types'
// Готові розклади покривають майже все, що справді налаштовують.
// Довільний cron лишається, але не мусить бути першим, що бачить людина.
const PRESETS: { cron: string; label: string }[] = [
{ cron: '0 3 * * *', label: 'щодня о 03:00' },
{ cron: '0 */6 * * *', label: 'кожні 6 годин' },
{ cron: '0 3 * * 1', label: 'щопонеділка о 03:00' },
{ cron: '0 3 1 * *', label: 'першого числа о 03:00' },
]
/**
* Розклад резервного копіювання хоста.
*
@ -32,10 +24,10 @@ export function BackupPolicyForm({
const [policy, setPolicy] = useState<BackupPolicy | null>(null)
const [enabled, setEnabled] = useState(true)
const [cron, setCron] = useState('0 3 * * *')
// Окремий прапорець, а не висновок із самого виразу: вибір «свій
// розклад» має відкрити поле навіть тоді, коли поточне значення
// випадково збігається з пресетом.
const [customMode, setCustomMode] = useState(false)
// Чи слідує хост глобальному розкладу. Знімається щойно людина
// задає розклад тут: інакше найближча зміна глобального затерла б те,
// що вона щойно налаштувала руками.
const [followsDefault, setFollowsDefault] = useState(false)
const [profileID, setProfileID] = useState('')
const [credID, setCredID] = useState('')
const [loading, setLoading] = useState(true)
@ -55,7 +47,7 @@ export function BackupPolicyForm({
if (pol) {
setEnabled(pol.enabled)
setCron(pol.cron)
setCustomMode(!PRESETS.some((x) => x.cron === pol.cron))
setFollowsDefault(pol.follows_default)
setProfileID(pol.profile_id ?? '')
setCredID(pol.credential_id ?? '')
}
@ -67,7 +59,6 @@ export function BackupPolicyForm({
}, [device.id])
const cliCreds = credentials.filter((c) => c.proto === 'ssh' || c.proto === 'telnet')
const custom = customMode || !PRESETS.some((p) => p.cron === cron)
return (
<Modal title={`Резервне копіювання: ${device.name}`} onClose={onClose}>
@ -80,39 +71,13 @@ export function BackupPolicyForm({
Збирати конфіг за розкладом
</label>
<Field label="Розклад">
<select
className={inputClass}
value={custom ? '' : cron}
onChange={(e) => {
if (e.target.value === '') {
setCustomMode(true)
return
}
setCustomMode(false)
setCron(e.target.value)
}}
>
{PRESETS.map((p) => (
<option key={p.cron} value={p.cron}>
{p.label}
</option>
))}
<option value="">свій розклад</option>
</select>
</Field>
<CronPicker value={cron} onChange={setCron} />
{custom && (
<Field
label="Вираз cron"
hint="Хвилина, година, день місяця, місяць, день тижня — як у crontab"
>
<input
className={`${inputClass} font-mono`}
value={cron}
onChange={(e) => setCron(e.target.value)}
/>
</Field>
{followsDefault && (
<p className="rounded border border-slate-800 bg-slate-900/60 p-2.5 text-[11px] leading-relaxed text-slate-500">
Зараз хост слідує спільному розкладу. Щойно ви збережете цю форму, він отримає
власний і зміни спільного його більше не чіпатимуть.
</p>
)}
<Field
@ -177,6 +142,8 @@ export function BackupPolicyForm({
credential_id: credID,
enabled,
cron: cron.trim(),
// Збереження цієї форми означає «в мене свій розклад».
follows_default: false,
})
onClose()
} catch (e) {

View file

@ -1,17 +1,8 @@
import { useState } from 'react'
import { Toggle, inputClass } from './ui'
import { IntervalInput, Toggle, inputClass } from './ui'
import type { CheckInput } from '../api/client'
import type { CheckType } from '../types'
/** Готові інтервали. Довільне число теж можна ввести. */
const INTERVALS = [5, 10, 30, 60, 120, 300, 600, 1800, 3600]
function humanInterval(sec: number): string {
if (sec < 60) return `${sec} с`
if (sec < 3600) return `${Math.round(sec / 60)} хв`
return `${Math.round(sec / 3600)} год`
}
// Розумні типові інтервали. Пінг дешевий і має ловити обриви швидко;
// прохід по таблиці інтерфейсів коштує десятки OID-ів, і робити його
// щоп'ять секунд означає навантажити обладнання заради даних, які
@ -95,23 +86,10 @@ export function ChecksEditor({
<label className="flex items-center gap-1.5 text-[11px] text-slate-500">
кожні
<select
className="rounded border border-slate-700 bg-slate-800 px-1.5 py-1 text-xs text-slate-200"
value={INTERVALS.includes(c.interval_sec) ? c.interval_sec : 0}
onChange={(e) => {
const v = Number(e.target.value)
if (v > 0) patch(i, { interval_sec: v })
}}
>
{INTERVALS.map((s) => (
<option key={s} value={s}>
{humanInterval(s)}
</option>
))}
{!INTERVALS.includes(c.interval_sec) && (
<option value={0}>{humanInterval(c.interval_sec)}</option>
)}
</select>
<IntervalInput
value={c.interval_sec ?? 60}
onChange={(v) => patch(i, { interval_sec: v })}
/>
</label>
<Toggle

View file

@ -0,0 +1,77 @@
import { useState } from 'react'
import { Field, inputClass } from './ui'
// Готові розклади покривають майже все, що справді налаштовують.
// Довільний cron лишається, але не мусить бути першим, що бачить людина.
export const CRON_PRESETS: { cron: string; label: string }[] = [
{ cron: '0 * * * *', label: 'щогодини' },
{ cron: '0 */6 * * *', label: 'кожні 6 годин' },
{ cron: '0 3 * * *', label: 'щодня о 03:00' },
{ cron: '0 3 * * 1', label: 'щопонеділка о 03:00' },
{ cron: '0 3 1 * *', label: 'першого числа о 03:00' },
]
/**
* Вибір розкладу: пресети плюс власний вираз cron.
*
* Режим тримається окремим прапорцем, а не виводиться з виразу: інакше
* пункт «свій розклад» не відкривав би поле тоді, коли власний вираз
* випадково збігся з пресетом. Знайдено живим прогоном обробник
* `select` відсікав порожнє значення, тобто саме той випадок, заради
* якого пункт існує.
*/
export function CronPicker({
value,
onChange,
disabled,
label = 'Розклад',
}: {
value: string
onChange: (cron: string) => void
disabled?: boolean
label?: string
}) {
const [customMode, setCustomMode] = useState(!CRON_PRESETS.some((p) => p.cron === value))
const custom = customMode || !CRON_PRESETS.some((p) => p.cron === value)
return (
<>
<Field label={label}>
<select
className={inputClass}
value={custom ? '' : value}
disabled={disabled}
onChange={(e) => {
if (e.target.value === '') {
setCustomMode(true)
return
}
setCustomMode(false)
onChange(e.target.value)
}}
>
{CRON_PRESETS.map((p) => (
<option key={p.cron} value={p.cron}>
{p.label}
</option>
))}
<option value="">свій розклад</option>
</select>
</Field>
{custom && (
<Field
label="Вираз cron"
hint="Хвилина, година, день місяця, місяць, день тижня — як у crontab"
>
<input
className={`${inputClass} font-mono`}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
)}
</>
)
}

View file

@ -1,3 +1,4 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
/**
@ -321,3 +322,173 @@ export function plural(n: number, one: string, few: string, many: string): strin
return `${n} ${many}`
}
}
/**
* Підтвердження руйнівної дії.
*
* Замість вбудованого `confirm()`: той не вміє показати наслідки, а
* саме наслідки й важливі. «Видалити групу?» і «Видалити групу? 34
* хости втратять доступ» це різні питання, і людина відповідає на них
* по-різному.
*
* Текст кнопки задається окремо: «Видалити» під питанням «Видалити?»
* читається швидше, ніж «Так».
*/
export function ConfirmDialog({
title,
message,
detail,
confirmLabel = 'Видалити',
onConfirm,
onClose,
}: {
title: string
message: ReactNode
/** Наслідки: що саме зникне або перестане працювати. */
detail?: ReactNode
confirmLabel?: string
onConfirm: () => void | Promise<void>
onClose: () => void
}) {
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
return (
<Modal title={title} onClose={onClose}>
<div className="space-y-3">
<p className="text-sm leading-relaxed text-slate-300">{message}</p>
{detail && (
<p className="rounded border border-amber-900/60 bg-amber-950/30 p-2.5 text-xs leading-relaxed text-amber-200">
{detail}
</p>
)}
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="danger"
disabled={busy}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await onConfirm()
onClose()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Виконуємо…' : confirmLabel}
</Button>
</div>
</div>
</Modal>
)
}
/** Опис відкладеного підтвердження: що спитати і що зробити після «так». */
export type ConfirmRequest = {
title: string
message: ReactNode
detail?: ReactNode
confirmLabel?: string
onConfirm: () => void | Promise<void>
}
/**
* Одне підтвердження на сторінку.
*
* Сторінка тримає `useState<ConfirmRequest | null>` і малює
* `<Confirm request={…} onClose={…} />`; будь-яка кнопка видалення
* замість дії кладе туди запит.
*/
export function Confirm({
request,
onClose,
}: {
request: ConfirmRequest | null
onClose: () => void
}) {
if (!request) return null
return <ConfirmDialog {...request} onClose={onClose} />
}
/** Готові інтервали для списку. Довільне число вводиться руками. */
export const INTERVAL_PRESETS = [5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 21600, 86400]
export function humanInterval(sec: number): string {
if (sec < 60) return `${sec} с`
if (sec < 3600) {
const m = sec / 60
return Number.isInteger(m) ? `${m} хв` : `${sec} с`
}
if (sec < 86400) {
const h = sec / 3600
return Number.isInteger(h) ? `${h} год` : `${Math.round(sec / 60)} хв`
}
const d = sec / 86400
return Number.isInteger(d) ? `${d} д` : `${Math.round(sec / 3600)} год`
}
/**
* Інтервал опитування: список готових значень плюс ручний ввід.
*
* Список сам по собі не годиться рано чи пізно комусь потрібні 45
* секунд, і відсутність такої можливості перетворює продукт на
* «майже підходить». Ручний ввід сам по собі теж не годиться: у
* дев'яти випадках із десяти потрібне значення є в списку, і змушувати
* друкувати його марна робота.
*/
export function IntervalInput({
value,
onChange,
disabled,
}: {
value: number
onChange: (sec: number) => void
disabled?: boolean
}) {
const [manual, setManual] = useState(!INTERVAL_PRESETS.includes(value))
return (
<div className="flex gap-1.5">
<select
className={inputClass}
value={manual ? '' : String(value)}
disabled={disabled}
onChange={(e) => {
if (e.target.value === '') {
setManual(true)
return
}
setManual(false)
onChange(Number(e.target.value))
}}
>
{INTERVAL_PRESETS.map((s) => (
<option key={s} value={s}>
{humanInterval(s)}
</option>
))}
<option value="">свій</option>
</select>
{manual && (
<div className="flex items-center gap-1">
<input
className={`${inputClass} w-20 text-right tabular-nums`}
type="number"
min={5}
max={86400}
value={value}
disabled={disabled}
onChange={(e) => onChange(Math.max(0, Number(e.target.value)))}
/>
<span className="text-xs text-slate-500">с</span>
</div>
)}
</div>
)
}

View file

@ -3,6 +3,7 @@ import { api } from '../api/client'
import { session } from '../api/session'
import {
Button,
Confirm,
DataTable,
ErrorNote,
Field,
@ -12,6 +13,7 @@ import {
StatusBadge,
inputClass,
} from '../components/ui'
import type { ConfirmRequest } from '../components/ui'
import type { Channel, Severity } from '../types'
const kindLabel: Record<string, string> = {
@ -35,6 +37,7 @@ export function ChannelsPage() {
const [channels, setChannels] = useState<Channel[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
const [creating, setCreating] = useState(false)
const [testing, setTesting] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; error?: string } | null>(
@ -159,16 +162,28 @@ export function ChannelsPage() {
>
{testing === c.id ? '…' : 'Перевірити'}
</Button>
<button
className="px-1 text-xs text-slate-600 hover:text-red-400"
<Button
kind="danger"
title="Видалити канал разом із його секретом"
onClick={async () => {
await api.deleteChannel(c.id)
await reload()
}}
onClick={() =>
setConfirm({
title: 'Видалити канал',
message: (
<>
Канал <b>{c.name}</b> буде видалено разом із його токеном.
</>
),
detail:
'Алерти, які йшли сюди, більше не доставлятимуться. Якщо це був єдиний канал, сповіщення припиняться взагалі.',
onConfirm: async () => {
await api.deleteChannel(c.id)
await reload()
},
})
}
>
</button>
Видалити
</Button>
</div>
),
},
@ -186,6 +201,7 @@ export function ChannelsPage() {
}}
/>
)}
<Confirm request={confirm} onClose={() => setConfirm(null)} />
</>
)
}

View file

@ -13,6 +13,7 @@ import {
inputClass,
} from '../components/ui'
import { BackupPolicyForm } from '../components/BackupPolicyForm'
import { BackupDefaultsForm } from '../components/BackupDefaultsForm'
import type {
BackupPolicy,
ConfigJobRow,
@ -53,6 +54,7 @@ export function ConfigsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [defaults, setDefaults] = useState(false)
const [creds, setCreds] = useState<Credential[]>([])
const canCollect = session.can('ncm:write')
@ -81,12 +83,17 @@ export function ConfigsPage() {
title="Конфіги"
subtitle="Історія змін на обладнанні"
actions={
<input
className={`${inputClass} w-40 md:w-56`}
placeholder="пошук хоста…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<>
<Button onClick={() => setDefaults(true)} title="Розклад збору для всіх хостів">
Спільний розклад
</Button>
<input
className={`${inputClass} w-40 md:w-56`}
placeholder="пошук хоста…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</>
}
/>
@ -135,6 +142,13 @@ export function ConfigsPage() {
</div>
)}
</div>
{defaults && (
<BackupDefaultsForm
credentials={creds}
onClose={() => setDefaults(false)}
onSaved={() => setDefaults(false)}
/>
)}
</>
)
}

View file

@ -0,0 +1,298 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api/client'
import { session } from '../api/session'
import {
Button,
Card,
Confirm,
ErrorNote,
Field,
Modal,
PageHeader,
Spinner,
inputClass,
plural,
} from '../components/ui'
import type { ConfirmRequest } from '../components/ui'
import type { Credential } from '../types'
// Протоколи, які вміє зонд. Порт за замовчуванням підставляється сам:
// 161 для SNMP і 22 для SSH знає кожен, і вводити їх щоразу — марна
// робота, але змінити їх треба вміти (обладнання за NAT).
const PROTOS: { key: string; label: string; port: number; secretLabel: string; hasUser: boolean }[] =
[
{ key: 'snmp_v2c', label: 'SNMP v2c', port: 161, secretLabel: 'Community', hasUser: false },
{ key: 'snmp_v3', label: 'SNMP v3', port: 161, secretLabel: 'Пароль (authKey)', hasUser: true },
{ key: 'ssh', label: 'SSH', port: 22, secretLabel: 'Пароль', hasUser: true },
{ key: 'telnet', label: 'Telnet', port: 23, secretLabel: 'Пароль', hasUser: true },
]
const protoLabel = (k: string) => PROTOS.find((p) => p.key === k)?.label ?? k
/**
* Доступи до обладнання: SNMP-community, паролі SSH і Telnet.
*
* Самі секрети сторінка ніколи не показує й не отримує з сервера:
* розшифрувати пароль заради показу означає віддати його туди, звідки
* він уже не повернеться. Змінити пароль можна, підглянути ні.
*/
export function CredentialsPage() {
const canWrite = session.can('devices:write')
const [creds, setCreds] = useState<Credential[]>([])
const [usage, setUsage] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(true)
const [err, setErr] = useState<string | null>(null)
const [editing, setEditing] = useState<Credential | null>(null)
const [creating, setCreating] = useState(false)
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
const reload = useCallback(() => {
api
.listCredentialsWithUsage()
.then((r) => {
setCreds(r.credentials)
setUsage(r.usage)
})
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false))
}, [])
useEffect(reload, [reload])
if (loading) return <Spinner />
return (
<div className="space-y-4">
<PageHeader
title="Доступи до обладнання"
subtitle="SNMP-community, паролі SSH і Telnet — зашифровані, показу не підлягають"
actions={
canWrite ? (
<Button kind="primary" onClick={() => setCreating(true)}>
Новий доступ
</Button>
) : null
}
/>
<ErrorNote>{err}</ErrorNote>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{creds.map((c) => (
<Card key={c.id} className="flex flex-col p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-slate-200">{c.name}</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{protoLabel(c.proto)}
{c.username ? ` · ${c.username}` : ''}
{c.port ? ` · порт ${c.port}` : ''}
</div>
</div>
{c.is_default && (
<span className="shrink-0 rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400">
типовий
</span>
)}
</div>
<div className="mt-auto flex items-center justify-between gap-2 pt-3 text-[11px] text-slate-600">
<span>{plural(usage[c.id] ?? 0, 'хост', 'хости', 'хостів')}</span>
{canWrite && (
<div className="flex gap-1.5">
<Button onClick={() => setEditing(c)}>Змінити</Button>
<Button
kind="danger"
onClick={() =>
setConfirm({
title: 'Видалити доступ',
message: (
<>
Доступ <b>{c.name}</b> ({protoLabel(c.proto)}) буде видалено разом із
паролем.
</>
),
detail:
(usage[c.id] ?? 0) > 0
? `Його використовує ${plural(usage[c.id], 'хост', 'хости', 'хостів')} — вони перестануть опитуватись усім, крім пінга.`
: undefined,
onConfirm: async () => {
await api.deleteCredential(c.id)
reload()
},
})
}
>
</Button>
</div>
)}
</div>
</Card>
))}
</div>
{creds.length === 0 && (
<p className="py-8 text-center text-xs leading-relaxed text-slate-600">
Доступів ще немає. Без них працює лише пінг SNMP і збір конфігів потребують
community або пароля.
</p>
)}
{(creating || editing) && (
<CredentialForm
credential={editing}
onClose={() => {
setCreating(false)
setEditing(null)
}}
onSaved={() => {
setCreating(false)
setEditing(null)
reload()
}}
/>
)}
<Confirm request={confirm} onClose={() => setConfirm(null)} />
</div>
)
}
function CredentialForm({
credential,
onClose,
onSaved,
}: {
credential: Credential | null
onClose: () => void
onSaved: () => void
}) {
const [name, setName] = useState(credential?.name ?? '')
const [proto, setProto] = useState(credential?.proto ?? 'snmp_v2c')
const [username, setUsername] = useState(credential?.username ?? '')
const [port, setPort] = useState(credential?.port ?? 161)
const [secret, setSecret] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const spec = PROTOS.find((p) => p.key === proto) ?? PROTOS[0]
const editingExisting = credential !== null
return (
<Modal title={credential ? `Доступ: ${credential.name}` : 'Новий доступ'} onClose={onClose}>
<div className="space-y-3">
<Field label="Назва" hint="Як його називати в списку хоста">
<input
className={inputClass}
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field
label="Протокол"
hint={editingExisting ? 'Змінити не можна — заведіть новий' : undefined}
>
<select
className={inputClass}
value={proto}
disabled={editingExisting}
onChange={(e) => {
setProto(e.target.value)
const p = PROTOS.find((x) => x.key === e.target.value)
if (p) setPort(p.port)
}}
>
{PROTOS.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
</select>
</Field>
<Field label="Порт">
<input
className={`${inputClass} tabular-nums`}
type="number"
min={1}
max={65535}
value={port}
onChange={(e) => setPort(Number(e.target.value))}
/>
</Field>
</div>
{spec.hasUser && (
<Field label="Користувач">
<input
className={inputClass}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</Field>
)}
<Field
label={spec.secretLabel}
hint={
editingExisting
? 'Порожньо — лишити поточний. Показати збережений неможливо: він зашифрований'
: 'Зберігається зашифрованим, у відповідях API не з’являється'
}
>
<input
className={inputClass}
type="password"
autoComplete="new-password"
value={secret}
placeholder={editingExisting ? '••••••••' : ''}
onChange={(e) => setSecret(e.target.value)}
/>
</Field>
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || !name.trim() || (!editingExisting && !secret)}
onClick={async () => {
setBusy(true)
setErr(null)
try {
if (credential) {
await api.updateCredential(credential.id, {
name: name.trim(),
username,
port,
secret,
})
} else {
await api.createCredential({
name: name.trim(),
proto,
username,
port,
secret,
})
}
onSaved()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Збереження…' : 'Зберегти'}
</Button>
</div>
</div>
</Modal>
)
}

View file

@ -3,12 +3,14 @@ import { useOutletContext } from 'react-router-dom'
import { api, type CheckInput, type DeviceInput } from '../api/client'
import { session } from '../api/session'
import { liveEvents } from '../api/ws'
import type { ConfirmRequest } from '../components/ui'
import type { LiveAlerts } from '../hooks/useAlerts'
import {
Button,
DataTable,
ErrorNote,
Field,
Confirm,
Modal,
PageHeader,
Spinner,
@ -371,6 +373,7 @@ function DeviceForm({
const [templates, setTemplates] = useState<Template[]>([])
const [templateIDs, setTemplateIDs] = useState<string[]>([])
const [templatesLoaded, setTemplatesLoaded] = useState(false)
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
@ -589,6 +592,8 @@ function DeviceForm({
<ErrorNote>{err}</ErrorNote>
<Confirm request={confirm} onClose={() => setConfirm(null)} />
<div className="flex flex-wrap justify-end gap-2 pt-1">
{onDelete && (
<Button
@ -596,17 +601,19 @@ function DeviceForm({
disabled={busy}
className="mr-auto"
title="Хост зникне зі списків, але його історія збережеться"
onClick={async () => {
setBusy(true)
setErr(null)
try {
await onDelete()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
onClick={() =>
setConfirm({
title: 'Видалити хост',
message: (
<>
Хост <b>{device?.name ?? name}</b> зникне зі списків і мап.
</>
),
detail:
'Опитування припиниться, активні алерти по ньому закриються. Зібрані метрики та конфіги збережуться — хост позначається видаленим, а не витирається.',
onConfirm: onDelete,
})
}
>
Видалити
</Button>

View file

@ -3,6 +3,7 @@ import { api } from '../api/client'
import { session } from '../api/session'
import {
Button,
Confirm,
Card,
DataTable,
ErrorNote,
@ -12,6 +13,7 @@ import {
Spinner,
inputClass,
} from '../components/ui'
import type { ConfirmRequest } from '../components/ui'
import type { AccessLevel, DeviceGroup, TeamMember, UserGroup } from '../types'
const levelLabel: Record<AccessLevel | 'none', string> = {
@ -42,6 +44,7 @@ export function GroupsPage() {
const [team, setTeam] = useState<TeamMember[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
const [newDeviceGroup, setNewDeviceGroup] = useState(false)
const [newUserGroup, setNewUserGroup] = useState(false)
const [editing, setEditing] = useState<UserGroup | null>(null)
@ -143,13 +146,25 @@ export function GroupsPage() {
header: '',
cell: (g) =>
canWriteDevices && (
<button
className="px-1 text-xs text-slate-600 hover:text-red-400"
<Button
kind="danger"
title="Видалити групу — хости лишаться, зникне лише набір"
onClick={() => void act(() => api.deleteDeviceGroup(g.id))}
onClick={() =>
setConfirm({
title: 'Видалити групу хостів',
message: (
<>
Групу <b>{g.name}</b> буде видалено.
</>
),
detail:
'Самі хости лишаться — зникне лише набір. Але групи доступу, які на неї посилались, втратять цю межу: люди можуть побачити більше, ніж бачили.',
onConfirm: () => act(() => api.deleteDeviceGroup(g.id)),
})
}
>
</button>
Видалити
</Button>
),
},
]}
@ -180,13 +195,24 @@ export function GroupsPage() {
{canWriteUsers && (
<span className="ml-auto flex items-center gap-1.5">
<Button onClick={() => setEditing(g)}>Налаштувати</Button>
<button
className="px-1 text-xs text-slate-600 hover:text-red-400"
onClick={() => void act(() => api.deleteUserGroup(g.id))}
<Button
kind="danger"
title="Видалити групу доступу"
onClick={() =>
setConfirm({
title: 'Видалити групу доступу',
message: (
<>
Групу <b>{g.name}</b> буде видалено.
</>
),
detail: `Її ${g.member_ids.length} учасників втратять задані нею межі. Якщо інших груп у людини немає, вона побачить усі хости — саме так працює відсутність груп.`,
onConfirm: () => act(() => api.deleteUserGroup(g.id)),
})
}
>
</button>
Видалити
</Button>
</span>
)}
</div>
@ -264,6 +290,7 @@ export function GroupsPage() {
}}
/>
)}
<Confirm request={confirm} onClose={() => setConfirm(null)} />
</>
)
}

View file

@ -4,6 +4,7 @@ import { session } from '../api/session'
import { isAlertEvent, useLiveRefresh } from '../hooks/useLiveRefresh'
import {
Button,
Confirm,
DataTable,
ErrorNote,
Field,
@ -13,7 +14,8 @@ import {
Toggle,
inputClass,
} from '../components/ui'
import type { AlertRule, Severity } from '../types'
import type { ConfirmRequest } from '../components/ui'
import type { AlertRule, Channel, DeviceGroup, Severity } from '../types'
// Списки метрик повторюють whitelist сервера. Дублювання свідоме: без
// нього людина дізнавалась би про друкарську помилку не з форми, а з
@ -59,7 +61,9 @@ export function RulesPage() {
const [rules, setRules] = useState<AlertRule[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState<AlertRule | null>(null)
const canWrite = session.can('alerts:write')
@ -166,17 +170,30 @@ export function RulesPage() {
await reload()
}}
/>
{canWrite && <Button onClick={() => setEditing(r)}>Змінити</Button>}
{canWrite && (
<button
className="text-xs text-slate-600 hover:text-red-400"
<Button
kind="danger"
title="Видалити правило разом з його алертами"
onClick={async () => {
await api.deleteAlertRule(r.id)
await reload()
}}
onClick={() =>
setConfirm({
title: 'Видалити правило',
message: (
<>
Правило <b>{r.name}</b> буде видалено.
</>
),
detail:
'Активні алерти, підняті цим правилом, зникнуть разом із ним. Якщо треба лише тимчасово зупинити — вимкніть перемикачем.',
onConfirm: async () => {
await api.deleteAlertRule(r.id)
await reload()
},
})
}
>
</button>
Видалити
</Button>
)}
</div>
),
@ -186,15 +203,21 @@ export function RulesPage() {
)}
</div>
{creating && (
{(creating || editing) && (
<RuleForm
onClose={() => setCreating(false)}
rule={editing}
onClose={() => {
setCreating(false)
setEditing(null)
}}
onSaved={async () => {
setCreating(false)
setEditing(null)
await reload()
}}
/>
)}
<Confirm request={confirm} onClose={() => setConfirm(null)} />
</>
)
}
@ -214,20 +237,59 @@ function describeCondition(r: AlertRule): string {
return `${name} ${c.op ?? ''} ${c.value ?? ''} (${scope})`
}
function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Promise<void> }) {
const [name, setName] = useState('')
const [source, setSource] = useState('icmp')
const [metric, setMetric] = useState('loss_pct')
const [metricKey, setMetricKey] = useState('')
const [op, setOp] = useState('>')
const [value, setValue] = useState('20')
const [agg, setAgg] = useState('')
const [severity, setSeverity] = useState<Severity>('warning')
const [forSec, setForSec] = useState('180')
const [topo, setTopo] = useState(true)
function RuleForm({
rule,
onClose,
onSaved,
}: {
rule: AlertRule | null
onClose: () => void
onSaved: () => Promise<void>
}) {
const cond = (rule?.condition ?? {}) as Record<string, unknown>
const sel = (rule?.selector ?? {}) as { group_ids?: string[] }
const [name, setName] = useState(rule?.name ?? '')
const [source, setSource] = useState(rule?.source ?? 'icmp')
const [metric, setMetric] = useState(String(cond.metric ?? 'loss_pct'))
const [metricKey, setMetricKey] = useState(String(cond.metric_key ?? ''))
const [op, setOp] = useState(String(cond.op ?? '>'))
const [value, setValue] = useState(String(cond.value ?? '20'))
const [agg, setAgg] = useState(String(cond.agg ?? ''))
const [severity, setSeverity] = useState<Severity>(rule?.severity ?? 'warning')
const [forSec, setForSec] = useState(String(rule?.for_seconds ?? 180))
const [topo, setTopo] = useState(rule?.depends_on_topology ?? true)
const [groupIDs, setGroupIDs] = useState<string[]>(sel.group_ids ?? [])
const [channelIDs, setChannelIDs] = useState<string[]>(rule?.channel_ids ?? [])
const [onResolve, setOnResolve] = useState(rule?.notify_on_resolve ?? true)
const [quietFrom, setQuietFrom] = useState(rule?.notify_schedule?.quiet?.[0]?.from ?? '')
const [quietTo, setQuietTo] = useState(rule?.notify_schedule?.quiet?.[0]?.to ?? '')
const [groups, setGroups] = useState<DeviceGroup[]>([])
const [channels, setChannels] = useState<Channel[]>([])
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
// Довідники для вибору «кому» й «через що». Помилка тут не має
// закривати форму: правило без явних каналів піде за маршрутами
// тенанта, і це робочий стан, а не збій.
useEffect(() => {
let alive = true
Promise.all([
api.listDeviceGroups().catch(() => [] as DeviceGroup[]),
api.listChannels().catch(() => [] as Channel[]),
]).then(([g, ch]) => {
if (!alive) return
setGroups(g)
setChannels(ch)
})
return () => {
alive = false
}
}, [])
const toggle = (list: string[], set: (v: string[]) => void, id: string) =>
set(list.includes(id) ? list.filter((x) => x !== id) : [...list, id])
const isNoData = metric === 'no_data'
const metrics = metricsBySource[source] ?? []
@ -242,13 +304,23 @@ function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Pr
: { metric, op, value: Number(value) }
if (!isNoData && agg) condition.agg = agg
await api.createAlertRule({
await api.saveAlertRule(rule?.id ?? null, {
name: name.trim(),
source,
severity,
condition,
// Порожній набір груп означає «усі хости»: правило без меж —
// найчастіший випадок, і вимагати вибору означало б змусити
// людину відмічати всі групи по черзі.
selector: groupIDs.length > 0 ? { group_ids: groupIDs } : {},
for_seconds: Number(forSec) || 60,
depends_on_topology: topo,
channel_ids: channelIDs,
notify_on_resolve: onResolve,
notify_schedule:
quietFrom && quietTo
? { tz: Intl.DateTimeFormat().resolvedOptions().timeZone, quiet: [{ from: quietFrom, to: quietTo }] }
: null,
})
await onSaved()
} catch (e) {
@ -259,7 +331,7 @@ function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Pr
}
return (
<Modal title="Нове правило" onClose={onClose}>
<Modal title={rule ? `Правило: ${rule.name}` : 'Нове правило'} onClose={onClose} wide>
<div className="space-y-3">
<Field label="Назва">
<input
@ -392,6 +464,109 @@ function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Pr
</span>
</label>
<div className="space-y-2 border-t border-slate-800 pt-3">
<div className="text-xs font-medium text-slate-400">Кого стосується</div>
{groups.length === 0 ? (
<p className="text-[11px] text-slate-600">Груп хостів ще немає правило діє на всі.</p>
) : (
<>
<p className="text-[11px] text-slate-600">
Нічого не відмічено правило діє на всі хости
</p>
<ul className="max-h-32 space-y-1 overflow-y-auto rounded border border-slate-800 p-2">
{groups.map((g) => (
<li key={g.id}>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={groupIDs.includes(g.id)}
onChange={() => toggle(groupIDs, setGroupIDs, g.id)}
/>
<span className="text-slate-200">{g.name}</span>
</label>
</li>
))}
</ul>
</>
)}
</div>
<div className="space-y-2 border-t border-slate-800 pt-3">
<div className="text-xs font-medium text-slate-400">Куди слати</div>
{channels.length === 0 ? (
<p className="text-[11px] leading-relaxed text-slate-600">
Каналів ще немає. Заведіть Telegram, Discord або вебхук на сторінці «Сповіщення»
без жодного каналу алерти лишаться тільки на екрані.
</p>
) : (
<>
<p className="text-[11px] leading-relaxed text-slate-600">
Нічого не відмічено піде за загальними маршрутами тенанта, тобто в усі
придатні канали
</p>
<ul className="max-h-32 space-y-1 overflow-y-auto rounded border border-slate-800 p-2">
{channels.map((c) => (
<li key={c.id}>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={channelIDs.includes(c.id)}
onChange={() => toggle(channelIDs, setChannelIDs, c.id)}
/>
<span className="text-slate-200">{c.name}</span>
<span className="text-xs text-slate-600">{c.kind}</span>
{!c.enabled && (
<span className="text-[10px] text-amber-500">вимкнений</span>
)}
</label>
</li>
))}
</ul>
</>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Тиха година з" hint="Порожньо — не глушити">
<input
className={inputClass}
type="time"
value={quietFrom}
onChange={(e) => setQuietFrom(e.target.value)}
/>
</Field>
<Field label="до">
<input
className={inputClass}
type="time"
value={quietTo}
onChange={(e) => setQuietTo(e.target.value)}
/>
</Field>
</div>
{quietFrom && quietTo && (
<p className="text-[11px] leading-relaxed text-slate-600">
У ці години сповіщення не йдуть крім рівня «аварія»: сенс чергування в тому,
щоб його підняли.
</p>
)}
<label className="flex items-start gap-2.5 rounded border border-slate-800 bg-slate-900 p-2.5">
<input
type="checkbox"
className="mt-0.5"
checked={onResolve}
onChange={(e) => setOnResolve(e.target.checked)}
/>
<span className="text-xs leading-snug text-slate-400">
Повідомляти про відновлення
<span className="mt-0.5 block text-slate-600">
«Впало» без «піднялося» змушує йти перевіряти руками і цим знецінює саме
повідомлення про аварію.
</span>
</span>
</label>
</div>
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
@ -401,7 +576,7 @@ function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Pr
disabled={busy || !name.trim() || (source === 'metric' && !metricKey.trim())}
onClick={() => void submit()}
>
{busy ? 'Збереження…' : 'Створити'}
{busy ? 'Збереження…' : rule ? 'Зберегти' : 'Створити'}
</Button>
</div>
</div>

View file

@ -3,6 +3,7 @@ import { api } from '../api/client'
import { session } from '../api/session'
import {
Button,
Confirm,
DataTable,
ErrorNote,
Field,
@ -12,6 +13,7 @@ import {
ago,
inputClass,
} from '../components/ui'
import type { ConfirmRequest } from '../components/ui'
import type { Role, TeamMember } from '../types'
export function TeamPage() {
@ -21,6 +23,7 @@ export function TeamPage() {
const [error, setError] = useState<string | null>(null)
const [adding, setAdding] = useState(false)
const [editing, setEditing] = useState<TeamMember | null>(null)
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
const canWrite = session.can('users:write')
const me = session.me()
@ -128,13 +131,27 @@ export function TeamPage() {
{/* Себе не прибирають, власника теж: організація
без власника не відновлюється через інтерфейс. */}
{m.user_id !== me?.userID && m.role_key !== 'owner' && (
<button
className="px-1 text-xs text-slate-600 hover:text-red-400"
title="Прибрати з організації — усі його сесії тут буде відкликано"
onClick={() => void act(() => api.removeMember(m.user_id))}
<Button
kind="danger"
title="Прибрати з організації"
onClick={() =>
setConfirm({
title: 'Прибрати з організації',
message: (
<>
<b>{m.username || m.email}</b> втратить доступ до цієї
організації.
</>
),
detail:
'Усі його сесії тут буде відкликано негайно. Сам обліковий запис і його історія дій збережуться.',
confirmLabel: 'Прибрати',
onConfirm: () => act(() => api.removeMember(m.user_id)),
})
}
>
</button>
Прибрати
</Button>
)}
</div>
) : null,
@ -193,6 +210,7 @@ export function TeamPage() {
}}
/>
)}
<Confirm request={confirm} onClose={() => setConfirm(null)} />
</>
)
}

View file

@ -9,19 +9,27 @@ import {
Modal,
PageHeader,
Spinner,
Confirm,
IntervalInput,
Toggle,
inputClass,
plural,
} from '../components/ui'
import type { ConfirmRequest } from '../components/ui'
import type { Template, TemplateItem } from '../types'
const INTERVALS = [30, 60, 120, 300, 600, 1800, 3600]
// Типи перевірок, які вміє описати шаблон.
//
// snmp.get адресується OID-ом, решта — параметрами. Список навмисно
// короткий: тип, якого агент не вміє, у формі лише обіцяв би роботу.
const ITEM_TYPES: { key: string; label: string; byOID: boolean }[] = [
{ key: 'snmp.get', label: 'SNMP: значення за OID', byOID: true },
{ key: 'icmp.ping', label: 'Пінг (ICMP)', byOID: false },
{ key: 'snmp.if', label: 'SNMP: лічильники портів', byOID: false },
{ key: 'http.status', label: 'HTTP: код відповіді', byOID: false },
]
function humanInterval(sec: number): string {
if (sec < 60) return `${sec} с`
if (sec < 3600) return `${Math.round(sec / 60)} хв`
return `${Math.round(sec / 3600)} год`
}
const byOID = (t: string) => ITEM_TYPES.find((x) => x.key === t)?.byOID ?? true
/**
* Шаблони опитування.
@ -38,6 +46,8 @@ export function TemplatesPage() {
const [err, setErr] = useState<string | null>(null)
const [editing, setEditing] = useState<Template | null>(null)
const [creating, setCreating] = useState(false)
const [importing, setImporting] = useState(false)
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
const reload = useCallback(() => {
api
@ -65,11 +75,24 @@ export function TemplatesPage() {
title="Шаблони опитування"
subtitle="Набір метрик, який чіпляється до хоста одним рухом"
actions={
canWrite ? (
<Button kind="primary" onClick={() => setCreating(true)}>
Новий шаблон
<>
<Button
onClick={() =>
api
.exportTemplates()
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
}
title="Забрати всі шаблони одним файлом"
>
Експорт
</Button>
) : null
{canWrite && <Button onClick={() => setImporting(true)}>Імпорт</Button>}
{canWrite && (
<Button kind="primary" onClick={() => setCreating(true)}>
Новий шаблон
</Button>
)}
</>
}
/>
@ -77,7 +100,7 @@ export function TemplatesPage() {
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{list.map((t) => (
<Card key={t.id}>
<Card key={t.id} className="flex flex-col p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-slate-200">{t.name}</div>
@ -94,26 +117,45 @@ export function TemplatesPage() {
<p className="mt-2 text-xs leading-relaxed text-slate-500">{t.description}</p>
)}
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-600">
<div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-3 text-[11px] text-slate-600">
<span>
{plural(t.item_count, 'метрика', 'метрики', 'метрик')} ·{' '}
{plural(t.device_count, 'хост', 'хости', 'хостів')}
</span>
<div className="flex gap-1.5">
<Button
kind="ghost"
title="Забрати цей шаблон файлом"
onClick={() =>
api
.exportTemplates([t.id])
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
}
>
</Button>
<Button onClick={() => open(t.id)}>{t.is_builtin ? 'Переглянути' : 'Змінити'}</Button>
{canWrite && !t.is_builtin && (
<Button
kind="danger"
onClick={async () => {
if (!confirm(`Видалити шаблон «${t.name}»? Породжені ним перевірки зникнуть.`))
return
try {
await api.deleteTemplate(t.id)
reload()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
}
}}
onClick={() =>
setConfirm({
title: 'Видалити шаблон',
message: (
<>
Шаблон <b>{t.name}</b> буде видалено разом з усіма його метриками.
</>
),
detail:
t.device_count > 0
? `Він прив'язаний до ${plural(t.device_count, 'хоста', 'хостів', 'хостів')} — породжені ним перевірки зникнуть, і ці метрики перестануть збиратись.`
: undefined,
onConfirm: async () => {
await api.deleteTemplate(t.id)
reload()
},
})
}
>
</Button>
@ -128,6 +170,10 @@ export function TemplatesPage() {
<p className="py-8 text-center text-xs text-slate-600">Шаблонів ще немає</p>
)}
{importing && <ImportForm onClose={() => setImporting(false)} onImported={reload} />}
<Confirm request={confirm} onClose={() => setConfirm(null)} />
{(editing || creating) && (
<TemplateForm
template={editing}
@ -147,7 +193,16 @@ export function TemplatesPage() {
)
}
const emptyItem = (): TemplateItem => ({
/**
* Елемент у стані форми.
*
* paramsText сирий текст поля, а не розібраний об'єкт: людина має
* право надрукувати незавершений JSON і не втратити його на кожному
* натисканні клавіші. Розбір відкладається до збереження.
*/
type EditItem = TemplateItem & { paramsText?: string }
const emptyItem = (): EditItem => ({
key: '',
name: '',
check_type: 'snmp.get',
@ -159,6 +214,11 @@ const emptyItem = (): TemplateItem => ({
enabled: true,
})
const toEdit = (it: TemplateItem): EditItem => ({
...it,
paramsText: it.params ? JSON.stringify(it.params) : '',
})
function TemplateForm({
template,
readOnly,
@ -174,17 +234,19 @@ function TemplateForm({
const [name, setName] = useState(template?.name ?? '')
const [description, setDescription] = useState(template?.description ?? '')
const [vendor, setVendor] = useState(template?.vendor ?? '')
const [items, setItems] = useState<TemplateItem[]>(template?.items ?? [emptyItem()])
const [items, setItems] = useState<EditItem[]>(
template?.items?.map(toEdit) ?? [emptyItem()],
)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
function patch(i: number, next: Partial<TemplateItem>) {
function patch(i: number, next: Partial<EditItem>) {
setItems(items.map((it, n) => (n === i ? { ...it, ...next } : it)))
}
// Ключ елемента потрібен базі, але не людині: він виводиться з назви
// метрики, а вручну задавати його немає навіщо.
function keyFor(it: TemplateItem, i: number): string {
function keyFor(it: EditItem, i: number): string {
if (it.key) return it.key
const from = (it.metric_key || it.name || `item-${i + 1}`)
.toLowerCase()
@ -238,15 +300,76 @@ function TemplateForm({
</Field>
<div className="space-y-2 border-t border-slate-800 pt-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-400">Метрики</span>
{!readOnly && (
<Button onClick={() => setItems([...items, emptyItem()])}>Додати метрику</Button>
)}
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-medium text-slate-400">Перевірки</span>
<div className="flex flex-wrap gap-1.5">
{template && (
<Button
onClick={() =>
api
.exportTemplates([template.id])
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
}
title="Зберегти цей шаблон у файл"
>
Експорт
</Button>
)}
{!readOnly && (
<>
<label
className="cursor-pointer rounded border border-slate-700 bg-slate-800 px-2.5 py-1
text-xs text-slate-200 hover:bg-slate-700"
title="Замінити вміст форми вмістом файлу"
>
Імпорт
<input
type="file"
accept="application/json,.json"
className="hidden"
onChange={async (e) => {
const f = e.target.files?.[0]
if (!f) return
setErr(null)
try {
const doc = JSON.parse(await f.text())
const t: Template | undefined = doc.templates?.[0] ?? doc
if (!t || !Array.isArray(t.items)) {
throw new Error('у файлі немає жодного шаблону з перевірками')
}
// Ключ і назву не чіпаємо: людина відкрила
// конкретний шаблон, щоб замінити його вміст,
// а не щоб він перетворився на інший.
setItems(t.items.map(toEdit))
if (t.description) setDescription(t.description)
if (t.vendor) setVendor(t.vendor)
} catch (e2) {
setErr(
e2 instanceof SyntaxError
? `Файл не читається як JSON: ${e2.message}`
: e2 instanceof Error
? e2.message
: String(e2),
)
} finally {
e.target.value = ''
}
}}
/>
</label>
<Button onClick={() => setItems([...items, emptyItem()])}>Додати</Button>
</>
)}
</div>
</div>
{!readOnly && (
<p className="text-[11px] text-slate-600">
Імпорт замінює перевірки нижче вмістом файлу. Зберігається лише після «Зберегти».
</p>
)}
{items.map((it, i) => (
<div key={i} className="rounded border border-slate-800 bg-slate-900/40 p-2.5">
<div key={i} className="rounded border border-slate-800 bg-slate-900/40 p-3">
<div className="grid gap-2 sm:grid-cols-2">
<Field label="Назва">
<input
@ -256,56 +379,85 @@ function TemplateForm({
onChange={(e) => patch(i, { name: e.target.value })}
/>
</Field>
<Field label="OID">
<input
className={`${inputClass} font-mono`}
value={it.oid}
<Field label="Тип перевірки">
<select
className={inputClass}
value={it.check_type}
disabled={readOnly}
placeholder=".1.3.6.1.2.1.1.3.0"
onChange={(e) => patch(i, { oid: e.target.value })}
/>
onChange={(e) => patch(i, { check_type: e.target.value })}
>
{ITEM_TYPES.map((t) => (
<option key={t.key} value={t.key}>
{t.label}
</option>
))}
</select>
</Field>
<Field label="Ключ метрики" hint="Під цим ім'ям число ляже в історію">
<input
className={`${inputClass} font-mono`}
value={it.metric_key}
disabled={readOnly}
placeholder="cpu.util_pct"
onChange={(e) => patch(i, { metric_key: e.target.value })}
/>
</Field>
<div className="grid grid-cols-3 gap-2">
<Field label="Одиниця">
{byOID(it.check_type) ? (
<>
<Field label="OID">
<input
className={`${inputClass} font-mono`}
value={it.oid}
disabled={readOnly}
placeholder=".1.3.6.1.2.1.1.3.0"
onChange={(e) => patch(i, { oid: e.target.value })}
/>
</Field>
<Field label="Ключ метрики" hint="Під цим ім'ям число ляже в історію">
<input
className={`${inputClass} font-mono`}
value={it.metric_key}
disabled={readOnly}
placeholder="cpu.util_pct"
onChange={(e) => patch(i, { metric_key: e.target.value })}
/>
</Field>
</>
) : (
<Field
label="Параметри"
hint="JSON, як у формі хоста. Порожньо — типові значення"
>
<input
className={inputClass}
value={it.unit}
className={`${inputClass} font-mono`}
value={it.paramsText ?? ''}
disabled={readOnly}
onChange={(e) => patch(i, { unit: e.target.value })}
placeholder='{"count":3}'
onChange={(e) => patch(i, { paramsText: e.target.value })}
/>
</Field>
<Field label="Множник" hint="0.1 — десяті">
<input
className={inputClass}
value={it.scale}
disabled={readOnly}
onChange={(e) => patch(i, { scale: Number(e.target.value) || 1 })}
/>
</Field>
<Field label="Інтервал">
<select
className={inputClass}
value={it.interval_sec}
disabled={readOnly}
onChange={(e) => patch(i, { interval_sec: Number(e.target.value) })}
>
{INTERVALS.map((s) => (
<option key={s} value={s}>
{humanInterval(s)}
</option>
))}
</select>
</Field>
</div>
)}
{byOID(it.check_type) && (
<div className="grid grid-cols-2 gap-2">
<Field label="Одиниця">
<input
className={inputClass}
value={it.unit}
disabled={readOnly}
onChange={(e) => patch(i, { unit: e.target.value })}
/>
</Field>
<Field label="Множник" hint="0.1 — десяті">
<input
className={inputClass}
value={it.scale}
disabled={readOnly}
onChange={(e) => patch(i, { scale: Number(e.target.value) || 1 })}
/>
</Field>
</div>
)}
<Field label="Інтервал опитування">
<IntervalInput
value={it.interval_sec}
disabled={readOnly}
onChange={(v) => patch(i, { interval_sec: v })}
/>
</Field>
</div>
<div className="mt-2 flex items-center justify-between">
@ -348,12 +500,35 @@ function TemplateForm({
description: description.trim(),
vendor: vendor.trim(),
items: items
.filter((it) => it.oid.trim() && it.metric_key.trim())
.map((it, i) => ({ ...it, key: keyFor(it, i) })),
// Метрика SNMP без OID або ключа нічого не збирає —
// мовчки зберегти її означає показати шаблон, який
// виглядає робочим і не робить нічого.
.filter((it) =>
byOID(it.check_type)
? it.oid.trim() !== '' && it.metric_key.trim() !== ''
: true,
)
.map((it, i) => {
const { paramsText, ...rest } = it
return {
...rest,
key: keyFor(it, i),
params:
!byOID(it.check_type) && paramsText?.trim()
? (JSON.parse(paramsText) as Record<string, unknown>)
: undefined,
}
}),
})
onSaved()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
setErr(
e instanceof SyntaxError
? `Параметри не читаються як JSON: ${e.message}`
: e instanceof Error
? e.message
: String(e),
)
} finally {
setBusy(false)
}
@ -367,3 +542,138 @@ function TemplateForm({
</Modal>
)
}
/**
* Імпорт шаблонів із файлу.
*
* Формат свій, не Zabbix-YAML: там елемент описується ключем виду
* `snmp.get[...]`, до нього чіпляються препроцесинг, value maps і
* тригери нічого з цього тут поки немає, і вдавати сумісність
* означало б мовчки втрачати половину імпортованого.
*/
function ImportForm({
onClose,
onImported,
}: {
onClose: () => void
// Лише перечитати список за спиною модалки. Закриття — окремо: підсумок
// імпорту має лишитись на екрані, поки людина його не прочитає.
onImported: () => void
}) {
const [text, setText] = useState('')
const [fileName, setFileName] = useState('')
const [update, setUpdate] = useState(false)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [result, setResult] = useState<{
created: string[]
updated: string[]
skipped: string[]
} | null>(null)
return (
<Modal title="Імпорт шаблонів" onClose={onClose} wide>
<div className="space-y-3">
<Field label="Файл" hint="JSON, отриманий кнопкою «Експорт»">
<input
type="file"
accept="application/json,.json"
className="w-full text-xs text-slate-400 file:mr-3 file:rounded file:border file:border-slate-700
file:bg-slate-800 file:px-3 file:py-1.5 file:text-xs file:text-slate-200"
onChange={async (e) => {
const f = e.target.files?.[0]
if (!f) return
setFileName(f.name)
setText(await f.text())
setResult(null)
setErr(null)
}}
/>
</Field>
<Field
label={fileName ? `Вміст: ${fileName}` : 'Або вставте документ сюди'}
hint="Можна перевірити й підправити перед заливкою"
>
<textarea
className={`${inputClass} h-40 font-mono text-[11px]`}
value={text}
onChange={(e) => {
setText(e.target.value)
setResult(null)
}}
placeholder='{"format":"netpulse-templates","templates":[…]}'
/>
</Field>
<label className="flex items-center gap-2.5 text-sm text-slate-300">
<Toggle checked={update} onChange={setUpdate} />
Оновлювати наявні
</label>
<p className="-mt-1.5 text-[11px] text-slate-600">
Без цього шаблон із таким самим ключем пропускається. Вбудовані не змінюються
ніколи вони спільні для всіх.
</p>
<ErrorNote>{err}</ErrorNote>
{result && (
<div className="space-y-1 rounded border border-slate-800 bg-slate-900/60 p-3 text-xs">
<ImportLine label="Створено" items={result.created} tone="text-emerald-400" />
<ImportLine label="Оновлено" items={result.updated} tone="text-sky-400" />
<ImportLine label="Пропущено" items={result.skipped} tone="text-slate-500" />
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>{result ? 'Закрити' : 'Скасувати'}</Button>
<Button
kind="primary"
disabled={busy || text.trim() === ''}
onClick={async () => {
setBusy(true)
setErr(null)
setResult(null)
try {
const doc = JSON.parse(text)
const r = await api.importTemplates(doc, update)
setResult(r)
if (r.created.length > 0 || r.updated.length > 0) onImported()
} catch (e) {
setErr(
e instanceof SyntaxError
? `Документ не читається як JSON: ${e.message}`
: e instanceof Error
? e.message
: String(e),
)
} finally {
setBusy(false)
}
}}
>
{busy ? 'Заливаємо…' : 'Імпортувати'}
</Button>
</div>
</div>
</Modal>
)
}
function ImportLine({
label,
items,
tone,
}: {
label: string
items: string[]
tone: string
}) {
if (items.length === 0) return null
return (
<div>
<span className={tone}>{label}:</span>{' '}
<span className="font-mono text-slate-400">{items.join(', ')}</span>
</div>
)
}

View file

@ -270,6 +270,8 @@ export interface BackupPolicy {
enabled: boolean
cron: string
on_syslog: boolean
/** Чи слідує хост глобальному розкладу. */
follows_default: boolean
last_backup_at?: string
next_backup_at?: string
}
@ -413,6 +415,10 @@ export interface AlertRule {
depends_on_topology: boolean
enabled: boolean
active_alerts: number
/** Куди слати. Порожньо — за загальними маршрутами тенанта. */
channel_ids: string[]
notify_on_resolve: boolean
notify_schedule?: { tz?: string; quiet?: { days?: number[]; from: string; to: string }[] } | null
}
// ---------------------------------------------------------------------
@ -465,12 +471,15 @@ export type TemplateItem = {
key: string
name: string
check_type: string
/** Лише для snmp.get: решта типів адресується параметрами. */
oid: string
metric_key: string
unit: string
scale: number
interval_sec: number
enabled: boolean
/** Параметри перевірки для типів без OID: {"count":3} для пінга. */
params?: Record<string, unknown>
}
export type Template = {
@ -484,3 +493,14 @@ export type Template = {
item_count: number
device_count: number
}
/** Глобальний розклад збору конфігів. */
export type BackupDefaults = {
enabled: boolean
cron: string
profile_id?: string
credential_id?: string
/** Скільки хостів слідує глобальному розкладу, а скільки має власний. */
following_count: number
custom_count: number
}