Netpulse_SasS/server/API.md
zotac 8bf2902522 Етап 3: запис у мапу — редактор полотна
PATCH /api/v1/maps/{id} з оптимістичним блокуванням за revision, плюс
створення, видалення й автопобудова з виявленої топології.

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

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

Автопобудова ідемпотентна: повторний запуск не дублює вузлів і не скидає
ручну розкладку.

Тестами знайдено: revision <= $2 - $3 з двома нетипізованими параметрами
дає "operator is not unique: unknown - unknown" — потрібні явні касти.

Перевірено: 23 інтеграційні тести API (-race), плюс живий прогін проти
даних, зібраних агентом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:58:16 +03:00

18 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, тому дані обидва бачать однакові.

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

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 добудувати мапу з виявленої топології
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}

WebSocket

GET /api/v1/ws
Sec-WebSocket-Protocol: netpulse.token.<токен>

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

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

{"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

Живий прогін

Агент, 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 — свідомо відкладено.