Етап 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:
byrsapty 2026-08-24 14:07:54 +03:00
parent 436eff343d
commit 2de1894fd5
19 changed files with 1714 additions and 5 deletions

View file

@ -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 по ядрах, диски, сенсори) шаблон описати не вміє.

View file

@ -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`. Це працює рівно для одного випадку — інтерфейсів. Щойно

View 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;

View file

@ -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`, а тоді цього хоста в ній ще не було.
### Доступи до обладнання

View 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)
}
}

View file

@ -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,

View file

@ -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)
}

View file

@ -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 {

View file

@ -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))

View 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})
}

View file

@ -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

View 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
}

View file

@ -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 />} />} />

View file

@ -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 ?? []),
}

View file

@ -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' },

View file

@ -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}`
}
}

View file

@ -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))

View 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>
)
}

View file

@ -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
}