From 2de1894fd5dc3f80419a11219f834c0b01ebb29c Mon Sep 17 00:00:00 2001 From: byrsapty Date: Mon, 24 Aug 2026 14:07:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=95=D1=82=D0=B0=D0=BF=206:=20=D1=88=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BD=D0=B8=20=D0=BE=D0=BF=D0=B8=D1=82=D1=83?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=BD=D1=8F=20+=20=D0=B7=D0=B2=D1=96=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BB=D0=B0=D0=BD=D1=96=D0=B2=20=D0=B7?= =?UTF-8?q?=D0=BE=D0=BD=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Схема 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 --- HISTORY.md | 93 ++++++ ROADMAP.md | 12 +- db/migrations/0015_templates.sql | 206 +++++++++++++ server/API.md | 81 +++++ server/internal/grpcapi/plan_sync.go | 117 +++++++ server/internal/grpcapi/service.go | 22 ++ server/internal/grpcapi/streams.go | 4 + server/internal/httpapi/groups.go | 13 + server/internal/httpapi/server.go | 8 + server/internal/httpapi/templates.go | 195 ++++++++++++ server/internal/store/checks.go | 5 + server/internal/store/templates.go | 438 +++++++++++++++++++++++++++ web/src/App.tsx | 2 + web/src/api/client.ts | 30 ++ web/src/components/AppShell.tsx | 1 + web/src/components/ui.tsx | 28 +- web/src/pages/DevicesPage.tsx | 66 +++- web/src/pages/TemplatesPage.tsx | 369 ++++++++++++++++++++++ web/src/types.ts | 29 ++ 19 files changed, 1714 insertions(+), 5 deletions(-) create mode 100644 db/migrations/0015_templates.sql create mode 100644 server/internal/grpcapi/plan_sync.go create mode 100644 server/internal/httpapi/templates.go create mode 100644 server/internal/store/templates.go create mode 100644 web/src/pages/TemplatesPage.tsx 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