POST /api/v1/maps/{id}/undo повертає полотно до попереднього знімка.
Відкат оформлюється як нова ревізія, а не відмотування лічильника:
інакше клієнт зі старим номером тихо перезаписав би відкочене.
Ідентифікатори вузлів зберігаються, тож ребра прив'язуються назад самі.
В UI: малювання зв'язку від краю вузла, видалення по Delete, кнопка
відкату. Намальоване рукою ребро не прив'язується до topo.links —
лінія на полотні це подання, а не факт про мережу.
Виправлено флак у тестах: TestSchedulerRunsTaskAndFillsCredentials
перевіряв канал статусів знімком, хоча SUCCEEDED надсилається вже після
запису в sink. Під навантаженням падав раз на п'ять; тепер 0 з 8.
Перевірено наживо: намальовано зв'язок -> відкат -> видалено вузол ->
відкат повернув той самий id, ребро й живий стан лінка.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
20 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, тому дані обидва бачать однакові.
Автентифікація
Bearer-токен із core.api_tokens; у БД лежить лише sha256, сам токен показується
користувачу один раз при створенні.
Authorization: Bearer np_ui_xxxxxxxx
Порожній scopes означає повний доступ — так поводяться токени, створені власником
тенанта для себе. Інакше перевіряються права maps:read, devices:read, agents:read.
GET /healthz свідомо відкритий: його опитує балансувальник.
Ендпоїнти
| Метод | Шлях | Призначення |
|---|---|---|
GET |
/healthz |
стан процесу й кількість WebSocket-підписників |
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 |
інвентар |
GET |
/api/v1/agents |
зонди, версії, самометрики |
GET |
/api/v1/ws |
WebSocket: події та завантаження каналів |
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
Чого ще немає
- Undo: знімки в
topo.map_revisionsпишуться, але ендпоїнта відкату немає. - Історія метрик:
GET /api/v1/metricsдля графіків ізts.samples_5mне написано. - Алерти:
alr.alertsне віддаються, хоча схема готова. - Завантаження підкладок:
map_backgrounds.storage_keyможна задати патчем, але самого прийому файлів (S3/MinIO) ще немає. - Спільне редагування обмежене оптимістичним блокуванням: одночасний драг двома людьми закінчиться 409 для того, хто спізнився. Для повноцінної співпраці потрібен CRDT — свідомо відкладено.