fix: save button now fully persists canvas to DB

Root cause: handleAddNode/handleEdgeConfirm only updated Zustand store,
never creating records in the DB. canvasApi.save() only updated positions
of existing nodes, so nothing survived a page refresh.

Fix: save now sends the full canvas state (all nodes + edges + data) and
the backend does a full sync — upsert incoming, delete anything removed.
This means Save is the single source of truth: no need to call individual
create/update/delete APIs for every drag or edit.
This commit is contained in:
Pouzor
2026-03-07 01:54:59 +01:00
parent 3f8d23d215
commit 9eba62c5b5
4 changed files with 96 additions and 16 deletions
+33 -5
View File
@@ -23,12 +23,40 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
@router.post("/save") @router.post("/save")
async def save_canvas(body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): async def save_canvas(body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
# Update node positions from canvas incoming_node_ids = {n.id for n in body.nodes}
for node_pos in body.node_positions: incoming_edge_ids = {e.id for e in body.edges}
node = await db.get(Node, node_pos.id)
# Delete nodes removed from canvas
existing_nodes = (await db.execute(select(Node))).scalars().all()
for node in existing_nodes:
if node.id not in incoming_node_ids:
await db.delete(node)
# Delete edges removed from canvas
existing_edges = (await db.execute(select(Edge))).scalars().all()
for edge in existing_edges:
if edge.id not in incoming_edge_ids:
await db.delete(edge)
await db.flush()
# Upsert nodes
for node_data in body.nodes:
node = await db.get(Node, node_data.id)
if node: if node:
node.pos_x = node_pos.x for field, value in node_data.model_dump().items():
node.pos_y = node_pos.y setattr(node, field, value)
else:
db.add(Node(**node_data.model_dump()))
# Upsert edges
for edge_data in body.edges:
edge = await db.get(Edge, edge_data.id)
if edge:
for field, value in edge_data.model_dump().items():
setattr(edge, field, value)
else:
db.add(Edge(**edge_data.model_dump()))
# Upsert viewport # Upsert viewport
state = await db.get(CanvasState, 1) state = await db.get(CanvasState, 1)
+29 -4
View File
@@ -1,17 +1,42 @@
from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
from app.schemas.edges import EdgeResponse from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse from app.schemas.nodes import NodeResponse
class NodePosition(BaseModel): class NodeSave(BaseModel):
id: str id: str
x: float type: str
y: float label: str
hostname: str | None = None
ip: str | None = None
mac: str | None = None
os: str | None = None
status: str = "unknown"
check_method: str | None = None
check_target: str | None = None
services: list[Any] = []
notes: str | None = None
parent_id: str | None = None
pos_x: float = 0
pos_y: float = 0
class EdgeSave(BaseModel):
id: str
source: str
target: str
type: str = "ethernet"
label: str | None = None
vlan_id: int | None = None
speed: str | None = None
class CanvasSaveRequest(BaseModel): class CanvasSaveRequest(BaseModel):
node_positions: list[NodePosition] = [] nodes: list[NodeSave] = []
edges: list[EdgeSave] = []
viewport: dict = {} viewport: dict = {}
+29 -5
View File
@@ -36,15 +36,39 @@ export default function App() {
// Declare handleSave before the Ctrl+S effect so it is in scope // Declare handleSave before the Ctrl+S effect so it is in scope
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
try { try {
const nodePositions = nodes.map((n) => ({ id: n.id, x: n.position.x, y: n.position.y })) const nodesToSave = nodes.map((n) => ({
await canvasApi.save({ node_positions: nodePositions, viewport: {} }) id: n.id,
type: n.data.type,
label: n.data.label,
hostname: n.data.hostname ?? null,
ip: n.data.ip ?? null,
mac: n.data.mac ?? null,
os: n.data.os ?? null,
status: n.data.status,
check_method: n.data.check_method ?? null,
check_target: n.data.check_target ?? null,
services: n.data.services ?? [],
notes: n.data.notes ?? null,
parent_id: n.data.parent_id ?? null,
pos_x: n.position.x,
pos_y: n.position.y,
}))
const edgesToSave = edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
type: e.data?.type ?? 'ethernet',
label: e.data?.label ?? null,
vlan_id: e.data?.vlan_id ?? null,
speed: e.data?.speed ?? null,
}))
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: {} })
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
} catch { } catch {
markSaved() toast.error('Save failed')
toast.success('Canvas saved (local)')
} }
}, [nodes, markSaved]) }, [nodes, edges, markSaved])
// Keep a ref so the keydown handler always calls the latest version // Keep a ref so the keydown handler always calls the latest version
const handleSaveRef = useRef(handleSave) const handleSaveRef = useRef(handleSave)
+5 -2
View File
@@ -26,8 +26,11 @@ export const authApi = {
export const canvasApi = { export const canvasApi = {
load: () => api.get('/canvas'), load: () => api.get('/canvas'),
save: (payload: { node_positions: { id: string; x: number; y: number }[]; viewport: object }) => save: (payload: {
api.post('/canvas/save', payload), nodes: object[]
edges: object[]
viewport: object
}) => api.post('/canvas/save', payload),
} }
export const nodesApi = { export const nodesApi = {