diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx new file mode 100644 index 00000000..edf83044 --- /dev/null +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -0,0 +1,432 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { + ReactFlowProvider, + useNodesState, + useEdgesState, + addEdge, + useReactFlow, + type Node, + type Edge, + type Connection, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' + +import { NetworkCanvas } from '@/components/network/NetworkCanvas' +import { DiagramHeader } from '@/components/network/DiagramHeader' +import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar' +import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel' +import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel' +import { networkDiagramsApi, deviceTypesApi } from '@/api' +import { toast } from '@/lib/toast' +import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge } from '@/types' +import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode' + +function DiagramEditorInner() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const { getNodes, fitView } = useReactFlow() + + const [diagramId, setDiagramId] = useState(id || null) + const [name, setName] = useState('Untitled Diagram') + const [clientName, setClientName] = useState(null) + const [assetName, setAssetName] = useState(null) + const [description, setDescription] = useState(null) + + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + + const [selectedNode, setSelectedNode] = useState(null) + const [selectedEdge, setSelectedEdge] = useState(null) + + const [isDirty, setIsDirty] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [lastSavedAt, setLastSavedAt] = useState(null) + const isDirtyRef = useRef(false) + const diagramIdRef = useRef(id || null) + + const [deviceTypes, setDeviceTypes] = useState([]) + const [loading, setLoading] = useState(!!id) + + useEffect(() => { isDirtyRef.current = isDirty }, [isDirty]) + useEffect(() => { diagramIdRef.current = diagramId }, [diagramId]) + + const handleNodesChange: typeof onNodesChange = useCallback((changes) => { + onNodesChange(changes) + const hasRealChange = changes.some(c => c.type !== 'select') + if (hasRealChange) setIsDirty(true) + }, [onNodesChange]) + + const handleEdgesChange: typeof onEdgesChange = useCallback((changes) => { + onEdgesChange(changes) + const hasRealChange = changes.some(c => c.type !== 'select') + if (hasRealChange) setIsDirty(true) + }, [onEdgesChange]) + + const loadDeviceTypes = useCallback(async () => { + try { + const types = await deviceTypesApi.list() + setDeviceTypes(types) + } catch { /* ignore */ } + }, []) + + useEffect(() => { loadDeviceTypes() }, [loadDeviceTypes]) + + useEffect(() => { + if (!id) return + let cancelled = false + ;(async () => { + try { + const diagram = await networkDiagramsApi.get(id) + if (cancelled) return + setName(diagram.name) + setClientName(diagram.client_name) + setAssetName(diagram.asset_name) + setDescription(diagram.description) + setNodes( + diagram.nodes.map(n => ({ + id: n.id, + type: 'device', + position: n.position, + data: { + label: n.label, + deviceType: n.type, + properties: n.properties, + } satisfies DeviceNodeData, + })) + ) + setEdges( + diagram.edges.map(e => ({ + id: e.id, + source: e.source, + target: e.target, + type: 'connection', + label: e.label || undefined, + data: { + connectionType: e.connectionType, + speed: e.speed, + notes: e.notes, + }, + })) + ) + setLastSavedAt(new Date(diagram.updated_at)) + } catch { + toast.error('Failed to load diagram') + navigate('/network-diagrams') + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { cancelled = true } + }, [id, navigate, setNodes, setEdges]) + + const serializeNodes = useCallback(() => { + return getNodes().map(n => { + const data = n.data as unknown as DeviceNodeData + return { + id: n.id, + type: data.deviceType, + label: data.label, + position: n.position, + properties: data.properties, + } + }) + }, [getNodes]) + + const serializeEdges = useCallback((): DiagramEdge[] => { + return edges.map(e => ({ + id: e.id, + source: e.source, + target: e.target, + label: (e.label as string) || null, + connectionType: (e.data as Record)?.connectionType as string || 'ethernet', + speed: (e.data as Record)?.speed as string || null, + notes: (e.data as Record)?.notes as string || null, + })) + }, [edges]) + + const handleSave = useCallback(async () => { + setIsSaving(true) + try { + const payload = { + name, + client_name: clientName, + asset_name: assetName, + description, + nodes: serializeNodes(), + edges: serializeEdges(), + } + if (diagramIdRef.current) { + await networkDiagramsApi.update(diagramIdRef.current, payload) + } else { + const created = await networkDiagramsApi.create(payload) + setDiagramId(created.id) + navigate(`/network-diagrams/${created.id}`, { replace: true }) + } + setIsDirty(false) + setLastSavedAt(new Date()) + } catch { + toast.error('Failed to save diagram') + } finally { + setIsSaving(false) + } + }, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate]) + + useEffect(() => { + const interval = setInterval(() => { + if (isDirtyRef.current && diagramIdRef.current) { + handleSave() + } + }, 30_000) + return () => clearInterval(interval) + }, [handleSave]) + + const onConnect = useCallback((connection: Connection) => { + setEdges(eds => addEdge({ + ...connection, + type: 'connection', + data: { connectionType: 'ethernet' }, + }, eds)) + setIsDirty(true) + }, [setEdges]) + + const onDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + }, []) + + const onDrop = useCallback((event: React.DragEvent) => { + event.preventDefault() + const raw = event.dataTransfer.getData('application/reactflow-device') + if (!raw) return + + const { slug, label, category } = JSON.parse(raw) as { slug: string; label: string; category: string } + const reactFlowBounds = (event.target as HTMLElement).closest('.react-flow')?.getBoundingClientRect() + if (!reactFlowBounds) return + + const position = { + x: event.clientX - reactFlowBounds.left, + y: event.clientY - reactFlowBounds.top, + } + + const newNode: Node = { + id: `device-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + type: 'device', + position, + data: { + label, + deviceType: slug, + category, + properties: { + hostname: null, + ip: null, + subnet: null, + vendor: null, + model: null, + role: null, + vlan: null, + notes: null, + status: 'unknown', + } satisfies DeviceProperties, + } satisfies DeviceNodeData, + } + setNodes(nds => [...nds, newNode]) + setIsDirty(true) + }, [setNodes]) + + const handleNodeUpdate = useCallback((nodeId: string, updates: Partial) => { + setNodes(nds => nds.map(n => { + if (n.id !== nodeId) return n + return { ...n, data: { ...n.data, ...updates } } + })) + setIsDirty(true) + }, [setNodes]) + + const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial) => { + setEdges(eds => eds.map(e => { + if (e.id !== edgeId) return e + return { + ...e, + label: updates.label !== undefined ? (updates.label || undefined) : e.label, + data: { + ...(e.data || {}), + ...(updates.connectionType !== undefined ? { connectionType: updates.connectionType } : {}), + ...(updates.speed !== undefined ? { speed: updates.speed } : {}), + ...(updates.notes !== undefined ? { notes: updates.notes } : {}), + }, + } + })) + setSelectedEdge(prev => { + if (!prev || prev.id !== edgeId) return prev + return { + ...prev, + label: updates.label !== undefined ? (updates.label || undefined) : prev.label, + data: { + ...(prev.data || {}), + ...(updates.connectionType !== undefined ? { connectionType: updates.connectionType } : {}), + ...(updates.speed !== undefined ? { speed: updates.speed } : {}), + ...(updates.notes !== undefined ? { notes: updates.notes } : {}), + }, + } + }) + setIsDirty(true) + }, [setEdges]) + + const handleDeleteNode = useCallback((nodeId: string) => { + setNodes(nds => nds.filter(n => n.id !== nodeId)) + setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId)) + setSelectedNode(null) + setIsDirty(true) + }, [setNodes, setEdges]) + + const handleDeleteEdge = useCallback((edgeId: string) => { + setEdges(eds => eds.filter(e => e.id !== edgeId)) + setSelectedEdge(null) + setIsDirty(true) + }, [setEdges]) + + const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => { + const newNodes: Node[] = result.nodes.map(n => ({ + id: n.id, + type: 'device', + position: n.position, + data: { + label: n.label, + deviceType: n.type, + properties: n.properties, + } satisfies DeviceNodeData, + })) + + const newEdges: Edge[] = result.edges.map(e => ({ + id: e.id, + source: e.source, + target: e.target, + type: 'connection', + label: e.label || undefined, + data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes }, + })) + + if (mode === 'replace') { + setNodes(newNodes) + setEdges(newEdges) + } else { + setNodes(nds => [...nds, ...newNodes]) + setEdges(eds => [...eds, ...newEdges]) + } + + if (result.suggestedName && !diagramId) { + setName(result.suggestedName) + toast.success(`Generated: ${result.suggestedName}`) + } else { + toast.success('Diagram generated') + } + + if (result.notes) { + toast.info(result.notes) + } + + setIsDirty(true) + setTimeout(() => fitView({ padding: 0.2 }), 100) + }, [setNodes, setEdges, diagramId, fitView]) + + const getExistingBounds = useCallback(() => { + const currentNodes = getNodes() + if (currentNodes.length === 0) return null + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity + for (const n of currentNodes) { + minX = Math.min(minX, n.position.x) + maxX = Math.max(maxX, n.position.x + 120) + minY = Math.min(minY, n.position.y) + maxY = Math.max(maxY, n.position.y + 80) + } + return { minX, maxX, minY, maxY } + }, [getNodes]) + + const handleExportPng = useCallback(() => { + toast.info('PNG export — use your browser\'s screenshot tool or Print > Save as Image for now') + }, []) + + const handleExportPdf = useCallback(() => { + window.print() + }, []) + + const handleExportJson = useCallback(async () => { + if (!diagramId) return + try { + const data = await networkDiagramsApi.exportJson(diagramId) + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '')}.json` + a.click() + URL.revokeObjectURL(url) + } catch { + toast.error('Failed to export diagram') + } + }, [diagramId, name]) + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ { setName(n); setIsDirty(true) }} + onSave={handleSave} + onExportPng={handleExportPng} + onExportPdf={handleExportPdf} + onExportJson={handleExportJson} + /> +
+ +
+
+ +
+ 0} + /> +
+ +
+
+ ) +} + +export default function DiagramEditor() { + return ( + + + + ) +}