diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 40fd629..72016fe 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -2,6 +2,7 @@ from collections.abc import AsyncGenerator from contextlib import suppress from pathlib import Path +from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase @@ -26,36 +27,42 @@ async def init_db() -> None: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Add columns introduced after initial schema (idempotent) - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL") + # Migrate animated column from boolean (0/1) to string ('none'/'snake') + with suppress(OperationalError): + await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1") + with suppress(OperationalError): + sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL" + await conn.exec_driver_sql(sql) async def get_db() -> AsyncGenerator[AsyncSession, None]: diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 2654f8a..1e2daf2 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -33,7 +33,7 @@ class Node(Base): notes: Mapped[str | None] = mapped_column(Text) pos_x: Mapped[float] = mapped_column(Float, default=0) pos_y: Mapped[float] = mapped_column(Float, default=0) - parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id")) + parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE")) container_mode: Mapped[bool] = mapped_column(Boolean, default=False) custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) custom_icon: Mapped[str | None] = mapped_column(String, nullable=True) @@ -65,7 +65,7 @@ class Edge(Base): speed: Mapped[str | None] = mapped_column(String) custom_color: Mapped[str | None] = mapped_column(String) path_style: Mapped[str | None] = mapped_column(String) - animated: Mapped[bool] = mapped_column(Boolean, default=False) + animated: Mapped[str] = mapped_column(String, nullable=False, default='none') source_handle: Mapped[str | None] = mapped_column(String) target_handle: Mapped[str | None] = mapped_column(String) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 9c768d4..e3bb87b 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -1,9 +1,10 @@ from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from app.schemas.edges import EdgeResponse from app.schemas.nodes import NodeResponse +from app.schemas.utils import normalize_animated class NodeSave(BaseModel): @@ -44,10 +45,15 @@ class EdgeSave(BaseModel): speed: str | None = None custom_color: str | None = None path_style: str | None = None - animated: bool = False + animated: str = 'none' source_handle: str | None = None target_handle: str | None = None + @field_validator('animated', mode='before') + @classmethod + def validate_animated(cls, v: object) -> str: + return normalize_animated(v) + class CanvasSaveRequest(BaseModel): nodes: list[NodeSave] = [] diff --git a/backend/app/schemas/edges.py b/backend/app/schemas/edges.py index 0e77c9a..a663806 100644 --- a/backend/app/schemas/edges.py +++ b/backend/app/schemas/edges.py @@ -1,6 +1,8 @@ from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, field_validator + +from app.schemas.utils import normalize_animated class EdgeBase(BaseModel): @@ -12,10 +14,15 @@ class EdgeBase(BaseModel): speed: str | None = None custom_color: str | None = None path_style: str | None = None - animated: bool = False + animated: str = 'none' source_handle: str | None = None target_handle: str | None = None + @field_validator('animated', mode='before') + @classmethod + def validate_animated(cls, v: object) -> str: + return normalize_animated(v) + class EdgeCreate(EdgeBase): pass @@ -28,10 +35,17 @@ class EdgeUpdate(BaseModel): speed: str | None = None custom_color: str | None = None path_style: str | None = None - animated: bool | None = None + animated: str | None = None source_handle: str | None = None target_handle: str | None = None + @field_validator('animated', mode='before') + @classmethod + def validate_animated(cls, v: object) -> str | None: + if v is None: + return None + return normalize_animated(v) + class EdgeResponse(EdgeBase): id: str diff --git a/backend/app/schemas/utils.py b/backend/app/schemas/utils.py new file mode 100644 index 0000000..fab5721 --- /dev/null +++ b/backend/app/schemas/utils.py @@ -0,0 +1,9 @@ +def normalize_animated(v: object) -> str: + """Normalize legacy bool/int animated values to string mode ('none'/'snake'/'flow').""" + if v is True or v == 1 or v == '1': + return 'snake' + if v is False or v == 0 or v == '0' or v is None or v == 'none': + return 'none' + if v in ('snake', 'flow'): + return str(v) + return 'none' diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index f824ed7..7541398 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -98,18 +98,32 @@ describe('canvasStore', () => { expect(useCanvasStore.getState().selectedNodeId).toBeNull() }) - it('onNodesChange marks unsaved', () => { + it('onNodesChange marks unsaved for position changes', () => { useCanvasStore.getState().addNode(makeNode('n1')) useCanvasStore.getState().markSaved() - useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'n1', selected: true }]) + useCanvasStore.getState().onNodesChange([{ type: 'position', id: 'n1', dragging: false }]) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) - it('onEdgesChange marks unsaved', () => { + it('onNodesChange does not mark unsaved for select-only changes', () => { + useCanvasStore.getState().addNode(makeNode('n1')) + useCanvasStore.getState().markSaved() + useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'n1', selected: true }]) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) + + it('onEdgesChange marks unsaved for remove changes', () => { + useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) + useCanvasStore.getState().markSaved() + useCanvasStore.getState().onEdgesChange([{ type: 'remove', id: 'e1' }]) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + + it('onEdgesChange does not mark unsaved for select-only changes', () => { useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.getState().markSaved() useCanvasStore.getState().onEdgesChange([{ type: 'select', id: 'e1', selected: true }]) - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) }) it('onConnect adds an edge between two nodes', () => { @@ -130,6 +144,13 @@ describe('canvasStore', () => { expect(edges[0].data?.label).toBe('uplink') }) + it('onConnect preserves animated from edge data', () => { + const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', animated: 'snake' }) + useCanvasStore.getState().onConnect(conn) + const { edges } = useCanvasStore.getState() + expect(edges[0].data?.animated).toBe('snake') + }) + it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => { const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }, { type: 'cluster' }) useCanvasStore.getState().onConnect(conn) @@ -140,6 +161,15 @@ describe('canvasStore', () => { expect(edges[0].type).toBe('cluster') }) + it('deleteNode also removes children with matching parentId', () => { + useCanvasStore.getState().addNode(makeNode('parent')) + useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) + useCanvasStore.getState().deleteNode('parent') + const { nodes } = useCanvasStore.getState() + expect(nodes.find((n) => n.id === 'parent')).toBeUndefined() + expect(nodes.find((n) => n.id === 'child')).toBeUndefined() + }) + it('addNode with parent_id sets parentId and extent', () => { useCanvasStore.getState().addNode(makeNode('parent')) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 32064be..fec044b 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -127,13 +127,13 @@ export const useCanvasStore = create((set) => ({ onNodesChange: (changes) => set((state) => ({ nodes: applyNodeChanges(changes, state.nodes), - hasUnsavedChanges: true, + hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), })), onEdgesChange: (changes) => set((state) => ({ edges: applyEdgeChanges(changes, state.edges), - hasUnsavedChanges: true, + hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), })), onConnect: (connection) => @@ -150,7 +150,7 @@ export const useCanvasStore = create((set) => ({ sourceHandle: normalizeHandle(extra.sourceHandle), targetHandle: normalizeHandle(extra.targetHandle), type: edgeType, - data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style }, + data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated }, }, state.edges), hasUnsavedChanges: true, } @@ -163,10 +163,13 @@ export const useCanvasStore = create((set) => ({ const enriched = node.data.parent_id ? { ...node, parentId: node.data.parent_id, extent: 'parent' as const } : node - // Parents must come before children in the array + // Parents must come before children in the array (React Flow requirement) const withoutNew = state.nodes.filter((n) => n.id !== node.id) if (enriched.parentId) { - return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true } + const parentIdx = withoutNew.findIndex((n) => n.id === enriched.parentId) + const insertAt = parentIdx >= 0 ? parentIdx + 1 : withoutNew.length + const nodes = [...withoutNew.slice(0, insertAt), enriched, ...withoutNew.slice(insertAt)] + return { nodes, hasUnsavedChanges: true } } return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true } }), @@ -180,12 +183,20 @@ export const useCanvasStore = create((set) => ({ })), deleteNode: (id) => - set((state) => ({ - nodes: state.nodes.filter((n) => n.id !== id), - edges: state.edges.filter((e) => e.source !== id && e.target !== id), - selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId, - hasUnsavedChanges: true, - })), + set((state) => { + const idsToRemove = new Set() + const collect = (nodeId: string) => { + idsToRemove.add(nodeId) + state.nodes.filter((n) => n.parentId === nodeId).forEach((n) => collect(n.id)) + } + collect(id) + return { + nodes: state.nodes.filter((n) => !idsToRemove.has(n.id)), + edges: state.edges.filter((e) => !idsToRemove.has(e.source) && !idsToRemove.has(e.target)), + selectedNodeId: idsToRemove.has(state.selectedNodeId ?? '') ? null : state.selectedNodeId, + hasUnsavedChanges: true, + } + }), updateEdge: (id, data) => set((state) => ({ @@ -245,6 +256,6 @@ export const useCanvasStore = create((set) => ({ // React Flow requires parents before children in the array const parents = nodes.filter((n) => !n.parentId) const children = nodes.filter((n) => !!n.parentId) - set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null }) + set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [] }) }, }))