From 8bf29025226ee89502a74ce0e06ae6a1d3aca2d6 Mon Sep 17 00:00:00 2001 From: zotac Date: Fri, 14 Aug 2026 15:58:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=95=D1=82=D0=B0=D0=BF=203:=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=20=D1=83=20=D0=BC=D0=B0=D0=BF=D1=83=20?= =?UTF-8?q?=E2=80=94=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BE=D1=82=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- HISTORY.md | 54 +- server/API.md | 111 +++- server/internal/httpapi/api_test.go | 5 + server/internal/httpapi/maps_write.go | 135 +++++ server/internal/httpapi/maps_write_test.go | 477 +++++++++++++++ server/internal/httpapi/server.go | 4 + server/internal/httpapi/ws.go | 19 + server/internal/store/maps_write.go | 663 +++++++++++++++++++++ 8 files changed, 1462 insertions(+), 6 deletions(-) create mode 100644 server/internal/httpapi/maps_write.go create mode 100644 server/internal/httpapi/maps_write_test.go create mode 100644 server/internal/store/maps_write.go diff --git a/HISTORY.md b/HISTORY.md index bab9ea1..6e4f657 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -434,9 +434,59 @@ server/ - Немає `GET /api/v1/metrics` для графіків і віддачі `alr.alerts`. - Автопобудова мапи з `topo.links` — поки SQL-скрипт, не кнопка. +--- + +## 2026-08-14 — Етап 3 (частина 2): запис у мапу + +### Створено + +``` +server/internal/store/maps_write.go патч полотна, знімки, автопобудова +server/internal/httpapi/maps_write.go POST/PATCH/DELETE + /build +``` + +Ендпоїнти: `POST /api/v1/maps`, `PATCH /api/v1/maps/{id}`, +`DELETE /api/v1/maps/{id}`, `POST /api/v1/maps/{id}/build`. + +### Прийняті рішення + +1. **Усі скалярні поля патча — вказівники; nil означає «не чіпати».** Перетягування + шле лише x/y, і якби відсутні поля трактувались як порожні, кожен рух миші + стирав би стиль, розмір і прив'язку до пристрою. +2. **Ребро може посилатися на вузол, створений тим же патчем**, за `client_id` — + інакше зв'язок до нового вузла вимагав би двох запитів і проміжного стану. +3. **Оптимістичне блокування за `revision`.** Той, хто спізнився, отримує 409, а не + тихо затирає чужу правку: у NOC над однією мапою працюють кілька людей. +4. **Знімок пишеться тією ж транзакцією, що й зміна.** Інакше після збою в історії + лишався б крок, якого в мапі немає, і відкат ламав би її. Зберігаються останні 50. +5. **Невідоме поле в тілі — 400.** Мовчки проковтнути друкарську помилку клієнта + означає, що правка «збереглася», але не застосувалась. +6. **Автопобудова ідемпотентна**: наявні вузли не дублюються, координати не + чіпаються — інакше кожен запуск скидав би ручну розкладку. +7. **Ліміт тарифу → 402**, а не 500: UI має показати пропозицію змінити тариф. +8. **`map.updated` несе лише ревізію**, не патч. Розсилати дельти означало б тримати + на сервері модель того, що бачить кожен клієнт — це вже CRDT, окрема задача. + +### Знайдено тестами + +`revision <= $2 - $3` з двома нетипізованими параметрами дає +`operator is not unique: unknown - unknown`. Postgres не може вивести оператор +віднімання, коли обидва операнди — плейсхолдери. Потрібні явні касти. + +### Перевірено + +23 інтеграційні тести API (усі з `-race`), з них 12 нових на запис. +Найцінніші: драг не затирає сусідні поля; друга вкладка зі старою ревізією отримує +409, а перша правка ціла; видалення вузла не лишає ребер у нікуди; повторна +автопобудова не скидає ручну розкладку. + +**Живий прогін** проти даних, які агент зібрав сам: створення мапи → автопобудова +(+2 вузли, +1 ребро) → драг (ревізія 3) → патч зі старою ревізією (409) → повторна +побудова (+0/+0) → координати x=1500 y=640 збережені, 2 знімки в історії. + ### Далі -- Запис у мапу (`PATCH`) — без нього немає редактора. -- Фронтенд: React Flow поверх цього API. +- Фронтенд: React Flow поверх цього API — усе для нього вже є. +- Undo: знімки пишуться, ендпоїнта відкату немає. - `EnrollmentService` + видача сертифікатів зондам. - Планувальник NCM: бекап за cron і за Syslog-подією. diff --git a/server/API.md b/server/API.md index 9956b40..d9dd35c 100644 --- a/server/API.md +++ b/server/API.md @@ -32,7 +32,11 @@ Authorization: Bearer np_ui_xxxxxxxx |-------|------|-------------| | `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: події та завантаження каналів | @@ -83,6 +87,73 @@ Authorization: Bearer np_ui_xxxxxxxx Старіші дані не характеризують поточний стан, і показувати їх означало б брехати про живість пристрою. +### `PATCH /api/v1/maps/{id}` — редактор + +```jsonc +{ + "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`: + +```json +{"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` — автопобудова + +Додає на мапу пристрої, що беруть участь у виявлених лінках, і ребра між ними. +**Ідемпотентна:** наявні вузли не дублюються, а координати не чіпаються — інакше +кожен запуск скидав би розкладку, яку оператор робив руками. + +Нові вузли розставляються сіткою. Осмислену розкладку дає лише клієнт (він знає +розміри полотна й алгоритм), а сервер має покласти їх хоч кудись, але не в одну точку. + +```json +{"nodes_added": 2, "edges_added": 1, "revision": 2} +``` + ## WebSocket ``` @@ -110,6 +181,11 @@ Sec-WebSocket-Protocol: netpulse.token.<токен> | `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, окрема задача. Дві частоти навмисно різні. Зміна статусу — **подія**: рідка, але має дійти майже миттєво, інакше мапа бреше про стан мережі. Завантаження каналу — **величина**: вона @@ -148,6 +224,16 @@ Sec-WebSocket-Protocol: netpulse.token.<токен> | `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` | ### Живий прогін @@ -165,11 +251,28 @@ Sec-WebSocket-Protocol: netpulse.token.<токен> `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 +``` + ## Чого ще немає -- **Запис**: усі ендпоїнти read-only. Редактор мапи (перетягування вузлів, малювання - зв'язків) потребує `PATCH /api/v1/maps/{id}` з оптимістичним блокуванням за - `revision` — колонка вже є, обробника ще немає. +- **Undo**: знімки в `topo.map_revisions` пишуться, але ендпоїнта відкату немає. - **Історія метрик**: `GET /api/v1/metrics` для графіків із `ts.samples_5m` не написано. - **Алерти**: `alr.alerts` не віддаються, хоча схема готова. -- **Автопобудова мапи** з `topo.links` робиться поки SQL-скриптом, не кнопкою. +- **Завантаження підкладок**: `map_backgrounds.storage_key` можна задати патчем, але + самого прийому файлів (S3/MinIO) ще немає. +- **Спільне редагування** обмежене оптимістичним блокуванням: одночасний драг двома + людьми закінчиться 409 для того, хто спізнився. Для повноцінної співпраці потрібен + CRDT — свідомо відкладено. diff --git a/server/internal/httpapi/api_test.go b/server/internal/httpapi/api_test.go index 28ebf0e..c3fb21c 100644 --- a/server/internal/httpapi/api_test.go +++ b/server/internal/httpapi/api_test.go @@ -166,6 +166,11 @@ func (f *fixture) seed(t *testing.T) { f.deviceID, f.tenantID, f.agentID) } +func sha256sum(s string) []byte { + sum := sha256.Sum256([]byte(s)) + return sum[:] +} + func (f *fixture) get(t *testing.T, path, token string) (int, []byte) { t.Helper() req, err := http.NewRequest(http.MethodGet, f.srv.URL+path, nil) diff --git a/server/internal/httpapi/maps_write.go b/server/internal/httpapi/maps_write.go new file mode 100644 index 0000000..49141dd --- /dev/null +++ b/server/internal/httpapi/maps_write.go @@ -0,0 +1,135 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Обмеження тіла запиту. Полотно на 1000 вузлів у патчі — це ~1 МБ; +// 8 МБ лишає запас і водночас не дає одним запитом з'їсти пам'ять. +const maxPatchBody = 8 << 20 + +func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { + if !tok.Can("maps:write") { + writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write") + return + } + + var in struct { + Name string `json:"name"` + Slug string `json:"slug"` + Kind string `json:"kind"` + } + if !decodeBody(w, r, &in) { + return + } + + m, err := s.store.CreateMap(r.Context(), tok.TenantID, in.Name, in.Slug, in.Kind) + if err != nil { + s.writeStoreError(w, "створення мапи", err) + return + } + writeJSON(w, http.StatusCreated, m) +} + +func (s *Server) handleDeleteMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { + if !tok.Can("maps:write") { + writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write") + return + } + + if err := s.store.DeleteMap(r.Context(), tok.TenantID, r.PathValue("id")); err != nil { + s.writeStoreError(w, "видалення мапи", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handlePatchMap — редактор полотна. +func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { + if !tok.Can("maps:write") { + writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write") + return + } + + var patch store.MapPatch + if !decodeBody(w, r, &patch) { + return + } + + mapID := r.PathValue("id") + res, err := s.store.ApplyMapPatch(r.Context(), tok.TenantID, mapID, "", &patch) + if err != nil { + s.writeStoreError(w, "збереження мапи", err) + return + } + + // Інші відкриті полотна мають дізнатись про зміну: у NOC над однією + // мапою часто працюють кілька людей, і мовчазне розходження станів + // закінчується взаємним затиранням. + s.hub.BroadcastMapUpdate(tok.TenantID, mapID, res.Revision) + + writeJSON(w, http.StatusOK, res) +} + +// handleBuildMap добудовує мапу з виявленої топології. +func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) { + if !tok.Can("maps:write") { + writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write") + return + } + + mapID := r.PathValue("id") + st, err := s.store.BuildFromTopology(r.Context(), tok.TenantID, mapID, "") + if err != nil { + s.writeStoreError(w, "автопобудова мапи", err) + return + } + + if st.NodesAdded > 0 || st.EdgesAdded > 0 { + s.hub.BroadcastMapUpdate(tok.TenantID, mapID, st.Revision) + } + writeJSON(w, http.StatusOK, st) +} + +// --------------------------------------------------------------------- + +func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool { + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxPatchBody)) + // Невідоме поле — майже завжди друкарська помилка в клієнті. + // Мовчки її проковтнути означає, що правка «збереглась», але + // не застосувалась. + dec.DisallowUnknownFields() + + if err := dec.Decode(v); err != nil { + writeError(w, http.StatusBadRequest, "bad_body", "не вдалося розібрати тіло: "+err.Error()) + return false + } + return true +} + +func (s *Server) writeStoreError(w http.ResponseWriter, what string, err error) { + switch { + case errors.Is(err, store.ErrNotFound): + writeError(w, http.StatusNotFound, "not_found", "не знайдено") + + case errors.Is(err, store.ErrConflict): + // 409 із поточною ревізією дозволяє клієнту перечитати мапу + // й повторити правку, а не втратити її. + writeError(w, http.StatusConflict, "revision_conflict", err.Error()) + + case errors.Is(err, store.ErrPlanLimit): + // 402 — саме те, що сталося: потрібен інший тариф. + writeError(w, http.StatusPaymentRequired, "plan_limit", err.Error()) + + case errors.Is(err, store.ErrInvalid): + writeError(w, http.StatusBadRequest, "invalid", err.Error()) + + default: + s.log.Error(what, "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + } +} diff --git a/server/internal/httpapi/maps_write_test.go b/server/internal/httpapi/maps_write_test.go new file mode 100644 index 0000000..b04aed8 --- /dev/null +++ b/server/internal/httpapi/maps_write_test.go @@ -0,0 +1,477 @@ +package httpapi_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/netpulse/netpulse/server/internal/store" +) + +func (f *fixture) send(t *testing.T, method, path, token, body string) (int, []byte) { + t.Helper() + req, err := http.NewRequest(method, f.srv.URL+path, bytes.NewBufferString(body)) + if err != nil { + t.Fatalf("запит: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := f.srv.Client().Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(resp.Body) + return resp.StatusCode, buf.Bytes() +} + +func (f *fixture) mapState(t *testing.T) store.MapState { + t.Helper() + code, body := f.get(t, "/api/v1/maps/"+f.mapID, f.token) + if code != http.StatusOK { + t.Fatalf("стан мапи: код %d", code) + } + var st store.MapState + if err := json.Unmarshal(body, &st); err != nil { + t.Fatalf("розбір: %v", err) + } + return st +} + +// --------------------------------------------------------------------- + +// Перетягування вузла — найчастіша операція редактора. Клієнт шле лише +// x/y, і решта полів має лишитись недоторканою. +func TestPatchMoveNodeKeepsOtherFields(t *testing.T) { + f := setup(t) + + before := f.mapState(t) + var node store.MapNode + for _, n := range before.Nodes { + if n.DeviceID == f.deviceID { + node = n + } + } + if node.ID == "" { + t.Fatal("вузол не знайдено") + } + + patch := map[string]any{ + "revision": before.Revision, + "nodes": map[string]any{ + "upsert": []map[string]any{{"id": node.ID, "x": 777, "y": 888}}, + }, + } + raw, _ := json.Marshal(patch) + + code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)) + if code != http.StatusOK { + t.Fatalf("код %d: %s", code, body) + } + + var res store.PatchResult + if err := json.Unmarshal(body, &res); err != nil { + t.Fatalf("розбір: %v", err) + } + if res.Revision != before.Revision+1 { + t.Fatalf("ревізія = %d, очікували %d", res.Revision, before.Revision+1) + } + + after := f.mapState(t) + var moved store.MapNode + for _, n := range after.Nodes { + if n.ID == node.ID { + moved = n + } + } + if moved.X != 777 || moved.Y != 888 { + t.Fatalf("координати не збереглись: %v,%v", moved.X, moved.Y) + } + // Ось заради чого поля в патчі — вказівники. + if moved.Label != node.Label { + t.Fatalf("підпис затерто: %q → %q", node.Label, moved.Label) + } + if moved.DeviceID != node.DeviceID { + t.Fatal("прив'язку до пристрою затерто рухом миші") + } + if moved.Status != node.Status { + t.Fatalf("статус загубився: %q → %q", node.Status, moved.Status) + } +} + +// Дві людини редагують одну мапу. Той, хто спізнився, має отримати +// відмову, а не тихо затерти чужу правку. +func TestPatchRevisionConflict(t *testing.T) { + f := setup(t) + + before := f.mapState(t) + patch := func(rev int64, x float64) (int, []byte) { + raw, _ := json.Marshal(map[string]any{ + "revision": rev, + "nodes": map[string]any{ + "upsert": []map[string]any{{"id": before.Nodes[0].ID, "x": x}}, + }, + }) + return f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)) + } + + if code, body := patch(before.Revision, 10); code != http.StatusOK { + t.Fatalf("перший патч: код %d: %s", code, body) + } + + // Друга вкладка все ще тримає стару ревізію. + code, body := patch(before.Revision, 20) + if code != http.StatusConflict { + t.Fatalf("застаріла ревізія прийнята з кодом %d: %s", code, body) + } + + after := f.mapState(t) + if after.Nodes[0].X != 10 { + t.Fatalf("перша правка втрачена: x = %v", after.Nodes[0].X) + } +} + +// Без revision патч застосовується без перевірки — так робить лише +// серверний код; для UI це означало б мовчазне затирання. +func TestPatchWithoutRevisionSkipsCheck(t *testing.T) { + f := setup(t) + + raw, _ := json.Marshal(map[string]any{ + "nodes": map[string]any{ + "upsert": []map[string]any{{"id": f.mapState(t).Nodes[0].ID, "x": 42}}, + }, + }) + if code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK { + t.Fatalf("код %d: %s", code, body) + } +} + +// Створення вузла й ребра одним патчем: ребро посилається на вузол за +// клієнтським ідентифікатором, якого в базі ще немає. +func TestPatchCreatesNodeAndEdgeTogether(t *testing.T) { + f := setup(t) + + before := f.mapState(t) + existing := before.Nodes[0].ID + + raw, _ := json.Marshal(map[string]any{ + "revision": before.Revision, + "nodes": map[string]any{ + "upsert": []map[string]any{{ + "client_id": "tmp-1", "kind": "cloud", "label": "Інтернет", + "x": 900, "y": 100, "style": map[string]any{"icon": "cloud"}, + }}, + }, + "edges": map[string]any{ + "upsert": []map[string]any{{ + "client_id": "tmp-edge", "source_node_id": existing, + "target_node_id": "tmp-1", "label": "uplink", "style": "bezier", + }}, + }, + "comment": "додав хмару", + }) + + code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)) + if code != http.StatusOK { + t.Fatalf("код %d: %s", code, body) + } + + var res store.PatchResult + if err := json.Unmarshal(body, &res); err != nil { + t.Fatalf("розбір: %v", err) + } + newNode := res.NodeIDs["tmp-1"] + if newNode == "" { + t.Fatalf("сервер не повернув id нового вузла: %+v", res) + } + if res.EdgeIDs["tmp-edge"] == "" { + t.Fatalf("сервер не повернув id нового ребра: %+v", res) + } + + after := f.mapState(t) + if len(after.Nodes) != len(before.Nodes)+1 { + t.Fatalf("вузлів: %d", len(after.Nodes)) + } + + var linked bool + for _, e := range after.Edges { + if e.TargetNodeID == newNode && e.Label == "uplink" { + linked = true + if e.Style != "bezier" { + t.Fatalf("стиль ребра: %q", e.Style) + } + } + } + if !linked { + t.Fatal("ребро не прив'язалось до щойно створеного вузла") + } +} + +// Видалення вузла має забрати й ребра, що на нього спирались, — +// інакше полотно лишиться з ребрами в нікуди. +func TestPatchDeleteNodeRemovesEdges(t *testing.T) { + f := setup(t) + + before := f.mapState(t) + if len(before.Edges) == 0 { + t.Fatal("нема ребра для перевірки") + } + victim := before.Edges[0].SourceNodeID + + raw, _ := json.Marshal(map[string]any{ + "revision": before.Revision, + "nodes": map[string]any{"remove": []string{victim}}, + }) + if code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK { + t.Fatalf("код %d: %s", code, body) + } + + after := f.mapState(t) + for _, n := range after.Nodes { + if n.ID == victim { + t.Fatal("вузол не видалено") + } + } + if len(after.Edges) != 0 { + t.Fatalf("лишились ребра в нікуди: %d", len(after.Edges)) + } +} + +// Кожна правка лишає знімок — це основа undo в редакторі. +func TestPatchStoresRevisionSnapshot(t *testing.T) { + f := setup(t) + + before := f.mapState(t) + raw, _ := json.Marshal(map[string]any{ + "revision": before.Revision, + "nodes": map[string]any{ + "upsert": []map[string]any{{"id": before.Nodes[0].ID, "x": 555}}, + }, + "comment": "перетягнув ядро", + }) + if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK { + t.Fatalf("код %d", code) + } + + var ( + comment string + nodes int + revision int64 + ) + if err := f.pool.QueryRow(f.ctx, ` + SELECT COALESCE(comment,''), jsonb_array_length(snapshot->'nodes'), revision + FROM topo.map_revisions + WHERE map_id = $1 ORDER BY revision DESC LIMIT 1 + `, f.mapID).Scan(&comment, &nodes, &revision); err != nil { + t.Fatalf("знімок не збережено: %v", err) + } + if comment != "перетягнув ядро" { + t.Fatalf("коментар: %q", comment) + } + if nodes != len(before.Nodes) { + t.Fatalf("у знімку %d вузлів, на мапі %d", nodes, len(before.Nodes)) + } + if revision != before.Revision+1 { + t.Fatalf("ревізія знімка: %d", revision) + } +} + +// Чужий вузол не має редагуватись через свою мапу. +func TestPatchRejectsForeignNode(t *testing.T) { + f := setup(t) + other := setup(t) + + raw, _ := json.Marshal(map[string]any{ + "nodes": map[string]any{ + "upsert": []map[string]any{{"id": other.mapState(t).Nodes[0].ID, "x": 1}}, + }, + }) + code, body := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)) + if code != http.StatusBadRequest { + t.Fatalf("чужий вузол прийнято з кодом %d: %s", code, body) + } +} + +// Невідоме поле — майже завжди друкарська помилка в клієнті. Мовчки +// проковтнути її означає, що правка "збереглась", але не застосувалась. +func TestPatchRejectsUnknownField(t *testing.T) { + f := setup(t) + + code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, + `{"nodez":{"upsert":[]}}`) + if code != http.StatusBadRequest { + t.Fatalf("невідоме поле прийнято з кодом %d", code) + } +} + +func TestPatchRequiresWriteScope(t *testing.T) { + f := setup(t) + + // Токен лише на читання. + roToken := "np_ro_" + f.mapID + sum := sha256sum(roToken) + if _, err := f.pool.Exec(f.ctx, ` + INSERT INTO core.api_tokens (tenant_id, name, prefix, token_hash, scopes) + VALUES ($1, 'ro', 'np_ro_', $2, ARRAY['maps:read']) + `, f.tenantID, sum); err != nil { + t.Fatalf("seed: %v", err) + } + + code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, roToken, `{}`) + if code != http.StatusForbidden { + t.Fatalf("токен без maps:write прийнято з кодом %d", code) + } + // А читати він має право. + if code, _ := f.get(t, "/api/v1/maps", roToken); code != http.StatusOK { + t.Fatalf("читання відхилено з кодом %d", code) + } +} + +// --------------------------------------------------------------------- +// Створення, видалення, автопобудова +// --------------------------------------------------------------------- + +func TestCreateAndDeleteMap(t *testing.T) { + f := setup(t) + + code, body := f.send(t, http.MethodPost, "/api/v1/maps", f.token, + `{"name":"Друга","slug":"druha","kind":"floor_plan"}`) + if code != http.StatusCreated { + t.Fatalf("код %d: %s", code, body) + } + var m store.MapSummary + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("розбір: %v", err) + } + if m.Kind != "floor_plan" || m.Revision != 1 { + t.Fatalf("мапа: %+v", m) + } + + if code, _ := f.send(t, http.MethodDelete, "/api/v1/maps/"+m.ID, f.token, ""); code != http.StatusNoContent { + t.Fatalf("видалення: код %d", code) + } + if code, _ := f.get(t, "/api/v1/maps/"+m.ID, f.token); code != http.StatusNotFound { + t.Fatalf("видалена мапа доступна: код %d", code) + } + + // М'яке видалення: топологія лишається на місці. + var links int + if err := f.pool.QueryRow(f.ctx, + `SELECT count(*) FROM topo.links WHERE tenant_id = $1`, f.tenantID).Scan(&links); err != nil { + t.Fatalf("підрахунок: %v", err) + } + if links == 0 { + t.Fatal("видалення мапи забрало топологію") + } +} + +// Автопобудова перетворює виявлену топологію на полотно — і не псує +// розкладку при повторному запуску. +func TestBuildFromTopologyIsIdempotent(t *testing.T) { + f := setup(t) + + code, body := f.send(t, http.MethodPost, "/api/v1/maps", f.token, + `{"name":"Авто","slug":"avto"}`) + if code != http.StatusCreated { + t.Fatalf("код %d: %s", code, body) + } + var m store.MapSummary + _ = json.Unmarshal(body, &m) + + code, body = f.send(t, http.MethodPost, "/api/v1/maps/"+m.ID+"/build", f.token, "") + if code != http.StatusOK { + t.Fatalf("код %d: %s", code, body) + } + var st store.BuildStats + if err := json.Unmarshal(body, &st); err != nil { + t.Fatalf("розбір: %v", err) + } + if st.NodesAdded != 2 || st.EdgesAdded != 1 { + t.Fatalf("побудовано: %+v", st) + } + + // Оператор посунув вузол. + code, body = f.get(t, "/api/v1/maps/"+m.ID, f.token) + if code != http.StatusOK { + t.Fatalf("код %d", code) + } + var built store.MapState + _ = json.Unmarshal(body, &built) + + raw, _ := json.Marshal(map[string]any{ + "revision": built.Revision, + "nodes": map[string]any{ + "upsert": []map[string]any{{"id": built.Nodes[0].ID, "x": 1234, "y": 4321}}, + }, + }) + if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+m.ID, f.token, string(raw)); code != http.StatusOK { + t.Fatalf("правка: код %d", code) + } + + // Повторна побудова нічого не додає й не скидає розкладку. + code, body = f.send(t, http.MethodPost, "/api/v1/maps/"+m.ID+"/build", f.token, "") + if code != http.StatusOK { + t.Fatalf("код %d", code) + } + _ = json.Unmarshal(body, &st) + if st.NodesAdded != 0 || st.EdgesAdded != 0 { + t.Fatalf("повторна побудова додала: %+v", st) + } + + code, body = f.get(t, "/api/v1/maps/"+m.ID, f.token) + if code != http.StatusOK { + t.Fatalf("код %d", code) + } + var again store.MapState + _ = json.Unmarshal(body, &again) + for _, n := range again.Nodes { + if n.ID == built.Nodes[0].ID && (n.X != 1234 || n.Y != 4321) { + t.Fatalf("автопобудова скинула ручну розкладку: %v,%v", n.X, n.Y) + } + } +} + +// Правка має долетіти до інших відкритих полотен: у NOC над однією +// мапою часто працюють кілька людей. +func TestPatchBroadcastsToOtherViewers(t *testing.T) { + f := setup(t) + + ctx, cancel := context.WithTimeout(f.ctx, 20*time.Second) + defer cancel() + + conn := dialWS(t, ctx, f, f.token) + waitFor(t, ctx, conn, "hello") + + if err := conn.Write(ctx, websocket.MessageText, + []byte(`{"type":"subscribe","map_id":"`+f.mapID+`"}`)); err != nil { + t.Fatalf("підписка: %v", err) + } + waitFor(t, ctx, conn, "subscribed") + + before := f.mapState(t) + raw, _ := json.Marshal(map[string]any{ + "revision": before.Revision, + "nodes": map[string]any{ + "upsert": []map[string]any{{"id": before.Nodes[0].ID, "x": 99}}, + }, + }) + if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK { + t.Fatalf("патч: код %d", code) + } + + msg := waitFor(t, ctx, conn, "map.updated") + if msg["map_id"] != f.mapID { + t.Fatalf("подія про іншу мапу: %v", msg) + } + if rev, ok := msg["revision"].(float64); !ok || int64(rev) != before.Revision+1 { + t.Fatalf("ревізія в події: %v", msg["revision"]) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 6015874..5fdd5f2 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -49,7 +49,11 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /healthz", s.handleHealth) mux.Handle("GET /api/v1/maps", s.authenticated(s.handleListMaps)) + mux.Handle("POST /api/v1/maps", s.authenticated(s.handleCreateMap)) mux.Handle("GET /api/v1/maps/{id}", s.authenticated(s.handleGetMap)) + mux.Handle("PATCH /api/v1/maps/{id}", s.authenticated(s.handlePatchMap)) + mux.Handle("DELETE /api/v1/maps/{id}", s.authenticated(s.handleDeleteMap)) + mux.Handle("POST /api/v1/maps/{id}/build", s.authenticated(s.handleBuildMap)) mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices)) mux.Handle("GET /api/v1/agents", s.authenticated(s.handleListAgents)) diff --git a/server/internal/httpapi/ws.go b/server/internal/httpapi/ws.go index 816f6bf..9d4d858 100644 --- a/server/internal/httpapi/ws.go +++ b/server/internal/httpapi/ws.go @@ -233,6 +233,25 @@ func (h *Hub) broadcast(tenantID, mapID string, msg []byte) { } } +// BroadcastMapUpdate повідомляє інші відкриті полотна, що мапу змінили. +// +// Шлемо лише номер ревізії, а не сам патч: клієнт сам вирішить, чи +// перечитувати полотно. Розсилати зміни як дельти означало б тримати +// на сервері модель того, що бачить кожен клієнт, — а це вже спільне +// редагування з CRDT, окрема задача. +func (h *Hub) BroadcastMapUpdate(tenantID, mapID string, revision int64) { + msg, err := json.Marshal(map[string]any{ + "type": "map.updated", + "map_id": mapID, + "revision": revision, + "at": time.Now(), + }) + if err != nil { + return + } + h.broadcast(tenantID, mapID, msg) +} + // --------------------------------------------------------------------- // З'єднання // --------------------------------------------------------------------- diff --git a/server/internal/store/maps_write.go b/server/internal/store/maps_write.go new file mode 100644 index 0000000..e303fd9 --- /dev/null +++ b/server/internal/store/maps_write.go @@ -0,0 +1,663 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// Скільки знімків полотна тримати. Undo глибше 50 кроків ніхто не +// робить, а кожен знімок — це повна копія мапи в jsonb. +const maxRevisionsKept = 50 + +var ( + // ErrConflict — мапу змінив хтось інший, поки клієнт редагував. + ErrConflict = errors.New("мапу змінено іншим користувачем") + // ErrPlanLimit — упёрлись у ліміт тарифу (тригер у БД). + ErrPlanLimit = errors.New("досягнуто ліміт тарифу") + // ErrInvalid — некоректні дані від клієнта. + ErrInvalid = errors.New("некоректний запит") +) + +// --------------------------------------------------------------------- +// Форма патча +// +// Усі скалярні поля — вказівники: nil означає «не чіпати». Це не +// формальність. Перетягування вузла шле лише x/y, і якби відсутні поля +// трактувались як порожні, кожен рух миші стирав би стиль, розмір і +// прив'язку до пристрою. +// --------------------------------------------------------------------- + +type MapPatch struct { + // Очікувана ревізія. nil вимикає перевірку — так робить лише + // серверний код (автопобудова), не UI. + Revision *int64 `json:"revision"` + Name *string `json:"name,omitempty"` + LayoutAlgo *string `json:"layout_algo,omitempty"` + Viewport json.RawMessage `json:"viewport,omitempty"` + Grid json.RawMessage `json:"grid,omitempty"` + Clustering json.RawMessage `json:"clustering,omitempty"` + + Nodes NodePatch `json:"nodes"` + Edges EdgePatch `json:"edges"` + Backgrounds BackgroundPatch `json:"backgrounds"` + + Comment string `json:"comment,omitempty"` +} + +type NodePatch struct { + Upsert []NodeInput `json:"upsert"` + Remove []string `json:"remove"` +} + +type NodeInput struct { + ID string `json:"id,omitempty"` + // Тимчасовий ідентифікатор нового вузла. Сервер поверне мапу + // client_id → id, щоб полотно не перемальовувалось повністю після + // кожного створення. + ClientID string `json:"client_id,omitempty"` + + Kind string `json:"kind,omitempty"` + DeviceID string `json:"device_id,omitempty"` + ParentID string `json:"parent_id,omitempty"` + TargetMapID string `json:"target_map_id,omitempty"` + + Label *string `json:"label,omitempty"` + X *float64 `json:"x,omitempty"` + Y *float64 `json:"y,omitempty"` + Width *float64 `json:"width,omitempty"` + Height *float64 `json:"height,omitempty"` + ZIndex *int `json:"z_index,omitempty"` + Style json.RawMessage `json:"style,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Collapsed *bool `json:"collapsed,omitempty"` + Locked *bool `json:"locked,omitempty"` + Hidden *bool `json:"hidden,omitempty"` +} + +type EdgePatch struct { + Upsert []EdgeInput `json:"upsert"` + Remove []string `json:"remove"` +} + +type EdgeInput struct { + ID string `json:"id,omitempty"` + ClientID string `json:"client_id,omitempty"` + + SourceNodeID string `json:"source_node_id,omitempty"` + TargetNodeID string `json:"target_node_id,omitempty"` + SourceInterfaceID string `json:"source_interface_id,omitempty"` + TargetInterfaceID string `json:"target_interface_id,omitempty"` + LinkID string `json:"link_id,omitempty"` + + Label *string `json:"label,omitempty"` + Style *string `json:"style,omitempty"` + Dash *string `json:"dash,omitempty"` + Color *string `json:"color,omitempty"` + WidthPx *float64 `json:"width_px,omitempty"` + Waypoints json.RawMessage `json:"waypoints,omitempty"` + Animation json.RawMessage `json:"animation,omitempty"` + Thresholds json.RawMessage `json:"thresholds,omitempty"` + ShowMetrics *bool `json:"show_metrics,omitempty"` + ZIndex *int `json:"z_index,omitempty"` + Locked *bool `json:"locked,omitempty"` + Hidden *bool `json:"hidden,omitempty"` +} + +type BackgroundPatch struct { + Upsert []BackgroundInput `json:"upsert"` + Remove []string `json:"remove"` +} + +type BackgroundInput struct { + ID string `json:"id,omitempty"` + ClientID string `json:"client_id,omitempty"` + + Kind string `json:"kind,omitempty"` + StorageKey *string `json:"storage_key,omitempty"` + MimeType *string `json:"mime_type,omitempty"` + X *float64 `json:"x,omitempty"` + Y *float64 `json:"y,omitempty"` + Width *float64 `json:"width,omitempty"` + Height *float64 `json:"height,omitempty"` + Rotation *float64 `json:"rotation,omitempty"` + Opacity *float64 `json:"opacity,omitempty"` + Locked *bool `json:"locked,omitempty"` + ZIndex *int `json:"z_index,omitempty"` + Geo json.RawMessage `json:"geo,omitempty"` + Rack json.RawMessage `json:"rack,omitempty"` +} + +// PatchResult — що повернути клієнту після збереження. +type PatchResult struct { + Revision int64 `json:"revision"` + NodeIDs map[string]string `json:"node_ids,omitempty"` + EdgeIDs map[string]string `json:"edge_ids,omitempty"` + BgIDs map[string]string `json:"background_ids,omitempty"` +} + +// --------------------------------------------------------------------- +// Створення й видалення мапи +// --------------------------------------------------------------------- + +func (s *Store) CreateMap(ctx context.Context, tenantID, name, slug, kind string) (*MapSummary, error) { + if name == "" || slug == "" { + return nil, fmt.Errorf("%w: потрібні name і slug", ErrInvalid) + } + if kind == "" { + kind = "logical" + } + + var m MapSummary + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + INSERT INTO topo.maps (tenant_id, name, slug, kind) + VALUES ($1, $2, $3, $4::topo.map_kind) + RETURNING id::text, name, slug, kind::text, is_default, revision, updated_at + `, tenantID, name, slug, kind).Scan(&m.ID, &m.Name, &m.Slug, &m.Kind, + &m.IsDefault, &m.Revision, &m.UpdatedAt) + }) + if err != nil { + return nil, mapPgError(err) + } + return &m, nil +} + +// DeleteMap — м'яке видалення: топологія й історія лишаються, зникає +// лише полотно. Мапа — це подання, а не дані. +func (s *Store) DeleteMap(ctx context.Context, tenantID, mapID string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, ` + UPDATE topo.maps SET deleted_at = now(), updated_at = now() + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + `, mapID, tenantID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil + }) +} + +// --------------------------------------------------------------------- +// Патч +// --------------------------------------------------------------------- + +// ApplyMapPatch застосовує зміни полотна атомарно. +// +// Оптимістичне блокування: клієнт надсилає ревізію, яку бачив. Якщо за +// цей час мапу змінив хтось інший — 409, а не тихе затирання. У NOC над +// однією мапою часто працюють кілька людей, і мовчазна втрата чужих +// правок гірша за помилку. +func (s *Store) ApplyMapPatch(ctx context.Context, tenantID, mapID, authorID string, p *MapPatch) (*PatchResult, error) { + res := &PatchResult{ + NodeIDs: map[string]string{}, + EdgeIDs: map[string]string{}, + BgIDs: map[string]string{}, + } + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Блокуємо рядок мапи: усі зміни полотна серіалізуються через + // нього, тому паралельні патчі не переплітаються. + var current int64 + err := tx.QueryRow(ctx, ` + SELECT revision FROM topo.maps + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + FOR UPDATE + `, mapID, tenantID).Scan(¤t) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if p.Revision != nil && *p.Revision != current { + return fmt.Errorf("%w: очікувано %d, у базі %d", ErrConflict, *p.Revision, current) + } + + if err := applyNodes(ctx, tx, tenantID, mapID, &p.Nodes, res); err != nil { + return err + } + // Ребра після вузлів: нове ребро може посилатися на вузол, + // створений у цьому ж патчі. + if err := applyEdges(ctx, tx, tenantID, mapID, &p.Edges, res); err != nil { + return err + } + if err := applyBackgrounds(ctx, tx, tenantID, mapID, &p.Backgrounds, res); err != nil { + return err + } + + var newRev int64 + if err := tx.QueryRow(ctx, ` + UPDATE topo.maps SET + name = COALESCE($3, name), + layout_algo = COALESCE($4::topo.layout_algo, layout_algo), + viewport = COALESCE($5::jsonb, viewport), + grid = COALESCE($6::jsonb, grid), + clustering = COALESCE($7::jsonb, clustering), + revision = revision + 1, + updated_at = now() + WHERE id = $1 AND tenant_id = $2 + RETURNING revision + `, mapID, tenantID, p.Name, p.LayoutAlgo, + rawArg(p.Viewport), rawArg(p.Grid), rawArg(p.Clustering)).Scan(&newRev); err != nil { + return err + } + res.Revision = newRev + + return snapshotMap(ctx, tx, tenantID, mapID, newRev, authorID, p.Comment) + }) + + if err != nil { + return nil, mapPgError(err) + } + return res, nil +} + +func applyNodes(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *NodePatch, res *PatchResult) error { + // Видаляємо першими: клієнт міг видалити вузол і одразу створити + // новий на тому ж місці. + if len(p.Remove) > 0 { + if _, err := tx.Exec(ctx, ` + DELETE FROM topo.map_nodes + WHERE map_id = $1 AND tenant_id = $2 AND id = ANY($3::uuid[]) + `, mapID, tenantID, p.Remove); err != nil { + return err + } + } + + for i, n := range p.Upsert { + if n.ID == "" { + if n.Kind == "" { + return fmt.Errorf("%w: вузол[%d] без kind", ErrInvalid, i) + } + if n.X == nil || n.Y == nil { + return fmt.Errorf("%w: вузол[%d] без координат", ErrInvalid, i) + } + + var id string + if err := tx.QueryRow(ctx, ` + INSERT INTO topo.map_nodes + (tenant_id, map_id, kind, device_id, parent_node_id, target_map_id, + label, x, y, width, height, z_index, style, data, collapsed, locked, hidden) + VALUES ($1,$2,$3::topo.node_kind,$4,$5,$6,$7,$8,$9,$10,$11, + COALESCE($12,0), COALESCE($13::jsonb,'{}'::jsonb), + COALESCE($14::jsonb,'{}'::jsonb), + COALESCE($15,false), COALESCE($16,false), COALESCE($17,false)) + RETURNING id::text + `, tenantID, mapID, n.Kind, nullUUID(n.DeviceID), nullUUID(n.ParentID), + nullUUID(n.TargetMapID), n.Label, *n.X, *n.Y, n.Width, n.Height, + n.ZIndex, rawArg(n.Style), rawArg(n.Data), + n.Collapsed, n.Locked, n.Hidden).Scan(&id); err != nil { + return err + } + if n.ClientID != "" { + res.NodeIDs[n.ClientID] = id + } + continue + } + + tag, err := tx.Exec(ctx, ` + UPDATE topo.map_nodes SET + label = COALESCE($4, label), + x = COALESCE($5, x), + y = COALESCE($6, y), + width = COALESCE($7, width), + height = COALESCE($8, height), + z_index = COALESCE($9, z_index), + style = COALESCE($10::jsonb, style), + data = COALESCE($11::jsonb, data), + collapsed = COALESCE($12, collapsed), + locked = COALESCE($13, locked), + hidden = COALESCE($14, hidden), + parent_node_id = COALESCE($15, parent_node_id), + updated_at = now() + WHERE id = $1 AND map_id = $2 AND tenant_id = $3 + `, n.ID, mapID, tenantID, n.Label, n.X, n.Y, n.Width, n.Height, n.ZIndex, + rawArg(n.Style), rawArg(n.Data), n.Collapsed, n.Locked, n.Hidden, + nullUUID(n.ParentID)) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: вузол %s не належить цій мапі", ErrInvalid, n.ID) + } + } + return nil +} + +func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgePatch, res *PatchResult) error { + if len(p.Remove) > 0 { + if _, err := tx.Exec(ctx, ` + DELETE FROM topo.map_edges + WHERE map_id = $1 AND tenant_id = $2 AND id = ANY($3::uuid[]) + `, mapID, tenantID, p.Remove); err != nil { + return err + } + } + + for i, e := range p.Upsert { + src, dst := e.SourceNodeID, e.TargetNodeID + // Ребро могло бути намальоване до вузлів, створених цим же + // патчем, — підставляємо їхні справжні id. + if id, ok := res.NodeIDs[src]; ok { + src = id + } + if id, ok := res.NodeIDs[dst]; ok { + dst = id + } + + if e.ID == "" { + if src == "" || dst == "" { + return fmt.Errorf("%w: ребро[%d] без кінців", ErrInvalid, i) + } + var id string + if err := tx.QueryRow(ctx, ` + INSERT INTO topo.map_edges + (tenant_id, map_id, source_node_id, target_node_id, + source_interface_id, target_interface_id, link_id, label, + style, dash, color, width_px, waypoints, animation, thresholds, + show_metrics, z_index, locked, hidden) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8, + COALESCE($9::topo.edge_style,'smoothstep'), + COALESCE($10::topo.edge_dash,'solid'), + $11, COALESCE($12,2), + COALESCE($13::jsonb,'[]'::jsonb), + COALESCE($14::jsonb,'{"enabled":true,"speed_source":"utilization"}'::jsonb), + COALESCE($15::jsonb,'{}'::jsonb), + COALESCE($16,true), COALESCE($17,0), + COALESCE($18,false), COALESCE($19,false)) + RETURNING id::text + `, tenantID, mapID, src, dst, + nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID), + nullUUID(e.LinkID), e.Label, e.Style, e.Dash, e.Color, e.WidthPx, + rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds), + e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden).Scan(&id); err != nil { + return err + } + if e.ClientID != "" { + res.EdgeIDs[e.ClientID] = id + } + continue + } + + tag, err := tx.Exec(ctx, ` + UPDATE topo.map_edges SET + label = COALESCE($4, label), + style = COALESCE($5::topo.edge_style, style), + dash = COALESCE($6::topo.edge_dash, dash), + color = COALESCE($7, color), + width_px = COALESCE($8, width_px), + waypoints = COALESCE($9::jsonb, waypoints), + animation = COALESCE($10::jsonb, animation), + thresholds = COALESCE($11::jsonb, thresholds), + show_metrics = COALESCE($12, show_metrics), + z_index = COALESCE($13, z_index), + locked = COALESCE($14, locked), + hidden = COALESCE($15, hidden), + source_interface_id = COALESCE($16, source_interface_id), + target_interface_id = COALESCE($17, target_interface_id), + link_id = COALESCE($18, link_id), + updated_at = now() + WHERE id = $1 AND map_id = $2 AND tenant_id = $3 + `, e.ID, mapID, tenantID, e.Label, e.Style, e.Dash, e.Color, e.WidthPx, + rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds), + e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden, + nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID), nullUUID(e.LinkID)) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: ребро %s не належить цій мапі", ErrInvalid, e.ID) + } + } + return nil +} + +func applyBackgrounds(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *BackgroundPatch, res *PatchResult) error { + if len(p.Remove) > 0 { + if _, err := tx.Exec(ctx, ` + DELETE FROM topo.map_backgrounds + WHERE map_id = $1 AND tenant_id = $2 AND id = ANY($3::uuid[]) + `, mapID, tenantID, p.Remove); err != nil { + return err + } + } + + for i, b := range p.Upsert { + if b.ID == "" { + if b.Kind == "" { + return fmt.Errorf("%w: підкладка[%d] без kind", ErrInvalid, i) + } + var id string + if err := tx.QueryRow(ctx, ` + INSERT INTO topo.map_backgrounds + (tenant_id, map_id, kind, storage_key, mime_type, + x, y, width, height, rotation, opacity, locked, z_index, geo, rack) + VALUES ($1,$2,$3::topo.background_kind,$4,$5, + COALESCE($6,0), COALESCE($7,0), $8, $9, + COALESCE($10,0), COALESCE($11,1), + COALESCE($12,true), COALESCE($13,0), + $14::jsonb, $15::jsonb) + RETURNING id::text + `, tenantID, mapID, b.Kind, b.StorageKey, b.MimeType, + b.X, b.Y, b.Width, b.Height, b.Rotation, b.Opacity, + b.Locked, b.ZIndex, rawArg(b.Geo), rawArg(b.Rack)).Scan(&id); err != nil { + return err + } + if b.ClientID != "" { + res.BgIDs[b.ClientID] = id + } + continue + } + + tag, err := tx.Exec(ctx, ` + UPDATE topo.map_backgrounds SET + storage_key = COALESCE($4, storage_key), + mime_type = COALESCE($5, mime_type), + x = COALESCE($6, x), y = COALESCE($7, y), + width = COALESCE($8, width), height = COALESCE($9, height), + rotation = COALESCE($10, rotation), + opacity = COALESCE($11, opacity), + locked = COALESCE($12, locked), + z_index = COALESCE($13, z_index), + geo = COALESCE($14::jsonb, geo), + rack = COALESCE($15::jsonb, rack), + updated_at = now() + WHERE id = $1 AND map_id = $2 AND tenant_id = $3 + `, b.ID, mapID, tenantID, b.StorageKey, b.MimeType, b.X, b.Y, + b.Width, b.Height, b.Rotation, b.Opacity, b.Locked, b.ZIndex, + rawArg(b.Geo), rawArg(b.Rack)) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: підкладка %s не належить цій мапі", ErrInvalid, b.ID) + } + } + return nil +} + +// snapshotMap зберігає повний стан полотна для undo. +// +// Знімок робиться в тій самій транзакції, що й зміна: інакше після збою +// в історії лишався б крок, якого в мапі немає, і відкат ламав би її. +func snapshotMap(ctx context.Context, tx pgx.Tx, tenantID, mapID string, revision int64, authorID, comment string) error { + if _, err := tx.Exec(ctx, ` + INSERT INTO topo.map_revisions (tenant_id, map_id, revision, author_id, comment, snapshot) + SELECT $1, $2, $3, $4, NULLIF($5,''), jsonb_build_object( + 'nodes', COALESCE((SELECT jsonb_agg(to_jsonb(n) ORDER BY n.id) + FROM topo.map_nodes n WHERE n.map_id = $2), '[]'::jsonb), + 'edges', COALESCE((SELECT jsonb_agg(to_jsonb(e) ORDER BY e.id) + FROM topo.map_edges e WHERE e.map_id = $2), '[]'::jsonb), + 'backgrounds', COALESCE((SELECT jsonb_agg(to_jsonb(b) ORDER BY b.id) + FROM topo.map_backgrounds b WHERE b.map_id = $2), '[]'::jsonb) + ) + ON CONFLICT (map_id, revision) DO NOTHING + `, tenantID, mapID, revision, nullUUID(authorID), comment); err != nil { + return err + } + + // Історія не має рости вічно: кожен знімок — повна копія полотна. + // + // Типи вказані явно: віднімання двох нетипізованих параметрів + // Postgres розібрати не може («operator is not unique: unknown - unknown»). + _, err := tx.Exec(ctx, ` + DELETE FROM topo.map_revisions + WHERE map_id = $1 AND revision <= $2::bigint - $3::int + `, mapID, revision, maxRevisionsKept) + return err +} + +// --------------------------------------------------------------------- +// Автопобудова +// --------------------------------------------------------------------- + +type BuildStats struct { + NodesAdded int `json:"nodes_added"` + EdgesAdded int `json:"edges_added"` + Revision int64 `json:"revision"` +} + +// BuildFromTopology доповнює мапу тим, що виявив агент. +// +// Ідемпотентна: вже наявні вузли й ребра не дублюються, координати +// існуючих не чіпаються. Це принципово — інакше кожен запуск скидав би +// розкладку, яку оператор робив руками. +// +// Нові вузли розставляються сіткою: осмислену розкладку дає лише +// клієнт (він знає розміри полотна й алгоритм), а сервер має покласти +// їх хоч кудись, але не в одну точку. +func (s *Store) BuildFromTopology(ctx context.Context, tenantID, mapID, authorID string) (*BuildStats, error) { + st := &BuildStats{} + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var revision int64 + err := tx.QueryRow(ctx, ` + SELECT revision FROM topo.maps + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + FOR UPDATE + `, mapID, tenantID).Scan(&revision) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + + // Пристрої, які є в лінках, але ще не на мапі. + tag, err := tx.Exec(ctx, ` + WITH existing AS ( + SELECT count(*) AS n FROM topo.map_nodes WHERE map_id = $1 + ), missing AS ( + SELECT d.id, d.name, + row_number() OVER (ORDER BY d.name) - 1 AS pos + FROM inv.devices d + WHERE d.tenant_id = $2 + AND d.deleted_at IS NULL + AND d.enabled + AND EXISTS ( + SELECT 1 FROM topo.links l + WHERE l.tenant_id = $2 AND (l.a_device_id = d.id OR l.b_device_id = d.id) + ) + AND NOT EXISTS ( + SELECT 1 FROM topo.map_nodes n + WHERE n.map_id = $1 AND n.device_id = d.id + ) + ) + INSERT INTO topo.map_nodes (tenant_id, map_id, kind, device_id, label, x, y) + SELECT $2, $1, 'device', m.id, m.name, + 120 + 260 * ((existing.n + m.pos) % 6), + 120 + 180 * ((existing.n + m.pos) / 6) + FROM missing m, existing + `, mapID, tenantID) + if err != nil { + return err + } + st.NodesAdded = int(tag.RowsAffected()) + + // Ребра для лінків, обидва кінці яких уже на мапі. + tag, err = tx.Exec(ctx, ` + INSERT INTO topo.map_edges + (tenant_id, map_id, source_node_id, target_node_id, + source_interface_id, target_interface_id, link_id, label) + SELECT $2, $1, na.id, nb.id, l.a_interface_id, l.b_interface_id, l.id, + COALESCE(ia.name,'?') || ' → ' || COALESCE(ib.name,'?') + FROM topo.links l + JOIN topo.map_nodes na ON na.map_id = $1 AND na.device_id = l.a_device_id + JOIN topo.map_nodes nb ON nb.map_id = $1 AND nb.device_id = l.b_device_id + LEFT JOIN inv.interfaces ia ON ia.id = l.a_interface_id + LEFT JOIN inv.interfaces ib ON ib.id = l.b_interface_id + WHERE l.tenant_id = $2 + AND NOT EXISTS ( + SELECT 1 FROM topo.map_edges e + WHERE e.map_id = $1 AND e.link_id = l.id + ) + `, mapID, tenantID) + if err != nil { + return err + } + st.EdgesAdded = int(tag.RowsAffected()) + + if st.NodesAdded == 0 && st.EdgesAdded == 0 { + st.Revision = revision + return nil + } + + if err := tx.QueryRow(ctx, ` + UPDATE topo.maps SET revision = revision + 1, updated_at = now() + WHERE id = $1 AND tenant_id = $2 + RETURNING revision + `, mapID, tenantID).Scan(&st.Revision); err != nil { + return err + } + + return snapshotMap(ctx, tx, tenantID, mapID, st.Revision, authorID, + fmt.Sprintf("автопобудова: +%d вузлів, +%d ребер", st.NodesAdded, st.EdgesAdded)) + }) + + if err != nil { + return nil, mapPgError(err) + } + return st, nil +} + +// --------------------------------------------------------------------- + +// rawArg перетворює порожній json.RawMessage на NULL, щоб COALESCE у +// запиті лишив старе значення. +func rawArg(m json.RawMessage) any { + if len(m) == 0 { + return nil + } + return string(m) +} + +// mapPgError перекладає помилки БД у доменні. +// +// Ліміт тарифу піднімає тригер bill.assert_device_limit / +// assert_map_node_limit з підказкою upgrade_plan. Клієнту треба +// показати пропозицію змінити тариф, а не «внутрішню помилку». +func mapPgError(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Hint == "upgrade_plan" { + return fmt.Errorf("%w: %s", ErrPlanLimit, pgErr.Message) + } + switch pgErr.Code { + case "23505": // unique_violation + return fmt.Errorf("%w: такий запис уже існує", ErrInvalid) + case "23503": // foreign_key_violation + return fmt.Errorf("%w: посилання на неіснуючий об'єкт", ErrInvalid) + case "23514", "22P02": // check_violation, invalid_text_representation + return fmt.Errorf("%w: %s", ErrInvalid, pgErr.Message) + } + } + return err +}