Netpulse_SasS/server/API.md
byrsapty cd8d4c62ed Історія метрик, шаблони будь-яких перевірок, маршрути правил
Метрики збиралися в ts.samples і не показувалися ніде — побачити
зібране можна було лише через psql. Додано GET /devices/{id}/series і
/metrics: джерело (сирі дані, 5m, 1h) обирається за потрібним кроком,
бакетизація в БД, пропуск у даних лишається пропуском, а не лінією
через діру.

Знайдено живим прогоном: зонд працює рівно годину. CredentialTTL —
година, Credentials() свідомо не віддає прострочені (щоб не блокувати
облікові записи на пристроях), а поновлення не просив ніхто:
CredentialRequest є в контракті з Етапу 2, сервер його обробляє, агент
не надсилає. Будь-яка інсталяція припиняла збирати SNMP через годину
після старту й мовчала про це.

Шаблон описує перевірки будь-якого типу, не лише OID. Пачкою в один PDU
збираються тільки snmp.get; решта — елемент на чек, слід у
core.checks.template_item_key. Вбудований шаблон «Доступність (ICMP)».
Імпорт/експорт глобальний і поштучний, свій формат замість Zabbix-YAML.

Спільний розклад бекапів із перевизначенням на хості: прапорець
follows_default, а не порівняння значень — власний розклад може
випадково збігтися зі спільним.

Правило саме каже, куди йде його алерт: канали, тихі години, групи
хостів, повідомлення про відновлення. Канали правила перекривають
маршрути повністю.

Доступи до обладнання отримали свою сторінку: SSH-паролі й
SNMP-community заводяться, змінюються й видаляються з вебу. Секрет
назовні не повертається ніколи.

Дрібниці за скаргами: відступи в картках шаблонів, українська множина,
ручний ввід інтервалу опитування, підтвердження видалення з описом
наслідків замість «Ви впевнені?», помітні кнопки видалення замість
сірого ✕ у кутку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:21:50 +03:00

66 KiB
Raw Blame History

NetPulse API — REST і WebSocket для фронтенду

go build -o netpulse-api ./cmd/netpulse-api
./netpulse-api -listen :8080 -dsn "postgres://..." -cert api.pem -key api.key

Окремий процес від netpulse-server (AgentService) навмисно: зонди й браузери мають різні профілі навантаження, різні мережеві периметри й різні цикли релізів. Спільним лишається лише шар store, тому дані обидва бачать однакові.

Автентифікація

Два рівноправні способи довести, хто ти. Обидва приходять одним заголовком:

Authorization: Bearer <токен>

Сервер розрізняє їх за формою: рівно дві крапки — це JWT людини, інакше машинний токен. Це навмисно не залежить від префікса, бо префікс може змінитись, а формат JWT — ні.

Люди входять за логіном (core.users.username), а не за поштою: у мережевій інсталяції половина облікових записів технічні (noc, monitoring, oncall) і скриньки не мають узагалі. Пошта лишається прийнятною — сервер шукає за обома полями, — але вона більше не обов'язкова.

