Етап 4: редактор мапи — зв'язки, видалення, відкат
POST /api/v1/maps/{id}/undo повертає полотно до попереднього знімка.
Відкат оформлюється як нова ревізія, а не відмотування лічильника:
інакше клієнт зі старим номером тихо перезаписав би відкочене.
Ідентифікатори вузлів зберігаються, тож ребра прив'язуються назад самі.
В UI: малювання зв'язку від краю вузла, видалення по Delete, кнопка
відкату. Намальоване рукою ребро не прив'язується до topo.links —
лінія на полотні це подання, а не факт про мережу.
Виправлено флак у тестах: TestSchedulerRunsTaskAndFillsCredentials
перевіряв канал статусів знімком, хоча SUCCEEDED надсилається вже після
запису в sink. Під навантаженням падав раз на п'ять; тепер 0 з 8.
Перевірено наживо: намальовано зв'язок -> відкат -> видалено вузол ->
відкат повернув той самий id, ребро й живий стан лінка.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3de5643b98
commit
bee55be3d1
14 changed files with 3092 additions and 14 deletions
47
HISTORY.md
47
HISTORY.md
|
|
@ -540,9 +540,50 @@ React 18 + React Flow 12 + Tailwind 4 + Vite 6. Збірка 345 КБ (112 КБ
|
|||
`requestAnimationFrame`, який там не викликається. Вузли рендеряться, бо це
|
||||
звичайний DOM. Скріншот у згорнутій панелі непридатний для перевірки.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-14 — Етап 4 (частина 2): редактор доведено до робочого стану
|
||||
|
||||
### Створено
|
||||
|
||||
```
|
||||
server/internal/store/maps_undo.go відкат до попереднього знімка
|
||||
server/internal/httpapi/maps_write.go POST /maps/{id}/undo
|
||||
web/src/components/MapCanvas.tsx onConnect, onDelete
|
||||
web/src/hooks/useLiveMap.ts undo + canUndo
|
||||
```
|
||||
|
||||
### Прийняті рішення
|
||||
|
||||
1. **Відкат — нова ревізія, а не відмотування лічильника.** Інакше клієнт із
|
||||
номером 10 після повернення до 9 отримав би «свою» ревізію знову актуальною
|
||||
й тихо перезаписав відкочене.
|
||||
2. **Ідентифікатори вузлів зберігаються при відкаті** — ребра прив'язуються
|
||||
назад самі, а виділення в UI й зовнішні посилання не ламаються.
|
||||
3. **Порядок відновлення** — ребра геть → вузли геть → вузли назад → ребра
|
||||
назад: зовнішні ключі не дозволяють інакше.
|
||||
4. **Намальоване рукою ребро не прив'язується до `topo.links`:** лінія на
|
||||
полотні — це подання, а не факт про мережу. Автовиявлення прив'яже саме.
|
||||
5. **Видалення не перелічує ребра вузла:** на полотні їх прибирає React Flow,
|
||||
у базі — каскад FK.
|
||||
|
||||
### Знайдено флак у тестах (виправлено)
|
||||
|
||||
`TestSchedulerRunsTaskAndFillsCredentials` падав приблизно раз на п'ять під
|
||||
навантаженням: тест перевіряв канал статусів **знімком** (`for len(ch) > 0`),
|
||||
а `STATE_SUCCEEDED` надсилається вже ПІСЛЯ запису в sink. На завантаженій машині
|
||||
проміжок розширювався. Замінено на очікування з дедлайном; 8 прогонів під
|
||||
штучним навантаженням — 0 падінь.
|
||||
|
||||
### Перевірено наживо
|
||||
|
||||
Повний ланцюг проти працюючого стека: намальовано зв'язок (ребер 1 → 2) →
|
||||
відкат (2 → 1) → видалення вузла (2 вузли → 1, ребер 0) → відкат повернув
|
||||
**той самий id вузла**, ребро й живий стан лінка. UI показує обидві кнопки й
|
||||
підказку про жести.
|
||||
|
||||
### Далі
|
||||
|
||||
- Малювання зв'язків мишею (PATCH це вміє, UI ще ні).
|
||||
- Підкладки-плани приміщень на полотні.
|
||||
- Undo поверх `topo.map_revisions`.
|
||||
- Підкладки-плани приміщень на полотні (потрібен прийом файлів).
|
||||
- Історія метрик для графіків.
|
||||
- `EnrollmentService` + видача сертифікатів зондам.
|
||||
|
|
|
|||
|
|
@ -207,17 +207,29 @@ func TestSchedulerRunsTaskAndFillsCredentials(t *testing.T) {
|
|||
t.Fatalf("результати не дійшли: results=%d checks=%d", r, c)
|
||||
}
|
||||
|
||||
var gotSucceeded bool
|
||||
for len(statuses) > 0 {
|
||||
if (<-statuses).State == npv1.TaskStatusUpdate_STATE_SUCCEEDED {
|
||||
gotSucceeded = true
|
||||
}
|
||||
}
|
||||
if !gotSucceeded {
|
||||
// Чекаємо на статус, а не перевіряємо знімок каналу: SUCCEEDED
|
||||
// надсилається вже ПІСЛЯ запису в sink, тому на завантаженій машині
|
||||
// його там ще немає в мить, коли результати вже на місці.
|
||||
if !waitForStatus(statuses, npv1.TaskStatusUpdate_STATE_SUCCEEDED, 2*time.Second) {
|
||||
t.Fatal("сервер не отримав STATE_SUCCEEDED")
|
||||
}
|
||||
}
|
||||
|
||||
// waitForStatus чекає на потрібний стан, пропускаючи решту.
|
||||
func waitForStatus(ch <-chan *npv1.TaskStatusUpdate, want npv1.TaskStatusUpdate_State, d time.Duration) bool {
|
||||
deadline := time.After(d)
|
||||
for {
|
||||
select {
|
||||
case u := <-ch:
|
||||
if u.State == want {
|
||||
return true
|
||||
}
|
||||
case <-deadline:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Повільна задача не має накопичувати паралельні запуски: наступний цикл
|
||||
// пропускається з явним STATE_SKIPPED.
|
||||
func TestSchedulerSkipsWhenPreviousStillRunning(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ Authorization: Bearer np_ui_xxxxxxxx
|
|||
| `PATCH` | `/api/v1/maps/{id}` | **редактор полотна** з оптимістичним блокуванням |
|
||||
| `DELETE` | `/api/v1/maps/{id}` | м'яко видалити мапу (топологія лишається) |
|
||||
| `POST` | `/api/v1/maps/{id}/build` | добудувати мапу з виявленої топології |
|
||||
| `POST` | `/api/v1/maps/{id}/undo` | відкотити останню зміну полотна |
|
||||
| `GET` | `/api/v1/devices` | інвентар |
|
||||
| `GET` | `/api/v1/agents` | зонди, версії, самометрики |
|
||||
| `GET` | `/api/v1/ws` | WebSocket: події та завантаження каналів |
|
||||
|
|
@ -154,10 +155,27 @@ Authorization: Bearer np_ui_xxxxxxxx
|
|||
{"nodes_added": 2, "edges_added": 1, "revision": 2}
|
||||
```
|
||||
|
||||
## WebSocket
|
||||
### `POST /api/v1/maps/{id}/undo` — відкат
|
||||
|
||||
Повертає полотно до попереднього знімка з `topo.map_revisions`.
|
||||
|
||||
```json
|
||||
{"revision": 11, "restored_from": 9}
|
||||
```
|
||||
GET /api/v1/ws
|
||||
|
||||
**Відкат оформлюється як НОВА ревізія, а не як відмотування лічильника.** Інакше
|
||||
клієнти, що тримають номер 10, після повернення до 9 отримали б «свою» ревізію знову
|
||||
актуальною й тихо перезаписали б відкочене. Undo — така сама зміна, як будь-яка інша,
|
||||
і має рухати історію вперед.
|
||||
|
||||
**Ідентифікатори вузлів зберігаються.** Відновлений вузол повертається з тим самим
|
||||
`id`, тому ребра, що на нього спираються, знову працюють, а виділення в UI й зовнішні
|
||||
посилання не ламаються.
|
||||
|
||||
Відновлення йде в порядку «ребра геть → вузли геть → вузли назад → ребра назад»:
|
||||
зовнішні ключі не дозволяють інакше.
|
||||
|
||||
`409 nothing_to_undo` — у щойно створеної мапи історії ще немає.
|
||||
Sec-WebSocket-Protocol: netpulse.token.<токен>
|
||||
```
|
||||
|
||||
|
|
@ -234,6 +252,10 @@ Sec-WebSocket-Protocol: netpulse.token.<токен>
|
|||
| `TestPatchRequiresWriteScope` | токен `maps:read` не пише, але читає |
|
||||
| `TestBuildFromTopologyIsIdempotent` | повторна побудова нічого не додає й не скидає ручну розкладку |
|
||||
| `TestPatchBroadcastsToOtherViewers` | правка долітає до інших відкритих полотен як `map.updated` |
|
||||
| `TestUndoRestoresNodePosition` | відкат повертає координати й створює нову ревізію, а не відмотує лічильник |
|
||||
| `TestUndoRestoresDeletedNodeWithEdges` | видалений вузол повертається **з тим самим id**, ребра прив'язуються назад, живий стан лінка на місці |
|
||||
| `TestUndoWithoutHistory` | у мапи без історії — `409 nothing_to_undo` |
|
||||
| `TestUndoRequiresWriteScope` | токен `maps:read` не відкочує |
|
||||
|
||||
### Живий прогін
|
||||
|
||||
|
|
|
|||
186
server/internal/httpapi/maps_undo_test.go
Normal file
186
server/internal/httpapi/maps_undo_test.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package httpapi_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
func (f *fixture) undo(t *testing.T) (int, store.UndoResult) {
|
||||
t.Helper()
|
||||
code, body := f.send(t, http.MethodPost, "/api/v1/maps/"+f.mapID+"/undo", f.token, "")
|
||||
var res store.UndoResult
|
||||
if code == http.StatusOK {
|
||||
if err := json.Unmarshal(body, &res); err != nil {
|
||||
t.Fatalf("розбір: %v", err)
|
||||
}
|
||||
}
|
||||
return code, res
|
||||
}
|
||||
|
||||
// Найпростіший сценарій undo: посунув вузол, передумав.
|
||||
func TestUndoRestoresNodePosition(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
// Перший патч створює точку, до якої буде куди відкочуватись:
|
||||
// у щойно створеної мапи знімків ще немає.
|
||||
before := f.mapState(t)
|
||||
nodeID := before.Nodes[0].ID
|
||||
origX, origY := before.Nodes[0].X, before.Nodes[0].Y
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"revision": before.Revision,
|
||||
"nodes": map[string]any{"upsert": []map[string]any{{"id": nodeID, "x": origX, "y": origY}}},
|
||||
"comment": "початковий знімок",
|
||||
})
|
||||
if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK {
|
||||
t.Fatalf("підготовка: код %d", code)
|
||||
}
|
||||
|
||||
mid := f.mapState(t)
|
||||
raw, _ = json.Marshal(map[string]any{
|
||||
"revision": mid.Revision,
|
||||
"nodes": map[string]any{"upsert": []map[string]any{{"id": nodeID, "x": 4242, "y": 2424}}},
|
||||
})
|
||||
if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK {
|
||||
t.Fatalf("правка: код %d", code)
|
||||
}
|
||||
|
||||
code, res := f.undo(t)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("undo: код %d", code)
|
||||
}
|
||||
// Відкат — це нова ревізія, а не відмотування лічильника: інакше
|
||||
// клієнти зі старим номером тихо перезаписали б відкочене.
|
||||
if res.Revision <= mid.Revision+1 {
|
||||
t.Fatalf("відкат не створив нову ревізію: %+v", res)
|
||||
}
|
||||
|
||||
after := f.mapState(t)
|
||||
var n *store.MapNode
|
||||
for i := range after.Nodes {
|
||||
if after.Nodes[i].ID == nodeID {
|
||||
n = &after.Nodes[i]
|
||||
}
|
||||
}
|
||||
if n == nil {
|
||||
t.Fatal("вузол зник після відкату")
|
||||
}
|
||||
if n.X != origX || n.Y != origY {
|
||||
t.Fatalf("координати не відновились: %v,%v (було %v,%v)", n.X, n.Y, origX, origY)
|
||||
}
|
||||
}
|
||||
|
||||
// Головний сценарій: випадково видалив вузол — undo має повернути і його,
|
||||
// і ребра, що на нього спирались. Саме тут відкат легко зламати.
|
||||
func TestUndoRestoresDeletedNodeWithEdges(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": before.Nodes[0].X}}},
|
||||
"comment": "початковий знімок",
|
||||
})
|
||||
if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK {
|
||||
t.Fatalf("підготовка: код %d", code)
|
||||
}
|
||||
|
||||
mid := f.mapState(t)
|
||||
victim := mid.Edges[0].SourceNodeID
|
||||
raw, _ = json.Marshal(map[string]any{
|
||||
"revision": mid.Revision,
|
||||
"nodes": map[string]any{"remove": []string{victim}},
|
||||
})
|
||||
if code, _ := f.send(t, http.MethodPatch, "/api/v1/maps/"+f.mapID, f.token, string(raw)); code != http.StatusOK {
|
||||
t.Fatalf("видалення: код %d", code)
|
||||
}
|
||||
|
||||
deleted := f.mapState(t)
|
||||
if len(deleted.Nodes) != len(mid.Nodes)-1 || len(deleted.Edges) != 0 {
|
||||
t.Fatalf("видалення не спрацювало: %d вузлів, %d ребер",
|
||||
len(deleted.Nodes), len(deleted.Edges))
|
||||
}
|
||||
|
||||
if code, _ := f.undo(t); code != http.StatusOK {
|
||||
t.Fatalf("undo: код %d", code)
|
||||
}
|
||||
|
||||
after := f.mapState(t)
|
||||
if len(after.Nodes) != len(mid.Nodes) {
|
||||
t.Fatalf("вузли не відновились: %d, було %d", len(after.Nodes), len(mid.Nodes))
|
||||
}
|
||||
if len(after.Edges) != len(mid.Edges) {
|
||||
t.Fatalf("ребра не відновились: %d, було %d", len(after.Edges), len(mid.Edges))
|
||||
}
|
||||
|
||||
// Ідентифікатори мають зберегтись: інакше зламались би посилання
|
||||
// в UI і в будь-яких зовнішніх закладках.
|
||||
found := false
|
||||
for _, n := range after.Nodes {
|
||||
if n.ID == victim {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("вузол відновлено з новим id")
|
||||
}
|
||||
// І ребро має знову вказувати на нього.
|
||||
if after.Edges[0].SourceNodeID != victim && after.Edges[0].TargetNodeID != victim {
|
||||
t.Fatalf("ребро не прив'язалось назад: %+v", after.Edges[0])
|
||||
}
|
||||
// Живий стан підмішується на читанні, тож має бути на місці й після відкату.
|
||||
if after.Edges[0].LinkStatus == "" {
|
||||
t.Fatal("після відкату ребро втратило зв'язок із topo.links")
|
||||
}
|
||||
}
|
||||
|
||||
// У щойно створеної мапи історії немає — відкочуватись нема куди.
|
||||
func TestUndoWithoutHistory(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
code, body := f.send(t, http.MethodPost, "/api/v1/maps", f.token,
|
||||
`{"name":"Порожня","slug":"empty"}`)
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("створення: код %d", code)
|
||||
}
|
||||
var m store.MapSummary
|
||||
_ = json.Unmarshal(body, &m)
|
||||
|
||||
code, body = f.send(t, http.MethodPost, "/api/v1/maps/"+m.ID+"/undo", f.token, "")
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("код %d: %s", code, body)
|
||||
}
|
||||
if !contains(string(body), "nothing_to_undo") {
|
||||
t.Fatalf("немає зрозумілого коду: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndoRequiresWriteScope(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
roToken := "np_ro_undo_" + f.mapID
|
||||
if _, err := f.pool.Exec(f.ctx, `
|
||||
INSERT INTO core.api_tokens (tenant_id, name, prefix, token_hash, scopes)
|
||||
VALUES ($1, 'ro-undo', 'np_ro_', $2, ARRAY['maps:read'])
|
||||
`, f.tenantID, sha256sum(roToken)); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if code, _ := f.send(t, http.MethodPost, "/api/v1/maps/"+f.mapID+"/undo", roToken, ""); code != http.StatusForbidden {
|
||||
t.Fatalf("токен без maps:write відкотив мапу: код %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (func() bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
|
|
@ -95,6 +95,28 @@ func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, tok *sto
|
|||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
// handleUndoMap відкочує полотно до попереднього знімка.
|
||||
func (s *Server) handleUndoMap(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")
|
||||
res, err := s.store.UndoMap(r.Context(), tok.TenantID, mapID, "")
|
||||
if errors.Is(err, store.ErrNothingToUndo) {
|
||||
writeError(w, http.StatusConflict, "nothing_to_undo", "історії змін ще немає")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "відкат мапи", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.hub.BroadcastMapUpdate(tok.TenantID, mapID, res.Revision)
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ func (s *Server) Handler() http.Handler {
|
|||
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("POST /api/v1/maps/{id}/undo", s.authenticated(s.handleUndoMap))
|
||||
mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices))
|
||||
mux.Handle("GET /api/v1/agents", s.authenticated(s.handleListAgents))
|
||||
|
||||
|
|
|
|||
135
server/internal/store/maps_undo.go
Normal file
135
server/internal/store/maps_undo.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// UndoResult — що вийшло після відкату.
|
||||
type UndoResult struct {
|
||||
Revision int64 `json:"revision"`
|
||||
RestoredFrom int64 `json:"restored_from"`
|
||||
}
|
||||
|
||||
// ErrNothingToUndo — історії ще немає (мапу щойно створили).
|
||||
var ErrNothingToUndo = errors.New("немає до чого відкочуватись")
|
||||
|
||||
// UndoMap повертає полотно до попереднього знімка.
|
||||
//
|
||||
// Відкат оформлюється як НОВА ревізія, а не як відмотування лічильника.
|
||||
// Інакше клієнти, що тримають номер 8, після відкату до 7 отримали б
|
||||
// «свою» ревізію знову актуальною й тихо перезаписали б відкочене.
|
||||
// Undo — така сама зміна, як будь-яка інша, і має рухати історію вперед.
|
||||
//
|
||||
// Ідентифікатори вузлів зберігаються зі знімка: інакше ребра, що на них
|
||||
// посилаються, довелося б переписувати, а зовнішні посилання (виділення
|
||||
// в UI, закладки) зламалися б.
|
||||
func (s *Store) UndoMap(ctx context.Context, tenantID, mapID, authorID string) (*UndoResult, error) {
|
||||
res := &UndoResult{}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Найсвіжіший знімок ДО поточного стану. Номери ревізій можуть
|
||||
// мати проміжки (автопобудова, кілька правок), тому шукаємо
|
||||
// максимальний менший, а не current-1.
|
||||
var (
|
||||
from int64
|
||||
nodes []byte
|
||||
edges []byte
|
||||
backs []byte
|
||||
)
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT revision,
|
||||
(snapshot->'nodes')::text::bytea,
|
||||
(snapshot->'edges')::text::bytea,
|
||||
(snapshot->'backgrounds')::text::bytea
|
||||
FROM topo.map_revisions
|
||||
WHERE map_id = $1 AND revision < $2
|
||||
ORDER BY revision DESC
|
||||
LIMIT 1
|
||||
`, mapID, current).Scan(&from, &nodes, &edges, &backs)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNothingToUndo
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Порядок важливий: ребра посилаються на вузли, тож видаляємо
|
||||
// їх першими, а вставляємо — останніми.
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM topo.map_edges WHERE map_id = $1`, mapID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM topo.map_nodes WHERE map_id = $1`, mapID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM topo.map_backgrounds WHERE map_id = $1`, mapID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Вузли вставляємо двома проходами: спершу без parent_node_id,
|
||||
// потім проставляємо його. Інакше вкладений вузол міг би
|
||||
// зустрітися раніше за свого батька й упертись у FK.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO topo.map_nodes
|
||||
SELECT (r).* FROM (
|
||||
SELECT jsonb_populate_record(null::topo.map_nodes, e) AS r
|
||||
FROM jsonb_array_elements($1::jsonb) e
|
||||
) t
|
||||
`, string(nodes)); err != nil {
|
||||
return fmt.Errorf("відновлення вузлів: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO topo.map_backgrounds
|
||||
SELECT (r).* FROM (
|
||||
SELECT jsonb_populate_record(null::topo.map_backgrounds, e) AS r
|
||||
FROM jsonb_array_elements($1::jsonb) e
|
||||
) t
|
||||
`, string(backs)); err != nil {
|
||||
return fmt.Errorf("відновлення підкладок: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO topo.map_edges
|
||||
SELECT (r).* FROM (
|
||||
SELECT jsonb_populate_record(null::topo.map_edges, e) AS r
|
||||
FROM jsonb_array_elements($1::jsonb) e
|
||||
) t
|
||||
`, string(edges)); err != nil {
|
||||
return fmt.Errorf("відновлення ребер: %w", err)
|
||||
}
|
||||
|
||||
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(&res.Revision); err != nil {
|
||||
return err
|
||||
}
|
||||
res.RestoredFrom = from
|
||||
|
||||
return snapshotMap(ctx, tx, tenantID, mapID, res.Revision, authorID,
|
||||
fmt.Sprintf("відкат до ревізії %d", from))
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, mapPgError(err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
|
@ -94,10 +94,26 @@ src/
|
|||
рендеряться, бо це звичайний DOM. Це поведінка бібліотеки, не помилка — але вона
|
||||
робить скріншот у згорнутій панелі непридатним для перевірки.
|
||||
|
||||
## Редагування
|
||||
|
||||
| Дія | Як |
|
||||
|-----|-----|
|
||||
| Пересунути вузол | перетягнути; зберігається на відпусканні |
|
||||
| Намалювати зв'язок | тягнути від краю вузла до іншого |
|
||||
| Видалити | виділити й натиснути `Delete` або `Backspace` |
|
||||
| Відкотити | кнопка «↶ Відкотити» в шапці |
|
||||
| Добудувати з топології | кнопка в шапці; не чіпає ручну розкладку |
|
||||
|
||||
Намальоване рукою ребро свідомо створюється **без прив'язки до `topo.links`**: лінія
|
||||
на полотні — це подання, а не факт про мережу. Автовиявлення прив'яже її саме, коли
|
||||
справді побачить цей зв'язок.
|
||||
|
||||
Видалення вузла окремо не перелічує його ребра: на полотні їх прибирає React Flow,
|
||||
у базі — каскад зовнішнього ключа.
|
||||
|
||||
## Чого ще немає
|
||||
|
||||
- **Входу**: токен береться зі змінної збірки або `localStorage`.
|
||||
- **Малювання зв'язків мишею**: `PATCH` це вміє, UI ще ні.
|
||||
- **Підкладок**: план приміщення віддається API, але полотно його не малює.
|
||||
- **Графіків**: історія метрик не віддається сервером.
|
||||
- **Кластеризації при віддаленні** — поле в схемі є, реалізації немає.
|
||||
|
|
|
|||
2539
web/package-lock.json
generated
Normal file
2539
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -67,6 +67,20 @@ export default function App() {
|
|||
Добудувати з топології
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded border border-slate-700 bg-slate-800 px-3 py-1 text-sm hover:bg-slate-700
|
||||
disabled:opacity-40"
|
||||
disabled={!live.canUndo}
|
||||
onClick={() => void live.undo()}
|
||||
title="Відкотити останню зміну полотна"
|
||||
>
|
||||
↶ Відкотити
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-slate-600">
|
||||
тягніть від краю вузла — зв'язок; Delete — видалити
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-4 text-xs">
|
||||
<Legend />
|
||||
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
MapState,
|
||||
MapSummary,
|
||||
PatchResult,
|
||||
UndoResult,
|
||||
} from '../types'
|
||||
|
||||
// Токен береться зі змінної збірки або з localStorage. Повноцінного
|
||||
|
|
@ -74,6 +75,9 @@ export const api = {
|
|||
buildMap: (id: string) =>
|
||||
request<BuildResult>(`/api/v1/maps/${id}/build`, { method: 'POST' }),
|
||||
|
||||
undoMap: (id: string) =>
|
||||
request<UndoResult>(`/api/v1/maps/${id}/undo`, { method: 'POST' }),
|
||||
|
||||
listDevices: () =>
|
||||
request<{ devices: DeviceSummary[] }>('/api/v1/devices').then((r) => r.devices ?? []),
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
ReactFlow,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeChange,
|
||||
} from '@xyflow/react'
|
||||
|
|
@ -96,6 +98,48 @@ export function MapCanvas({ state, onPatch, onSelect }: Props) {
|
|||
[onNodesChange, onSelect],
|
||||
)
|
||||
|
||||
// Малювання зв'язку мишею. Ребро зберігається одразу: намальована,
|
||||
// але не збережена лінія — це обіцянка, якої полотно не тримає.
|
||||
//
|
||||
// Ребро свідомо створюється без прив'язки до topo.links: намальоване
|
||||
// рукою — це подання, а не факт про мережу. Автовиявлення прив'яже
|
||||
// його само, коли справді побачить цей зв'язок.
|
||||
const handleConnect = useCallback(
|
||||
(c: Connection) => {
|
||||
if (!c.source || !c.target || c.source === c.target) return
|
||||
onPatch(
|
||||
{
|
||||
edges: {
|
||||
upsert: [
|
||||
{
|
||||
client_id: `e-${Date.now()}`,
|
||||
source_node_id: c.source,
|
||||
target_node_id: c.target,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'намальовано звʼязок',
|
||||
)
|
||||
},
|
||||
[onPatch],
|
||||
)
|
||||
|
||||
// Видалення виділеного (Delete/Backspace). React Flow сам прибирає
|
||||
// ребра зниклих вузлів на полотні; на сервері те саме робить каскад
|
||||
// зовнішнього ключа, тому окремо їх перелічувати не треба.
|
||||
const handleDelete = useCallback(
|
||||
({ nodes: dn, edges: de }: { nodes: Node[]; edges: Edge[] }) => {
|
||||
const patch: MapPatch = {}
|
||||
if (dn.length > 0) patch.nodes = { remove: dn.map((n) => n.id) }
|
||||
if (de.length > 0) patch.edges = { remove: de.map((e) => e.id) }
|
||||
if (!patch.nodes && !patch.edges) return
|
||||
|
||||
onPatch(patch, 'видалення з полотна')
|
||||
},
|
||||
[onPatch],
|
||||
)
|
||||
|
||||
// Зберігаємо після відпускання, а не під час перетягування: інакше
|
||||
// один драг — це сотня запитів і сотня ревізій в історії.
|
||||
//
|
||||
|
|
@ -124,6 +168,10 @@ export function MapCanvas({ state, onPatch, onSelect }: Props) {
|
|||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStop={handleDragStop}
|
||||
onConnect={handleConnect}
|
||||
onDelete={handleDelete}
|
||||
deleteKeyCode={['Delete', 'Backspace']}
|
||||
connectionRadius={30}
|
||||
defaultViewport={state.viewport}
|
||||
snapToGrid={state.grid?.snap ?? true}
|
||||
snapGrid={[state.grid?.size ?? 16, state.grid?.size ?? 16]}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export interface LiveMap {
|
|||
reload: () => Promise<void>
|
||||
patch: (p: MapPatch, comment?: string) => Promise<void>
|
||||
build: () => Promise<void>
|
||||
undo: () => Promise<void>
|
||||
/** Чи є куди відкочуватись — вмикає кнопку в шапці. */
|
||||
canUndo: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -177,6 +180,24 @@ export function useLiveMap(mapID: string | null): LiveMap {
|
|||
[mapID, reload],
|
||||
)
|
||||
|
||||
const undo = useCallback(async () => {
|
||||
if (!mapID) return
|
||||
try {
|
||||
await api.undoMap(mapID)
|
||||
// Відкат міняє полотно цілком, тож перечитуємо, а не мерджимо:
|
||||
// вгадувати, що саме повернулось, — вірний спосіб розійтися
|
||||
// з сервером.
|
||||
await reload()
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
setError('Немає до чого відкочуватись')
|
||||
return
|
||||
}
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [mapID, reload])
|
||||
|
||||
const build = useCallback(async () => {
|
||||
if (!mapID) return
|
||||
try {
|
||||
|
|
@ -187,5 +208,17 @@ export function useLiveMap(mapID: string | null): LiveMap {
|
|||
}
|
||||
}, [mapID, reload])
|
||||
|
||||
return { state, loading, error, connection, lastEventAt, reload, patch, build }
|
||||
return {
|
||||
state,
|
||||
loading,
|
||||
error,
|
||||
connection,
|
||||
lastEventAt,
|
||||
reload,
|
||||
patch,
|
||||
build,
|
||||
undo,
|
||||
// Ревізія 1 — щойно створена мапа: знімків ще немає.
|
||||
canUndo: (state?.revision ?? 0) > 1,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,11 @@ export interface PatchResult {
|
|||
edge_ids?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface UndoResult {
|
||||
revision: number
|
||||
restored_from: number
|
||||
}
|
||||
|
||||
export interface BuildResult {
|
||||
nodes_added: number
|
||||
edges_added: number
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue