diff --git a/HISTORY.md b/HISTORY.md index 0342e17..88e3316 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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)` +з правилом 11–14. + +**Інтервал опитування вводиться руками.** Список сам по собі не годиться: +рано чи пізно комусь потрібні 45 секунд, і відсутність такої можливості +робить продукт «майже підходящим». Ручний ввід сам по собі теж не +годиться: у дев'яти випадках із десяти значення є в списку. + +**Підтвердження показує наслідки, а не питає «Ви впевнені?».** «Видалити +групу?» і «Видалити групу? 34 хости втратять межі доступу» — різні +питання, і людина відповідає на них по-різному. Вбудований `confirm()` +другого не вміє, тому свій діалог. + +**Кнопки видалення стали кнопками.** Сірий `✕` у кутку рядка формально +існував і фактично не знаходився — користувач повідомив, що видалення +користувачів «немає». + diff --git a/ROADMAP.md b/ROADMAP.md index 923b498..bcefb91 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,6 +102,9 @@ POST /api/v1/roles кастомна роль із набором > звірка планів зонда, REST і редактор у вебі, чотири вбудовані шаблони. > Перевірено наскрізно на живому net-snmp. > +> **Додано 2026-08-24:** шаблон описує перевірки будь-якого типу (не лише +> OID), імпорт/експорт глобальний і поштучний, ручні інтервали. +> > **Лишилось:** автопризначення за `sysObjectID`, прототипи шаблонів, > `snmp.walk` як тип елемента (таблиці з динамічним індексом), тригери > всередині шаблону. diff --git a/agent/internal/session/creds_test.go b/agent/internal/session/creds_test.go new file mode 100644 index 0000000..d668815 --- /dev/null +++ b/agent/internal/session/creds_test.go @@ -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("попросили поновлення для пачки без терміну") + } +} diff --git a/agent/internal/session/session.go b/agent/internal/session/session.go index 4b08d35..cc7eeea 100644 --- a/agent/internal/session/session.go +++ b/agent/internal/session/session.go @@ -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{} } // --------------------------------------------------------------------- diff --git a/db/migrations/0016_templates_any_check.sql b/db/migrations/0016_templates_any_check.sql new file mode 100644 index 0000000..d9ff33f --- /dev/null +++ b/db/migrations/0016_templates_any_check.sql @@ -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; diff --git a/db/migrations/0017_backup_defaults.sql b/db/migrations/0017_backup_defaults.sql new file mode 100644 index 0000000..f957a5a --- /dev/null +++ b/db/migrations/0017_backup_defaults.sql @@ -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; diff --git a/db/migrations/0018_rule_actions.sql b/db/migrations/0018_rule_actions.sql new file mode 100644 index 0000000..b2eefd0 --- /dev/null +++ b/db/migrations/0018_rule_actions.sql @@ -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; diff --git a/server/API.md b/server/API.md index 7432a8e..59ffcae 100644 --- a/server/API.md +++ b/server/API.md @@ -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`, а токен — у diff --git a/server/internal/alerting/notify.go b/server/internal/alerting/notify.go index 2883400..83fee78 100644 --- a/server/internal/alerting/notify.go +++ b/server/internal/alerting/notify.go @@ -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 і чекають, що працюватиме. Вимагати ще й маршрут означало б // мовчати саме там, де налаштування щойно зроблене й здається повним. diff --git a/server/internal/alerting/notify_test.go b/server/internal/alerting/notify_test.go index 0788127..accd0d3 100644 --- a/server/internal/alerting/notify_test.go +++ b/server/internal/alerting/notify_test.go @@ -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)) + } +} diff --git a/server/internal/httpapi/alerts.go b/server/internal/httpapi/alerts.go index 7c60807..04d1c30 100644 --- a/server/internal/httpapi/alerts.go +++ b/server/internal/httpapi/alerts.go @@ -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) diff --git a/server/internal/httpapi/checks.go b/server/internal/httpapi/checks.go index 8cd027d..d8bd419 100644 --- a/server/internal/httpapi/checks.go +++ b/server/internal/httpapi/checks.go @@ -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) +} diff --git a/server/internal/httpapi/metrics.go b/server/internal/httpapi/metrics.go new file mode 100644 index 0000000..301c449 --- /dev/null +++ b/server/internal/httpapi/metrics.go @@ -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" +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 34f1009..2b11083 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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)) diff --git a/server/internal/httpapi/templates.go b/server/internal/httpapi/templates.go index e0159e4..56f95a3 100644 --- a/server/internal/httpapi/templates.go +++ b/server/internal/httpapi/templates.go @@ -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) +} diff --git a/server/internal/store/alerts_channels.go b/server/internal/store/alerts_channels.go index 35a93ad..9c456ba 100644 --- a/server/internal/store/alerts_channels.go +++ b/server/internal/store/alerts_channels.go @@ -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 +} diff --git a/server/internal/store/alerts_query.go b/server/internal/store/alerts_query.go index e996677..fdc01f0 100644 --- a/server/internal/store/alerts_query.go +++ b/server/internal/store/alerts_query.go @@ -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 + }) +} diff --git a/server/internal/store/checks.go b/server/internal/store/checks.go index 0ffea3b..3d4b68f 100644 --- a/server/internal/store/checks.go +++ b/server/internal/store/checks.go @@ -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" + } +} diff --git a/server/internal/store/metrics.go b/server/internal/store/metrics.go new file mode 100644 index 0000000..6a399b7 --- /dev/null +++ b/server/internal/store/metrics.go @@ -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 +} diff --git a/server/internal/store/ncm_schedule.go b/server/internal/store/ncm_schedule.go index e720eb5..59a0ed1 100644 --- a/server/internal/store/ncm_schedule.go +++ b/server/internal/store/ncm_schedule.go @@ -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 + }) +} diff --git a/server/internal/store/templates.go b/server/internal/store/templates.go index 145ecb9..2a66f33 100644 --- a/server/internal/store/templates.go +++ b/server/internal/store/templates.go @@ -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, + ¶ms); 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, ¶ms); 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 +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 375b108..850b76a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } />} /> } />} /> } />} /> + } />} /> } />} /> } />} /> } />} /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7584534..6d696dd 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(`/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 }>( + '/api/v1/credentials', + ).then((r) => ({ credentials: r.credentials ?? [], usage: r.usage ?? {} })), + + updateCredential: ( + id: string, + c: { name: string; username?: string; port?: number; secret?: string }, + ) => request(`/api/v1/credentials/${id}`, { method: 'PATCH', body: JSON.stringify(c) }), + + deleteCredential: (id: string) => + request(`/api/v1/credentials/${id}`, { method: 'DELETE' }), + + // --- глобальний розклад бекапів --- + + getBackupDefaults: () => request('/api/v1/ncm/backup-defaults'), + + setBackupDefaults: (d: { + enabled: boolean + cron: string + profile_id?: string + credential_id?: string + apply_to_all?: boolean + }) => request('/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 - selector?: Record - 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 + selector?: Record + for_seconds: number + depends_on_topology: boolean + channel_ids?: string[] + notify_schedule?: Record | 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(`/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 }), + }, + ), } diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index e7bfe54..e04aa9b 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -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' }, diff --git a/web/src/components/BackupDefaultsForm.tsx b/web/src/components/BackupDefaultsForm.tsx new file mode 100644 index 0000000..cd8c315 --- /dev/null +++ b/web/src/components/BackupDefaultsForm.tsx @@ -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([]) + const [d, setD] = useState(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(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 ( + + {loading ? ( +

