diff --git a/HISTORY.md b/HISTORY.md
index 64827a7..0342e17 100644
--- a/HISTORY.md
+++ b/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 по ядрах, диски, сенсори) шаблон описати не вміє.
diff --git a/ROADMAP.md b/ROADMAP.md
index 99dacb4..923b498 100644
--- a/ROADMAP.md
+++ b/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`. Це працює рівно для одного випадку — інтерфейсів. Щойно
diff --git a/db/migrations/0015_templates.sql b/db/migrations/0015_templates.sql
new file mode 100644
index 0000000..138e9f8
--- /dev/null
+++ b/db/migrations/0015_templates.sql
@@ -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;
diff --git a/server/API.md b/server/API.md
index 2c5d69b..7432a8e 100644
--- a/server/API.md
+++ b/server/API.md
@@ -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`, а тоді цього хоста в ній ще не було.
### Доступи до обладнання
diff --git a/server/internal/grpcapi/plan_sync.go b/server/internal/grpcapi/plan_sync.go
new file mode 100644
index 0000000..fc764bf
--- /dev/null
+++ b/server/internal/grpcapi/plan_sync.go
@@ -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)
+ }
+}
diff --git a/server/internal/grpcapi/service.go b/server/internal/grpcapi/service.go
index 1051a3d..f95ac67 100644
--- a/server/internal/grpcapi/service.go
+++ b/server/internal/grpcapi/service.go
@@ -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,
diff --git a/server/internal/grpcapi/streams.go b/server/internal/grpcapi/streams.go
index 246e661..9396bf9 100644
--- a/server/internal/grpcapi/streams.go
+++ b/server/internal/grpcapi/streams.go
@@ -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)
}
diff --git a/server/internal/httpapi/groups.go b/server/internal/httpapi/groups.go
index 8244e1b..cf04071 100644
--- a/server/internal/httpapi/groups.go
+++ b/server/internal/httpapi/groups.go
@@ -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 {
diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go
index f693781..34f1009 100644
--- a/server/internal/httpapi/server.go
+++ b/server/internal/httpapi/server.go
@@ -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))
diff --git a/server/internal/httpapi/templates.go b/server/internal/httpapi/templates.go
new file mode 100644
index 0000000..e0159e4
--- /dev/null
+++ b/server/internal/httpapi/templates.go
@@ -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})
+}
diff --git a/server/internal/store/checks.go b/server/internal/store/checks.go
index b4f0b7a..0ffea3b 100644
--- a/server/internal/store/checks.go
+++ b/server/internal/store/checks.go
@@ -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
diff --git a/server/internal/store/templates.go b/server/internal/store/templates.go
new file mode 100644
index 0000000..145ecb9
--- /dev/null
+++ b/server/internal/store/templates.go
@@ -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
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 1e962fa..375b108 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -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() {
} />} />
} />} />
} />} />
+ } />} />
} />} />
} />} />
} />} />
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 35567a4..7584534 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -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(`/api/v1/templates/${id}`),
+
+ saveTemplate: (id: string | null, t: Partial) =>
+ request<{ id: string }>(id ? `/api/v1/templates/${id}` : '/api/v1/templates', {
+ method: id ? 'PUT' : 'POST',
+ body: JSON.stringify(t),
+ }),
+
+ deleteTemplate: (id: string) =>
+ request(`/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 ?? []),
}
diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx
index 798b34e..e7bfe54 100644
--- a/web/src/components/AppShell.tsx
+++ b/web/src/components/AppShell.tsx
@@ -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' },
diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx
index 49d9ffb..90de6d3 100644
--- a/web/src/components/ui.tsx
+++ b/web/src/components/ui.tsx
@@ -246,16 +246,19 @@ export function Modal({
title,
children,
onClose,
+ wide = false,
}: {
title: string
children: ReactNode
onClose: () => void
+ /** Форми з двома колонками не читаються у вузькому вікні. */
+ wide?: boolean
}) {
return (
{title}
@@ -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}`
+ }
+}
diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx
index 6f53a47..5c87339 100644
--- a/web/src/pages/DevicesPage.tsx
+++ b/web/src/pages/DevicesPage.tsx
@@ -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 = {
@@ -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(device?.group_ids ?? [])
const [credIDs, setCredIDs] = useState([])
+ const [templates, setTemplates] = useState([])
+ const [templateIDs, setTemplateIDs] = useState([])
+ const [templatesLoaded, setTemplatesLoaded] = useState(false)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState(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)}
>
-
+
{agents.map((a) => (
)}
+ {templates.length > 0 && (
+
+
Шаблони опитування
+
+ Метрики шаблону додаються поверх перевірок нижче й оновлюються разом із ним
+
+
+ {templates.map((t) => (
+
+
+
+ ))}
+
+
+ )}
+
{checksLoaded ? (
) : (
@@ -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))
diff --git a/web/src/pages/TemplatesPage.tsx b/web/src/pages/TemplatesPage.tsx
new file mode 100644
index 0000000..84090d3
--- /dev/null
+++ b/web/src/pages/TemplatesPage.tsx
@@ -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([])
+ const [loading, setLoading] = useState(true)
+ const [err, setErr] = useState(null)
+ const [editing, setEditing] = useState(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
+
+ return (
+