Етап 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>
This commit is contained in:
parent
328850be06
commit
8bf2902522
8 changed files with 1462 additions and 6 deletions
54
HISTORY.md
54
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-подією.
|
||||
|
|
|
|||
111
server/API.md
111
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 — свідомо відкладено.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
135
server/internal/httpapi/maps_write.go
Normal file
135
server/internal/httpapi/maps_write.go
Normal file
|
|
@ -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", "внутрішня помилка")
|
||||
}
|
||||
}
|
||||
477
server/internal/httpapi/maps_write_test.go
Normal file
477
server/internal/httpapi/maps_write_test.go
Normal file
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// З'єднання
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
|
|||
663
server/internal/store/maps_write.go
Normal file
663
server/internal/store/maps_write.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue