Свій SVG-графік замість recharts: із сотні кілобайт бібліотеки потрібна одна ламана, а SVG ще й масштабується без переобчислення на ресайз. Ряди групуються за одиницею виміру — відсотки й біти на секунду на спільній осі дають пряму лінію біля нуля. Пропуск у даних розриває лінію, а не з'єднує сусідів прямою. Перевірено наживо: 6 годин читаються з сирих даних (крок 72 с), тиждень — з роллапу 5m (крок 2016 с), підписи портів і одиниці на місці. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
68 KiB
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.
Формат логіна: 3–64 символи з латиниці, цифр, крапки, дефіса й підкреслення; перший і останній символ — літера або цифра. Перевіряється і в БД (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, а тоді цього хоста в ній ще не було.
Історія метрик
| Метод | Шлях | Призначення |
|---|---|---|
GET |
/api/v1/devices/{id}/series |
які метрики є в хоста + останнє значення |
GET |
/api/v1/devices/{id}/metrics |
точки для графіка |
Параметри запиту точок: series=1,2,3 (обов'язково, до 20),
range=6h або пара from/to в RFC3339, points (типово 300,
стеля 2000).
Крок рахується з бажаної кількості точок, а не приходить ззовні: інакше вузьке вікно з дрібним кроком повернуло б десятки тисяч точок, з яких екран покаже сотні.
Джерело обирається за кроком і повідомляється в полі source:
крок до 5 хвилин — сирі ts.samples, до години — роллап
ts.samples_5m, далі — ts.samples_1h. Читати сирі точки за місяць —
це мільйони рядків заради трьохсот пікселів; брати годинні бакети на
вікні в п'ять хвилин — це графік з однієї точки.
Значення точки може бути null. Пропуск і нуль — різні речі: лінія,
проведена через діру в даних, каже «все було добре», хоча насправді
нічого не відомо.
series_id приходить від клієнта, тож належність хосту перевіряється
явним запитом до ts.series під RLS. Самі ts.* під RLS не стоять
(несумісно зі стисненням), і без цієї перевірки чужий ідентифікатор
віддав би чужі дані.
Доступи до обладнання
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.
Три правила обчислення
- Хто не входить у жодну групу — не обмежений групами взагалі. Це свідомо не по-заббіксівськи: там користувач без груп не бачить нічого, і кожна нова інсталяція починається з питання «чому порожньо». Тут звуження вмикається тоді, коли його справді налаштували, а до того доступ визначає роль.
- Заборона перемагає дозвіл. Хост у двох групах, де одна дає читання, а друга забороняє, лишається невидимим — інакше заборону можна обійти, додавши об'єкт у будь-яку іншу групу.
- Серед дозволів виграє найширший. Читання в одній групі й запис у другій дають запис.
Обчислення живе в БД (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.
Метрики: для icmp — rtt_avg_ms, rtt_min_ms, rtt_max_ms,
jitter_ms, loss_pct, reachable; для interface — in_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 — свідомо відкладено.