Завантаження…

+ ) : ( +
+ + + + + + + + + + + + + {d && ( +

+ Зараз спільному розкладу слідує{' '} + {plural(d.following_count, 'хост', 'хости', 'хостів')}, власний розклад мають{' '} + {plural(d.custom_count, 'хост', 'хости', 'хостів')}. +

+ )} + + {(d?.custom_count ?? 0) > 0 && ( + + )} + + {err} + +
+ + +
+
+ )} +
+ ) +} diff --git a/web/src/components/BackupPolicyForm.tsx b/web/src/components/BackupPolicyForm.tsx index c20107d..da60305 100644 --- a/web/src/components/BackupPolicyForm.tsx +++ b/web/src/components/BackupPolicyForm.tsx @@ -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(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 ( @@ -80,39 +71,13 @@ export function BackupPolicyForm({ Збирати конфіг за розкладом - - - + - {custom && ( - - setCron(e.target.value)} - /> - + {followsDefault && ( +

+ Зараз хост слідує спільному розкладу. Щойно ви збережете цю форму, він отримає + власний — і зміни спільного його більше не чіпатимуть. +

)} кожні - + patch(i, { interval_sec: v })} + /> 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 ( + <> + + + + + {custom && ( + + onChange(e.target.value)} + /> + + )} + + ) +} diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx index 90de6d3..05d6b8e 100644 --- a/web/src/components/ui.tsx +++ b/web/src/components/ui.tsx @@ -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 + onClose: () => void +}) { + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + return ( + +
+

{message}

+ {detail && ( +

+ {detail} +

+ )} + {err} +
+ + +
+
+
+ ) +} + +/** Опис відкладеного підтвердження: що спитати і що зробити після «так». */ +export type ConfirmRequest = { + title: string + message: ReactNode + detail?: ReactNode + confirmLabel?: string + onConfirm: () => void | Promise +} + +/** + * Одне підтвердження на сторінку. + * + * Сторінка тримає `useState` і малює + * ``; будь-яка кнопка видалення + * замість дії кладе туди запит. + */ +export function Confirm({ + request, + onClose, +}: { + request: ConfirmRequest | null + onClose: () => void +}) { + if (!request) return null + return +} + +/** Готові інтервали для списку. Довільне число вводиться руками. */ +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 ( +
+ + {manual && ( +
+ onChange(Math.max(0, Number(e.target.value)))} + /> + с +
+ )} +
+ ) +} diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index 3afb00c..b235acc 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -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 = { @@ -35,6 +37,7 @@ export function ChannelsPage() { const [channels, setChannels] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [confirm, setConfirm] = useState(null) const [creating, setCreating] = useState(false) const [testing, setTesting] = useState(null) const [testResult, setTestResult] = useState<{ id: string; ok: boolean; error?: string } | null>( @@ -159,16 +162,28 @@ export function ChannelsPage() { > {testing === c.id ? '…' : 'Перевірити'} - + Видалити + ), }, @@ -186,6 +201,7 @@ export function ChannelsPage() { }} /> )} + setConfirm(null)} /> ) } diff --git a/web/src/pages/ConfigsPage.tsx b/web/src/pages/ConfigsPage.tsx index 5cf0d7e..6c1f08e 100644 --- a/web/src/pages/ConfigsPage.tsx +++ b/web/src/pages/ConfigsPage.tsx @@ -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(null) const [query, setQuery] = useState('') + const [defaults, setDefaults] = useState(false) const [creds, setCreds] = useState([]) const canCollect = session.can('ncm:write') @@ -81,12 +83,17 @@ export function ConfigsPage() { title="Конфіги" subtitle="Історія змін на обладнанні" actions={ - setQuery(e.target.value)} - /> + <> + + setQuery(e.target.value)} + /> + } /> @@ -135,6 +142,13 @@ export function ConfigsPage() { )} + {defaults && ( + setDefaults(false)} + onSaved={() => setDefaults(false)} + /> + )} ) } diff --git a/web/src/pages/CredentialsPage.tsx b/web/src/pages/CredentialsPage.tsx new file mode 100644 index 0000000..536ecb6 --- /dev/null +++ b/web/src/pages/CredentialsPage.tsx @@ -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([]) + const [usage, setUsage] = useState>({}) + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [confirm, setConfirm] = useState(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 + + return ( +
+ setCreating(true)}> + Новий доступ + + ) : null + } + /> + + {err} + +
+ {creds.map((c) => ( + +
+
+
{c.name}
+
+ {protoLabel(c.proto)} + {c.username ? ` · ${c.username}` : ''} + {c.port ? ` · порт ${c.port}` : ''} +
+
+ {c.is_default && ( + + типовий + + )} +
+ +
+ {plural(usage[c.id] ?? 0, 'хост', 'хости', 'хостів')} + {canWrite && ( +
+ + +
+ )} +
+
+ ))} +
+ + {creds.length === 0 && ( +

+ Доступів ще немає. Без них працює лише пінг — SNMP і збір конфігів потребують + community або пароля. +

+ )} + + {(creating || editing) && ( + { + setCreating(false) + setEditing(null) + }} + onSaved={() => { + setCreating(false) + setEditing(null) + reload() + }} + /> + )} + + setConfirm(null)} /> +
+ ) +} + +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(null) + + const spec = PROTOS.find((p) => p.key === proto) ?? PROTOS[0] + const editingExisting = credential !== null + + return ( + +
+ + setName(e.target.value)} + /> + + +
+ + + + + setPort(Number(e.target.value))} + /> + +
+ + {spec.hasUser && ( + + setUsername(e.target.value)} + /> + + )} + + + setSecret(e.target.value)} + /> + + + {err} + +
+ + +
+
+
+ ) +} diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx index 5c87339..1b4e662 100644 --- a/web/src/pages/DevicesPage.tsx +++ b/web/src/pages/DevicesPage.tsx @@ -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([]) const [templateIDs, setTemplateIDs] = useState([]) const [templatesLoaded, setTemplatesLoaded] = useState(false) + const [confirm, setConfirm] = useState(null) const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) @@ -589,6 +592,8 @@ function DeviceForm({ {err} + setConfirm(null)} /> +
{onDelete && ( diff --git a/web/src/pages/GroupsPage.tsx b/web/src/pages/GroupsPage.tsx index 151b27b..18a890b 100644 --- a/web/src/pages/GroupsPage.tsx +++ b/web/src/pages/GroupsPage.tsx @@ -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 = { @@ -42,6 +44,7 @@ export function GroupsPage() { const [team, setTeam] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [confirm, setConfirm] = useState(null) const [newDeviceGroup, setNewDeviceGroup] = useState(false) const [newUserGroup, setNewUserGroup] = useState(false) const [editing, setEditing] = useState(null) @@ -143,13 +146,25 @@ export function GroupsPage() { header: '', cell: (g) => canWriteDevices && ( - + Видалити + ), }, ]} @@ -180,13 +195,24 @@ export function GroupsPage() { {canWriteUsers && ( - + Видалити + )}
@@ -264,6 +290,7 @@ export function GroupsPage() { }} /> )} + setConfirm(null)} /> ) } diff --git a/web/src/pages/RulesPage.tsx b/web/src/pages/RulesPage.tsx index bceab9c..7300e01 100644 --- a/web/src/pages/RulesPage.tsx +++ b/web/src/pages/RulesPage.tsx @@ -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([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [confirm, setConfirm] = useState(null) const [creating, setCreating] = useState(false) + const [editing, setEditing] = useState(null) const canWrite = session.can('alerts:write') @@ -166,17 +170,30 @@ export function RulesPage() { await reload() }} /> + {canWrite && } {canWrite && ( - + Видалити + )} ), @@ -186,15 +203,21 @@ export function RulesPage() { )} - {creating && ( + {(creating || editing) && ( setCreating(false)} + rule={editing} + onClose={() => { + setCreating(false) + setEditing(null) + }} onSaved={async () => { setCreating(false) + setEditing(null) await reload() }} /> )} + 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 }) { - 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('warning') - const [forSec, setForSec] = useState('180') - const [topo, setTopo] = useState(true) +function RuleForm({ + rule, + onClose, + onSaved, +}: { + rule: AlertRule | null + onClose: () => void + onSaved: () => Promise +}) { + const cond = (rule?.condition ?? {}) as Record + 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(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(sel.group_ids ?? []) + const [channelIDs, setChannelIDs] = useState(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([]) + const [channels, setChannels] = useState([]) const [busy, setBusy] = useState(false) const [err, setErr] = useState(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 ( - +
void; onSaved: () => Pr +
+
Кого стосується
+ {groups.length === 0 ? ( +

Груп хостів ще немає — правило діє на всі.

+ ) : ( + <> +

+ Нічого не відмічено — правило діє на всі хости +

+
    + {groups.map((g) => ( +
  • + +
  • + ))} +
+ + )} +
+ +
+
Куди слати
+ {channels.length === 0 ? ( +

+ Каналів ще немає. Заведіть Telegram, Discord або вебхук на сторінці «Сповіщення» — + без жодного каналу алерти лишаться тільки на екрані. +

+ ) : ( + <> +

+ Нічого не відмічено — піде за загальними маршрутами тенанта, тобто в усі + придатні канали +

+
    + {channels.map((c) => ( +
  • + +
  • + ))} +
+ + )} + +
+ + setQuietFrom(e.target.value)} + /> + + + setQuietTo(e.target.value)} + /> + +
+ {quietFrom && quietTo && ( +

+ У ці години сповіщення не йдуть — крім рівня «аварія»: сенс чергування в тому, + щоб його підняли. +

+ )} + + +
+ {err}
@@ -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 ? 'Зберегти' : 'Створити'}
diff --git a/web/src/pages/TeamPage.tsx b/web/src/pages/TeamPage.tsx index 2795cc6..07a0817 100644 --- a/web/src/pages/TeamPage.tsx +++ b/web/src/pages/TeamPage.tsx @@ -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(null) const [adding, setAdding] = useState(false) const [editing, setEditing] = useState(null) + const [confirm, setConfirm] = useState(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' && ( - + Прибрати + )} ) : null, @@ -193,6 +210,7 @@ export function TeamPage() { }} /> )} + setConfirm(null)} /> ) } diff --git a/web/src/pages/TemplatesPage.tsx b/web/src/pages/TemplatesPage.tsx index 84090d3..c091092 100644 --- a/web/src/pages/TemplatesPage.tsx +++ b/web/src/pages/TemplatesPage.tsx @@ -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(null) const [editing, setEditing] = useState