Етап 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:
zotac 2026-08-14 15:58:16 +03:00
parent 328850be06
commit 8bf2902522
8 changed files with 1462 additions and 6 deletions

View file

@ -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-подією.

View file

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

View file

@ -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)

View 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", "внутрішня помилка")
}
}

View 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"])
}
}

View file

@ -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))

View file

@ -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)
}
// ---------------------------------------------------------------------
// З'єднання
// ---------------------------------------------------------------------

View 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(&current)
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
}