Люди входять через /api/v1/auth/login і отримують короткий access-токен (HS256, 15 хвилин, у пам'яті вкладки) плюс refresh-сесію в httpOnly-кукі np_refresh (30 днів, SameSite=Strict, Path=/api/v1/auth). Access-токен свідомо не кладеться в localStorage: будь-який XSS звідти його забирає, а з замикання модуля — ні.

Машини (NOC-екрани, скрипти, кіоски) користуються токенами з core.api_tokens; у БД лежить лише sha256, сам токен показується один раз при створенні. Порожній scopes означає повний доступ — так поводяться токени, створені власником тенанта для себе.

Права в обох випадках зводяться до одного набору рядків (maps:write, users:write, billing:manage, …) і перевіряються однаково. Для людини набір щоразу читається з БД за членством, а не береться з токена: інакше відкликана роль жила б до кінця TTL.

GET /healthz свідомо відкритий: його опитує балансувальник.

Перший користувач

Порожня база не має кого впустити, тому власник заводиться CLI-утилітою, а не через API:

./netpulse-user -dsn "postgres://..." -tenant acme -login admin -role owner

-list показує учасників, -reset-password міняє пароль і відкликає всі сесії.

Ендпоїнти

Метод Шлях Призначення
GET /healthz стан процесу й кількість WebSocket-підписників
POST /api/v1/auth/login вхід за логіном і паролем
POST /api/v1/auth/refresh обмін refresh-кукі на новий access-токен
POST /api/v1/auth/logout відкликати поточну сесію
POST /api/v1/auth/password змінити свій пароль
GET /api/v1/me хто я і що мені можна
GET /api/v1/team учасники організації
POST /api/v1/team завести користувача (users:write)
PATCH /api/v1/team/{id} змінити роль (users:write)
DELETE /api/v1/team/{id} прибрати з організації (users:write)
GET /api/v1/roles доступні ролі з правами
GET /api/v1/maps перелік мап із лічильниками вузлів і ребер
POST /api/v1/maps створити мапу
GET /api/v1/maps/{id} повний стан полотна разом із живими статусами
PATCH /api/v1/maps/{id} редактор полотна з оптимістичним блокуванням
DELETE /api/v1/maps/{id} м'яко видалити мапу (топологія лишається)
POST /api/v1/maps/{id}/build добудувати мапу з виявленої топології
POST /api/v1/maps/{id}/undo відкотити останню зміну полотна
GET /api/v1/devices інвентар (звужений групами доступу)
POST /api/v1/devices додати хост (devices:write)
PATCH /api/v1/devices/{id} змінити хост і його групи
DELETE /api/v1/devices/{id} мʼяко видалити хост
GET /api/v1/check-types що система вміє опитувати
GET /api/v1/devices/{id}/checks перевірки хоста
PUT /api/v1/devices/{id}/checks замінити набір перевірок
GET /api/v1/credentials доступи до обладнання (без секретів)
POST /api/v1/credentials створити доступ (devices:write)
GET /api/v1/device-groups групи хостів із лічильниками
POST /api/v1/device-groups створити групу хостів
DELETE /api/v1/device-groups/{id} видалити групу
GET /api/v1/user-groups групи доступу з правами
POST /api/v1/user-groups створити групу доступу
PATCH /api/v1/user-groups/{id} склад і права одним запитом
DELETE /api/v1/user-groups/{id} видалити групу доступу
GET /api/v1/agents зонди, версії, самометрики
GET /api/v1/alerts активні алерти + лічильники
POST /api/v1/alerts/{id}/ack підтвердити (alerts:ack)
POST /api/v1/alerts/{id}/close закрити вручну (alerts:ack)
POST /api/v1/mutes заглушити пристрій (alerts:ack)
GET /api/v1/alert-rules правила з лічильником активних
POST /api/v1/alert-rules створити правило (alerts:write)
PATCH /api/v1/alert-rules/{id} увімкнути/вимкнути (alerts:write)
DELETE /api/v1/alert-rules/{id} видалити правило (alerts:write)
GET /api/v1/channels канали доставки (без секретів)
POST /api/v1/channels створити канал (alerts:write)
POST /api/v1/channels/{id}/test пробне повідомлення (alerts:write)
DELETE /api/v1/channels/{id} видалити канал (alerts:write)
GET /api/v1/ws WebSocket: події та завантаження каналів

POST /api/v1/auth/login — вхід

{"login": "admin", "password": "…", "tenant_id": "…"}  // tenant_id — опційно

Поле login приймає і логін, і пошту. Старе email теж лишається робочим: інтеграції, написані до перейменування, не мають ламатися через назву ключа.

Успіх:

{
  "access_token": "eyJ…",
  "expires_in": 900,
  "user": {"id": "…", "username": "admin", "email": "admin@acme.io", "full_name": "Admin"},
  "tenant": {"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"},
  "permissions": ["maps:read", "maps:write", "users:write", ]
}

Якщо людина працює в кількох організаціях і tenant_id не вказано, токен не видається — приходить перелік на вибір, і клієнт повторює вхід із tenant_id:

{"tenants": [{"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"}, ]}

Помилки навмисно нерозрізненні: невідомий логін і невірний пароль дають однаковий 401 bad_credentials, і на неіснуючому користувачі сервер спалює стільки ж часу на фіктивній перевірці argon2id. Інакше час відповіді сам би розказував, які акаунти існують.

Після 10 невдач з одного логіна або IP за 15 хвилин — 429 too_many_attempts. Лічильник живе в core.login_attempts (гіпертаблиця), тому переживає рестарт процесу й працює на кількох інстансах одразу.

POST /api/v1/auth/refresh — продовження сесії

Тіла немає — сервер читає кукі np_refresh. Відповідь така сама, як у login. Токен ротується: старий рядок сесії відкликається, видається новий. Це перетворює викрадену кукі на видиму подію — злодій і власник не можуть користуватись однією сесією паралельно, другий отримає 401 no_session.

Членство перевіряється при кожній ротації, тож прибраний з організації користувач випадає щонайбільше за 15 хвилин, а не за 30 днів.

GET /api/v1/me

{"tenant_id": "…", "user_id": "…", "email": "admin@acme.io", "permissions": [...]}

Для машинного токена замість user_id/email приходить token_name.

Формат логіна: 364 символи з латиниці, цифр, крапки, дефіса й підкреслення; перший і останній символ — літера або цифра. Перевіряється і в БД (CHECK), і в API — відмова БД виглядає як «violates check constraint», і людині з неї нічого не зрозуміло.

GET|POST|PATCH|DELETE /api/v1/team

Керування учасниками; усе, крім читання, потребує users:write.

PATCH міняє роль, профіль і пароль одним запитом:

{"role_id": "…", "username": "noc-a", "email": "noc@acme.io",
 "full_name": "Черговий", "password": "…"}

Порожнє поле означає «не чіпати» — форма не стирає того, чого не показувала. Порожній email прибирає адресу (сигнал -). Зміна пароля відкликає всі сесії цієї людини: інакше той, хто знав старий, лишається всередині до місяця, доки не протермінується refresh.

Профіль редагується лише в того, хто працює тільки в цій організації. core.users глобальна: одна людина може мати доступ до кількох тенантів (типово для MSP). Логін, пошта й пароль — її власність, а не власність організації, тому адмін філії не може змінити їх тому, хто заходить тим самим акаунтом ще кудись — інакше той навіть не дізнався б. Спроба дає 409 shared_user. Роль і членство в групах локальні й редагуються завжди.

Три обмеження вшиті навмисно й не обходяться параметрами: роль owner не видається через API (лише CLI), не можна змінити роль самому собі й не можна прибрати себе з організації. Кожне з них рятує від одного й того самого — організації без жодного власника.

DELETE відкликає всі сесії цієї людини в цьому тенанті, не чіпаючи інші організації. Зміна власного пароля відкликає всі сесії скрізь.

GET /api/v1/maps/{id} — головний запит продукту

Одним викликом повертає готове до рендеру полотно: мапу, підкладки, вузли, ребраі живий стан. Це не оптимізація: без підмішаного статусу мапа малювалася б сірою й лише потім доганяла кольори сотнею дозапитів.

{
  "id": "…", "name": "Автомапа", "layout_algo": "manual",
  "viewport": {"x":0,"y":0,"zoom":1},
  "grid": {"enabled":true,"size":16,"snap":true},
  "clustering": {"enabled":true,"zoom_threshold":0.4},

  "backgrounds": [
    {"kind":"image","storage_key":"s3://…/floorplan.svg","opacity":0.6,
     "x":0,"y":0,"width":2400,"height":1600,"locked":true}
  ],

  "nodes": [
    {"id":"…","kind":"device","label":"core-sw","x":100,"y":200,
     "device_id":"…","style":{"icon":"switch"},
     "status":"up","rtt_ms":0.113,"loss_pct":0}      // ← фарбує вузол
  ],

  "edges": [
    {"id":"…","source_node_id":"…","target_node_id":"…",
     "source_port":"Gi0/1","target_port":"ether1","label":"Gi0/1 → ether1",
     "style":"smoothstep","waypoints":[],
     "animation":{"enabled":true,"speed_source":"utilization"},
     "thresholds":{"warn_pct":70,"crit_pct":90},
     "link_status":"up","util_pct":7.2,"capacity_bps":10000000000}
  ]
}

link_status виводиться з кінців лінка, а не читається з колонки. У схемі є topo.links.status, але її ніхто не підтримує: писати туди означало б оновлювати всі лінки пристрою на кожну зміну його статусу й тримати це узгодженим. Стан лінка — похідна величина, тож рахується на читанні. Порядок гілок важливий: обрив перекриває все інше, а «невідомо» стоїть перед «up», щоб мапа не малювала зеленим те, чого ще жодного разу не опитували.

rtt_ms і loss_pct беруться лише за останні 15 хвилин (ts.device_last_icmp). Старіші дані не характеризують поточний стан, і показувати їх означало б брехати про живість пристрою.

PATCH /api/v1/maps/{id} — редактор

{
  "revision": 7,                    // ревізія, яку бачив клієнт
  "viewport": {"x":0,"y":0,"zoom":1.2},
  "nodes": {
    "upsert": [
      {"id": "…", "x": 1500, "y": 640},                   // перетягування
      {"client_id": "tmp-1", "kind": "cloud",             // новий вузол
       "label": "Інтернет", "x": 900, "y": 100}
    ],
    "remove": ["…"]
  },
  "edges": {
    "upsert": [{"client_id": "tmp-e", "source_node_id": "…",
                "target_node_id": "tmp-1", "style": "bezier"}]
  },
  "comment": "додав аплінк"
}

Відповідь повертає нову ревізію й мапу client_id → id:

{"revision": 8, "node_ids": {"tmp-1": "…"}, "edge_ids": {"tmp-e": "…"}}

Усі скалярні поля — необов'язкові, і відсутнє означає «не чіпати». Це не формальність: перетягування шле лише x/y, і якби відсутні поля трактувались як порожні, кожен рух миші стирав би стиль, розмір і прив'язку до пристрою.

Ребро може посилатися на вузол, створений у цьому ж патчі, за client_id — інакше намалювати зв'язок до нового вузла вимагало б двох запитів і проміжного стану.

Оптимістичне блокування. Клієнт надсилає ревізію, яку бачив; якщо мапу встиг змінити хтось інший — 409 revision_conflict із поточним номером, а не тихе затирання. У NOC над однією мапою часто працюють кілька людей, і мовчазна втрата чужих правок гірша за помилку. Пропустити revision можна, але це вимикає перевірку — так робить лише серверний код.

Кожна правка лишає знімок у topo.map_revisions (зберігаються останні 50) — це основа undo. Знімок пишеться тією ж транзакцією, що й зміна: інакше після збою в історії лишався б крок, якого в мапі немає, і відкат ламав би її.

Невідоме поле в тілі — 400. Мовчки проковтнути друкарську помилку клієнта означає, що правка «збереглася», але не застосувалась.

Код Коли
409 revision_conflict мапу змінив хтось інший
402 plan_limit упёрлись у ліміт тарифу (тригер у БД) — UI має показати пропозицію змінити тариф
400 invalid чужий вузол, невідоме поле, некоректний uuid

POST /api/v1/maps/{id}/build — автопобудова

Додає на мапу пристрої, що беруть участь у виявлених лінках, і ребра між ними. Ідемпотентна: наявні вузли не дублюються, а координати не чіпаються — інакше кожен запуск скидав би розкладку, яку оператор робив руками.

Нові вузли розставляються сіткою. Осмислену розкладку дає лише клієнт (він знає розміри полотна й алгоритм), а сервер має покласти їх хоч кудись, але не в одну точку.

{"nodes_added": 2, "edges_added": 1, "revision": 2}

POST /api/v1/maps/{id}/undo — відкат

Повертає полотно до попереднього знімка з topo.map_revisions.

{"revision": 11, "restored_from": 9}

Відкат оформлюється як НОВА ревізія, а не як відмотування лічильника. Інакше клієнти, що тримають номер 10, після повернення до 9 отримали б «свою» ревізію знову актуальною й тихо перезаписали б відкочене. Undo — така сама зміна, як будь-яка інша, і має рухати історію вперед.

Ідентифікатори вузлів зберігаються. Відновлений вузол повертається з тим самим id, тому ребра, що на нього спираються, знову працюють, а виділення в UI й зовнішні посилання не ламаються.

Відновлення йде в порядку «ребра геть → вузли геть → вузли назад → ребра назад»: зовнішні ключі не дозволяють інакше.

409 nothing_to_undoу щойно створеної мапи історії ще немає. Sec-WebSocket-Protocol: netpulse.token.<токен>


Браузерний WebSocket API не дозволяє довільні заголовки, тому токен їде підпротоколом.
Сам токен при цьому не потрапляє в URL, а отже і в логи проксі.

**Клієнт → сервер**

```json
{"type": "subscribe", "map_id": "…"}

Мапа перевіряється на належність тенанту: вгаданий id не відкриє чужу топологію.

Сервер → клієнт

type Коли Вміст
hello одразу після рукостискання tenant_id
subscribed підтвердження підписки map_id
error напр. чужа мапа code, map_id
device.status зміна статусу пристрою device_id, status, previous_status, reason
link.load кожні 5 с для підписаної мапи map_id, links: [{link_id, status, util_pct}]
map.updated мапу змінив інший клієнт map_id, revision

map.updated несе лише номер ревізії, а не сам патч: клієнт сам вирішує, чи перечитувати полотно. Розсилати зміни дельтами означало б тримати на сервері модель того, що бачить кожен клієнт, — а це вже спільне редагування з CRDT, окрема задача.

Дві частоти навмисно різні. Зміна статусу — подія: рідка, але має дійти майже миттєво, інакше мапа бреше про стан мережі. Завантаження каналу — величина: вона змінюється весь час, і слати її частіше, ніж оновлюються лічильники (60 с), означає слати ту саму цифру по колу.

Звідки беруться події

core.event_outbox наповнюється тією ж транзакцією, що й зміна, яку описує. Інакше WebSocket міг би розповісти про перехід, якого в базі ще (або вже) немає.

Транспорт — опитування таблиці раз на секунду, а не LISTEN/NOTIFY. NOTIFY не переживає падіння підписника й обмежений 8 КБ на повідомлення, а тут потрібна гарантія, що жодна зміна статусу не загубиться між перезапусками API. Ціна — один дешевий запит за індексом.

Нова сесія починає з кінця журналу: клієнт щойно завантажив повний стан мапи, і все старіше в ньому вже враховано.

Підписник, який не встигає читати, відключається, а не сповільнює решту: тримати його чергу означало б віддавати пам'ять і затримувати всіх інших.

Стан перевірки

Тести працюють проти справжньої БД, справжнього HTTP і справжнього WebSocket (NETPULSE_TEST_DSN; без змінної пропускаються). go test -raceусі проходять.

Тест Що доводить
TestAuthRequired без токена й з чужим токеном — 401; healthz відкритий
TestMapStateIsRenderReady одним викликом приходять підкладка, координати, статус вузла, RTT, порти ребра, util_pct, capacity_bps, налаштування анімації
TestLinkStatusFollowsEndpoints лінк червоніє, коли впав кінець або порт, — без жодних змін у topo.links
TestTenantIsolation чужа мапа за точним id → 404; у переліку пристроїв немає чужого тенанта
TestBadMapID некоректний uuid → 400, а не 500
TestWebSocketRequiresToken без токена з'єднання не відкривається
TestWebSocketDeliversStatusChange справжній батч телеметрії → applyDeviceStatus → outbox → hub → браузер отримав device.status із previous_status
TestWebSocketRejectsForeignMap підписка на чужу мапу відхилена
TestWebSocketPushesLinkLoads періодичний link.load із реальним util_pct
TestPatchMoveNodeKeepsOtherFields драг шле лише x/y — підпис, прив'язка до пристрою й статус не затираються
TestPatchRevisionConflict друга вкладка зі старою ревізією отримує 409, перша правка ціла
TestPatchCreatesNodeAndEdgeTogether ребро прив'язується до вузла, створеного тим же патчем, за client_id
TestPatchDeleteNodeRemovesEdges видалення вузла не лишає ребер у нікуди
TestPatchStoresRevisionSnapshot знімок із коментарем і повним складом вузлів
TestPatchRejectsForeignNode вузол чужої мапи не редагується через свою
TestPatchRejectsUnknownField друкарська помилка в клієнті — 400, а не тиха втрата
TestPatchRequiresWriteScope токен maps:read не пише, але читає
TestBuildFromTopologyIsIdempotent повторна побудова нічого не додає й не скидає ручну розкладку
TestPatchBroadcastsToOtherViewers правка долітає до інших відкритих полотен як map.updated
TestUndoRestoresNodePosition відкат повертає координати й створює нову ревізію, а не відмотує лічильник
TestUndoRestoresDeletedNodeWithEdges видалений вузол повертається з тим самим id, ребра прив'язуються назад, живий стан лінка на місці
TestUndoWithoutHistory у мапи без історії — 409 nothing_to_undo
TestUndoRequiresWriteScope токен maps:read не відкочує

Живий прогін

Агент, netpulse-server і netpulse-api запущені разом проти справжнього snmpd:

мапа: Автомапа | вузлів: 2 | ребер: 1
вузол  snmp-host   статус=up  rtt=0.113 мс  loss=0
вузол  gateway     статус=up  rtt=0.827 мс  loss=0
ребро  eth0 → ?    порти=eth0->None  лінк=up  util=7.0e-06%  capacity=10000000000
зонд   probe-snmp: online, linux/amd64, пристроїв=2,
       health={rss_bytes: 11624464, dropped_samples: 0, clock_skew_ms: 0}

target_port порожній — і це правильно: лінк знайдено через ARP, а ARP не повідомляє порт віддаленої сторони. Заповнить його LLDP, коли поруч буде обладнання, що його шле.

Живий прогін редактора

Проти справжніх даних, зібраних агентом:

1) створюємо порожню мапу              map_id=a4eb2025…
2) будуємо з виявленої топології       +2 вузлів, +1 ребер, ревізія 2
3) читаємо полотно                     вузли snmp-host(380,120) і gateway(120,120), обидва up
4) пересуваємо вузол                   нова ревізія 3
5) той самий патч зі старою ревізією   409 revision_conflict
6) повторна автопобудова               +0 вузлів, +0 ребер
7) координати після побудови           x=1500 y=640 — ручна розкладка збережена
                                       знімків в історії: 2

Опитування хоста

Хост сам по собі нічого не робить. Опитує його core.checks — рядок «пристрій X, тип перевірки Y, кожні N секунд, з такими параметрами». Тому POST /api/v1/devices приймає перевірки одразу:

{
  "name": "sw-core-1",
  "address": "10.0.0.1",
  "kind": "switch",
  "credential_ids": ["…"],
  "checks": [
    {"check_type": "icmp.ping", "params": {"count": 3}, "interval_sec": 30},
    {"check_type": "snmp.if",   "params": {"use_hc_counters": true}, "interval_sec": 300}
  ]
}

Створювати хост без перевірок можна, але це свідомий вибір: такий хост лежить у списку й не опитується ніколи. Форма в UI попереджає про це прямим текстом і починає новий хост із icmp.ping — єдиної перевірки, яка працює будь-де без налаштування.

GET /api/v1/check-types віддає перелік із params_schema (JSON Schema) для кожного типу. Форма будує поля з неї, а не зі свого списку: інакше кожен новий тип перевірки, доданий плагіном, вимагав би перезбирання фронтенду. Поле available каже, чи плагін увімкнений цьому тенанту — базові (is_core) доступні завжди, решта потребує запису в core.plugin_installs.

Правка перевірок іде за id, а не перестворенням. Унікальний індекс checks_uniq включає md5(params), тому «видалити й вставити» на зміні параметрів створило б ДРУГУ перевірку того самого типу. Плюс перестворення скидає next_run_at і збиває рівномірність опитування по всьому парку.

PUT /api/v1/devices/{id}/checks замінює набір цілком: форма показує повний список, і зняту перевірку треба вміти зняти. Перевірки на інтерфейсах (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, щоб форма зберігалася одним запитом.

{
  "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. snmp.get адресується OID-ом і збирається в пачку; icmp.ping, http.status і snmp.if описуються полем params — тим самим, що лягає в core.checks.params. Ділити на «пінг заводиться руками, а SNMP шаблоном» означало б змусити людину пам'ятати, що саме шаблон покриває.

Для негрупованих типів елемент відповідає окремому чеку, і його сліду в core.checks.template_item_key вистачає, щоб упізнати рядок. Ключ елемента, а не його id: збереження шаблону перезаписує елементи цілком, тож id живуть недовго.

Елементи замінюються цілком, а не додаються. Форма редагує шаблон як один документ, і «прибрати метрику» має бути таким самим звичайним рухом, як «додати». Крапку на початку OID сервер дописує сам: у документації вендорів її пишуть, і відмовляти через символ, який нічого не означає, — дурний спосіб витратити людині хвилину.

Вбудовані шаблони (tenant_id IS NULL) не редагуються й не видаляються403 builtin. Вони спільні для всіх тенантів, і правка одного мовчки змінила б опитування в чужих мережах. Хто хоче свій варіант — робить копію.

Реконсиляція в перевірки

Прив'язка шаблону не створює перевірку на кожну метрику. Елементи групуються за (шаблон, тип, інтервал) в один snmp.get: агент уміє питати список OID однією пачкою, і сотня окремих перевірок замість однієї пачки — це сотня SNMP-сесій там, де досить кількох PDU.

Інтервал у ключі групування, бо пачка ходить цілком: змішавши хвилинну метрику з п'ятихвилинною, ми або опитували б рідкісну надто часто, або часту — надто рідко.

core.checks.template_id позначає породжені рядки. Без цієї позначки відв'язування шаблону не знало б, що прибирати, а зміна OID плодила б другу перевірку замість правки першої. ON DELETE CASCADE, а не SET NULL: перевірка без шаблону, який її створив, нікому не належить — вона б просто тихо опитувала пристрій вічно.

Обмін

Метод Шлях Призначення
GET /api/v1/templates/export документ із шаблонами (?ids=a,b — вибрані)
POST /api/v1/templates/import залити документ

Формат свій, не Zabbix-YAML: там елемент описується ключем виду snmp.get[...], до нього чіпляються препроцесинг, value maps і тригери — нічого з цього тут поки немає, і вдавати сумісність означало б мовчки втрачати половину імпортованого.

update_existing вирішує долю збігів за ключем: без нього наявний шаблон іде в skipped. Мовчазне перезаписування — найшвидший спосіб втратити локальні правки. Вбудований шаблон не чіпається ніколи: він спільний для всіх тенантів.

Відповідь — три списки ключів: created, updated, skipped.

Як зміна доїжджає до зонда

Перевірки міняє REST-процес, а живу сесію зонда тримає AgentService — інший процес. Звірка планів раз на п'ять секунд порівнює хеш плану в базі з тим, що зараз у зонда, і перезаливає план при розбіжності.

Повний план, а не дельта. Дельта вміє додавати й міняти, але не знає, що зникло; порівняння хешів теж не знає — воно каже лише «інакше». Перезалив кількох тисяч задач раз на зміну дешевший за перевірку, яка лишилась опитувати видалений хост.

Разом із планом ідуть модулі (у плані міг з'явитись перший snmp-чек на зонді, де модуль не вмикали) і креденшели. Останнє знайдено живим прогоном: хост, приписаний зонду вже після його підключення, отримував задачі й падав на кожній із «немає SNMP-креденшелів» — пачка доступів видається на Hello, а тоді цього хоста в ній ще не було.

Доступи до обладнання

SNMP-community, паролі SSH і Telnet живуть в inv.credentials, зашифровані тим самим кільцем, що й секрети каналів. Прив'язка до хоста — credential_ids у тілі хоста. Без доступу працює лише icmp.ping.

Метод Шлях Призначення
GET /api/v1/credentials перелік + usage: скільки хостів кожним користується
POST /api/v1/credentials створити
PATCH /api/v1/credentials/{id} змінити (порожній secret — лишити пароль)
DELETE /api/v1/credentials/{id} видалити разом із секретом

Секрет ніколи не повертається назовні. Розшифрувати пароль заради показу означає віддати його туди, звідки він уже не повернеться, тому форма редагування показує порожнє поле: змінити пароль можна, підглянути — ні. Порожній secret у PATCH означає «лишити як є».

Протокол доступу не змінюється після створення: зміна snmp_v2c на ssh перетворює запис на інший об'єкт, і чесніше завести новий.

Комплект доступів живе годину (CredentialTTL). Зонд просить новий за десять хвилин до кінця терміну і не частіше, ніж раз на хвилину. Без цього поновлення зонд працював рівно годину: Credentials() свідомо не віддає прострочені, щоб не блокувати облікові записи на пристроях, — і після цього кожна перевірка падала з «немає креденшелів».

Профілі збору конфігу

ncm.profiles описує, як зняти конфіг із конкретної платформи: які команди виконати, за яким промптом ловити кінець виводу, що вирізати з diff (scrub_patterns) і що замаскувати перед записом у Git (redact_patterns).

Вбудовано 147 платформ на 67 вендорів — Cisco, Huawei, Juniper, MikroTik, Eltex, D-Link, HP, Brocade, Extreme, Alcatel, Allied Telesis, Qtech, ZTE, BDCOM та інші.

Джерело істини — db/profiles/catalog.json; міграція з нього породжується збіркою. Додати платформу означає відредагувати каталог, а не писати SQL. Подробиці — db/profiles/README.md.

Промпти й команди вимкнення пейджера задані за родиною CLI (cisco, huawei, juniper, mikrotik, eltex, …) і перевіряються на живому залізі: одна родина покриває десятки платформ, і дрібні відхилення трапляються. Команди збору натомість специфічні для платформи.

Тенант може завести власний профіль (ncm.profiles із заповненим tenant_id) — вбудовані при цьому лишаються недоторканими.

Збір конфігу

Метод Шлях Призначення
POST /api/v1/devices/{id}/collect-config зібрати зараз (ncm:write)
GET /api/v1/devices/{id}/config-jobs історія збору
GET /api/v1/devices/{id}/configs версії конфігу
GET /api/v1/configs/{id} текст версії
GET /api/v1/configs/{id}/diff порівняння з попередньою (?from=…)
GET /api/v1/devices/{id}/backup-policy розклад бекапу хоста
PUT /api/v1/devices/{id}/backup-policy змінити розклад (ncm:write)
GET /api/v1/ncm-profiles довідник профілів для форми

Ланцюг такий: REST кладе рядок у ncm.jobs зі станом queued → диспетчер усередині AgentService забирає його, якщо потрібний зонд на зв'язку, і штовхає ConfigJob у живу сесію → зонд заходить по SSH/Telnet, виконує команди профілю й вивантажує результат стрімом → сервер звіряє sha256, дедуплікує за content_hash і закриває завдання.

Черга в БД, а не прямий виклик, бо REST і AgentService — різні процеси, і живу сесію зонда тримає лише другий. Черга робить передачу явною й переживає перезапуск обох. Вибірка йде під FOR UPDATE SKIP LOCKED: два екземпляри AgentService не надішлють одне завдання двічі.

Повторний збір незміненого конфігу дає статус unchanged — пристрій опитано, конфіг звірено, нового коміту не потрібно. Це успіх, а не відсутність результату, і окремий статус потрібен, щоб у журналі було видно, коли конфіг востаннє справді мінявся.

Розклад

Розклад буває спільний і поштучний.

Метод Шлях Призначення
GET /api/v1/ncm/backup-defaults спільний розклад тенанта
PUT /api/v1/ncm/backup-defaults змінити спільний

Спільний розклад заводить політику кожному придатному хосту з follows_default = true і протягує на них свій cron. Хост, якому задали власний розклад через PUT /devices/{id}/backup-policy, прапорець втрачає — і зміни спільного його більше не чіпають. Це не порівняння значень, а саме прапорець: власний розклад може випадково збігтися зі спільним, і тоді зміна спільного мовчки потягла б за собою хост, який навмисно налаштували окремо.

apply_to_all повертає під спільний розклад усіх, включно з тими, хто має власний. Руйнівно, тому окремим прапорцем, а не побічним ефектом збереження. Відповідь містить following_count і custom_count — без цих двох чисел форма не каже головного: кого саме зачепить зміна.

Вимкнення спільного розкладу зупиняє лише тих, хто йому слідує. Хост із власним розкладом налаштували свідомо.

PUT /api/v1/devices/{id}/backup-policy приймає:

{
  "enabled": true,
  "cron": "0 3 * * *",
  "profile_id": "",
  "credential_id": ""
}

Порожні profile_id і credential_id означають «підібрати автоматично»: профіль — за виробником хоста, доступ — прив'язаний до хоста. Явно заданий завжди виграє: так лікується прошивка, що поводиться не як решта родини.

Вираз cron розбирається власним парсером (internal/cronx), п'ятипольовий, як у crontab: хвилина, година, день місяця, місяць, день тижня. Підтримані *, списки 1,2, діапазони 8-20, кроки */15 і 8-20/4, назви місяців і днів (jan, mon), неділя і як 0, і як 7. Якщо задані одночасно день місяця й день тижня, підходить збіг за будь-яким — це правило самого cron, інакше «щоп'ятниці та першого числа» не записати.

Некоректний вираз відхиляється одразу з 400 bad_cron і людським поясненням (розклад: години: 99 поза межами 0..23) — краще сказати це у формі, ніж мовчки не робити бекапів.

Планувальник усередині AgentService прокидається раз на хвилину (cron дрібніший за хвилину не буває) під advisory-блокуванням, тож у кластері розклад розкручує рівно один екземпляр. Порядок кроків — спершу перенести next_backup_at, потім поставити завдання: падіння між ними коштує одного пропущеного бекапу, а зворотний порядок дав би нескінченну чергу однакових завдань.

next_backup_at IS NULL вважається «час настав»: так виглядає щойно збережена політика, і чекати добу до першого бекапу означало б не зробити його тоді, коли він найпотрібніший. З цієї ж причини будь-яка зміна політики обнуляє позначку.

Зависле в running завдання повертається у відмову за десять хвилин: зонд міг зникнути разом із ним, і без цього хост лишився б без бекапів назавжди.

Порівняння версій

GET /api/v1/configs/{id}/diff без параметрів порівнює версію з попередньою — саме це питання ставлять у дев'яти випадках із десяти: «що змінилось цього разу». Явне ?from= дає порівняння з будь-якою іншою версією.

{
  "hunks": [{
    "old_start": 8, "old_lines": 3, "new_start": 8, "new_lines": 4,
    "lines": [
      {"op": "=", "old_num": 8, "new_num": 8,  "text": "ID=debian"},
      {"op": "+",                "new_num": 11, "text": "new-ct"}
    ]
  }],
  "lines_added": 1,
  "lines_removed": 0
}

Результат кешується в ncm.diffs: порівняння двох конкретних версій незмінне назавжди, і рахувати його щоразу при відкритті сторінки — це палити процесор на відому відповідь. Підсумок +N/M дозаписується й у саму версію, щоб список історії показував його без розшифровки двох тіл на кожен рядок.

Перша зібрана версія повертає {"first": true} — порівнювати нема з чим, і це не помилка.

Дуже великі версії, що розійшлися повністю, дають "truncated": true і грубу заміну блоку замість порядкових змін: точне порівняння там коштувало б квадратичного часу, а користі не дало б — людині однаково доведеться читати весь блок. Чесна позначка краща за правдоподібний, але вигаданий diff.

Тіло конфігу лежить у БД зашифрованим (core.secrets), тому без ключа процес відповідає 503, а не порожнім рядком: мовчазна порожнеча виглядала б як «пристрій віддав порожній конфіг».

Групи й доступ до хостів

Два незалежні виміри, які не можна змішувати в одному списку прав:

  • Роль відповідає на питання «що людині вільно робити» — maps:write, alerts:ack, users:write.
  • Група доступу відповідає на «над якими хостами». Інженер над однією філією та інженер над усією мережею мають однакову роль і різний доступ.

Група доступу (core.user_groups) містить людей і видає права на групи хостів (inv.device_groups) одним із трьох рівнів: read, write, deny.

Три правила обчислення

  1. Хто не входить у жодну групу — не обмежений групами взагалі. Це свідомо не по-заббіксівськи: там користувач без груп не бачить нічого, і кожна нова інсталяція починається з питання «чому порожньо». Тут звуження вмикається тоді, коли його справді налаштували, а до того доступ визначає роль.
  2. Заборона перемагає дозвіл. Хост у двох групах, де одна дає читання, а друга забороняє, лишається невидимим — інакше заборону можна обійти, додавши об'єкт у будь-яку іншу групу.
  3. Серед дозволів виграє найширший. Читання в одній групі й запис у другій дають запис.

Обчислення живе в БД (core.accessible_devices) і читається раз на запит: питати про кожен хост окремо означало б перетворити список на N запитів рівно тоді, коли хостів багато.

Звуження діє на /api/v1/devices, /api/v1/alerts, лічильники алертів і операції запису (редагування хоста, заглушення). Алерт без пристрою (наприклад, про сам зонд) видно всім: сховати його від обмеженого користувача означало б приховати аварію, до якої групи не мають стосунку.

Живий прогін груп

1) вхід за логіном 'admin'          200, роль owner
2) вхід тим самим, але поштою       200 — сумісність збережена
3) створено дві групи хостів        201/201
4) хости розкладено                 gateway→Магістраль, snmp-host→Доступ
5) новий хост через API             201
6) лічильники груп                  Доступ=2, Магістраль=1
7) група доступу для 'eng'          лише «Доступ», рівень read
8) що бачить eng                    snmp-host, test-host — обидва writable=false
                                    gateway зник із вибірки
9) алерти під eng                   1 замість 3
10) eng редагує чужий хост          403
11) видалення хоста власником       204

Алерти

Движок правил живе всередині netpulse-api і обчислюється раз на -alert-interval (за замовчуванням 30 с). Кілька екземплярів API за балансувальником безпечні: тік бере pg_try_advisory_lock, тож правила рахує рівно один — дедуплікацію алертів захищає індекс, а от сповіщення пішли б у кількох копіях.

Движок не тримає стану між тіками. Вікно for_seconds — це запит по часу до TSDB, а не лічильник у пам'яті, тому перезапуск процесу нічого не збиває.

Умови правил

// Усі виміри вікна мають задовольняти умову — це і є антифлап.
{"metric": "loss_pct", "op": ">", "value": 20}

// Агрегат за вікно порівнюється один раз.
{"metric": "rtt_avg_ms", "op": ">", "value": 150, "agg": "avg"}

// Даних немає взагалі — моніторинг, що мовчить про мертвий зонд,
// показує зелену мапу мертвої мережі.
{"metric": "no_data"}

// Довільна серія з ts.samples.
{"metric_key": "cpu.util", "op": ">", "value": 85, "agg": "avg"}

op: > >= < <= == !=. agg: avg min max sum count last. Метрики: для icmprtt_avg_ms, rtt_min_ms, rtt_max_ms, jitter_ms, loss_pct, reachable; для interfacein_bps, out_bps, in_pps, out_pps, util_in_pct, util_out_pct, in_errors, out_errors, in_discards, out_discards, oper_up.

Списки закриті навмисно: значення з condition потрапляє в текст запиту, і будь-яке послаблення тут перетворюється на SQL-ін'єкцію через JSON у таблиці правил.

selector обмежує область: device_ids, group_ids, site_ids, kinds, vendors, tags. Порожній означає «до всього» — найчастіший випадок, і вимагати для нього переліку означало б ламати правило щоразу, коли додається пристрій.

Кореляція за топологією

Правило з depends_on_topology (типово увімкнено) не піднімає алерти на пристроях, що стоять за іншим недоступним пристроєм. Причина аварії — на межі зони недоступності: у кого лишився хоч один живий сусід, той упав сам; хто оточений виключно мертвими — наслідок.

Пристрій без жодного відомого зв'язку завжди лишається причиною: топологія про нього нічого не знає, тож списати його на чужу аварію було б вигадкою. Повністю мертвий острів алертує весь — краще зайвий шум, ніж мовчазне ковтання цілого сегмента.

Придушення

  • Вікна обслуговування (alr.maintenance_windows) — за розкладом; вікно без селектора накриває весь тенант.
  • Ручне заглушення (alr.mutes) — кнопка в UI, стеля 7 днів. Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути моніторинг назавжди.

Придушений алерт лишається видимим у списку (окремим фільтром), але не надсилає сповіщень.

Куди йде алерт

Порядок вирішення: канали самого правила → маршрути тенанта → усі придатні канали. Кожен наступний крок — це відповідь на «а якщо нічого не налаштовано», і останній навмисно не мовчить.

Правило приймає в тілі:

{
  "channel_ids": ["…"],          // порожньо — за маршрутами тенанта
  "notify_on_resolve": true,     // «впало» без «піднялося» знецінює саме себе
  "notify_schedule": {"tz": "Europe/Kyiv",
                      "quiet": [{"from": "23:00", "to": "07:00"}]},
  "selector": {"group_ids": ["…"]}  // порожньо — усі хости
}

Канали правила перекривають маршрути повністю. Інакше «шліть це черговому» перетворювалося б на «шліть це черговому і ще туди, куди вирішить спільна політика».

Тиха година правила глушить усе, крім disaster — те саме правило, що в маршрутах. Вимкнений канал не отримує алерт навіть тоді, коли правило назвало його явно: вимкнення — це рішення про канал, а не про правило.

PUT /api/v1/alert-rules/{id} замінює правило цілком.

Канали й маршрути

Канал зберігає несекретну частину в config, а токен — у core.secrets, зашифрований тим самим кільцем, що й паролі від обладнання. Перелік каналів секрети не розшифровує взагалі.

Тенант без жодного маршруту отримує сповіщення в усі придатні канали: підключили Telegram — має працювати без додаткових налаштувань. Маршрути з'являються тоді, коли треба розділити потоки.

Тихі години маршруту глушать усе, крім disaster: сенс чергування в тому, щоб його підняли.

Вебхуки на внутрішні адреси заблоковані (SSRF), бо адресу задає користувач тенанта, а запит іде з сервера. У self-hosted це знімається прапорцем -allow-private-webhooks — там внутрішня мережа належить тому самому, хто налаштовує вебхук.

Живий прогін алертів

1) вхід власника                  200
2) активні алерти                 200, 1 алерт: gateway 0.98 мс > 0.5
                                  snmp-host (0.08 мс) не спрацював — поріг посередині
3) правила                        200, 3 правила, «no_data» і «помилки на порту» мовчать
4) канал webhook створено         201
5) перевірка каналу               200, ok=true — повідомлення дійшло
6) нове правило                   201
7) після тіку                     3 алерти, сповіщень по 1
8) підтвердження                  200, стан=acknowledged
9) глядач пробує підтвердити      403
10) глядач читає алерти           200

дедуплікація: за три тіки алертів 3→3, сповіщень 2→2
вимкнення правила                 його алерти закрито, чужий ack не зачеплено
повернення правила                алерти піднялись знову
заглушення пристрою               рівно його алерт → suppressed (mute)
видалення правила                 його алерти закрито, сиріт не лишилось
доставок за прогін                5 на 5 подій — жодного повтору

Живий прогін входу

1) вхід власника                       200, роль owner, прав 20, кукі: ['np_refresh']
2) хто я                               200, admin@netpulse.local
3) власник читає команду               200, учасників 2
4) вхід інженера                       maps:write=True, users:write=False
5) інженер пробує читати команду        403 forbidden
6) інженер читає мапи                  200, мап 1
7) невірний пароль                     401 bad_credentials
8) вихід                               204; refresh після виходу — 401 no_session

Права так само доводяться до UI: під глядачем (devices:read, maps:read, alerts:read) у шапці лишається сам вибір мапи й вихід — кнопки «Добудувати» та «Відкотити» зникають, полотно стає нередагованим, а блок зондів у бічній панелі не показується взагалі.

Чого ще немає

  • Історія метрик: GET /api/v1/metrics для графіків із ts.samples_5m не написано.
  • Ескалації: alr.escalation_policies і повторні сповіщення не реалізовані — сповіщення надсилається один раз при піднятті алерту.
  • Web Push і Telegram-кнопки: alr.push_subscriptions порожня, а callback_data кнопок Ack/Mute у Telegram нікуди не приходить — бот-приймач не написано.
  • Правила з джерел syslog, trap, ncm, compliance: движок їх свідомо пропускає, бо вони обробляються подіями, а не опитуванням.
  • Редагування правил: PATCH міняє лише enabled; умову правила змінюють перестворенням.
  • Завантаження підкладок: map_backgrounds.storage_key можна задати патчем, але самого прийому файлів (S3/MinIO) ще немає.
  • Спільне редагування обмежене оптимістичним блокуванням: одночасний драг двома людьми закінчиться 409 для того, хто спізнився. Для повноцінної співпраці потрібен CRDT — свідомо відкладено.