Етап 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:
zotac 2026-08-14 21:46:18 +03:00
parent 3de5643b98
commit bee55be3d1
14 changed files with 3092 additions and 14 deletions

View file

@ -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` + видача сертифікатів зондам.

View file

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

View file

@ -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` не відкочує |
### Живий прогін

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

View file

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

View file

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

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

View file

@ -94,10 +94,26 @@ src/
рендеряться, бо це звичайний DOM. Це поведінка бібліотеки, не помилка — але вона
робить скріншот у згорнутій панелі непридатним для перевірки.
## Редагування
| Дія | Як |
|-----|-----|
| Пересунути вузол | перетягнути; зберігається на відпусканні |
| Намалювати зв'язок | тягнути від краю вузла до іншого |
| Видалити | виділити й натиснути `Delete` або `Backspace` |
| Відкотити | кнопка «↶ Відкотити» в шапці |
| Добудувати з топології | кнопка в шапці; не чіпає ручну розкладку |
Намальоване рукою ребро свідомо створюється **без прив'язки до `topo.links`**: лінія
на полотні — це подання, а не факт про мережу. Автовиявлення прив'яже її саме, коли
справді побачить цей зв'язок.
Видалення вузла окремо не перелічує його ребра: на полотні їх прибирає React Flow,
у базі — каскад зовнішнього ключа.
## Чого ще немає
- **Входу**: токен береться зі змінної збірки або `localStorage`.
- **Малювання зв'язків мишею**: `PATCH` це вміє, UI ще ні.
- **Підкладок**: план приміщення віддається API, але полотно його не малює.
- **Графіків**: історія метрик не віддається сервером.
- **Кластеризації при віддаленні** — поле в схемі є, реалізації немає.

2539
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -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} />

View file

@ -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 ?? []),

View file

@ -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]}

View file

@ -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,
}
}

View file

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