Історія метрик, шаблони будь-яких перевірок, маршрути правил
Метрики збиралися в ts.samples і не показувалися ніде — побачити
зібране можна було лише через psql. Додано GET /devices/{id}/series і
/metrics: джерело (сирі дані, 5m, 1h) обирається за потрібним кроком,
бакетизація в БД, пропуск у даних лишається пропуском, а не лінією
через діру.
Знайдено живим прогоном: зонд працює рівно годину. CredentialTTL —
година, Credentials() свідомо не віддає прострочені (щоб не блокувати
облікові записи на пристроях), а поновлення не просив ніхто:
CredentialRequest є в контракті з Етапу 2, сервер його обробляє, агент
не надсилає. Будь-яка інсталяція припиняла збирати SNMP через годину
після старту й мовчала про це.
Шаблон описує перевірки будь-якого типу, не лише OID. Пачкою в один PDU
збираються тільки snmp.get; решта — елемент на чек, слід у
core.checks.template_item_key. Вбудований шаблон «Доступність (ICMP)».
Імпорт/експорт глобальний і поштучний, свій формат замість Zabbix-YAML.
Спільний розклад бекапів із перевизначенням на хості: прапорець
follows_default, а не порівняння значень — власний розклад може
випадково збігтися зі спільним.
Правило саме каже, куди йде його алерт: канали, тихі години, групи
хостів, повідомлення про відновлення. Канали правила перекривають
маршрути повністю.
Доступи до обладнання отримали свою сторінку: SSH-паролі й
SNMP-community заводяться, змінюються й видаляються з вебу. Секрет
назовні не повертається ніколи.
Дрібниці за скаргами: відступи в картках шаблонів, українська множина,
ручний ввід інтервалу опитування, підтвердження видалення з описом
наслідків замість «Ви впевнені?», помітні кнопки видалення замість
сірого ✕ у кутку.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2de1894fd5
commit
cd8d4c62ed
38 changed files with 3450 additions and 309 deletions
139
HISTORY.md
139
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()`
|
||||
другого не вміє, тому свій діалог.
|
||||
|
||||
**Кнопки видалення стали кнопками.** Сірий `✕` у кутку рядка формально
|
||||
існував і фактично не знаходився — користувач повідомив, що видалення
|
||||
користувачів «немає».
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ POST /api/v1/roles кастомна роль із набором
|
|||
> звірка планів зонда, REST і редактор у вебі, чотири вбудовані шаблони.
|
||||
> Перевірено наскрізно на живому net-snmp.
|
||||
>
|
||||
> **Додано 2026-08-24:** шаблон описує перевірки будь-якого типу (не лише
|
||||
> OID), імпорт/експорт глобальний і поштучний, ручні інтервали.
|
||||
>
|
||||
> **Лишилось:** автопризначення за `sysObjectID`, прототипи шаблонів,
|
||||
> `snmp.walk` як тип елемента (таблиці з динамічним індексом), тригери
|
||||
> всередині шаблону.
|
||||
|
|
|
|||
73
agent/internal/session/creds_test.go
Normal file
73
agent/internal/session/creds_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// Комплект доступів живе годину. Якщо його не поновлювати, зонд через
|
||||
// цю годину замовкає: Credentials() свідомо не віддає прострочені, і
|
||||
// кожна перевірка падає з «немає креденшелів».
|
||||
//
|
||||
// Перевіряємо саме рішення, а не мережу: підсовуємо пачку, що от-от
|
||||
// протухне, і чекаємо на запит поновлення.
|
||||
func TestSessionAsksToRenewExpiringCredentials(t *testing.T) {
|
||||
s := &Session{}
|
||||
out := make(chan *npv1.ControlUp, 4)
|
||||
ctx := context.Background()
|
||||
|
||||
// Свіжа пачка — просити нічого.
|
||||
s.applyCredentials(&npv1.CredentialBundle{
|
||||
ExpiresAt: timestamppb.New(time.Now().Add(time.Hour)),
|
||||
})
|
||||
s.maybeRenewCredentials(ctx, out)
|
||||
if len(out) != 0 {
|
||||
t.Fatal("попросили поновлення на свіжій пачці")
|
||||
}
|
||||
|
||||
// Лишилось менше запасу — час просити.
|
||||
s.applyCredentials(&npv1.CredentialBundle{
|
||||
ExpiresAt: timestamppb.New(time.Now().Add(credRenewBefore / 2)),
|
||||
})
|
||||
s.maybeRenewCredentials(ctx, out)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("очікували один запит, отримали %d", len(out))
|
||||
}
|
||||
msg := <-out
|
||||
req := msg.GetCredentialRequest()
|
||||
if req == nil {
|
||||
t.Fatal("надіслано не CredentialRequest")
|
||||
}
|
||||
if req.Reason != "expiring" {
|
||||
t.Fatalf("причина %q", req.Reason)
|
||||
}
|
||||
|
||||
// Повторний тік одразу після запиту не має додати другого.
|
||||
s.maybeRenewCredentials(ctx, out)
|
||||
if len(out) != 0 {
|
||||
t.Fatal("запит продубльовано, антиспам не спрацював")
|
||||
}
|
||||
|
||||
// Уже прострочена пачка — причина інша.
|
||||
s.applyCredentials(&npv1.CredentialBundle{
|
||||
ExpiresAt: timestamppb.New(time.Now().Add(-time.Minute)),
|
||||
})
|
||||
s.maybeRenewCredentials(ctx, out)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("очікували запит на простроченій пачці, отримали %d", len(out))
|
||||
}
|
||||
if r := (<-out).GetCredentialRequest(); r == nil || r.Reason != "expired" {
|
||||
t.Fatalf("причина %v", r)
|
||||
}
|
||||
|
||||
// Пачка без TTL — поновлювати нічого.
|
||||
s.applyCredentials(&npv1.CredentialBundle{})
|
||||
s.maybeRenewCredentials(ctx, out)
|
||||
if len(out) != 0 {
|
||||
t.Fatal("попросили поновлення для пачки без терміну")
|
||||
}
|
||||
}
|
||||
|
|
@ -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{}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
|
|||
58
db/migrations/0016_templates_any_check.sql
Normal file
58
db/migrations/0016_templates_any_check.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0016_templates_any_check.sql
|
||||
-- Шаблон перестає бути «набором OID» і стає набором перевірок будь-якого
|
||||
-- типу: пінг, HTTP, SNMP.
|
||||
--
|
||||
-- Причина проста: «як опитувати цей клас пристроїв» — це одне рішення, і
|
||||
-- ділити його на «пінг заводиться руками, а SNMP шаблоном» означає
|
||||
-- змусити людину пам'ятати, що саме шаблон покриває, а що ні.
|
||||
-- =====================================================================
|
||||
|
||||
-- Параметри перевірки для типів, які не описуються одним OID:
|
||||
-- {"count":3,"packet_size":56} для icmp.ping, {"url":"…"} для http.status.
|
||||
ALTER TABLE tpl.items
|
||||
ADD COLUMN params jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
-- OID і ключ метрики більше не обов'язкові: у пінга немає ані того, ані
|
||||
-- іншого — його метрики фіксовані самим типом перевірки.
|
||||
ALTER TABLE tpl.items ALTER COLUMN oid DROP NOT NULL;
|
||||
ALTER TABLE tpl.items ALTER COLUMN metric_key DROP NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Один чек на елемент для негрупованих типів
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Пачкою в один PDU збираються лише OID (snmp.get). Два пінги з різними
|
||||
-- параметрами в одному чеку не існують, тому для решти типів елемент
|
||||
-- шаблону відповідає окремому чеку — і його треба вміти впізнати.
|
||||
--
|
||||
-- Ключ елемента, а не його id: SaveTemplate перезаписує елементи цілком,
|
||||
-- тож id живуть недовго, а ключ стабільний за визначенням.
|
||||
ALTER TABLE core.checks
|
||||
ADD COLUMN template_item_key text NOT NULL DEFAULT '';
|
||||
|
||||
DROP INDEX IF EXISTS core.checks_template_uniq;
|
||||
CREATE UNIQUE INDEX checks_template_uniq
|
||||
ON core.checks (device_id, template_id, check_type, interval_sec, template_item_key)
|
||||
WHERE template_id IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Вбудований шаблон доступності
|
||||
--
|
||||
-- Пінг — єдина перевірка, яка працює будь-де без налаштування, тому
|
||||
-- саме він має бути тим шаблоном, який чіпляють першим.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO tpl.templates (id, tenant_id, key, name, description, vendor, is_builtin) VALUES
|
||||
('00000000-0000-0000-0000-0000000000c5'::uuid, NULL, 'icmp-basic',
|
||||
'Доступність (ICMP)',
|
||||
'Пінг раз на 30 секунд: час відгуку, втрати, джитер. Працює будь-де без доступів і є основою для правил недоступності.',
|
||||
NULL, true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO tpl.items
|
||||
(template_id, key, name, check_type, oid, metric_key, unit, scale, interval_sec, params)
|
||||
VALUES
|
||||
('00000000-0000-0000-0000-0000000000c5', 'ping', 'Пінг',
|
||||
'icmp.ping', NULL, NULL, 'ms', 1, 30, '{"count":3,"packet_size":56}'::jsonb)
|
||||
ON CONFLICT DO NOTHING;
|
||||
36
db/migrations/0017_backup_defaults.sql
Normal file
36
db/migrations/0017_backup_defaults.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0017_backup_defaults.sql
|
||||
-- Глобальний розклад збору конфігів плюс перевизначення на хості.
|
||||
--
|
||||
-- Досі розклад існував лише поштучно: щоб бекапити сто пристроїв, треба
|
||||
-- було сто разів відкрити форму. Глобальне правило — це те, чим воно й
|
||||
-- має бути: значення за замовчуванням, від якого хост може відступити.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE ncm.backup_defaults (
|
||||
tenant_id uuid PRIMARY KEY REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
enabled boolean NOT NULL DEFAULT false,
|
||||
cron text NOT NULL DEFAULT '0 3 * * *',
|
||||
-- Профіль і доступ теж можуть бути спільними: у більшості мереж
|
||||
-- обладнання однорідне, і задавати їх поштучно — марна робота.
|
||||
profile_id uuid REFERENCES ncm.profiles(id) ON DELETE SET NULL,
|
||||
credential_id uuid REFERENCES inv.credentials(id) ON DELETE SET NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Чи слідує хост глобальному розкладу.
|
||||
--
|
||||
-- Прапорець, а не порівняння значень: розклад хоста може випадково
|
||||
-- збігтися з глобальним, і тоді зміна глобального мовчки потягла б за
|
||||
-- собою хост, який навмисно налаштували окремо.
|
||||
ALTER TABLE ncm.device_policies
|
||||
ADD COLUMN follows_default boolean NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE ncm.backup_defaults ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ncm.backup_defaults FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON ncm.backup_defaults
|
||||
USING (tenant_id = core.current_tenant())
|
||||
WITH CHECK (tenant_id = core.current_tenant());
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ncm.backup_defaults
|
||||
TO netpulse_app, netpulse_worker;
|
||||
32
db/migrations/0018_rule_actions.sql
Normal file
32
db/migrations/0018_rule_actions.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0018_rule_actions.sql
|
||||
-- Правило саме каже, куди йде його алерт.
|
||||
--
|
||||
-- Досі маршрутизація жила тільки в alr.routes — окремій сутності з
|
||||
-- власним фільтром. Це працює для великих інсталяцій, де маршрути
|
||||
-- будують один раз, і не працює для звичайного випадку «це правило
|
||||
-- важливе, шліть його черговому в Telegram»: людина створює правило й
|
||||
-- очікує тут же сказати, кому воно телефонує.
|
||||
--
|
||||
-- Маршрути лишаються: вони вирішують інше завдання — спільну політику
|
||||
-- на всі правила разом.
|
||||
-- =====================================================================
|
||||
|
||||
-- Порожній масив = «як вирішать маршрути». Саме порожній, а не NULL:
|
||||
-- відсутність вибору і свідомо порожній вибір тут — те саме, і два
|
||||
-- способи це записати лише плодили б розбіжність.
|
||||
ALTER TABLE alr.rules
|
||||
ADD COLUMN channel_ids uuid[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Тихі години правила: {"tz":"Europe/Kyiv","quiet":[{"days":[6,0],"from":"22:00","to":"08:00"}]}
|
||||
-- Той самий формат, що в alr.routes.schedule — щоб не заводити другий
|
||||
-- діалект того самого поняття.
|
||||
ALTER TABLE alr.rules
|
||||
ADD COLUMN notify_schedule jsonb;
|
||||
|
||||
-- Чи слати повідомлення про зняття алерту.
|
||||
--
|
||||
-- Типово так: «впало» без «піднялося» змушує людину йти перевіряти
|
||||
-- руками, і цим повідомлення про аварію знецінює саме себе.
|
||||
ALTER TABLE alr.rules
|
||||
ADD COLUMN notify_on_resolve boolean NOT NULL DEFAULT true;
|
||||
102
server/API.md
102
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`, а токен — у
|
||||
|
|
|
|||
|
|
@ -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 і чекають, що працюватиме. Вимагати ще й маршрут означало б
|
||||
// мовчати саме там, де налаштування щойно зроблене й здається повним.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
150
server/internal/httpapi/metrics.go
Normal file
150
server/internal/httpapi/metrics.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleListSeries(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:read") {
|
||||
return
|
||||
}
|
||||
deviceID := r.PathValue("id")
|
||||
if !p.Scope().CanRead(deviceID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListSeries(r.Context(), p.TenantID, deviceID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "метрики хоста", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"series": list})
|
||||
}
|
||||
|
||||
// handleQueryMetrics — точки для графіка.
|
||||
//
|
||||
// Вікно задається або парою from/to, або відносним `range` (`6h`, `7d`).
|
||||
// Відносне зручніше й чесніше для типового випадку «останні шість
|
||||
// годин»: абсолютні межі, пораховані в браузері, розповзаються з
|
||||
// серверним часом рівно настільки, наскільки розходяться годинники.
|
||||
func (s *Server) handleQueryMetrics(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:read") {
|
||||
return
|
||||
}
|
||||
deviceID := r.PathValue("id")
|
||||
if !p.Scope().CanRead(deviceID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
var ids []int64
|
||||
for _, raw := range strings.Split(q.Get("series"), ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_series", "ідентифікатор ряду має бути числом")
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_series", "потрібен хоча б один ряд")
|
||||
return
|
||||
}
|
||||
if len(ids) > 20 {
|
||||
writeError(w, http.StatusBadRequest, "too_many",
|
||||
"понад 20 ліній на одному графіку не читаються")
|
||||
return
|
||||
}
|
||||
|
||||
mq := store.MetricQuery{DeviceID: deviceID, SeriesIDs: ids}
|
||||
|
||||
if v := q.Get("range"); v != "" {
|
||||
d, err := parseRange(v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_range", err.Error())
|
||||
return
|
||||
}
|
||||
mq.To = time.Now()
|
||||
mq.From = mq.To.Add(-d)
|
||||
} else {
|
||||
if v := q.Get("from"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_from", "from має бути в форматі RFC3339")
|
||||
return
|
||||
}
|
||||
mq.From = t
|
||||
}
|
||||
if v := q.Get("to"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_to", "to має бути в форматі RFC3339")
|
||||
return
|
||||
}
|
||||
mq.To = t
|
||||
}
|
||||
}
|
||||
|
||||
if v := q.Get("points"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
mq.Points = n
|
||||
}
|
||||
}
|
||||
|
||||
res, err := s.store.QueryMetrics(r.Context(), p.TenantID, mq)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "історія метрик", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// parseRange розбирає відносне вікно: 30m, 6h, 7d.
|
||||
//
|
||||
// Свій розбір, а не time.ParseDuration, бо той не знає «d», а доба —
|
||||
// найчастіше вживане вікно після годин. Стеля в рік: далі роллапи
|
||||
// однаково зачищає retention.
|
||||
func parseRange(v string) (time.Duration, error) {
|
||||
if len(v) < 2 {
|
||||
return 0, errBadRange
|
||||
}
|
||||
n, err := strconv.Atoi(v[:len(v)-1])
|
||||
if err != nil || n <= 0 {
|
||||
return 0, errBadRange
|
||||
}
|
||||
var unit time.Duration
|
||||
switch v[len(v)-1] {
|
||||
case 'm':
|
||||
unit = time.Minute
|
||||
case 'h':
|
||||
unit = time.Hour
|
||||
case 'd':
|
||||
unit = 24 * time.Hour
|
||||
default:
|
||||
return 0, errBadRange
|
||||
}
|
||||
d := time.Duration(n) * unit
|
||||
if d > 365*24*time.Hour {
|
||||
return 0, errBadRange
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
var errBadRange = errRange{}
|
||||
|
||||
type errRange struct{}
|
||||
|
||||
func (errRange) Error() string {
|
||||
return "вікно задається як 30m, 6h або 7d, максимум 365d"
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
282
server/internal/store/metrics.go
Normal file
282
server/internal/store/metrics.go
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// SeriesInfo — один ряд метрики, який можна намалювати.
|
||||
//
|
||||
// Ключа метрики недостатньо, щоб назвати ряд: на одному хості
|
||||
// `if.in_bps` існує стільки разів, скільки в нього портів. Тому назва
|
||||
// збирається з ключа плюс порт або мітки.
|
||||
type SeriesInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
MetricKey string `json:"metric_key"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
InterfaceID string `json:"interface_id,omitempty"`
|
||||
InterfaceName string `json:"interface_name,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
LastValue *float64 `json:"last_value,omitempty"`
|
||||
LastAt *time.Time `json:"last_at,omitempty"`
|
||||
}
|
||||
|
||||
// ListSeries — які метрики взагалі є в цього хоста.
|
||||
//
|
||||
// Останнє значення береться тут же: список метрик без чисел змушував би
|
||||
// клацнути кожну, щоб дізнатись, чи в ній щось є.
|
||||
func (s *Store) ListSeries(ctx context.Context, tenantID, deviceID string) ([]SeriesInfo, error) {
|
||||
out := []SeriesInfo{}
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT s.id, s.metric_key, COALESCE(s.unit,''),
|
||||
COALESCE(s.interface_id::text,''), COALESCE(i.name,''),
|
||||
s.labels::text,
|
||||
(SELECT value FROM ts.samples m
|
||||
WHERE m.series_id = s.id ORDER BY m.ts DESC LIMIT 1),
|
||||
(SELECT ts FROM ts.samples m
|
||||
WHERE m.series_id = s.id ORDER BY m.ts DESC LIMIT 1)
|
||||
FROM ts.series s
|
||||
LEFT JOIN inv.interfaces i ON i.id = s.interface_id
|
||||
WHERE s.tenant_id = $1 AND s.device_id = $2
|
||||
ORDER BY s.metric_key, i.name NULLS FIRST
|
||||
`, tenantID, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var si SeriesInfo
|
||||
var labels string
|
||||
if err := rows.Scan(&si.ID, &si.MetricKey, &si.Unit,
|
||||
&si.InterfaceID, &si.InterfaceName, &labels,
|
||||
&si.LastValue, &si.LastAt); err != nil {
|
||||
return err
|
||||
}
|
||||
si.Labels = decodeLabels(labels)
|
||||
out = append(out, si)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Point — одна точка графіка.
|
||||
//
|
||||
// Val — вказівник, бо пропуск і нуль — різні речі. Лінія, проведена
|
||||
// через діру в даних, каже «все було добре», хоча насправді нічого не
|
||||
// відомо; це найгірший різновид брехні в моніторингу.
|
||||
type Point struct {
|
||||
Ts time.Time `json:"ts"`
|
||||
Val *float64 `json:"v"`
|
||||
Min *float64 `json:"min,omitempty"`
|
||||
Max *float64 `json:"max,omitempty"`
|
||||
}
|
||||
|
||||
// MetricSeries — ряд разом із точками.
|
||||
type MetricSeries struct {
|
||||
ID int64 `json:"id"`
|
||||
MetricKey string `json:"metric_key"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
InterfaceName string `json:"interface_name,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Points []Point `json:"points"`
|
||||
}
|
||||
|
||||
// MetricQuery — що саме малюємо.
|
||||
type MetricQuery struct {
|
||||
DeviceID string
|
||||
SeriesIDs []int64
|
||||
From time.Time
|
||||
To time.Time
|
||||
// Скільки точок хоче намалювати клієнт. Крок рахується з нього, а
|
||||
// не приходить ззовні: інакше вузьке вікно з дрібним кроком
|
||||
// повернуло б десятки тисяч точок, з яких екран покаже сотні.
|
||||
Points int
|
||||
}
|
||||
|
||||
// MetricResult — відповідь на запит графіка.
|
||||
type MetricResult struct {
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
StepSec int `json:"step_sec"`
|
||||
Source string `json:"source"`
|
||||
Series []MetricSeries `json:"series"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxPoints = 2000
|
||||
defaultPoints = 300
|
||||
)
|
||||
|
||||
// pickSource обирає найгрубіший роллап, крок якого ще вміщується у
|
||||
// потрібний.
|
||||
//
|
||||
// Читати сирі дані за місяць — це мільйони рядків заради трьохсот
|
||||
// пікселів. Але й брати годинні бакети на вікні в п'ять хвилин не
|
||||
// можна: графік вийде з однієї точки.
|
||||
func pickSource(step time.Duration) (table string, bucket time.Duration, name string) {
|
||||
switch {
|
||||
case step < 5*time.Minute:
|
||||
return "", 0, "raw"
|
||||
case step < time.Hour:
|
||||
return "ts.samples_5m", 5 * time.Minute, "5m"
|
||||
default:
|
||||
return "ts.samples_1h", time.Hour, "1h"
|
||||
}
|
||||
}
|
||||
|
||||
// QueryMetrics віддає точки для графіка.
|
||||
//
|
||||
// Бакетизація робиться в БД (time_bucket), а не в Go: тягнути сирі
|
||||
// точки, щоб усереднити їх у застосунку, означає передати по мережі те,
|
||||
// що однаково буде викинуто.
|
||||
func (s *Store) QueryMetrics(ctx context.Context, tenantID string, q MetricQuery) (*MetricResult, error) {
|
||||
if q.To.IsZero() {
|
||||
q.To = time.Now()
|
||||
}
|
||||
if q.From.IsZero() || !q.From.Before(q.To) {
|
||||
q.From = q.To.Add(-6 * time.Hour)
|
||||
}
|
||||
if q.Points <= 0 {
|
||||
q.Points = defaultPoints
|
||||
}
|
||||
if q.Points > maxPoints {
|
||||
q.Points = maxPoints
|
||||
}
|
||||
if len(q.SeriesIDs) == 0 {
|
||||
return &MetricResult{From: q.From, To: q.To, Series: []MetricSeries{}}, nil
|
||||
}
|
||||
|
||||
span := q.To.Sub(q.From)
|
||||
step := span / time.Duration(q.Points)
|
||||
table, bucket, source := pickSource(step)
|
||||
|
||||
// Крок не може бути дрібнішим за бакет джерела: просити 10-секундні
|
||||
// точки з п'ятихвилинного роллапу безглуздо — вийде та сама
|
||||
// п'ятихвилинка, лише повторена тридцять разів.
|
||||
if bucket > 0 && step < bucket {
|
||||
step = bucket
|
||||
}
|
||||
if step < time.Second {
|
||||
step = time.Second
|
||||
}
|
||||
|
||||
var sql string
|
||||
if table == "" {
|
||||
sql = `
|
||||
SELECT s.series_id,
|
||||
time_bucket($1::interval, s.ts) AS bucket,
|
||||
avg(s.value), min(s.value), max(s.value)
|
||||
FROM ts.samples s
|
||||
WHERE s.series_id = ANY($2::bigint[]) AND s.ts >= $3 AND s.ts <= $4
|
||||
GROUP BY 1, 2 ORDER BY 1, 2`
|
||||
} else {
|
||||
sql = fmt.Sprintf(`
|
||||
SELECT s.series_id,
|
||||
time_bucket($1::interval, s.bucket) AS bucket,
|
||||
avg(s.avg_value), min(s.min_value), max(s.max_value)
|
||||
FROM %s s
|
||||
WHERE s.series_id = ANY($2::bigint[]) AND s.bucket >= $3 AND s.bucket <= $4
|
||||
GROUP BY 1, 2 ORDER BY 1, 2`, table)
|
||||
}
|
||||
|
||||
res := &MetricResult{
|
||||
From: q.From,
|
||||
To: q.To,
|
||||
StepSec: int(step / time.Second),
|
||||
Source: source,
|
||||
Series: []MetricSeries{},
|
||||
}
|
||||
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
// Метадані рядів читаємо під RLS і з явною перевіркою хоста:
|
||||
// series_id приходить від клієнта, і без цієї перевірки чужий
|
||||
// ідентифікатор віддав би чужі дані. Самі ts.* під RLS не
|
||||
// стоять (несумісно зі стисненням), тож фільтр тут — єдиний.
|
||||
meta := map[int64]*MetricSeries{}
|
||||
order := []int64{}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT s.id, s.metric_key, COALESCE(s.unit,''),
|
||||
COALESCE(i.name,''), s.labels::text
|
||||
FROM ts.series s
|
||||
LEFT JOIN inv.interfaces i ON i.id = s.interface_id
|
||||
WHERE s.tenant_id = $1 AND s.device_id = $2 AND s.id = ANY($3::bigint[])
|
||||
ORDER BY s.metric_key
|
||||
`, tenantID, q.DeviceID, q.SeriesIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var ms MetricSeries
|
||||
var labels string
|
||||
if err := rows.Scan(&ms.ID, &ms.MetricKey, &ms.Unit,
|
||||
&ms.InterfaceName, &labels); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
ms.Labels = decodeLabels(labels)
|
||||
ms.Points = []Point{}
|
||||
meta[ms.ID] = &ms
|
||||
order = append(order, ms.ID)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Питаємо лише ті ряди, що пройшли перевірку належності.
|
||||
prows, err := tx.Query(ctx, sql, step, order, q.From, q.To)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer prows.Close()
|
||||
for prows.Next() {
|
||||
var id int64
|
||||
var p Point
|
||||
if err := prows.Scan(&id, &p.Ts, &p.Val, &p.Min, &p.Max); err != nil {
|
||||
return err
|
||||
}
|
||||
if m := meta[id]; m != nil {
|
||||
m.Points = append(m.Points, p)
|
||||
}
|
||||
}
|
||||
if err := prows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range order {
|
||||
res.Series = append(res.Series, *meta[id])
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// decodeLabels розбирає jsonb-мітки в мапу.
|
||||
//
|
||||
// Помилка розбору не має валити графік: мітки — це підпис до лінії, а
|
||||
// не самі дані. Порожня мапа гірша за жодну лише на вигляд.
|
||||
func decodeLabels(raw string) map[string]string {
|
||||
if raw == "" || raw == "{}" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { MapPage } from './pages/MapPage'
|
|||
import { DevicesPage } from './pages/DevicesPage'
|
||||
import { GroupsPage } from './pages/GroupsPage'
|
||||
import { TemplatesPage } from './pages/TemplatesPage'
|
||||
import { CredentialsPage } from './pages/CredentialsPage'
|
||||
import { ConfigsPage } from './pages/ConfigsPage'
|
||||
import { AlertsPage } from './pages/AlertsPage'
|
||||
import { RulesPage } from './pages/RulesPage'
|
||||
|
|
@ -53,6 +54,7 @@ export default function App() {
|
|||
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
|
||||
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
|
||||
<Route path="/groups" element={<Guard perm="devices:read" element={<GroupsPage />} />} />
|
||||
<Route path="/credentials" element={<Guard perm="devices:read" element={<CredentialsPage />} />} />
|
||||
<Route path="/templates" element={<Guard perm="devices:read" element={<TemplatesPage />} />} />
|
||||
<Route path="/configs" element={<Guard perm="ncm:read" element={<ConfigsPage />} />} />
|
||||
<Route path="/alerts" element={<Guard perm="alerts:read" element={<AlertsPage />} />} />
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import type {
|
|||
TeamMember,
|
||||
UserGroup,
|
||||
UndoResult,
|
||||
BackupDefaults,
|
||||
Template,
|
||||
} from '../types'
|
||||
|
||||
|
|
@ -308,6 +309,8 @@ export const api = {
|
|||
enabled: boolean
|
||||
cron: string
|
||||
on_syslog?: boolean
|
||||
/** true — повернути хост під глобальний розклад. */
|
||||
follows_default?: boolean
|
||||
},
|
||||
) => request<void>(`/api/v1/devices/${deviceID}/backup-policy`, {
|
||||
method: 'PUT',
|
||||
|
|
@ -320,6 +323,35 @@ export const api = {
|
|||
listCredentials: () =>
|
||||
request<{ credentials: Credential[] }>('/api/v1/credentials').then((r) => r.credentials ?? []),
|
||||
|
||||
/** Доступи разом із тим, скільки хостів кожним користується. */
|
||||
listCredentialsWithUsage: () =>
|
||||
request<{ credentials: Credential[]; usage: Record<string, number> }>(
|
||||
'/api/v1/credentials',
|
||||
).then((r) => ({ credentials: r.credentials ?? [], usage: r.usage ?? {} })),
|
||||
|
||||
updateCredential: (
|
||||
id: string,
|
||||
c: { name: string; username?: string; port?: number; secret?: string },
|
||||
) => request<void>(`/api/v1/credentials/${id}`, { method: 'PATCH', body: JSON.stringify(c) }),
|
||||
|
||||
deleteCredential: (id: string) =>
|
||||
request<void>(`/api/v1/credentials/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// --- глобальний розклад бекапів ---
|
||||
|
||||
getBackupDefaults: () => request<BackupDefaults>('/api/v1/ncm/backup-defaults'),
|
||||
|
||||
setBackupDefaults: (d: {
|
||||
enabled: boolean
|
||||
cron: string
|
||||
profile_id?: string
|
||||
credential_id?: string
|
||||
apply_to_all?: boolean
|
||||
}) => request<BackupDefaults>('/api/v1/ncm/backup-defaults', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(d),
|
||||
}),
|
||||
|
||||
createCredential: (c: {
|
||||
name: string
|
||||
proto: string
|
||||
|
|
@ -388,17 +420,23 @@ export const api = {
|
|||
listAlertRules: () =>
|
||||
request<{ rules: AlertRule[] }>('/api/v1/alert-rules').then((r) => r.rules ?? []),
|
||||
|
||||
createAlertRule: (rule: {
|
||||
name: string
|
||||
source: string
|
||||
severity: string
|
||||
condition: Record<string, unknown>
|
||||
selector?: Record<string, unknown>
|
||||
for_seconds: number
|
||||
depends_on_topology: boolean
|
||||
}) =>
|
||||
request<{ id: string }>('/api/v1/alert-rules', {
|
||||
method: 'POST',
|
||||
saveAlertRule: (
|
||||
id: string | null,
|
||||
rule: {
|
||||
name: string
|
||||
source: string
|
||||
severity: string
|
||||
condition: Record<string, unknown>
|
||||
selector?: Record<string, unknown>
|
||||
for_seconds: number
|
||||
depends_on_topology: boolean
|
||||
channel_ids?: string[]
|
||||
notify_schedule?: Record<string, unknown> | null
|
||||
notify_on_resolve?: boolean
|
||||
},
|
||||
) =>
|
||||
request<{ id: string }>(id ? `/api/v1/alert-rules/${id}` : '/api/v1/alert-rules', {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
body: JSON.stringify(rule),
|
||||
}),
|
||||
|
||||
|
|
@ -477,4 +515,31 @@ export const api = {
|
|||
method: 'PUT',
|
||||
body: JSON.stringify({ template_ids: ids }),
|
||||
}).then((r) => r.template_ids ?? []),
|
||||
|
||||
/**
|
||||
* Експорт як завантажений файл.
|
||||
*
|
||||
* Через fetch, а не простим посиланням: ендпоїнт під токеном, а токен
|
||||
* живе в замиканні модуля й у заголовку — в URL його класти не можна.
|
||||
*/
|
||||
exportTemplates: async (ids?: string[]) => {
|
||||
const qs = ids && ids.length > 0 ? `?ids=${ids.join(',')}` : ''
|
||||
const doc = await request<unknown>(`/api/v1/templates/export${qs}`)
|
||||
const blob = new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `netpulse-templates-${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
importTemplates: (doc: unknown, updateExisting: boolean) =>
|
||||
request<{ created: string[]; updated: string[]; skipped: string[] }>(
|
||||
'/api/v1/templates/import',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...(doc as object), update_existing: updateExisting }),
|
||||
},
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
170
web/src/components/BackupDefaultsForm.tsx
Normal file
170
web/src/components/BackupDefaultsForm.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { Button, ErrorNote, Field, Modal, Toggle, inputClass, plural } from './ui'
|
||||
import { CronPicker } from './CronPicker'
|
||||
import type { BackupDefaults, Credential, NcmProfile } from '../types'
|
||||
|
||||
/**
|
||||
* Глобальний розклад збору конфігів.
|
||||
*
|
||||
* Задає значення за замовчуванням для всіх хостів. Хост, якому задали
|
||||
* власний розклад, з-під нього виходить — і зміна глобального його вже
|
||||
* не чіпає, доки цього не попросять окремо.
|
||||
*/
|
||||
export function BackupDefaultsForm({
|
||||
credentials,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
credentials: Credential[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [profiles, setProfiles] = useState<NcmProfile[]>([])
|
||||
const [d, setD] = useState<BackupDefaults | null>(null)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [cron, setCron] = useState('0 3 * * *')
|
||||
const [profileID, setProfileID] = useState('')
|
||||
const [credID, setCredID] = useState('')
|
||||
const [applyToAll, setApplyToAll] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
Promise.all([
|
||||
api.listNcmProfiles().catch(() => [] as NcmProfile[]),
|
||||
api.getBackupDefaults().catch(() => null),
|
||||
])
|
||||
.then(([pr, def]) => {
|
||||
if (!alive) return
|
||||
setProfiles(pr)
|
||||
setD(def)
|
||||
if (def) {
|
||||
setEnabled(def.enabled)
|
||||
setCron(def.cron)
|
||||
setProfileID(def.profile_id ?? '')
|
||||
setCredID(def.credential_id ?? '')
|
||||
}
|
||||
})
|
||||
.finally(() => alive && setLoading(false))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cliCreds = credentials.filter((c) => c.proto === 'ssh' || c.proto === 'telnet')
|
||||
|
||||
return (
|
||||
<Modal title="Розклад для всіх хостів" onClose={onClose}>
|
||||
{loading ? (
|
||||
<p className="py-6 text-center text-xs text-slate-600">Завантаження…</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2.5 text-sm text-slate-300">
|
||||
<Toggle checked={enabled} onChange={setEnabled} />
|
||||
Збирати конфіги за спільним розкладом
|
||||
</label>
|
||||
|
||||
<CronPicker value={cron} onChange={setCron} disabled={!enabled} />
|
||||
|
||||
<Field label="Профіль" hint="Не задано — сервер підбере за виробником хоста">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={profileID}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => setProfileID(e.target.value)}
|
||||
>
|
||||
<option value="">— автоматично —</option>
|
||||
{profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.key})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Доступ"
|
||||
hint={
|
||||
cliCreds.length === 0
|
||||
? 'Немає жодного доступу ssh або telnet — без нього збір неможливий'
|
||||
: 'Не задано — візьметься прив’язаний до хоста'
|
||||
}
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={credID}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => setCredID(e.target.value)}
|
||||
>
|
||||
<option value="">— автоматично —</option>
|
||||
{cliCreds.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.proto})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{d && (
|
||||
<p className="text-[11px] leading-relaxed text-slate-600">
|
||||
Зараз спільному розкладу слідує{' '}
|
||||
{plural(d.following_count, 'хост', 'хости', 'хостів')}, власний розклад мають{' '}
|
||||
{plural(d.custom_count, 'хост', 'хости', 'хостів')}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(d?.custom_count ?? 0) > 0 && (
|
||||
<label className="flex items-start gap-2.5 rounded border border-amber-900/60 bg-amber-950/20 p-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={applyToAll}
|
||||
onChange={(e) => setApplyToAll(e.target.checked)}
|
||||
/>
|
||||
<span className="text-xs leading-snug text-amber-200">
|
||||
Підпорядкувати всі хости
|
||||
<span className="mt-0.5 block text-amber-200/70">
|
||||
{plural(d?.custom_count ?? 0, 'хост', 'хости', 'хостів')} із власним розкладом
|
||||
перейде на спільний. Їхні налаштування буде втрачено.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || (enabled && cron.trim() === '')}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await api.setBackupDefaults({
|
||||
enabled,
|
||||
cron: cron.trim(),
|
||||
profile_id: profileID,
|
||||
credential_id: credID,
|
||||
apply_to_all: applyToAll,
|
||||
})
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Збереження…' : 'Зберегти'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +1,9 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { Button, ErrorNote, Field, Modal, Toggle, inputClass } from './ui'
|
||||
import { CronPicker } from './CronPicker'
|
||||
import type { BackupPolicy, Credential, DeviceSummary, NcmProfile } from '../types'
|
||||
|
||||
// Готові розклади покривають майже все, що справді налаштовують.
|
||||
// Довільний cron лишається, але не мусить бути першим, що бачить людина.
|
||||
const PRESETS: { cron: string; label: string }[] = [
|
||||
{ cron: '0 3 * * *', label: 'щодня о 03:00' },
|
||||
{ cron: '0 */6 * * *', label: 'кожні 6 годин' },
|
||||
{ cron: '0 3 * * 1', label: 'щопонеділка о 03:00' },
|
||||
{ cron: '0 3 1 * *', label: 'першого числа о 03:00' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Розклад резервного копіювання хоста.
|
||||
*
|
||||
|
|
@ -32,10 +24,10 @@ export function BackupPolicyForm({
|
|||
const [policy, setPolicy] = useState<BackupPolicy | null>(null)
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [cron, setCron] = useState('0 3 * * *')
|
||||
// Окремий прапорець, а не висновок із самого виразу: вибір «свій
|
||||
// розклад» має відкрити поле навіть тоді, коли поточне значення
|
||||
// випадково збігається з пресетом.
|
||||
const [customMode, setCustomMode] = useState(false)
|
||||
// Чи слідує хост глобальному розкладу. Знімається щойно людина
|
||||
// задає розклад тут: інакше найближча зміна глобального затерла б те,
|
||||
// що вона щойно налаштувала руками.
|
||||
const [followsDefault, setFollowsDefault] = useState(false)
|
||||
const [profileID, setProfileID] = useState('')
|
||||
const [credID, setCredID] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -55,7 +47,7 @@ export function BackupPolicyForm({
|
|||
if (pol) {
|
||||
setEnabled(pol.enabled)
|
||||
setCron(pol.cron)
|
||||
setCustomMode(!PRESETS.some((x) => x.cron === pol.cron))
|
||||
setFollowsDefault(pol.follows_default)
|
||||
setProfileID(pol.profile_id ?? '')
|
||||
setCredID(pol.credential_id ?? '')
|
||||
}
|
||||
|
|
@ -67,7 +59,6 @@ export function BackupPolicyForm({
|
|||
}, [device.id])
|
||||
|
||||
const cliCreds = credentials.filter((c) => c.proto === 'ssh' || c.proto === 'telnet')
|
||||
const custom = customMode || !PRESETS.some((p) => p.cron === cron)
|
||||
|
||||
return (
|
||||
<Modal title={`Резервне копіювання: ${device.name}`} onClose={onClose}>
|
||||
|
|
@ -80,39 +71,13 @@ export function BackupPolicyForm({
|
|||
Збирати конфіг за розкладом
|
||||
</label>
|
||||
|
||||
<Field label="Розклад">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={custom ? '' : cron}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '') {
|
||||
setCustomMode(true)
|
||||
return
|
||||
}
|
||||
setCustomMode(false)
|
||||
setCron(e.target.value)
|
||||
}}
|
||||
>
|
||||
{PRESETS.map((p) => (
|
||||
<option key={p.cron} value={p.cron}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="">свій розклад…</option>
|
||||
</select>
|
||||
</Field>
|
||||
<CronPicker value={cron} onChange={setCron} />
|
||||
|
||||
{custom && (
|
||||
<Field
|
||||
label="Вираз cron"
|
||||
hint="Хвилина, година, день місяця, місяць, день тижня — як у crontab"
|
||||
>
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={cron}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{followsDefault && (
|
||||
<p className="rounded border border-slate-800 bg-slate-900/60 p-2.5 text-[11px] leading-relaxed text-slate-500">
|
||||
Зараз хост слідує спільному розкладу. Щойно ви збережете цю форму, він отримає
|
||||
власний — і зміни спільного його більше не чіпатимуть.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Field
|
||||
|
|
@ -177,6 +142,8 @@ export function BackupPolicyForm({
|
|||
credential_id: credID,
|
||||
enabled,
|
||||
cron: cron.trim(),
|
||||
// Збереження цієї форми означає «в мене свій розклад».
|
||||
follows_default: false,
|
||||
})
|
||||
onClose()
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
import { useState } from 'react'
|
||||
import { Toggle, inputClass } from './ui'
|
||||
import { IntervalInput, Toggle, inputClass } from './ui'
|
||||
import type { CheckInput } from '../api/client'
|
||||
import type { CheckType } from '../types'
|
||||
|
||||
/** Готові інтервали. Довільне число теж можна ввести. */
|
||||
const INTERVALS = [5, 10, 30, 60, 120, 300, 600, 1800, 3600]
|
||||
|
||||
function humanInterval(sec: number): string {
|
||||
if (sec < 60) return `${sec} с`
|
||||
if (sec < 3600) return `${Math.round(sec / 60)} хв`
|
||||
return `${Math.round(sec / 3600)} год`
|
||||
}
|
||||
|
||||
// Розумні типові інтервали. Пінг дешевий і має ловити обриви швидко;
|
||||
// прохід по таблиці інтерфейсів коштує десятки OID-ів, і робити його
|
||||
// щоп'ять секунд означає навантажити обладнання заради даних, які
|
||||
|
|
@ -95,23 +86,10 @@ export function ChecksEditor({
|
|||
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
кожні
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-800 px-1.5 py-1 text-xs text-slate-200"
|
||||
value={INTERVALS.includes(c.interval_sec) ? c.interval_sec : 0}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value)
|
||||
if (v > 0) patch(i, { interval_sec: v })
|
||||
}}
|
||||
>
|
||||
{INTERVALS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{humanInterval(s)}
|
||||
</option>
|
||||
))}
|
||||
{!INTERVALS.includes(c.interval_sec) && (
|
||||
<option value={0}>{humanInterval(c.interval_sec)}</option>
|
||||
)}
|
||||
</select>
|
||||
<IntervalInput
|
||||
value={c.interval_sec ?? 60}
|
||||
onChange={(v) => patch(i, { interval_sec: v })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Toggle
|
||||
|
|
|
|||
77
web/src/components/CronPicker.tsx
Normal file
77
web/src/components/CronPicker.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useState } from 'react'
|
||||
import { Field, inputClass } from './ui'
|
||||
|
||||
// Готові розклади покривають майже все, що справді налаштовують.
|
||||
// Довільний cron лишається, але не мусить бути першим, що бачить людина.
|
||||
export const CRON_PRESETS: { cron: string; label: string }[] = [
|
||||
{ cron: '0 * * * *', label: 'щогодини' },
|
||||
{ cron: '0 */6 * * *', label: 'кожні 6 годин' },
|
||||
{ cron: '0 3 * * *', label: 'щодня о 03:00' },
|
||||
{ cron: '0 3 * * 1', label: 'щопонеділка о 03:00' },
|
||||
{ cron: '0 3 1 * *', label: 'першого числа о 03:00' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Вибір розкладу: пресети плюс власний вираз cron.
|
||||
*
|
||||
* Режим тримається окремим прапорцем, а не виводиться з виразу: інакше
|
||||
* пункт «свій розклад» не відкривав би поле тоді, коли власний вираз
|
||||
* випадково збігся з пресетом. Знайдено живим прогоном — обробник
|
||||
* `select` відсікав порожнє значення, тобто саме той випадок, заради
|
||||
* якого пункт існує.
|
||||
*/
|
||||
export function CronPicker({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
label = 'Розклад',
|
||||
}: {
|
||||
value: string
|
||||
onChange: (cron: string) => void
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
}) {
|
||||
const [customMode, setCustomMode] = useState(!CRON_PRESETS.some((p) => p.cron === value))
|
||||
const custom = customMode || !CRON_PRESETS.some((p) => p.cron === value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label={label}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={custom ? '' : value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '') {
|
||||
setCustomMode(true)
|
||||
return
|
||||
}
|
||||
setCustomMode(false)
|
||||
onChange(e.target.value)
|
||||
}}
|
||||
>
|
||||
{CRON_PRESETS.map((p) => (
|
||||
<option key={p.cron} value={p.cron}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="">свій розклад…</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{custom && (
|
||||
<Field
|
||||
label="Вираз cron"
|
||||
hint="Хвилина, година, день місяця, місяць, день тижня — як у crontab"
|
||||
>
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
|
|
@ -321,3 +322,173 @@ export function plural(n: number, one: string, few: string, many: string): strin
|
|||
return `${n} ${many}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Підтвердження руйнівної дії.
|
||||
*
|
||||
* Замість вбудованого `confirm()`: той не вміє показати наслідки, а
|
||||
* саме наслідки й важливі. «Видалити групу?» і «Видалити групу? 34
|
||||
* хости втратять доступ» — це різні питання, і людина відповідає на них
|
||||
* по-різному.
|
||||
*
|
||||
* Текст кнопки задається окремо: «Видалити» під питанням «Видалити?»
|
||||
* читається швидше, ніж «Так».
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
message,
|
||||
detail,
|
||||
confirmLabel = 'Видалити',
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
title: string
|
||||
message: ReactNode
|
||||
/** Наслідки: що саме зникне або перестане працювати. */
|
||||
detail?: ReactNode
|
||||
confirmLabel?: string
|
||||
onConfirm: () => void | Promise<void>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Modal title={title} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm leading-relaxed text-slate-300">{message}</p>
|
||||
{detail && (
|
||||
<p className="rounded border border-amber-900/60 bg-amber-950/30 p-2.5 text-xs leading-relaxed text-amber-200">
|
||||
{detail}
|
||||
</p>
|
||||
)}
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="danger"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await onConfirm()
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Виконуємо…' : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** Опис відкладеного підтвердження: що спитати і що зробити після «так». */
|
||||
export type ConfirmRequest = {
|
||||
title: string
|
||||
message: ReactNode
|
||||
detail?: ReactNode
|
||||
confirmLabel?: string
|
||||
onConfirm: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Одне підтвердження на сторінку.
|
||||
*
|
||||
* Сторінка тримає `useState<ConfirmRequest | null>` і малює
|
||||
* `<Confirm request={…} onClose={…} />`; будь-яка кнопка видалення
|
||||
* замість дії кладе туди запит.
|
||||
*/
|
||||
export function Confirm({
|
||||
request,
|
||||
onClose,
|
||||
}: {
|
||||
request: ConfirmRequest | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
if (!request) return null
|
||||
return <ConfirmDialog {...request} onClose={onClose} />
|
||||
}
|
||||
|
||||
/** Готові інтервали для списку. Довільне число вводиться руками. */
|
||||
export const INTERVAL_PRESETS = [5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 21600, 86400]
|
||||
|
||||
export function humanInterval(sec: number): string {
|
||||
if (sec < 60) return `${sec} с`
|
||||
if (sec < 3600) {
|
||||
const m = sec / 60
|
||||
return Number.isInteger(m) ? `${m} хв` : `${sec} с`
|
||||
}
|
||||
if (sec < 86400) {
|
||||
const h = sec / 3600
|
||||
return Number.isInteger(h) ? `${h} год` : `${Math.round(sec / 60)} хв`
|
||||
}
|
||||
const d = sec / 86400
|
||||
return Number.isInteger(d) ? `${d} д` : `${Math.round(sec / 3600)} год`
|
||||
}
|
||||
|
||||
/**
|
||||
* Інтервал опитування: список готових значень плюс ручний ввід.
|
||||
*
|
||||
* Список сам по собі не годиться — рано чи пізно комусь потрібні 45
|
||||
* секунд, і відсутність такої можливості перетворює продукт на
|
||||
* «майже підходить». Ручний ввід сам по собі теж не годиться: у
|
||||
* дев'яти випадках із десяти потрібне значення є в списку, і змушувати
|
||||
* друкувати його — марна робота.
|
||||
*/
|
||||
export function IntervalInput({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: number
|
||||
onChange: (sec: number) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [manual, setManual] = useState(!INTERVAL_PRESETS.includes(value))
|
||||
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={manual ? '' : String(value)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '') {
|
||||
setManual(true)
|
||||
return
|
||||
}
|
||||
setManual(false)
|
||||
onChange(Number(e.target.value))
|
||||
}}
|
||||
>
|
||||
{INTERVAL_PRESETS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{humanInterval(s)}
|
||||
</option>
|
||||
))}
|
||||
<option value="">свій…</option>
|
||||
</select>
|
||||
{manual && (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
className={`${inputClass} w-20 text-right tabular-nums`}
|
||||
type="number"
|
||||
min={5}
|
||||
max={86400}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
<span className="text-xs text-slate-500">с</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { api } from '../api/client'
|
|||
import { session } from '../api/session'
|
||||
import {
|
||||
Button,
|
||||
Confirm,
|
||||
DataTable,
|
||||
ErrorNote,
|
||||
Field,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
StatusBadge,
|
||||
inputClass,
|
||||
} from '../components/ui'
|
||||
import type { ConfirmRequest } from '../components/ui'
|
||||
import type { Channel, Severity } from '../types'
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
|
|
@ -35,6 +37,7 @@ export function ChannelsPage() {
|
|||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [testing, setTesting] = useState<string | null>(null)
|
||||
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; error?: string } | null>(
|
||||
|
|
@ -159,16 +162,28 @@ export function ChannelsPage() {
|
|||
>
|
||||
{testing === c.id ? '…' : 'Перевірити'}
|
||||
</Button>
|
||||
<button
|
||||
className="px-1 text-xs text-slate-600 hover:text-red-400"
|
||||
<Button
|
||||
kind="danger"
|
||||
title="Видалити канал разом із його секретом"
|
||||
onClick={async () => {
|
||||
await api.deleteChannel(c.id)
|
||||
await reload()
|
||||
}}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Видалити канал',
|
||||
message: (
|
||||
<>
|
||||
Канал <b>{c.name}</b> буде видалено разом із його токеном.
|
||||
</>
|
||||
),
|
||||
detail:
|
||||
'Алерти, які йшли сюди, більше не доставлятимуться. Якщо це був єдиний канал, сповіщення припиняться взагалі.',
|
||||
onConfirm: async () => {
|
||||
await api.deleteChannel(c.id)
|
||||
await reload()
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
Видалити
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -186,6 +201,7 @@ export function ChannelsPage() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
inputClass,
|
||||
} from '../components/ui'
|
||||
import { BackupPolicyForm } from '../components/BackupPolicyForm'
|
||||
import { BackupDefaultsForm } from '../components/BackupDefaultsForm'
|
||||
import type {
|
||||
BackupPolicy,
|
||||
ConfigJobRow,
|
||||
|
|
@ -53,6 +54,7 @@ export function ConfigsPage() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [defaults, setDefaults] = useState(false)
|
||||
const [creds, setCreds] = useState<Credential[]>([])
|
||||
|
||||
const canCollect = session.can('ncm:write')
|
||||
|
|
@ -81,12 +83,17 @@ export function ConfigsPage() {
|
|||
title="Конфіги"
|
||||
subtitle="Історія змін на обладнанні"
|
||||
actions={
|
||||
<input
|
||||
className={`${inputClass} w-40 md:w-56`}
|
||||
placeholder="пошук хоста…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<>
|
||||
<Button onClick={() => setDefaults(true)} title="Розклад збору для всіх хостів">
|
||||
Спільний розклад
|
||||
</Button>
|
||||
<input
|
||||
className={`${inputClass} w-40 md:w-56`}
|
||||
placeholder="пошук хоста…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
@ -135,6 +142,13 @@ export function ConfigsPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{defaults && (
|
||||
<BackupDefaultsForm
|
||||
credentials={creds}
|
||||
onClose={() => setDefaults(false)}
|
||||
onSaved={() => setDefaults(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
298
web/src/pages/CredentialsPage.tsx
Normal file
298
web/src/pages/CredentialsPage.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { session } from '../api/session'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Confirm,
|
||||
ErrorNote,
|
||||
Field,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Spinner,
|
||||
inputClass,
|
||||
plural,
|
||||
} from '../components/ui'
|
||||
import type { ConfirmRequest } from '../components/ui'
|
||||
import type { Credential } from '../types'
|
||||
|
||||
// Протоколи, які вміє зонд. Порт за замовчуванням підставляється сам:
|
||||
// 161 для SNMP і 22 для SSH знає кожен, і вводити їх щоразу — марна
|
||||
// робота, але змінити їх треба вміти (обладнання за NAT).
|
||||
const PROTOS: { key: string; label: string; port: number; secretLabel: string; hasUser: boolean }[] =
|
||||
[
|
||||
{ key: 'snmp_v2c', label: 'SNMP v2c', port: 161, secretLabel: 'Community', hasUser: false },
|
||||
{ key: 'snmp_v3', label: 'SNMP v3', port: 161, secretLabel: 'Пароль (authKey)', hasUser: true },
|
||||
{ key: 'ssh', label: 'SSH', port: 22, secretLabel: 'Пароль', hasUser: true },
|
||||
{ key: 'telnet', label: 'Telnet', port: 23, secretLabel: 'Пароль', hasUser: true },
|
||||
]
|
||||
|
||||
const protoLabel = (k: string) => PROTOS.find((p) => p.key === k)?.label ?? k
|
||||
|
||||
/**
|
||||
* Доступи до обладнання: SNMP-community, паролі SSH і Telnet.
|
||||
*
|
||||
* Самі секрети сторінка ніколи не показує й не отримує з сервера:
|
||||
* розшифрувати пароль заради показу означає віддати його туди, звідки
|
||||
* він уже не повернеться. Змінити пароль можна, підглянути — ні.
|
||||
*/
|
||||
export function CredentialsPage() {
|
||||
const canWrite = session.can('devices:write')
|
||||
|
||||
const [creds, setCreds] = useState<Credential[]>([])
|
||||
const [usage, setUsage] = useState<Record<string, number>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<Credential | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api
|
||||
.listCredentialsWithUsage()
|
||||
.then((r) => {
|
||||
setCreds(r.credentials)
|
||||
setUsage(r.usage)
|
||||
})
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (loading) return <Spinner />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Доступи до обладнання"
|
||||
subtitle="SNMP-community, паролі SSH і Telnet — зашифровані, показу не підлягають"
|
||||
actions={
|
||||
canWrite ? (
|
||||
<Button kind="primary" onClick={() => setCreating(true)}>
|
||||
Новий доступ
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{creds.map((c) => (
|
||||
<Card key={c.id} className="flex flex-col p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-slate-200">{c.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-slate-500">
|
||||
{protoLabel(c.proto)}
|
||||
{c.username ? ` · ${c.username}` : ''}
|
||||
{c.port ? ` · порт ${c.port}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{c.is_default && (
|
||||
<span className="shrink-0 rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400">
|
||||
типовий
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-3 text-[11px] text-slate-600">
|
||||
<span>{plural(usage[c.id] ?? 0, 'хост', 'хости', 'хостів')}</span>
|
||||
{canWrite && (
|
||||
<div className="flex gap-1.5">
|
||||
<Button onClick={() => setEditing(c)}>Змінити</Button>
|
||||
<Button
|
||||
kind="danger"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Видалити доступ',
|
||||
message: (
|
||||
<>
|
||||
Доступ <b>{c.name}</b> ({protoLabel(c.proto)}) буде видалено разом із
|
||||
паролем.
|
||||
</>
|
||||
),
|
||||
detail:
|
||||
(usage[c.id] ?? 0) > 0
|
||||
? `Його використовує ${plural(usage[c.id], 'хост', 'хости', 'хостів')} — вони перестануть опитуватись усім, крім пінга.`
|
||||
: undefined,
|
||||
onConfirm: async () => {
|
||||
await api.deleteCredential(c.id)
|
||||
reload()
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{creds.length === 0 && (
|
||||
<p className="py-8 text-center text-xs leading-relaxed text-slate-600">
|
||||
Доступів ще немає. Без них працює лише пінг — SNMP і збір конфігів потребують
|
||||
community або пароля.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(creating || editing) && (
|
||||
<CredentialForm
|
||||
credential={editing}
|
||||
onClose={() => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
onSaved={() => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CredentialForm({
|
||||
credential,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
credential: Credential | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState(credential?.name ?? '')
|
||||
const [proto, setProto] = useState(credential?.proto ?? 'snmp_v2c')
|
||||
const [username, setUsername] = useState(credential?.username ?? '')
|
||||
const [port, setPort] = useState(credential?.port ?? 161)
|
||||
const [secret, setSecret] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const spec = PROTOS.find((p) => p.key === proto) ?? PROTOS[0]
|
||||
const editingExisting = credential !== null
|
||||
|
||||
return (
|
||||
<Modal title={credential ? `Доступ: ${credential.name}` : 'Новий доступ'} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<Field label="Назва" hint="Як його називати в списку хоста">
|
||||
<input
|
||||
className={inputClass}
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field
|
||||
label="Протокол"
|
||||
hint={editingExisting ? 'Змінити не можна — заведіть новий' : undefined}
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={proto}
|
||||
disabled={editingExisting}
|
||||
onChange={(e) => {
|
||||
setProto(e.target.value)
|
||||
const p = PROTOS.find((x) => x.key === e.target.value)
|
||||
if (p) setPort(p.port)
|
||||
}}
|
||||
>
|
||||
{PROTOS.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Порт">
|
||||
<input
|
||||
className={`${inputClass} tabular-nums`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
onChange={(e) => setPort(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{spec.hasUser && (
|
||||
<Field label="Користувач">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label={spec.secretLabel}
|
||||
hint={
|
||||
editingExisting
|
||||
? 'Порожньо — лишити поточний. Показати збережений неможливо: він зашифрований'
|
||||
: 'Зберігається зашифрованим, у відповідях API не з’являється'
|
||||
}
|
||||
>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={secret}
|
||||
placeholder={editingExisting ? '••••••••' : ''}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || !name.trim() || (!editingExisting && !secret)}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
if (credential) {
|
||||
await api.updateCredential(credential.id, {
|
||||
name: name.trim(),
|
||||
username,
|
||||
port,
|
||||
secret,
|
||||
})
|
||||
} else {
|
||||
await api.createCredential({
|
||||
name: name.trim(),
|
||||
proto,
|
||||
username,
|
||||
port,
|
||||
secret,
|
||||
})
|
||||
}
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Збереження…' : 'Зберегти'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,12 +3,14 @@ import { useOutletContext } from 'react-router-dom'
|
|||
import { api, type CheckInput, type DeviceInput } from '../api/client'
|
||||
import { session } from '../api/session'
|
||||
import { liveEvents } from '../api/ws'
|
||||
import type { ConfirmRequest } from '../components/ui'
|
||||
import type { LiveAlerts } from '../hooks/useAlerts'
|
||||
import {
|
||||
Button,
|
||||
DataTable,
|
||||
ErrorNote,
|
||||
Field,
|
||||
Confirm,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Spinner,
|
||||
|
|
@ -371,6 +373,7 @@ function DeviceForm({
|
|||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [templateIDs, setTemplateIDs] = useState<string[]>([])
|
||||
const [templatesLoaded, setTemplatesLoaded] = useState(false)
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -589,6 +592,8 @@ function DeviceForm({
|
|||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 pt-1">
|
||||
{onDelete && (
|
||||
<Button
|
||||
|
|
@ -596,17 +601,19 @@ function DeviceForm({
|
|||
disabled={busy}
|
||||
className="mr-auto"
|
||||
title="Хост зникне зі списків, але його історія збережеться"
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await onDelete()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Видалити хост',
|
||||
message: (
|
||||
<>
|
||||
Хост <b>{device?.name ?? name}</b> зникне зі списків і мап.
|
||||
</>
|
||||
),
|
||||
detail:
|
||||
'Опитування припиниться, активні алерти по ньому закриються. Зібрані метрики та конфіги збережуться — хост позначається видаленим, а не витирається.',
|
||||
onConfirm: onDelete,
|
||||
})
|
||||
}
|
||||
>
|
||||
Видалити
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { api } from '../api/client'
|
|||
import { session } from '../api/session'
|
||||
import {
|
||||
Button,
|
||||
Confirm,
|
||||
Card,
|
||||
DataTable,
|
||||
ErrorNote,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
Spinner,
|
||||
inputClass,
|
||||
} from '../components/ui'
|
||||
import type { ConfirmRequest } from '../components/ui'
|
||||
import type { AccessLevel, DeviceGroup, TeamMember, UserGroup } from '../types'
|
||||
|
||||
const levelLabel: Record<AccessLevel | 'none', string> = {
|
||||
|
|
@ -42,6 +44,7 @@ export function GroupsPage() {
|
|||
const [team, setTeam] = useState<TeamMember[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
const [newDeviceGroup, setNewDeviceGroup] = useState(false)
|
||||
const [newUserGroup, setNewUserGroup] = useState(false)
|
||||
const [editing, setEditing] = useState<UserGroup | null>(null)
|
||||
|
|
@ -143,13 +146,25 @@ export function GroupsPage() {
|
|||
header: '',
|
||||
cell: (g) =>
|
||||
canWriteDevices && (
|
||||
<button
|
||||
className="px-1 text-xs text-slate-600 hover:text-red-400"
|
||||
<Button
|
||||
kind="danger"
|
||||
title="Видалити групу — хости лишаться, зникне лише набір"
|
||||
onClick={() => void act(() => api.deleteDeviceGroup(g.id))}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Видалити групу хостів',
|
||||
message: (
|
||||
<>
|
||||
Групу <b>{g.name}</b> буде видалено.
|
||||
</>
|
||||
),
|
||||
detail:
|
||||
'Самі хости лишаться — зникне лише набір. Але групи доступу, які на неї посилались, втратять цю межу: люди можуть побачити більше, ніж бачили.',
|
||||
onConfirm: () => act(() => api.deleteDeviceGroup(g.id)),
|
||||
})
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
Видалити
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
|
@ -180,13 +195,24 @@ export function GroupsPage() {
|
|||
{canWriteUsers && (
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<Button onClick={() => setEditing(g)}>Налаштувати</Button>
|
||||
<button
|
||||
className="px-1 text-xs text-slate-600 hover:text-red-400"
|
||||
onClick={() => void act(() => api.deleteUserGroup(g.id))}
|
||||
<Button
|
||||
kind="danger"
|
||||
title="Видалити групу доступу"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Видалити групу доступу',
|
||||
message: (
|
||||
<>
|
||||
Групу <b>{g.name}</b> буде видалено.
|
||||
</>
|
||||
),
|
||||
detail: `Її ${g.member_ids.length} учасників втратять задані нею межі. Якщо інших груп у людини немає, вона побачить усі хости — саме так працює відсутність груп.`,
|
||||
onConfirm: () => act(() => api.deleteUserGroup(g.id)),
|
||||
})
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
Видалити
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -264,6 +290,7 @@ export function GroupsPage() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { session } from '../api/session'
|
|||
import { isAlertEvent, useLiveRefresh } from '../hooks/useLiveRefresh'
|
||||
import {
|
||||
Button,
|
||||
Confirm,
|
||||
DataTable,
|
||||
ErrorNote,
|
||||
Field,
|
||||
|
|
@ -13,7 +14,8 @@ import {
|
|||
Toggle,
|
||||
inputClass,
|
||||
} from '../components/ui'
|
||||
import type { AlertRule, Severity } from '../types'
|
||||
import type { ConfirmRequest } from '../components/ui'
|
||||
import type { AlertRule, Channel, DeviceGroup, Severity } from '../types'
|
||||
|
||||
// Списки метрик повторюють whitelist сервера. Дублювання свідоме: без
|
||||
// нього людина дізнавалась би про друкарську помилку не з форми, а з
|
||||
|
|
@ -59,7 +61,9 @@ export function RulesPage() {
|
|||
const [rules, setRules] = useState<AlertRule[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState<AlertRule | null>(null)
|
||||
|
||||
const canWrite = session.can('alerts:write')
|
||||
|
||||
|
|
@ -166,17 +170,30 @@ export function RulesPage() {
|
|||
await reload()
|
||||
}}
|
||||
/>
|
||||
{canWrite && <Button onClick={() => setEditing(r)}>Змінити</Button>}
|
||||
{canWrite && (
|
||||
<button
|
||||
className="text-xs text-slate-600 hover:text-red-400"
|
||||
<Button
|
||||
kind="danger"
|
||||
title="Видалити правило разом з його алертами"
|
||||
onClick={async () => {
|
||||
await api.deleteAlertRule(r.id)
|
||||
await reload()
|
||||
}}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Видалити правило',
|
||||
message: (
|
||||
<>
|
||||
Правило <b>{r.name}</b> буде видалено.
|
||||
</>
|
||||
),
|
||||
detail:
|
||||
'Активні алерти, підняті цим правилом, зникнуть разом із ним. Якщо треба лише тимчасово зупинити — вимкніть перемикачем.',
|
||||
onConfirm: async () => {
|
||||
await api.deleteAlertRule(r.id)
|
||||
await reload()
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
Видалити
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
|
@ -186,15 +203,21 @@ export function RulesPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
{(creating || editing) && (
|
||||
<RuleForm
|
||||
onClose={() => setCreating(false)}
|
||||
rule={editing}
|
||||
onClose={() => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
onSaved={async () => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
await reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -214,20 +237,59 @@ function describeCondition(r: AlertRule): string {
|
|||
return `${name} ${c.op ?? ''} ${c.value ?? ''} (${scope})`
|
||||
}
|
||||
|
||||
function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Promise<void> }) {
|
||||
const [name, setName] = useState('')
|
||||
const [source, setSource] = useState('icmp')
|
||||
const [metric, setMetric] = useState('loss_pct')
|
||||
const [metricKey, setMetricKey] = useState('')
|
||||
const [op, setOp] = useState('>')
|
||||
const [value, setValue] = useState('20')
|
||||
const [agg, setAgg] = useState('')
|
||||
const [severity, setSeverity] = useState<Severity>('warning')
|
||||
const [forSec, setForSec] = useState('180')
|
||||
const [topo, setTopo] = useState(true)
|
||||
function RuleForm({
|
||||
rule,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
rule: AlertRule | null
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
}) {
|
||||
const cond = (rule?.condition ?? {}) as Record<string, unknown>
|
||||
const sel = (rule?.selector ?? {}) as { group_ids?: string[] }
|
||||
|
||||
const [name, setName] = useState(rule?.name ?? '')
|
||||
const [source, setSource] = useState(rule?.source ?? 'icmp')
|
||||
const [metric, setMetric] = useState(String(cond.metric ?? 'loss_pct'))
|
||||
const [metricKey, setMetricKey] = useState(String(cond.metric_key ?? ''))
|
||||
const [op, setOp] = useState(String(cond.op ?? '>'))
|
||||
const [value, setValue] = useState(String(cond.value ?? '20'))
|
||||
const [agg, setAgg] = useState(String(cond.agg ?? ''))
|
||||
const [severity, setSeverity] = useState<Severity>(rule?.severity ?? 'warning')
|
||||
const [forSec, setForSec] = useState(String(rule?.for_seconds ?? 180))
|
||||
const [topo, setTopo] = useState(rule?.depends_on_topology ?? true)
|
||||
const [groupIDs, setGroupIDs] = useState<string[]>(sel.group_ids ?? [])
|
||||
const [channelIDs, setChannelIDs] = useState<string[]>(rule?.channel_ids ?? [])
|
||||
const [onResolve, setOnResolve] = useState(rule?.notify_on_resolve ?? true)
|
||||
const [quietFrom, setQuietFrom] = useState(rule?.notify_schedule?.quiet?.[0]?.from ?? '')
|
||||
const [quietTo, setQuietTo] = useState(rule?.notify_schedule?.quiet?.[0]?.to ?? '')
|
||||
const [groups, setGroups] = useState<DeviceGroup[]>([])
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
// Довідники для вибору «кому» й «через що». Помилка тут не має
|
||||
// закривати форму: правило без явних каналів піде за маршрутами
|
||||
// тенанта, і це робочий стан, а не збій.
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
Promise.all([
|
||||
api.listDeviceGroups().catch(() => [] as DeviceGroup[]),
|
||||
api.listChannels().catch(() => [] as Channel[]),
|
||||
]).then(([g, ch]) => {
|
||||
if (!alive) return
|
||||
setGroups(g)
|
||||
setChannels(ch)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggle = (list: string[], set: (v: string[]) => void, id: string) =>
|
||||
set(list.includes(id) ? list.filter((x) => x !== id) : [...list, id])
|
||||
|
||||
const isNoData = metric === 'no_data'
|
||||
const metrics = metricsBySource[source] ?? []
|
||||
|
||||
|
|
@ -242,13 +304,23 @@ function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Pr
|
|||
: { metric, op, value: Number(value) }
|
||||
if (!isNoData && agg) condition.agg = agg
|
||||
|
||||
await api.createAlertRule({
|
||||
await api.saveAlertRule(rule?.id ?? null, {
|
||||
name: name.trim(),
|
||||
source,
|
||||
severity,
|
||||
condition,
|
||||
// Порожній набір груп означає «усі хости»: правило без меж —
|
||||
// найчастіший випадок, і вимагати вибору означало б змусити
|
||||
// людину відмічати всі групи по черзі.
|
||||
selector: groupIDs.length > 0 ? { group_ids: groupIDs } : {},
|
||||
for_seconds: Number(forSec) || 60,
|
||||
depends_on_topology: topo,
|
||||
channel_ids: channelIDs,
|
||||
notify_on_resolve: onResolve,
|
||||
notify_schedule:
|
||||
quietFrom && quietTo
|
||||
? { tz: Intl.DateTimeFormat().resolvedOptions().timeZone, quiet: [{ from: quietFrom, to: quietTo }] }
|
||||
: null,
|
||||
})
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
|
|
@ -259,7 +331,7 @@ function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Pr
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal title="Нове правило" onClose={onClose}>
|
||||
<Modal title={rule ? `Правило: ${rule.name}` : 'Нове правило'} onClose={onClose} wide>
|
||||
<div className="space-y-3">
|
||||
<Field label="Назва">
|
||||
<input
|
||||
|
|
@ -392,6 +464,109 @@ function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Pr
|
|||
</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3">
|
||||
<div className="text-xs font-medium text-slate-400">Кого стосується</div>
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-[11px] text-slate-600">Груп хостів ще немає — правило діє на всі.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[11px] text-slate-600">
|
||||
Нічого не відмічено — правило діє на всі хости
|
||||
</p>
|
||||
<ul className="max-h-32 space-y-1 overflow-y-auto rounded border border-slate-800 p-2">
|
||||
{groups.map((g) => (
|
||||
<li key={g.id}>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupIDs.includes(g.id)}
|
||||
onChange={() => toggle(groupIDs, setGroupIDs, g.id)}
|
||||
/>
|
||||
<span className="text-slate-200">{g.name}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3">
|
||||
<div className="text-xs font-medium text-slate-400">Куди слати</div>
|
||||
{channels.length === 0 ? (
|
||||
<p className="text-[11px] leading-relaxed text-slate-600">
|
||||
Каналів ще немає. Заведіть Telegram, Discord або вебхук на сторінці «Сповіщення» —
|
||||
без жодного каналу алерти лишаться тільки на екрані.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[11px] leading-relaxed text-slate-600">
|
||||
Нічого не відмічено — піде за загальними маршрутами тенанта, тобто в усі
|
||||
придатні канали
|
||||
</p>
|
||||
<ul className="max-h-32 space-y-1 overflow-y-auto rounded border border-slate-800 p-2">
|
||||
{channels.map((c) => (
|
||||
<li key={c.id}>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channelIDs.includes(c.id)}
|
||||
onChange={() => toggle(channelIDs, setChannelIDs, c.id)}
|
||||
/>
|
||||
<span className="text-slate-200">{c.name}</span>
|
||||
<span className="text-xs text-slate-600">{c.kind}</span>
|
||||
{!c.enabled && (
|
||||
<span className="text-[10px] text-amber-500">вимкнений</span>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Тиха година з" hint="Порожньо — не глушити">
|
||||
<input
|
||||
className={inputClass}
|
||||
type="time"
|
||||
value={quietFrom}
|
||||
onChange={(e) => setQuietFrom(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="до">
|
||||
<input
|
||||
className={inputClass}
|
||||
type="time"
|
||||
value={quietTo}
|
||||
onChange={(e) => setQuietTo(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{quietFrom && quietTo && (
|
||||
<p className="text-[11px] leading-relaxed text-slate-600">
|
||||
У ці години сповіщення не йдуть — крім рівня «аварія»: сенс чергування в тому,
|
||||
щоб його підняли.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label className="flex items-start gap-2.5 rounded border border-slate-800 bg-slate-900 p-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={onResolve}
|
||||
onChange={(e) => setOnResolve(e.target.checked)}
|
||||
/>
|
||||
<span className="text-xs leading-snug text-slate-400">
|
||||
Повідомляти про відновлення
|
||||
<span className="mt-0.5 block text-slate-600">
|
||||
«Впало» без «піднялося» змушує йти перевіряти руками — і цим знецінює саме
|
||||
повідомлення про аварію.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
|
|
@ -401,7 +576,7 @@ function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Pr
|
|||
disabled={busy || !name.trim() || (source === 'metric' && !metricKey.trim())}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{busy ? 'Збереження…' : 'Створити'}
|
||||
{busy ? 'Збереження…' : rule ? 'Зберегти' : 'Створити'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { api } from '../api/client'
|
|||
import { session } from '../api/session'
|
||||
import {
|
||||
Button,
|
||||
Confirm,
|
||||
DataTable,
|
||||
ErrorNote,
|
||||
Field,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
ago,
|
||||
inputClass,
|
||||
} from '../components/ui'
|
||||
import type { ConfirmRequest } from '../components/ui'
|
||||
import type { Role, TeamMember } from '../types'
|
||||
|
||||
export function TeamPage() {
|
||||
|
|
@ -21,6 +23,7 @@ export function TeamPage() {
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [editing, setEditing] = useState<TeamMember | null>(null)
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
|
||||
const canWrite = session.can('users:write')
|
||||
const me = session.me()
|
||||
|
|
@ -128,13 +131,27 @@ export function TeamPage() {
|
|||
{/* Себе не прибирають, власника — теж: організація
|
||||
без власника не відновлюється через інтерфейс. */}
|
||||
{m.user_id !== me?.userID && m.role_key !== 'owner' && (
|
||||
<button
|
||||
className="px-1 text-xs text-slate-600 hover:text-red-400"
|
||||
title="Прибрати з організації — усі його сесії тут буде відкликано"
|
||||
onClick={() => void act(() => api.removeMember(m.user_id))}
|
||||
<Button
|
||||
kind="danger"
|
||||
title="Прибрати з організації"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Прибрати з організації',
|
||||
message: (
|
||||
<>
|
||||
<b>{m.username || m.email}</b> втратить доступ до цієї
|
||||
організації.
|
||||
</>
|
||||
),
|
||||
detail:
|
||||
'Усі його сесії тут буде відкликано негайно. Сам обліковий запис і його історія дій збережуться.',
|
||||
confirmLabel: 'Прибрати',
|
||||
onConfirm: () => act(() => api.removeMember(m.user_id)),
|
||||
})
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
Прибрати
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null,
|
||||
|
|
@ -193,6 +210,7 @@ export function TeamPage() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,27 @@ import {
|
|||
Modal,
|
||||
PageHeader,
|
||||
Spinner,
|
||||
Confirm,
|
||||
IntervalInput,
|
||||
Toggle,
|
||||
inputClass,
|
||||
plural,
|
||||
} from '../components/ui'
|
||||
import type { ConfirmRequest } from '../components/ui'
|
||||
import type { Template, TemplateItem } from '../types'
|
||||
|
||||
const INTERVALS = [30, 60, 120, 300, 600, 1800, 3600]
|
||||
// Типи перевірок, які вміє описати шаблон.
|
||||
//
|
||||
// snmp.get адресується OID-ом, решта — параметрами. Список навмисно
|
||||
// короткий: тип, якого агент не вміє, у формі лише обіцяв би роботу.
|
||||
const ITEM_TYPES: { key: string; label: string; byOID: boolean }[] = [
|
||||
{ key: 'snmp.get', label: 'SNMP: значення за OID', byOID: true },
|
||||
{ key: 'icmp.ping', label: 'Пінг (ICMP)', byOID: false },
|
||||
{ key: 'snmp.if', label: 'SNMP: лічильники портів', byOID: false },
|
||||
{ key: 'http.status', label: 'HTTP: код відповіді', byOID: false },
|
||||
]
|
||||
|
||||
function humanInterval(sec: number): string {
|
||||
if (sec < 60) return `${sec} с`
|
||||
if (sec < 3600) return `${Math.round(sec / 60)} хв`
|
||||
return `${Math.round(sec / 3600)} год`
|
||||
}
|
||||
const byOID = (t: string) => ITEM_TYPES.find((x) => x.key === t)?.byOID ?? true
|
||||
|
||||
/**
|
||||
* Шаблони опитування.
|
||||
|
|
@ -38,6 +46,8 @@ export function TemplatesPage() {
|
|||
const [err, setErr] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<Template | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api
|
||||
|
|
@ -65,11 +75,24 @@ export function TemplatesPage() {
|
|||
title="Шаблони опитування"
|
||||
subtitle="Набір метрик, який чіпляється до хоста одним рухом"
|
||||
actions={
|
||||
canWrite ? (
|
||||
<Button kind="primary" onClick={() => setCreating(true)}>
|
||||
Новий шаблон
|
||||
<>
|
||||
<Button
|
||||
onClick={() =>
|
||||
api
|
||||
.exportTemplates()
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
title="Забрати всі шаблони одним файлом"
|
||||
>
|
||||
Експорт
|
||||
</Button>
|
||||
) : null
|
||||
{canWrite && <Button onClick={() => setImporting(true)}>Імпорт</Button>}
|
||||
{canWrite && (
|
||||
<Button kind="primary" onClick={() => setCreating(true)}>
|
||||
Новий шаблон
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
@ -77,7 +100,7 @@ export function TemplatesPage() {
|
|||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{list.map((t) => (
|
||||
<Card key={t.id}>
|
||||
<Card key={t.id} className="flex flex-col p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-slate-200">{t.name}</div>
|
||||
|
|
@ -94,26 +117,45 @@ export function TemplatesPage() {
|
|||
<p className="mt-2 text-xs leading-relaxed text-slate-500">{t.description}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-600">
|
||||
<div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-3 text-[11px] text-slate-600">
|
||||
<span>
|
||||
{plural(t.item_count, 'метрика', 'метрики', 'метрик')} ·{' '}
|
||||
{plural(t.device_count, 'хост', 'хости', 'хостів')}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
kind="ghost"
|
||||
title="Забрати цей шаблон файлом"
|
||||
onClick={() =>
|
||||
api
|
||||
.exportTemplates([t.id])
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button onClick={() => open(t.id)}>{t.is_builtin ? 'Переглянути' : 'Змінити'}</Button>
|
||||
{canWrite && !t.is_builtin && (
|
||||
<Button
|
||||
kind="danger"
|
||||
onClick={async () => {
|
||||
if (!confirm(`Видалити шаблон «${t.name}»? Породжені ним перевірки зникнуть.`))
|
||||
return
|
||||
try {
|
||||
await api.deleteTemplate(t.id)
|
||||
reload()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Видалити шаблон',
|
||||
message: (
|
||||
<>
|
||||
Шаблон <b>{t.name}</b> буде видалено разом з усіма його метриками.
|
||||
</>
|
||||
),
|
||||
detail:
|
||||
t.device_count > 0
|
||||
? `Він прив'язаний до ${plural(t.device_count, 'хоста', 'хостів', 'хостів')} — породжені ним перевірки зникнуть, і ці метрики перестануть збиратись.`
|
||||
: undefined,
|
||||
onConfirm: async () => {
|
||||
await api.deleteTemplate(t.id)
|
||||
reload()
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
|
|
@ -128,6 +170,10 @@ export function TemplatesPage() {
|
|||
<p className="py-8 text-center text-xs text-slate-600">Шаблонів ще немає</p>
|
||||
)}
|
||||
|
||||
{importing && <ImportForm onClose={() => setImporting(false)} onImported={reload} />}
|
||||
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
|
||||
{(editing || creating) && (
|
||||
<TemplateForm
|
||||
template={editing}
|
||||
|
|
@ -147,7 +193,16 @@ export function TemplatesPage() {
|
|||
)
|
||||
}
|
||||
|
||||
const emptyItem = (): TemplateItem => ({
|
||||
/**
|
||||
* Елемент у стані форми.
|
||||
*
|
||||
* paramsText — сирий текст поля, а не розібраний об'єкт: людина має
|
||||
* право надрукувати незавершений JSON і не втратити його на кожному
|
||||
* натисканні клавіші. Розбір відкладається до збереження.
|
||||
*/
|
||||
type EditItem = TemplateItem & { paramsText?: string }
|
||||
|
||||
const emptyItem = (): EditItem => ({
|
||||
key: '',
|
||||
name: '',
|
||||
check_type: 'snmp.get',
|
||||
|
|
@ -159,6 +214,11 @@ const emptyItem = (): TemplateItem => ({
|
|||
enabled: true,
|
||||
})
|
||||
|
||||
const toEdit = (it: TemplateItem): EditItem => ({
|
||||
...it,
|
||||
paramsText: it.params ? JSON.stringify(it.params) : '',
|
||||
})
|
||||
|
||||
function TemplateForm({
|
||||
template,
|
||||
readOnly,
|
||||
|
|
@ -174,17 +234,19 @@ function TemplateForm({
|
|||
const [name, setName] = useState(template?.name ?? '')
|
||||
const [description, setDescription] = useState(template?.description ?? '')
|
||||
const [vendor, setVendor] = useState(template?.vendor ?? '')
|
||||
const [items, setItems] = useState<TemplateItem[]>(template?.items ?? [emptyItem()])
|
||||
const [items, setItems] = useState<EditItem[]>(
|
||||
template?.items?.map(toEdit) ?? [emptyItem()],
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
function patch(i: number, next: Partial<TemplateItem>) {
|
||||
function patch(i: number, next: Partial<EditItem>) {
|
||||
setItems(items.map((it, n) => (n === i ? { ...it, ...next } : it)))
|
||||
}
|
||||
|
||||
// Ключ елемента потрібен базі, але не людині: він виводиться з назви
|
||||
// метрики, а вручну задавати його немає навіщо.
|
||||
function keyFor(it: TemplateItem, i: number): string {
|
||||
function keyFor(it: EditItem, i: number): string {
|
||||
if (it.key) return it.key
|
||||
const from = (it.metric_key || it.name || `item-${i + 1}`)
|
||||
.toLowerCase()
|
||||
|
|
@ -238,15 +300,76 @@ function TemplateForm({
|
|||
</Field>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-400">Метрики</span>
|
||||
{!readOnly && (
|
||||
<Button onClick={() => setItems([...items, emptyItem()])}>Додати метрику</Button>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-slate-400">Перевірки</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{template && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
api
|
||||
.exportTemplates([template.id])
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
title="Зберегти цей шаблон у файл"
|
||||
>
|
||||
Експорт
|
||||
</Button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<>
|
||||
<label
|
||||
className="cursor-pointer rounded border border-slate-700 bg-slate-800 px-2.5 py-1
|
||||
text-xs text-slate-200 hover:bg-slate-700"
|
||||
title="Замінити вміст форми вмістом файлу"
|
||||
>
|
||||
Імпорт
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
setErr(null)
|
||||
try {
|
||||
const doc = JSON.parse(await f.text())
|
||||
const t: Template | undefined = doc.templates?.[0] ?? doc
|
||||
if (!t || !Array.isArray(t.items)) {
|
||||
throw new Error('у файлі немає жодного шаблону з перевірками')
|
||||
}
|
||||
// Ключ і назву не чіпаємо: людина відкрила
|
||||
// конкретний шаблон, щоб замінити його вміст,
|
||||
// а не щоб він перетворився на інший.
|
||||
setItems(t.items.map(toEdit))
|
||||
if (t.description) setDescription(t.description)
|
||||
if (t.vendor) setVendor(t.vendor)
|
||||
} catch (e2) {
|
||||
setErr(
|
||||
e2 instanceof SyntaxError
|
||||
? `Файл не читається як JSON: ${e2.message}`
|
||||
: e2 instanceof Error
|
||||
? e2.message
|
||||
: String(e2),
|
||||
)
|
||||
} finally {
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<Button onClick={() => setItems([...items, emptyItem()])}>Додати</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<p className="text-[11px] text-slate-600">
|
||||
Імпорт замінює перевірки нижче вмістом файлу. Зберігається лише після «Зберегти».
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((it, i) => (
|
||||
<div key={i} className="rounded border border-slate-800 bg-slate-900/40 p-2.5">
|
||||
<div key={i} className="rounded border border-slate-800 bg-slate-900/40 p-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Field label="Назва">
|
||||
<input
|
||||
|
|
@ -256,56 +379,85 @@ function TemplateForm({
|
|||
onChange={(e) => patch(i, { name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="OID">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={it.oid}
|
||||
<Field label="Тип перевірки">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={it.check_type}
|
||||
disabled={readOnly}
|
||||
placeholder=".1.3.6.1.2.1.1.3.0"
|
||||
onChange={(e) => patch(i, { oid: e.target.value })}
|
||||
/>
|
||||
onChange={(e) => patch(i, { check_type: e.target.value })}
|
||||
>
|
||||
{ITEM_TYPES.map((t) => (
|
||||
<option key={t.key} value={t.key}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Ключ метрики" hint="Під цим ім'ям число ляже в історію">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={it.metric_key}
|
||||
disabled={readOnly}
|
||||
placeholder="cpu.util_pct"
|
||||
onChange={(e) => patch(i, { metric_key: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Field label="Одиниця">
|
||||
|
||||
{byOID(it.check_type) ? (
|
||||
<>
|
||||
<Field label="OID">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={it.oid}
|
||||
disabled={readOnly}
|
||||
placeholder=".1.3.6.1.2.1.1.3.0"
|
||||
onChange={(e) => patch(i, { oid: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Ключ метрики" hint="Під цим ім'ям число ляже в історію">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={it.metric_key}
|
||||
disabled={readOnly}
|
||||
placeholder="cpu.util_pct"
|
||||
onChange={(e) => patch(i, { metric_key: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field
|
||||
label="Параметри"
|
||||
hint="JSON, як у формі хоста. Порожньо — типові значення"
|
||||
>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={it.unit}
|
||||
className={`${inputClass} font-mono`}
|
||||
value={it.paramsText ?? ''}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => patch(i, { unit: e.target.value })}
|
||||
placeholder='{"count":3}'
|
||||
onChange={(e) => patch(i, { paramsText: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Множник" hint="0.1 — десяті">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={it.scale}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => patch(i, { scale: Number(e.target.value) || 1 })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Інтервал">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={it.interval_sec}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => patch(i, { interval_sec: Number(e.target.value) })}
|
||||
>
|
||||
{INTERVALS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{humanInterval(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{byOID(it.check_type) && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Одиниця">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={it.unit}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => patch(i, { unit: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Множник" hint="0.1 — десяті">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={it.scale}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => patch(i, { scale: Number(e.target.value) || 1 })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label="Інтервал опитування">
|
||||
<IntervalInput
|
||||
value={it.interval_sec}
|
||||
disabled={readOnly}
|
||||
onChange={(v) => patch(i, { interval_sec: v })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
|
|
@ -348,12 +500,35 @@ function TemplateForm({
|
|||
description: description.trim(),
|
||||
vendor: vendor.trim(),
|
||||
items: items
|
||||
.filter((it) => it.oid.trim() && it.metric_key.trim())
|
||||
.map((it, i) => ({ ...it, key: keyFor(it, i) })),
|
||||
// Метрика SNMP без OID або ключа нічого не збирає —
|
||||
// мовчки зберегти її означає показати шаблон, який
|
||||
// виглядає робочим і не робить нічого.
|
||||
.filter((it) =>
|
||||
byOID(it.check_type)
|
||||
? it.oid.trim() !== '' && it.metric_key.trim() !== ''
|
||||
: true,
|
||||
)
|
||||
.map((it, i) => {
|
||||
const { paramsText, ...rest } = it
|
||||
return {
|
||||
...rest,
|
||||
key: keyFor(it, i),
|
||||
params:
|
||||
!byOID(it.check_type) && paramsText?.trim()
|
||||
? (JSON.parse(paramsText) as Record<string, unknown>)
|
||||
: undefined,
|
||||
}
|
||||
}),
|
||||
})
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(
|
||||
e instanceof SyntaxError
|
||||
? `Параметри не читаються як JSON: ${e.message}`
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e),
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
|
@ -367,3 +542,138 @@ function TemplateForm({
|
|||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Імпорт шаблонів із файлу.
|
||||
*
|
||||
* Формат свій, не Zabbix-YAML: там елемент описується ключем виду
|
||||
* `snmp.get[...]`, до нього чіпляються препроцесинг, value maps і
|
||||
* тригери — нічого з цього тут поки немає, і вдавати сумісність
|
||||
* означало б мовчки втрачати половину імпортованого.
|
||||
*/
|
||||
function ImportForm({
|
||||
onClose,
|
||||
onImported,
|
||||
}: {
|
||||
onClose: () => void
|
||||
// Лише перечитати список за спиною модалки. Закриття — окремо: підсумок
|
||||
// імпорту має лишитись на екрані, поки людина його не прочитає.
|
||||
onImported: () => void
|
||||
}) {
|
||||
const [text, setText] = useState('')
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [update, setUpdate] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<{
|
||||
created: string[]
|
||||
updated: string[]
|
||||
skipped: string[]
|
||||
} | null>(null)
|
||||
|
||||
return (
|
||||
<Modal title="Імпорт шаблонів" onClose={onClose} wide>
|
||||
<div className="space-y-3">
|
||||
<Field label="Файл" hint="JSON, отриманий кнопкою «Експорт»">
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="w-full text-xs text-slate-400 file:mr-3 file:rounded file:border file:border-slate-700
|
||||
file:bg-slate-800 file:px-3 file:py-1.5 file:text-xs file:text-slate-200"
|
||||
onChange={async (e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
setFileName(f.name)
|
||||
setText(await f.text())
|
||||
setResult(null)
|
||||
setErr(null)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={fileName ? `Вміст: ${fileName}` : 'Або вставте документ сюди'}
|
||||
hint="Можна перевірити й підправити перед заливкою"
|
||||
>
|
||||
<textarea
|
||||
className={`${inputClass} h-40 font-mono text-[11px]`}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value)
|
||||
setResult(null)
|
||||
}}
|
||||
placeholder='{"format":"netpulse-templates","templates":[…]}'
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<label className="flex items-center gap-2.5 text-sm text-slate-300">
|
||||
<Toggle checked={update} onChange={setUpdate} />
|
||||
Оновлювати наявні
|
||||
</label>
|
||||
<p className="-mt-1.5 text-[11px] text-slate-600">
|
||||
Без цього шаблон із таким самим ключем пропускається. Вбудовані не змінюються
|
||||
ніколи — вони спільні для всіх.
|
||||
</p>
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
{result && (
|
||||
<div className="space-y-1 rounded border border-slate-800 bg-slate-900/60 p-3 text-xs">
|
||||
<ImportLine label="Створено" items={result.created} tone="text-emerald-400" />
|
||||
<ImportLine label="Оновлено" items={result.updated} tone="text-sky-400" />
|
||||
<ImportLine label="Пропущено" items={result.skipped} tone="text-slate-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>{result ? 'Закрити' : 'Скасувати'}</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || text.trim() === ''}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
setResult(null)
|
||||
try {
|
||||
const doc = JSON.parse(text)
|
||||
const r = await api.importTemplates(doc, update)
|
||||
setResult(r)
|
||||
if (r.created.length > 0 || r.updated.length > 0) onImported()
|
||||
} catch (e) {
|
||||
setErr(
|
||||
e instanceof SyntaxError
|
||||
? `Документ не читається як JSON: ${e.message}`
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e),
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Заливаємо…' : 'Імпортувати'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportLine({
|
||||
label,
|
||||
items,
|
||||
tone,
|
||||
}: {
|
||||
label: string
|
||||
items: string[]
|
||||
tone: string
|
||||
}) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
<span className={tone}>{label}:</span>{' '}
|
||||
<span className="font-mono text-slate-400">{items.join(', ')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,6 +270,8 @@ export interface BackupPolicy {
|
|||
enabled: boolean
|
||||
cron: string
|
||||
on_syslog: boolean
|
||||
/** Чи слідує хост глобальному розкладу. */
|
||||
follows_default: boolean
|
||||
last_backup_at?: string
|
||||
next_backup_at?: string
|
||||
}
|
||||
|
|
@ -413,6 +415,10 @@ export interface AlertRule {
|
|||
depends_on_topology: boolean
|
||||
enabled: boolean
|
||||
active_alerts: number
|
||||
/** Куди слати. Порожньо — за загальними маршрутами тенанта. */
|
||||
channel_ids: string[]
|
||||
notify_on_resolve: boolean
|
||||
notify_schedule?: { tz?: string; quiet?: { days?: number[]; from: string; to: string }[] } | null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
@ -465,12 +471,15 @@ export type TemplateItem = {
|
|||
key: string
|
||||
name: string
|
||||
check_type: string
|
||||
/** Лише для snmp.get: решта типів адресується параметрами. */
|
||||
oid: string
|
||||
metric_key: string
|
||||
unit: string
|
||||
scale: number
|
||||
interval_sec: number
|
||||
enabled: boolean
|
||||
/** Параметри перевірки для типів без OID: {"count":3} для пінга. */
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Template = {
|
||||
|
|
@ -484,3 +493,14 @@ export type Template = {
|
|||
item_count: number
|
||||
device_count: number
|
||||
}
|
||||
|
||||
/** Глобальний розклад збору конфігів. */
|
||||
export type BackupDefaults = {
|
||||
enabled: boolean
|
||||
cron: string
|
||||
profile_id?: string
|
||||
credential_id?: string
|
||||
/** Скільки хостів слідує глобальному розкладу, а скільки має власний. */
|
||||
following_count: number
|
||||
custom_count: number
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue