Етап 6: шаблони опитування + звірка планів зонда
Схема tpl.* (шаблон → елементи → прив'язка до хоста), реконсиляція в core.checks, REST, редактор у вебі, чотири вбудовані шаблони SNMP. Елемент шаблону — одна метрика; у чеки вони групуються за (шаблон, тип, інтервал) в один snmp.get. Сотня окремих чеків замість однієї пачки — це сотня SNMP-сесій там, де досить кількох PDU. Позначка template_id у core.checks дає реконсиляції право власності: без неї відв'язування шаблону не знало б, що прибирати. Звірка планів раз на 5 секунд — те, чого бракувало весь час. Чеки міняє REST-процес, живу сесію зонда тримає AgentService; досі будь-яка зміна доїжджала до зонда лише при обриві зв'язку, тобто ніколи. Знайдено живими прогонами й виправлено: - креденшели не їхали разом із планом, і хост, приписаний зонду після його підключення, падав на кожній задачі з «немає SNMP-креденшелів»; - форма хоста відв'язувала зонд: поле починалося порожнім, підпис обіцяв «не змінювати», сервер трактував порожнє буквально; - hrProcessorLoad.1 у базовому шаблоні — здогадка, а не адреса: на net-snmp «No Such Instance». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
436eff343d
commit
2de1894fd5
19 changed files with 1714 additions and 5 deletions
93
HISTORY.md
93
HISTORY.md
|
|
@ -1378,3 +1378,96 @@ schedule | unchanged | 09:39:08
|
|||
межами 0..23» і модалка лишається відкритою; `*/30 * * * *`
|
||||
зберігається, і заголовок хоста стає «за розкладом */30 * * * *».
|
||||
Розклад повернуто на `0 3 * * *`.
|
||||
|
||||
---
|
||||
|
||||
## Етап 6. Шаблони опитування
|
||||
|
||||
Досі кожен OID заводився руками на кожному хості. Те, що знімається з
|
||||
Mikrotik, однакове на всіх Mikrotik — але цей факт жив у голові
|
||||
інженера й повторювався стільки разів, скільки в мережі пристроїв.
|
||||
|
||||
### Схема
|
||||
|
||||
`tpl.templates` → `tpl.items` → `tpl.device_templates`. Вбудовані
|
||||
шаблони — `tenant_id IS NULL`, той самий прийом, що в `ncm.profiles`.
|
||||
|
||||
**Елемент — це одна метрика, а не чек.** Тримати в шаблоні «чек»
|
||||
означало б змішати те, що описує людина (метрику), з тим, що вигідно
|
||||
машині (пачку OID в одному PDU).
|
||||
|
||||
**У `tpl.items` немає власного `tenant_id`.** Він завжди дорівнював би
|
||||
шаблоновому, а дублювання ключа ізоляції — це запрошення до
|
||||
розбіжності. Видимість успадковується від шаблону через `EXISTS`.
|
||||
|
||||
### Реконсиляція
|
||||
|
||||
Елементи групуються за (шаблон, тип, інтервал) в один `snmp.get`. Сотня
|
||||
окремих чеків замість однієї пачки — це сотня SNMP-сесій там, де
|
||||
досить кількох PDU. Інтервал у ключі групування, бо пачка ходить
|
||||
цілком.
|
||||
|
||||
**Порядок ключів фіксується сортуванням.** `params` потрапляє в хеш
|
||||
плану, і «однаковий шаблон дав інший хеш» через порядок обходу map — це
|
||||
перезалив плану на кожному тіку звірки.
|
||||
|
||||
**`core.checks.template_id` + частковий унікальний індекс.** Спільний
|
||||
`checks_uniq` містить `md5(params)`, тож будь-яка правка списку OID
|
||||
виглядала б як новий чек.
|
||||
|
||||
**`ListDeviceChecks` і `SetDeviceChecks` тепер обходять чеки шаблонів.**
|
||||
Інакше вони показувалися б у формі ручних перевірок і зникали на кожне
|
||||
збереження, щоб за секунду з'явитися знову.
|
||||
|
||||
### Звірка планів — те, чого бракувало весь час
|
||||
|
||||
Чеки міняє REST-процес, а живу сесію зонда тримає AgentService. Досі
|
||||
зміна доїжджала до зонда лише при обриві зв'язку — тобто, за нормальної
|
||||
роботи, ніколи. Тік раз на п'ять секунд звіряє хеш плану в базі з тим,
|
||||
що зараз у зонда, і перезаливає повний план при розбіжності.
|
||||
|
||||
Повний план, а не дельта: дельта не знає, що зникло.
|
||||
|
||||
### Знайдено живими прогонами
|
||||
|
||||
**Креденшели не їхали разом із планом.** Хост, приписаний зонду вже
|
||||
після його підключення, отримував задачі й падав на кожній із «немає
|
||||
SNMP-креденшелів». Пачка доступів видається на `Hello`, а тоді цього
|
||||
хоста в ній ще не було. Тепер іде разом із планом.
|
||||
|
||||
**Форма хоста відв'язувала зонд.** Поле «Зонд» починалося порожнім,
|
||||
підпис обіцяв «— не змінювати —», а сервер трактував порожнє значення
|
||||
буквально. Будь-яке збереження форми лишало хост у списку й мовчки
|
||||
припиняло його опитувати. Помітили, коли після збереження шаблону в
|
||||
журналі з'явилось «план зонда оновлено, задач: 0». Форма тепер тягне
|
||||
поточний зонд, а підпис каже правду: «— без зонда —».
|
||||
|
||||
**`hrProcessorLoad.1` у вбудованому шаблоні — здогадка, а не адреса.**
|
||||
Це таблиця, індексована процесором; на net-snmp вона віддала «No Such
|
||||
Instance». Свій же коментар у міграції казав, що неперевірений шаблон
|
||||
гірший за відсутній — прибрали з базового, лишили у вендорному
|
||||
Mikrotik, де індекс фіксований.
|
||||
|
||||
### Перевірено наживо
|
||||
|
||||
Шаблон, створений повністю через веб (OID введено без крапки на
|
||||
початку — сервер дописав її сам), прив'язаний до хоста в тій самій
|
||||
формі:
|
||||
|
||||
```
|
||||
чеки хоста qa-netsnmp 60с qa.users ← .1.3.6.1.2.1.25.1.5.0
|
||||
snmp-generic 60с sys.uptime_sec ← .1.3.6.1.2.1.1.3.0
|
||||
snmp-host-resources 300с sys.users; sys.processes (два OID, одна пачка)
|
||||
план зонда оновлено за секунди, без переп'єднання
|
||||
метрики sys.uptime_sec = 862334.20 s (snmpget: 9 днів 22:46 — збігається)
|
||||
sys.processes = 41, qa.users = 0
|
||||
вбудований DELETE → 403 builtin, PUT → 403 builtin
|
||||
власний DELETE → 204, породжені ним чеки зникли каскадом
|
||||
```
|
||||
|
||||
### Чого ще немає
|
||||
|
||||
Автопризначення шаблону за `sysObjectID`. Прототипів (шаблон, що
|
||||
успадковує інший). Тригерів усередині шаблону — поки правила алертів
|
||||
заводяться окремо. `snmp.walk` як тип елемента: таблиці з динамічним
|
||||
індексом (CPU по ядрах, диски, сенсори) шаблон описати не вміє.
|
||||
|
|
|
|||
12
ROADMAP.md
12
ROADMAP.md
|
|
@ -96,7 +96,17 @@ POST /api/v1/roles кастомна роль із набором
|
|||
|
||||
---
|
||||
|
||||
## Етап 6. Шаблони опитування (Zabbix-подібні)
|
||||
## Етап 6. Шаблони опитування (Zabbix-подібні) — ⚠️ наполовину, 2026-08-24
|
||||
|
||||
> **Зроблено:** схема `tpl.*`, реконсиляція шаблонів у `core.checks`,
|
||||
> звірка планів зонда, REST і редактор у вебі, чотири вбудовані шаблони.
|
||||
> Перевірено наскрізно на живому net-snmp.
|
||||
>
|
||||
> **Лишилось:** автопризначення за `sysObjectID`, прототипи шаблонів,
|
||||
> `snmp.walk` як тип елемента (таблиці з динамічним індексом), тригери
|
||||
> всередині шаблону.
|
||||
|
||||
## Етап 6 (початковий план)
|
||||
|
||||
**Найбільша нова підсистема.** Зараз `snmp.if`-чеки народжує захардкоджений
|
||||
`autochecks.go`. Це працює рівно для одного випадку — інтерфейсів. Щойно
|
||||
|
|
|
|||
206
db/migrations/0015_templates.sql
Normal file
206
db/migrations/0015_templates.sql
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0015_templates.sql
|
||||
-- Шаблони опитування: набір метрик, який чіпляється до хоста одним
|
||||
-- рухом, замість того щоб заводити кожен OID руками.
|
||||
--
|
||||
-- Навіщо окрема сутність, а не просто чеки: те, що знімається з
|
||||
-- Mikrotik, однакове на всіх Mikrotik. Без шаблону цей факт живе в
|
||||
-- голові інженера й повторюється стільки разів, скільки в мережі
|
||||
-- пристроїв. Із шаблоном він живе в одному місці, і виправлення OID
|
||||
-- доїжджає до всіх хостів само.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS tpl;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Шаблон
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- tenant_id IS NULL — вбудований шаблон, спільний для всіх (той самий
|
||||
-- прийом, що в ncm.profiles). Тенант може завести власний; вбудовані
|
||||
-- при цьому лишаються недоторканими.
|
||||
CREATE TABLE tpl.templates (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
key core.slug NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
-- Підказка для UI: який шаблон запропонувати для цього виробника.
|
||||
-- Саме підказка, а не автопризначення: помилка виробника в інвентарі
|
||||
-- не має мовчки почати опитувати хост чужими OID.
|
||||
vendor text,
|
||||
is_builtin boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX tpl_templates_key_uniq
|
||||
ON tpl.templates (COALESCE(tenant_id,'00000000-0000-0000-0000-000000000000'::uuid), key);
|
||||
CREATE INDEX tpl_templates_vendor_idx ON tpl.templates (lower(vendor));
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Елемент шаблону — одна метрика
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Один рядок = один OID = одна метрика. Групування в реальні чеки
|
||||
-- робить реконсиляція: тримати тут «чек» означало б змішати те, що
|
||||
-- описує людина (метрику), з тим, що вигідно машині (пачку OID в
|
||||
-- одному PDU).
|
||||
CREATE TABLE tpl.items (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
template_id uuid NOT NULL REFERENCES tpl.templates(id) ON DELETE CASCADE,
|
||||
key core.slug NOT NULL,
|
||||
name text NOT NULL,
|
||||
check_type text NOT NULL REFERENCES core.check_types(key) ON DELETE RESTRICT,
|
||||
oid text NOT NULL,
|
||||
metric_key text NOT NULL,
|
||||
unit text NOT NULL DEFAULT '',
|
||||
-- Множник: сенсори часто віддають десяті градуса цілим числом.
|
||||
scale double precision NOT NULL DEFAULT 1 CHECK (scale <> 0),
|
||||
interval_sec int NOT NULL DEFAULT 60 CHECK (interval_sec BETWEEN 5 AND 86400),
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX tpl_items_key_uniq ON tpl.items (template_id, key);
|
||||
CREATE INDEX tpl_items_template_idx ON tpl.items (template_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Прив'язка шаблону до хоста
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE tpl.device_templates (
|
||||
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
|
||||
template_id uuid NOT NULL REFERENCES tpl.templates(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (device_id, template_id)
|
||||
);
|
||||
CREATE INDEX tpl_device_templates_tpl_idx ON tpl.device_templates (template_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Слід шаблону в чеках
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Чеки, породжені шаблоном, треба вміти впізнати: інакше відв'язування
|
||||
-- шаблону не знало б, що прибирати, а зміна OID плодила б другий чек
|
||||
-- замість правки першого.
|
||||
--
|
||||
-- ON DELETE CASCADE, а не SET NULL: чек без шаблону, який його створив,
|
||||
-- нікому не належить і нікому не потрібен — він би просто тихо опитував
|
||||
-- пристрій вічно.
|
||||
ALTER TABLE core.checks
|
||||
ADD COLUMN template_id uuid REFERENCES tpl.templates(id) ON DELETE CASCADE;
|
||||
|
||||
-- Один чек на (хост, шаблон, тип, інтервал). Саме інтервал у ключі, бо
|
||||
-- елементи з різною частотою не можна класти в один PDU: пачка ходить
|
||||
-- цілком і настільки часто, наскільки просить найшвидший її учасник.
|
||||
--
|
||||
-- Окремий індекс замість спільного checks_uniq: той містить md5(params),
|
||||
-- тож будь-яка правка списку OID виглядала б як новий чек.
|
||||
CREATE UNIQUE INDEX checks_template_uniq
|
||||
ON core.checks (device_id, template_id, check_type, interval_sec)
|
||||
WHERE template_id IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- RLS
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
GRANT USAGE ON SCHEMA tpl TO netpulse_app, netpulse_worker;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tpl
|
||||
TO netpulse_app, netpulse_worker;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA tpl
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO netpulse_app, netpulse_worker;
|
||||
|
||||
ALTER TABLE tpl.templates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tpl.templates FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tpl.device_templates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tpl.device_templates FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tpl.items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tpl.items FORCE ROW LEVEL SECURITY;
|
||||
|
||||
-- Вбудовані шаблони видно всім, редагувати можна лише свої.
|
||||
CREATE POLICY templates_visible ON tpl.templates
|
||||
USING (tenant_id IS NULL OR tenant_id = core.current_tenant())
|
||||
WITH CHECK (tenant_id = core.current_tenant());
|
||||
|
||||
CREATE POLICY device_templates_isolation ON tpl.device_templates
|
||||
USING (tenant_id = core.current_tenant())
|
||||
WITH CHECK (tenant_id = core.current_tenant());
|
||||
|
||||
-- У tpl.items немає власного tenant_id: він завжди дорівнював би
|
||||
-- шаблоновому, а дублювання ключа ізоляції — це запрошення до розбіжності.
|
||||
-- Видимість успадковується від шаблону через ту саму політику.
|
||||
CREATE POLICY items_follow_template ON tpl.items
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM tpl.templates t
|
||||
WHERE t.id = tpl.items.template_id
|
||||
AND (t.tenant_id IS NULL OR t.tenant_id = core.current_tenant())
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM tpl.templates t
|
||||
WHERE t.id = tpl.items.template_id AND t.tenant_id = core.current_tenant()
|
||||
));
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Вбудовані шаблони
|
||||
--
|
||||
-- Свідомо небагато й свідомо базові. Шаблон, який ніхто не перевіряв на
|
||||
-- живому залізі, гірший за його відсутність: він створює враження, що
|
||||
-- хост під наглядом, поки метрики мовчки порожні.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO tpl.templates (id, tenant_id, key, name, description, vendor, is_builtin) VALUES
|
||||
('00000000-0000-0000-0000-0000000000c1'::uuid, NULL, 'snmp-generic',
|
||||
'SNMP: базовий хост',
|
||||
'Час роботи системи — єдине число, яке віддає будь-який агент SNMP за RFC 1213. Падіння цього лічильника означає перезавантаження.',
|
||||
NULL, true),
|
||||
('00000000-0000-0000-0000-0000000000c2'::uuid, NULL, 'snmp-host-resources',
|
||||
'SNMP: ресурси хоста (HOST-RESOURCES-MIB)',
|
||||
'Кількість процесів і сесій. Працює на Linux, Windows і більшості мережевих ОС із HOST-RESOURCES-MIB.',
|
||||
NULL, true),
|
||||
('00000000-0000-0000-0000-0000000000c3'::uuid, NULL, 'snmp-ucd-linux',
|
||||
'SNMP: Linux (UCD-SNMP-MIB)',
|
||||
'Пам''ять, свопінг і середнє навантаження з net-snmp. Найточніше джерело для Linux-серверів.',
|
||||
'Linux', true),
|
||||
('00000000-0000-0000-0000-0000000000c4'::uuid, NULL, 'snmp-mikrotik',
|
||||
'SNMP: Mikrotik RouterOS',
|
||||
'Процесор, пам''ять і температура плати з MIKROTIK-MIB.',
|
||||
'Mikrotik', true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO tpl.items (template_id, key, name, check_type, oid, metric_key, unit, scale, interval_sec) VALUES
|
||||
-- Базовий хост (RFC 1213)
|
||||
('00000000-0000-0000-0000-0000000000c1', 'uptime', 'Час роботи',
|
||||
'snmp.get', '.1.3.6.1.2.1.1.3.0', 'sys.uptime_sec', 's', 0.01, 60),
|
||||
|
||||
-- HOST-RESOURCES-MIB
|
||||
--
|
||||
-- Без hrProcessorLoad: це таблиця, індексована процесором, і
|
||||
-- «.1» — здогадка, а не адреса. Перевірено на net-snmp: віддає
|
||||
-- «No Such Instance», тобто шаблон мовчки не збирав би нічого.
|
||||
-- Для одноядерних платформ, де індекс фіксований (Mikrotik),
|
||||
-- цей OID лишається у вендорному шаблоні.
|
||||
('00000000-0000-0000-0000-0000000000c2', 'processes', 'Процесів',
|
||||
'snmp.get', '.1.3.6.1.2.1.25.1.6.0', 'sys.processes', '', 1, 300),
|
||||
('00000000-0000-0000-0000-0000000000c2', 'users', 'Сесій користувачів',
|
||||
'snmp.get', '.1.3.6.1.2.1.25.1.5.0', 'sys.users', '', 1, 300),
|
||||
|
||||
-- UCD-SNMP-MIB
|
||||
('00000000-0000-0000-0000-0000000000c3', 'mem-avail', 'Вільна пам''ять',
|
||||
'snmp.get', '.1.3.6.1.4.1.2021.4.6.0', 'mem.available_bytes', 'B', 1024, 60),
|
||||
('00000000-0000-0000-0000-0000000000c3', 'mem-total', 'Всього пам''яті',
|
||||
'snmp.get', '.1.3.6.1.4.1.2021.4.5.0', 'mem.total_bytes', 'B', 1024, 300),
|
||||
('00000000-0000-0000-0000-0000000000c3', 'swap-avail', 'Вільний своп',
|
||||
'snmp.get', '.1.3.6.1.4.1.2021.4.4.0', 'swap.available_bytes', 'B', 1024, 300),
|
||||
('00000000-0000-0000-0000-0000000000c3', 'load1', 'Навантаження, 1 хв',
|
||||
'snmp.get', '.1.3.6.1.4.1.2021.10.1.5.1', 'cpu.load1', '', 0.01, 60),
|
||||
('00000000-0000-0000-0000-0000000000c3', 'load5', 'Навантаження, 5 хв',
|
||||
'snmp.get', '.1.3.6.1.4.1.2021.10.1.5.2', 'cpu.load5', '', 0.01, 60),
|
||||
|
||||
-- MIKROTIK-MIB
|
||||
('00000000-0000-0000-0000-0000000000c4', 'cpu-load', 'Завантаження CPU',
|
||||
'snmp.get', '.1.3.6.1.2.1.25.3.3.1.2.1', 'cpu.util_pct', '%', 1, 60),
|
||||
('00000000-0000-0000-0000-0000000000c4', 'mem-free', 'Вільна пам''ять',
|
||||
'snmp.get', '.1.3.6.1.2.1.25.2.3.1.6.65536', 'mem.available_bytes', 'B', 1024, 60),
|
||||
('00000000-0000-0000-0000-0000000000c4', 'board-temp', 'Температура плати',
|
||||
'snmp.get', '.1.3.6.1.4.1.14988.1.1.3.100.1.3.1', 'sensor.temp_c', '°C', 0.1, 300)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
|
@ -490,6 +490,87 @@ Sec-WebSocket-Protocol: netpulse.token.<токен>
|
|||
повний список, і зняту перевірку треба вміти зняти. Перевірки на
|
||||
інтерфейсах (`interface_id IS NOT NULL`) не чіпаються — їх заводить
|
||||
автовиявлення, і форма хоста про них не знає.
|
||||
Породжені шаблоном (`template_id IS NOT NULL`) — так само: ними володіє
|
||||
реконсиляція, і видалення тут означало б, що вони зникають на кожне
|
||||
збереження форми, щоб за секунду з'явитися знову. З цієї ж причини
|
||||
`GET /api/v1/devices/{id}/checks` їх не показує: у формі ручних
|
||||
перевірок їм нема що робити.
|
||||
|
||||
### Шаблони опитування
|
||||
|
||||
Те, що знімається з Mikrotik, однакове на всіх Mikrotik. Без шаблону цей
|
||||
факт живе в голові інженера й повторюється стільки разів, скільки в
|
||||
мережі пристроїв.
|
||||
|
||||
| Метод | Шлях | Призначення |
|
||||
|-------|------|-------------|
|
||||
| `GET` | `/api/v1/templates` | довідник із лічильниками |
|
||||
| `POST` | `/api/v1/templates` | створити (`devices:write`) |
|
||||
| `GET` | `/api/v1/templates/{id}` | шаблон разом з елементами |
|
||||
| `PUT` | `/api/v1/templates/{id}` | замінити цілком |
|
||||
| `DELETE` | `/api/v1/templates/{id}` | видалити |
|
||||
| `GET` | `/api/v1/devices/{id}/templates` | які шаблони на хості |
|
||||
| `PUT` | `/api/v1/devices/{id}/templates` | замінити набір |
|
||||
|
||||
Шаблон також приймається полем `template_ids` у тілі хоста — разом із
|
||||
`checks` і `credential_ids`, щоб форма зберігалася одним запитом.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"key": "mikrotik-crs",
|
||||
"name": "Mikrotik CRS",
|
||||
"vendor": "Mikrotik",
|
||||
"items": [
|
||||
{"name": "Температура", "oid": "1.3.6.1.4.1.14988.1.1.3.10.0",
|
||||
"metric_key": "sensor.temp_c", "unit": "°C", "scale": 0.1, "interval_sec": 300}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Елементи замінюються цілком, а не додаються.** Форма редагує шаблон як
|
||||
один документ, і «прибрати метрику» має бути таким самим звичайним
|
||||
рухом, як «додати». Крапку на початку OID сервер дописує сам: у
|
||||
документації вендорів її пишуть, і відмовляти через символ, який нічого
|
||||
не означає, — дурний спосіб витратити людині хвилину.
|
||||
|
||||
**Вбудовані шаблони (`tenant_id IS NULL`) не редагуються й не
|
||||
видаляються** — `403 builtin`. Вони спільні для всіх тенантів, і правка
|
||||
одного мовчки змінила б опитування в чужих мережах. Хто хоче свій
|
||||
варіант — робить копію.
|
||||
|
||||
#### Реконсиляція в перевірки
|
||||
|
||||
Прив'язка шаблону не створює перевірку на кожну метрику. Елементи
|
||||
групуються за **(шаблон, тип, інтервал)** в один `snmp.get`: агент уміє
|
||||
питати список OID однією пачкою, і сотня окремих перевірок замість
|
||||
однієї пачки — це сотня SNMP-сесій там, де досить кількох PDU.
|
||||
|
||||
Інтервал у ключі групування, бо пачка ходить цілком: змішавши хвилинну
|
||||
метрику з п'ятихвилинною, ми або опитували б рідкісну надто часто, або
|
||||
часту — надто рідко.
|
||||
|
||||
`core.checks.template_id` позначає породжені рядки. Без цієї позначки
|
||||
відв'язування шаблону не знало б, що прибирати, а зміна OID плодила б
|
||||
другу перевірку замість правки першої. `ON DELETE CASCADE`, а не
|
||||
`SET NULL`: перевірка без шаблону, який її створив, нікому не належить —
|
||||
вона б просто тихо опитувала пристрій вічно.
|
||||
|
||||
#### Як зміна доїжджає до зонда
|
||||
|
||||
Перевірки міняє REST-процес, а живу сесію зонда тримає AgentService —
|
||||
інший процес. Звірка планів раз на п'ять секунд порівнює хеш плану в
|
||||
базі з тим, що зараз у зонда, і перезаливає план при розбіжності.
|
||||
|
||||
**Повний план, а не дельта.** Дельта вміє додавати й міняти, але не
|
||||
знає, що зникло; порівняння хешів теж не знає — воно каже лише
|
||||
«інакше». Перезалив кількох тисяч задач раз на зміну дешевший за
|
||||
перевірку, яка лишилась опитувати видалений хост.
|
||||
|
||||
Разом із планом ідуть модулі (у плані міг з'явитись перший `snmp`-чек
|
||||
на зонді, де модуль не вмикали) і креденшели. Останнє знайдено живим
|
||||
прогоном: хост, приписаний зонду вже після його підключення, отримував
|
||||
задачі й падав на кожній із «немає SNMP-креденшелів» — пачка доступів
|
||||
видається на `Hello`, а тоді цього хоста в ній ще не було.
|
||||
|
||||
### Доступи до обладнання
|
||||
|
||||
|
|
|
|||
117
server/internal/grpcapi/plan_sync.go
Normal file
117
server/internal/grpcapi/plan_sync.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package grpcapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// PlanSyncInterval — як часто звіряти план зонда з базою.
|
||||
//
|
||||
// П'ять секунд: людина, яка щойно додала чек у вебі, ще дивиться на
|
||||
// екран, і затримка в цих межах читається як «спрацювало». Робити це
|
||||
// частіше немає сенсу — звірка все одно впирається в один SELECT на
|
||||
// зонда, а сплеск таких запитів на великому тенанті дорожчий за
|
||||
// секунду очікування.
|
||||
const PlanSyncInterval = 5 * time.Second
|
||||
|
||||
// SyncPlans доганяє зонди змінами, зробленими в UI.
|
||||
//
|
||||
// Зміни чеків робить REST API — окремий процес, який не має живої
|
||||
// сесії зонда. Черги тут не потрібно: план цілком описується своїм
|
||||
// хешем, тож достатньо періодично звіряти хеш у базі з тим, що зараз
|
||||
// у зонда, і перезалити план при розбіжності. Пропущений тік нічого не
|
||||
// ламає — наступний зробить те саме.
|
||||
func (s *Service) SyncPlans(ctx context.Context) {
|
||||
t := time.NewTicker(PlanSyncInterval)
|
||||
defer t.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.syncPlansTick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) syncPlansTick(ctx context.Context) {
|
||||
s.mu.RLock()
|
||||
sessions := make([]*agentSession, 0, len(s.sessions))
|
||||
for _, sess := range s.sessions {
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
for _, sess := range sessions {
|
||||
hash, err := s.store.PlanHash(ctx, sess.agent)
|
||||
if err != nil {
|
||||
s.log.Warn("звірка плану: хеш", "agent", sess.agent.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
if equalBytes(hash, sess.currentPlanHash()) {
|
||||
continue
|
||||
}
|
||||
|
||||
plan, err := s.store.BuildPlan(ctx, sess.agent)
|
||||
if err != nil {
|
||||
s.log.Warn("звірка плану: побудова", "agent", sess.agent.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Повний план, а не дельта: дельта вміє додавати й міняти, але
|
||||
// не знає, що зникло. Порівняння двох хешів теж не знає — воно
|
||||
// каже лише «інакше». Перезалив кількох тисяч задач раз на
|
||||
// зміну дешевший за чек, який лишився опитувати видалений хост.
|
||||
//
|
||||
// Модулі перевідсилаються заразом: у плані міг з'явитись
|
||||
// перший snmp-чек на зонді, де модуль snmp ще не вмикали.
|
||||
s.push(sess, &npv1.ControlDown{
|
||||
Payload: &npv1.ControlDown_ModuleControl{
|
||||
ModuleControl: store.ModulesForPlan(plan, sess.agent.Modules)},
|
||||
})
|
||||
|
||||
// Креденшели — теж заразом, і це не зайва обережність.
|
||||
// Знайдено живим прогоном: хост, приписаний зонду вже після
|
||||
// його підключення, отримував задачі й падав на кожній із них
|
||||
// із «немає SNMP-креденшелів». Пачка видається на Hello, а
|
||||
// тоді цього хоста в ній ще не було.
|
||||
if bundle, err := s.store.BuildCredentialBundle(ctx, sess.agent, s.ring); err != nil {
|
||||
s.log.Warn("звірка плану: креденшели", "agent", sess.agent.ID, "err", err)
|
||||
} else if len(bundle.ByDevice) > 0 {
|
||||
s.push(sess, &npv1.ControlDown{
|
||||
Payload: &npv1.ControlDown_Credentials{Credentials: bundle},
|
||||
})
|
||||
}
|
||||
|
||||
s.push(sess, &npv1.ControlDown{
|
||||
Payload: &npv1.ControlDown_TaskPlan{TaskPlan: plan},
|
||||
})
|
||||
|
||||
// Позначку ставимо одразу після постановки в чергу, а не після
|
||||
// підтвердження: черга сесії й так упорядкована, а зонд, який
|
||||
// відвалився, при повторному Hello принесе свій хеш і отримає
|
||||
// план заново.
|
||||
sess.setPlanHash(plan.GetPlanHash())
|
||||
|
||||
s.log.Info("план зонда оновлено",
|
||||
"agent", sess.agent.ID, "задач", len(plan.GetTasks()))
|
||||
}
|
||||
}
|
||||
|
||||
// setAgentPlanHash оновлює позначку після пуша дельти.
|
||||
//
|
||||
// Без цього наступний тік звірки побачив би розбіжність і перезалив
|
||||
// увесь план услід за дельтою, яка вже все зробила.
|
||||
func (s *Service) setAgentPlanHash(agentID string, hash []byte) {
|
||||
s.mu.RLock()
|
||||
sess := s.sessions[agentID]
|
||||
s.mu.RUnlock()
|
||||
if sess != nil {
|
||||
sess.setPlanHash(hash)
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,27 @@ type agentSession struct {
|
|||
out chan *npv1.ControlDown
|
||||
series *store.SeriesTable
|
||||
opened time.Time
|
||||
|
||||
// Хеш плану, який зараз має зонд.
|
||||
//
|
||||
// Потрібен, бо зміни в UI роблять інший процес (REST API), а живу
|
||||
// сесію тримає цей. Без цієї позначки чек, доданий у вебі,
|
||||
// доїжджав би до зонда лише після обриву зв'язку — тобто, за
|
||||
// нормальної роботи, ніколи.
|
||||
planMu sync.Mutex
|
||||
planHash []byte
|
||||
}
|
||||
|
||||
func (a *agentSession) setPlanHash(h []byte) {
|
||||
a.planMu.Lock()
|
||||
a.planHash = h
|
||||
a.planMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *agentSession) currentPlanHash() []byte {
|
||||
a.planMu.Lock()
|
||||
defer a.planMu.Unlock()
|
||||
return a.planHash
|
||||
}
|
||||
|
||||
func New(st *store.Store, ring *crypto.Keyring, log *slog.Logger) *Service {
|
||||
|
|
@ -240,6 +261,7 @@ func (s *Service) Control(stream npv1.AgentService_ControlServer) error {
|
|||
Payload: &npv1.ControlDown_TaskPlan{TaskPlan: plan},
|
||||
})
|
||||
}
|
||||
sess.setPlanHash(plan.GetPlanHash())
|
||||
|
||||
s.log.Info("зонд підключився",
|
||||
"agent", agent.ID, "tenant", agent.TenantID,
|
||||
|
|
|
|||
|
|
@ -235,6 +235,10 @@ func (s *Service) refreshInterfaceChecks(ctx context.Context, agent *store.Agent
|
|||
}},
|
||||
})
|
||||
|
||||
if delivered {
|
||||
s.setAgentPlanHash(agent.ID, hash)
|
||||
}
|
||||
|
||||
s.log.Info("snmp.if-чеки оновлено",
|
||||
"agent", agent.ID, "задач", len(tasks), "надіслано_наживо", delivered)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,12 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request, p *P
|
|||
return
|
||||
}
|
||||
}
|
||||
if body.TemplateIDs != nil {
|
||||
if err := s.store.SetDeviceTemplates(r.Context(), p.TenantID, id, body.TemplateIDs); err != nil {
|
||||
s.writeStoreError(w, "шаблони хоста", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
|
|
@ -159,6 +165,12 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request, p *P
|
|||
return
|
||||
}
|
||||
}
|
||||
if body.TemplateIDs != nil {
|
||||
if err := s.store.SetDeviceTemplates(r.Context(), p.TenantID, deviceID, body.TemplateIDs); err != nil {
|
||||
s.writeStoreError(w, "шаблони хоста", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +217,7 @@ type deviceBody struct {
|
|||
Enabled *bool `json:"enabled"`
|
||||
} `json:"checks"`
|
||||
CredentialIDs []string `json:"credential_ids"`
|
||||
TemplateIDs []string `json:"template_ids"`
|
||||
}
|
||||
|
||||
func (b deviceBody) checkInputs() []store.CheckInput {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,14 @@ func (s *Server) Handler() http.Handler {
|
|||
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/templates", s.authenticated(s.handleListTemplates))
|
||||
mux.Handle("POST /api/v1/templates", s.authenticated(s.handleSaveTemplate))
|
||||
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))
|
||||
mux.Handle("GET /api/v1/devices/{id}/templates", s.authenticated(s.handleGetDeviceTemplates))
|
||||
mux.Handle("PUT /api/v1/devices/{id}/templates", s.authenticated(s.handleSetDeviceTemplates))
|
||||
|
||||
mux.Handle("GET /api/v1/credentials", s.authenticated(s.handleListCredentials))
|
||||
mux.Handle("POST /api/v1/credentials", s.authenticated(s.handleCreateCredential))
|
||||
|
||||
|
|
|
|||
195
server/internal/httpapi/templates.go
Normal file
195
server/internal/httpapi/templates.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListTemplates(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "шаблони", err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []store.Template{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": list})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTemplate(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:read") {
|
||||
return
|
||||
}
|
||||
t, err := s.store.GetTemplate(r.Context(), p.TenantID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "шаблон", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// handleSaveTemplate створює новий шаблон (POST) або замінює наявний (PUT).
|
||||
//
|
||||
// Елементи приходять повним списком: форма редагує шаблон як єдиний
|
||||
// документ, і часткові оновлення тут лише додали б способів отримати
|
||||
// стан, якого людина не просила.
|
||||
func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:write") {
|
||||
return
|
||||
}
|
||||
|
||||
var in struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
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"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||||
return
|
||||
}
|
||||
|
||||
in.Key = strings.TrimSpace(in.Key)
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Key == "" || in.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "ключ і назва обов'язкові")
|
||||
return
|
||||
}
|
||||
|
||||
t := store.Template{
|
||||
ID: r.PathValue("id"),
|
||||
Key: in.Key,
|
||||
Name: in.Name,
|
||||
Description: strings.TrimSpace(in.Description),
|
||||
Vendor: strings.TrimSpace(in.Vendor),
|
||||
}
|
||||
for _, i := range in.Items {
|
||||
oid := strings.TrimSpace(i.OID)
|
||||
metric := strings.TrimSpace(i.MetricKey)
|
||||
if oid == "" || metric == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"кожна метрика потребує OID і ключ метрики")
|
||||
return
|
||||
}
|
||||
// Крапка на початку — те, як OID пишуть у документації вендорів
|
||||
// і як його чекає gosnmp. Дописати її самим дешевше, ніж
|
||||
// відмовляти людині через символ, який нічого не означає.
|
||||
if !strings.HasPrefix(oid, ".") {
|
||||
oid = "." + oid
|
||||
}
|
||||
enabled := true
|
||||
if i.Enabled != nil {
|
||||
enabled = *i.Enabled
|
||||
}
|
||||
t.Items = append(t.Items, store.TemplateItem{
|
||||
Key: strings.TrimSpace(i.Key),
|
||||
Name: strings.TrimSpace(i.Name),
|
||||
CheckType: i.CheckType,
|
||||
OID: oid,
|
||||
MetricKey: metric,
|
||||
Unit: strings.TrimSpace(i.Unit),
|
||||
Scale: i.Scale,
|
||||
IntervalSec: i.IntervalSec,
|
||||
Enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
id, err := s.store.SaveTemplate(r.Context(), p.TenantID, t)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrBuiltin) {
|
||||
writeError(w, http.StatusForbidden, "builtin",
|
||||
"вбудований шаблон не редагується — зробіть копію")
|
||||
return
|
||||
}
|
||||
s.writeStoreError(w, "збереження шаблону", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:write") {
|
||||
return
|
||||
}
|
||||
err := s.store.DeleteTemplate(r.Context(), p.TenantID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrBuiltin) {
|
||||
writeError(w, http.StatusForbidden, "builtin", "вбудований шаблон не видаляється")
|
||||
return
|
||||
}
|
||||
s.writeStoreError(w, "видалення шаблону", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Прив'язка до хоста
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleGetDeviceTemplates(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
|
||||
}
|
||||
ids, err := s.store.DeviceTemplateIDs(r.Context(), p.TenantID, deviceID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "шаблони хоста", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"template_ids": ids})
|
||||
}
|
||||
|
||||
func (s *Server) handleSetDeviceTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:write") {
|
||||
return
|
||||
}
|
||||
deviceID := r.PathValue("id")
|
||||
if !p.Scope().CanWrite(deviceID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
|
||||
return
|
||||
}
|
||||
|
||||
var in struct {
|
||||
TemplateIDs []string `json:"template_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.SetDeviceTemplates(r.Context(), p.TenantID, deviceID, in.TemplateIDs); err != nil {
|
||||
s.writeStoreError(w, "прив'язка шаблонів", err)
|
||||
return
|
||||
}
|
||||
|
||||
ids, err := s.store.DeviceTemplateIDs(r.Context(), p.TenantID, deviceID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "шаблони хоста", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"template_ids": ids})
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@ func (s *Store) ListDeviceChecks(ctx context.Context, tenantID, deviceID string)
|
|||
FROM core.checks c
|
||||
LEFT JOIN core.check_types ct ON ct.key = c.check_type
|
||||
WHERE c.tenant_id = $1 AND c.device_id = $2
|
||||
AND c.template_id IS NULL
|
||||
ORDER BY c.check_type
|
||||
`, tenantID, deviceID)
|
||||
if err != nil {
|
||||
|
|
@ -201,10 +202,14 @@ func (s *Store) SetDeviceChecks(ctx context.Context, tenantID, deviceID string,
|
|||
|
||||
// Прибираємо зняте у формі. Перевірки на інтерфейсах не чіпаємо:
|
||||
// їх заводить автовиявлення, і форма хоста про них не знає.
|
||||
// Породжені шаблоном — так само: ними володіє реконсиляція, і
|
||||
// видалення тут означало б, що вони зникають на кожне
|
||||
// збереження форми, щоб за секунду з'явитися знову.
|
||||
_, err := tx.Exec(ctx, `
|
||||
DELETE FROM core.checks
|
||||
WHERE tenant_id = $1 AND device_id = $2
|
||||
AND interface_id IS NULL
|
||||
AND template_id IS NULL
|
||||
AND NOT (id = ANY($3::uuid[]))
|
||||
`, tenantID, deviceID, nonNilIDs(keep))
|
||||
return err
|
||||
|
|
|
|||
438
server/internal/store/templates.go
Normal file
438
server/internal/store/templates.go
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrBuiltin — спроба змінити вбудований шаблон.
|
||||
//
|
||||
// Вбудовані спільні для всіх тенантів: правка одного мовчки змінила б
|
||||
// опитування в чужих мережах. Хто хоче свій варіант — робить копію.
|
||||
var ErrBuiltin = errors.New("вбудований шаблон не редагується")
|
||||
|
||||
// Template — шаблон опитування.
|
||||
type Template struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
Items []TemplateItem `json:"items,omitempty"`
|
||||
ItemCount int `json:"item_count"`
|
||||
DeviceCount int `json:"device_count"`
|
||||
}
|
||||
|
||||
// TemplateItem — одна метрика шаблону.
|
||||
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"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Читання
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// ListTemplates — довідник шаблонів із лічильниками.
|
||||
func (s *Store) ListTemplates(ctx context.Context, tenantID string) ([]Template, error) {
|
||||
var out []Template
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT t.id::text, t.key, t.name, COALESCE(t.description,''),
|
||||
COALESCE(t.vendor,''), t.is_builtin,
|
||||
(SELECT count(*) FROM tpl.items i WHERE i.template_id = t.id),
|
||||
(SELECT count(*) FROM tpl.device_templates dt WHERE dt.template_id = t.id)
|
||||
FROM tpl.templates t
|
||||
ORDER BY t.is_builtin DESC, t.name
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var t Template
|
||||
if err := rows.Scan(&t.ID, &t.Key, &t.Name, &t.Description,
|
||||
&t.Vendor, &t.IsBuiltin, &t.ItemCount, &t.DeviceCount); err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetTemplate читає шаблон разом з елементами.
|
||||
func (s *Store) GetTemplate(ctx context.Context, tenantID, id string) (*Template, error) {
|
||||
var t Template
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT id::text, key, name, COALESCE(description,''),
|
||||
COALESCE(vendor,''), is_builtin
|
||||
FROM tpl.templates WHERE id = $1
|
||||
`, id).Scan(&t.ID, &t.Key, &t.Name, &t.Description, &t.Vendor, &t.IsBuiltin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := templateItems(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Items = items
|
||||
t.ItemCount = len(items)
|
||||
return tx.QueryRow(ctx, `
|
||||
SELECT count(*) FROM tpl.device_templates WHERE template_id = $1
|
||||
`, id).Scan(&t.DeviceCount)
|
||||
})
|
||||
if err != nil {
|
||||
if isNoRows(err) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
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
|
||||
FROM tpl.items WHERE template_id = $1 ORDER BY name
|
||||
`, templateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []TemplateItem{}
|
||||
for rows.Next() {
|
||||
var i TemplateItem
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Запис
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// SaveTemplate створює або оновлює власний шаблон разом з елементами.
|
||||
//
|
||||
// Елементи замінюються цілком, а не додаються: форма редагує шаблон як
|
||||
// один документ, і «прибрати метрику» має бути таким самим звичайним
|
||||
// рухом, як «додати». Ключ елемента при цьому стабільний, тож рядок,
|
||||
// який не змінився, лишається тим самим рядком.
|
||||
func (s *Store) SaveTemplate(ctx context.Context, tenantID string, t Template) (string, error) {
|
||||
var id string
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
if t.ID != "" {
|
||||
var builtin bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT is_builtin FROM tpl.templates WHERE id = $1
|
||||
`, t.ID).Scan(&builtin); err != nil {
|
||||
if isNoRows(err) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if builtin {
|
||||
return ErrBuiltin
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE tpl.templates
|
||||
SET key = $2, name = $3, description = NULLIF($4,''),
|
||||
vendor = NULLIF($5,''), updated_at = now()
|
||||
WHERE id = $1 AND tenant_id = $6
|
||||
RETURNING id::text
|
||||
`, t.ID, t.Key, t.Name, t.Description, t.Vendor, tenantID).Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM tpl.items WHERE template_id = $1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO tpl.templates (tenant_id, key, name, description, vendor, is_builtin)
|
||||
VALUES ($1, $2, $3, NULLIF($4,''), NULLIF($5,''), false)
|
||||
RETURNING id::text
|
||||
`, tenantID, t.Key, t.Name, t.Description, t.Vendor).Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, i := range t.Items {
|
||||
if i.Scale == 0 {
|
||||
i.Scale = 1
|
||||
}
|
||||
if i.IntervalSec == 0 {
|
||||
i.IntervalSec = 60
|
||||
}
|
||||
if i.CheckType == "" {
|
||||
i.CheckType = "snmp.get"
|
||||
}
|
||||
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)
|
||||
`, id, i.Key, i.Name, i.CheckType, i.OID, i.MetricKey, i.Unit,
|
||||
i.Scale, i.IntervalSec, i.Enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Чеки на всіх прив'язаних хостах мають наздогнати нову редакцію:
|
||||
// сенс шаблону саме в тому, що виправлений OID доїжджає сам.
|
||||
if err := s.reconcileTemplate(ctx, tenantID, id); err != nil {
|
||||
return id, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// DeleteTemplate прибирає власний шаблон.
|
||||
//
|
||||
// Породжені чеки зникають каскадом через core.checks.template_id — саме
|
||||
// для цього колонка й існує.
|
||||
func (s *Store) DeleteTemplate(ctx context.Context, tenantID, id string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
var builtin bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT is_builtin FROM tpl.templates WHERE id = $1
|
||||
`, id).Scan(&builtin); err != nil {
|
||||
if isNoRows(err) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if builtin {
|
||||
return ErrBuiltin
|
||||
}
|
||||
_, err := tx.Exec(ctx, `
|
||||
DELETE FROM tpl.templates WHERE id = $1 AND tenant_id = $2
|
||||
`, id, tenantID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Прив'язка до хостів
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// DeviceTemplateIDs — які шаблони прив'язані до хоста.
|
||||
func (s *Store) DeviceTemplateIDs(ctx context.Context, tenantID, deviceID string) ([]string, error) {
|
||||
ids := []string{}
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT template_id::text FROM tpl.device_templates WHERE device_id = $1
|
||||
`, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// SetDeviceTemplates замінює набір шаблонів хоста й перебудовує чеки.
|
||||
func (s *Store) SetDeviceTemplates(ctx context.Context, tenantID, deviceID string, templateIDs []string) error {
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
var owned bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM inv.devices
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
|
||||
`, deviceID, tenantID).Scan(&owned); err != nil {
|
||||
return err
|
||||
}
|
||||
if !owned {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM tpl.device_templates WHERE device_id = $1
|
||||
`, deviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range templateIDs {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO tpl.device_templates (device_id, template_id, tenant_id)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING
|
||||
`, deviceID, id, tenantID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ReconcileDeviceTemplates(ctx, tenantID, deviceID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Реконсиляція: шаблони → core.checks
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// oidSpec — елемент params.oids для snmp.get. Імена полів мусять
|
||||
// збігатися з agent/internal/modules/snmp.OIDSpec.
|
||||
type oidSpec struct {
|
||||
OID string `json:"oid"`
|
||||
MetricKey string `json:"metric_key"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Scale float64 `json:"scale"`
|
||||
}
|
||||
|
||||
type checkGroup struct {
|
||||
templateID string
|
||||
checkType string
|
||||
intervalSec int
|
||||
oids []oidSpec
|
||||
}
|
||||
|
||||
// ReconcileDeviceTemplates приводить чеки хоста у відповідність до його
|
||||
// шаблонів.
|
||||
//
|
||||
// Елементи групуються за (шаблон, тип, інтервал) в один чек: агент
|
||||
// уміє питати список OID однією пачкою, і сотня окремих чеків замість
|
||||
// однієї пачки — це сотня SNMP-сесій там, де досить кількох PDU.
|
||||
// Інтервал у ключі групування, бо пачка ходить цілком: змішавши
|
||||
// хвилинну метрику з п'ятихвилинною, ми або опитували б рідкісну надто
|
||||
// часто, або часту — надто рідко.
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) reconcileTemplate(ctx context.Context, tenantID, templateID string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT device_id::text FROM tpl.device_templates WHERE template_id = $1
|
||||
`, templateID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := reconcileDevice(ctx, tx, tenantID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
`, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
k := fmt.Sprintf("%s|%s|%d", g.templateID, g.checkType, g.intervalSec)
|
||||
if groups[k] == nil {
|
||||
g.oids = []oidSpec{}
|
||||
groups[k] = &g
|
||||
}
|
||||
groups[k].oids = append(groups[k].oids, o)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Порядок ключів фіксуємо: params потрапляє в хеш плану, і
|
||||
// «однаковий шаблон дав інший хеш» через порядок map — це
|
||||
// перезалив плану на кожному тіку звірки.
|
||||
keys := make([]string, 0, len(groups))
|
||||
for k := range groups {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
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 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)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
kept = append(kept, id)
|
||||
}
|
||||
|
||||
// Прибираємо те, що шаблони більше не породжують: відв'язаний
|
||||
// шаблон, вимкнений елемент або змінений інтервал.
|
||||
_, err = tx.Exec(ctx, `
|
||||
DELETE FROM core.checks
|
||||
WHERE device_id = $1 AND template_id IS NOT NULL
|
||||
AND NOT (id = ANY($2::uuid[]))
|
||||
`, deviceID, kept)
|
||||
return err
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { LoginPage } from './components/LoginPage'
|
|||
import { MapPage } from './pages/MapPage'
|
||||
import { DevicesPage } from './pages/DevicesPage'
|
||||
import { GroupsPage } from './pages/GroupsPage'
|
||||
import { TemplatesPage } from './pages/TemplatesPage'
|
||||
import { ConfigsPage } from './pages/ConfigsPage'
|
||||
import { AlertsPage } from './pages/AlertsPage'
|
||||
import { RulesPage } from './pages/RulesPage'
|
||||
|
|
@ -52,6 +53,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="/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 />} />} />
|
||||
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import type {
|
|||
TeamMember,
|
||||
UserGroup,
|
||||
UndoResult,
|
||||
Template,
|
||||
} from '../types'
|
||||
|
||||
/** Те, що приймає POST/PATCH /api/v1/devices. */
|
||||
|
|
@ -51,6 +52,8 @@ export interface DeviceInput {
|
|||
/** Як опитувати. Без цього хост лежить у списку й не опитується. */
|
||||
checks?: CheckInput[]
|
||||
credential_ids?: string[]
|
||||
/** Шаблони опитування. undefined — форма про них не говорила. */
|
||||
template_ids?: string[]
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -447,4 +450,31 @@ export const api = {
|
|||
})
|
||||
session.clear()
|
||||
},
|
||||
|
||||
// --- шаблони опитування ---
|
||||
|
||||
listTemplates: () =>
|
||||
request<{ templates: Template[] }>('/api/v1/templates').then((r) => r.templates ?? []),
|
||||
|
||||
getTemplate: (id: string) => request<Template>(`/api/v1/templates/${id}`),
|
||||
|
||||
saveTemplate: (id: string | null, t: Partial<Template>) =>
|
||||
request<{ id: string }>(id ? `/api/v1/templates/${id}` : '/api/v1/templates', {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
body: JSON.stringify(t),
|
||||
}),
|
||||
|
||||
deleteTemplate: (id: string) =>
|
||||
request<void>(`/api/v1/templates/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getDeviceTemplates: (deviceID: string) =>
|
||||
request<{ template_ids: string[] }>(`/api/v1/devices/${deviceID}/templates`).then(
|
||||
(r) => r.template_ids ?? [],
|
||||
),
|
||||
|
||||
setDeviceTemplates: (deviceID: string, ids: string[]) =>
|
||||
request<{ template_ids: string[] }>(`/api/v1/devices/${deviceID}/templates`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ template_ids: ids }),
|
||||
}).then((r) => r.template_ids ?? []),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const navItems: NavItem[] = [
|
|||
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
|
||||
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
|
||||
{ to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' },
|
||||
{ to: '/templates', 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' },
|
||||
|
|
|
|||
|
|
@ -246,16 +246,19 @@ export function Modal({
|
|||
title,
|
||||
children,
|
||||
onClose,
|
||||
wide = false,
|
||||
}: {
|
||||
title: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
/** Форми з двома колонками не читаються у вузькому вікні. */
|
||||
wide?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/70 md:items-center md:p-4">
|
||||
<div
|
||||
className="max-h-[90dvh] w-full overflow-y-auto rounded-t-xl border border-slate-800
|
||||
bg-slate-900 md:max-w-lg md:rounded-xl"
|
||||
className={`max-h-[90dvh] w-full overflow-y-auto rounded-t-xl border border-slate-800
|
||||
bg-slate-900 md:rounded-xl ${wide ? 'md:max-w-3xl' : 'md:max-w-lg'}`}
|
||||
>
|
||||
<div className="flex items-center border-b border-slate-800 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-slate-100">{title}</h2>
|
||||
|
|
@ -297,3 +300,24 @@ export function ago(iso?: string): string {
|
|||
if (sec < 86400) return `${Math.round(sec / 3600)} год`
|
||||
return `${Math.round(sec / 86400)} дн`
|
||||
}
|
||||
|
||||
/**
|
||||
* Українська множина: 1 метрика, 2 метрики, 5 метрик.
|
||||
*
|
||||
* «1 метрик» — дрібниця, але саме з таких дрібниць складається
|
||||
* враження, що інтерфейс писали не для людей.
|
||||
*/
|
||||
export function plural(n: number, one: string, few: string, many: string): string {
|
||||
const mod100 = n % 100
|
||||
if (mod100 >= 11 && mod100 <= 14) return `${n} ${many}`
|
||||
switch (n % 10) {
|
||||
case 1:
|
||||
return `${n} ${one}`
|
||||
case 2:
|
||||
case 3:
|
||||
case 4:
|
||||
return `${n} ${few}`
|
||||
default:
|
||||
return `${n} ${many}`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
StatusBadge,
|
||||
ago,
|
||||
inputClass,
|
||||
plural,
|
||||
} from '../components/ui'
|
||||
import { ChecksEditor } from '../components/ChecksEditor'
|
||||
import type {
|
||||
|
|
@ -24,6 +25,7 @@ import type {
|
|||
Credential,
|
||||
DeviceGroup,
|
||||
DeviceSummary,
|
||||
Template,
|
||||
} from '../types'
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
|
|
@ -356,10 +358,19 @@ function DeviceForm({
|
|||
const [address, setAddress] = useState(device?.address ?? '')
|
||||
const [kind, setKind] = useState(device?.kind ?? 'other')
|
||||
const [vendor, setVendor] = useState(device?.vendor ?? '')
|
||||
const [agentID, setAgentID] = useState('')
|
||||
// Поточний зонд, а не порожньо.
|
||||
//
|
||||
// Знайдено живим прогоном: поле починалося порожнім, підпис обіцяв
|
||||
// «не змінювати», а сервер трактував порожнє значення буквально —
|
||||
// і будь-яке збереження форми відв'язувало хост від зонда. Хост
|
||||
// після цього лишався в списку й переставав опитуватись, мовчки.
|
||||
const [agentID, setAgentID] = useState(device?.agent_id ?? '')
|
||||
const [enabled, setEnabled] = useState(device?.enabled ?? true)
|
||||
const [groupIDs, setGroupIDs] = useState<string[]>(device?.group_ids ?? [])
|
||||
const [credIDs, setCredIDs] = useState<string[]>([])
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [templateIDs, setTemplateIDs] = useState<string[]>([])
|
||||
const [templatesLoaded, setTemplatesLoaded] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -401,6 +412,27 @@ function DeviceForm({
|
|||
}
|
||||
}, [device])
|
||||
|
||||
// Довідник шаблонів і прив'язки хоста. Новий хост шаблонів не має —
|
||||
// але список усе одно потрібен, бо саме на створенні їх і чіпляють.
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
Promise.all([
|
||||
api.listTemplates().catch(() => [] as Template[]),
|
||||
device ? api.getDeviceTemplates(device.id).catch(() => [] as string[]) : Promise.resolve([]),
|
||||
]).then(([list, ids]) => {
|
||||
if (!alive) return
|
||||
setTemplates(list)
|
||||
setTemplateIDs(ids)
|
||||
setTemplatesLoaded(true)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [device])
|
||||
|
||||
const toggleTemplate = (id: string) =>
|
||||
setTemplateIDs((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
||||
|
||||
const toggleGroup = (id: string) =>
|
||||
setGroupIDs((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
||||
|
||||
|
|
@ -450,7 +482,7 @@ function DeviceForm({
|
|||
value={agentID}
|
||||
onChange={(e) => setAgentID(e.target.value)}
|
||||
>
|
||||
<option value="">— не змінювати —</option>
|
||||
<option value="">— без зонда —</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
|
|
@ -514,6 +546,32 @@ function DeviceForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{templates.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-slate-400">Шаблони опитування</div>
|
||||
<p className="text-[11px] text-slate-600">
|
||||
Метрики шаблону додаються поверх перевірок нижче й оновлюються разом із ним
|
||||
</p>
|
||||
<ul className="max-h-40 space-y-1 overflow-y-auto rounded border border-slate-800 p-2">
|
||||
{templates.map((t) => (
|
||||
<li key={t.id}>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={templateIDs.includes(t.id)}
|
||||
onChange={() => toggleTemplate(t.id)}
|
||||
/>
|
||||
<span className="text-slate-200">{t.name}</span>
|
||||
<span className="text-xs text-slate-600">
|
||||
{plural(t.item_count, 'метрика', 'метрики', 'метрик')}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checksLoaded ? (
|
||||
<ChecksEditor types={checkTypes} checks={checks} onChange={setChecks} />
|
||||
) : (
|
||||
|
|
@ -574,6 +632,10 @@ function DeviceForm({
|
|||
// усе опитування хоста.
|
||||
...(checksLoaded ? { checks } : {}),
|
||||
...(credIDs.length > 0 ? { credential_ids: credIDs } : {}),
|
||||
// Так само, як з перевірками: шлемо лише те, що встигли
|
||||
// побачити, інакше швидке збереження відв'язало б усі
|
||||
// шаблони хоста.
|
||||
...(templatesLoaded ? { template_ids: templateIDs } : {}),
|
||||
})
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
|
|
|
|||
369
web/src/pages/TemplatesPage.tsx
Normal file
369
web/src/pages/TemplatesPage.tsx
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { session } from '../api/session'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ErrorNote,
|
||||
Field,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Spinner,
|
||||
Toggle,
|
||||
inputClass,
|
||||
plural,
|
||||
} from '../components/ui'
|
||||
import type { Template, TemplateItem } from '../types'
|
||||
|
||||
const INTERVALS = [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)} год`
|
||||
}
|
||||
|
||||
/**
|
||||
* Шаблони опитування.
|
||||
*
|
||||
* Сенс сутності: те, що знімається з Mikrotik, однакове на всіх
|
||||
* Mikrotik. Шаблон тримає цей факт в одному місці, і виправлений OID
|
||||
* доїжджає до всіх прив'язаних хостів сам.
|
||||
*/
|
||||
export function TemplatesPage() {
|
||||
const canWrite = session.can('devices:write')
|
||||
|
||||
const [list, setList] = useState<Template[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<Template | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
api
|
||||
.listTemplates()
|
||||
.then(setList)
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(reload, [reload])
|
||||
|
||||
async function open(id: string) {
|
||||
try {
|
||||
setEditing(await api.getTemplate(id))
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Spinner />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Шаблони опитування"
|
||||
subtitle="Набір метрик, який чіпляється до хоста одним рухом"
|
||||
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">
|
||||
{list.map((t) => (
|
||||
<Card key={t.id}>
|
||||
<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>
|
||||
<div className="truncate font-mono text-[11px] text-slate-600">{t.key}</div>
|
||||
</div>
|
||||
{t.is_builtin && (
|
||||
<span className="shrink-0 rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400">
|
||||
вбудований
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{t.description && (
|
||||
<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">
|
||||
<span>
|
||||
{plural(t.item_count, 'метрика', 'метрики', 'метрик')} ·{' '}
|
||||
{plural(t.device_count, 'хост', 'хости', 'хостів')}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<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))
|
||||
}
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{list.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-slate-600">Шаблонів ще немає</p>
|
||||
)}
|
||||
|
||||
{(editing || creating) && (
|
||||
<TemplateForm
|
||||
template={editing}
|
||||
readOnly={!canWrite || (editing?.is_builtin ?? false)}
|
||||
onClose={() => {
|
||||
setEditing(null)
|
||||
setCreating(false)
|
||||
}}
|
||||
onSaved={() => {
|
||||
setEditing(null)
|
||||
setCreating(false)
|
||||
reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyItem = (): TemplateItem => ({
|
||||
key: '',
|
||||
name: '',
|
||||
check_type: 'snmp.get',
|
||||
oid: '',
|
||||
metric_key: '',
|
||||
unit: '',
|
||||
scale: 1,
|
||||
interval_sec: 60,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
function TemplateForm({
|
||||
template,
|
||||
readOnly,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
template: Template | null
|
||||
readOnly: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [key, setKey] = useState(template?.key ?? '')
|
||||
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 [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
function patch(i: number, next: Partial<TemplateItem>) {
|
||||
setItems(items.map((it, n) => (n === i ? { ...it, ...next } : it)))
|
||||
}
|
||||
|
||||
// Ключ елемента потрібен базі, але не людині: він виводиться з назви
|
||||
// метрики, а вручну задавати його немає навіщо.
|
||||
function keyFor(it: TemplateItem, i: number): string {
|
||||
if (it.key) return it.key
|
||||
const from = (it.metric_key || it.name || `item-${i + 1}`)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return from || `item-${i + 1}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={template ? `Шаблон: ${template.name}` : 'Новий шаблон'}
|
||||
onClose={onClose}
|
||||
wide
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Назва">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={name}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Ключ" hint="Латиниця, цифри, дефіс">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={key}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Опис">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={description}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Виробник" hint="Підказка, для якого заліза пропонувати шаблон">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={vendor}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setVendor(e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{items.map((it, i) => (
|
||||
<div key={i} className="rounded border border-slate-800 bg-slate-900/40 p-2.5">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Field label="Назва">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={it.name}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => patch(i, { name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<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>
|
||||
<div className="grid grid-cols-3 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<Toggle
|
||||
checked={it.enabled}
|
||||
onChange={(v) => patch(i, { enabled: v })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
Опитувати
|
||||
</label>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
kind="danger"
|
||||
onClick={() => setItems(items.filter((_, n) => n !== i))}
|
||||
>
|
||||
Прибрати
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>{readOnly ? 'Закрити' : 'Скасувати'}</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || !name.trim() || !key.trim()}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await api.saveTemplate(template?.id ?? null, {
|
||||
key: key.trim(),
|
||||
name: name.trim(),
|
||||
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) })),
|
||||
})
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Збереження…' : 'Зберегти'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -171,6 +171,8 @@ export interface DeviceSummary {
|
|||
last_seen_at?: string
|
||||
interface_count: number
|
||||
group_ids: string[]
|
||||
/** Зонд, який опитує хост. Порожньо — жоден. */
|
||||
agent_id?: string
|
||||
/** Чи можна редагувати саме цей хост — рахується з груп користувача. */
|
||||
writable: boolean
|
||||
}
|
||||
|
|
@ -455,3 +457,30 @@ export type WsMessage =
|
|||
type: 'alert.updated'
|
||||
payload: { alert_id: string; state: AlertState; suppressed_by?: string }
|
||||
}
|
||||
|
||||
// --- шаблони опитування ---
|
||||
|
||||
export type TemplateItem = {
|
||||
id?: string
|
||||
key: string
|
||||
name: string
|
||||
check_type: string
|
||||
oid: string
|
||||
metric_key: string
|
||||
unit: string
|
||||
scale: number
|
||||
interval_sec: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type Template = {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
description?: string
|
||||
vendor?: string
|
||||
is_builtin: boolean
|
||||
items?: TemplateItem[]
|
||||
item_count: number
|
||||
device_count: number
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue