- Straight edges: replace SmoothStepEdge with BaseEdge + getStraightPath so connections draw direct diagonal lines instead of orthogonal bent paths - Snap-to-grid: add snapToGrid/snapGrid=[20,20] to NetworkCanvas so nodes align consistently when dragged - ISP in Cloud: remove standalone "Internet" sidebar section, inject ISP into the Cloud category loop with search support and correct item count - Group node resize: add NodeResizer to GroupNode (subnet/VLAN/site/DMZ), handles visible when selected; dimensions saved/restored correctly on reload (also fixes group node load bug where type was always 'device') - DiagramNode type: add nodeType and style optional fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
564 lines
18 KiB
TypeScript
564 lines
18 KiB
TypeScript
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 { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
|
|
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
|
|
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, DiagramNode } from '@/types'
|
|
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
|
|
|
|
type ContextMenuState = {
|
|
type: 'node' | 'canvas'
|
|
position: { x: number; y: number }
|
|
nodeId?: string
|
|
} | null
|
|
|
|
function DiagramEditorInner() {
|
|
const { id } = useParams<{ id: string }>()
|
|
const navigate = useNavigate()
|
|
const { getNodes, fitView, screenToFlowPosition } = useReactFlow()
|
|
|
|
const [diagramId, setDiagramId] = useState<string | null>(id || null)
|
|
const [name, setName] = useState('Untitled Diagram')
|
|
const [clientName, setClientName] = useState<string | null>(null)
|
|
const [assetName, setAssetName] = useState<string | null>(null)
|
|
const [description, setDescription] = useState<string | null>(null)
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
|
|
|
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
|
|
|
const selectedNode = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) ?? null : null
|
|
const selectedEdge = selectedEdgeId ? edges.find(e => e.id === selectedEdgeId) ?? null : null
|
|
|
|
const [isDirty, setIsDirty] = useState(false)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
|
|
const isDirtyRef = useRef(false)
|
|
const diagramIdRef = useRef<string | null>(id || null)
|
|
|
|
const [deviceTypes, setDeviceTypes] = useState<DeviceTypeResponse[]>([])
|
|
const [loading, setLoading] = useState(!!id)
|
|
const [isDragOver, setIsDragOver] = useState(false)
|
|
|
|
const canvasRef = useRef<HTMLDivElement | null>(null)
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
|
|
|
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
|
|
useEffect(() => { diagramIdRef.current = diagramId }, [diagramId])
|
|
|
|
const {
|
|
copyNodes,
|
|
pasteNodes,
|
|
duplicateNodes,
|
|
selectAll,
|
|
deleteSelected,
|
|
hasClipboard,
|
|
} = useCanvasShortcuts({
|
|
nodes,
|
|
edges,
|
|
setNodes,
|
|
setEdges,
|
|
setIsDirty: (v: boolean) => setIsDirty(v),
|
|
canvasRef,
|
|
})
|
|
|
|
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 => {
|
|
if (n.nodeType === 'group') {
|
|
return {
|
|
id: n.id,
|
|
type: 'group',
|
|
position: n.position,
|
|
style: n.style || { width: 300, height: 200 },
|
|
data: {
|
|
label: n.label,
|
|
groupType: n.type,
|
|
},
|
|
}
|
|
}
|
|
return {
|
|
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((): DiagramNode[] => {
|
|
return getNodes().map(n => {
|
|
if (n.type === 'group') {
|
|
const data = n.data as Record<string, unknown>
|
|
const width = typeof n.style?.width === 'number' ? n.style.width : (n.measured?.width ?? 300)
|
|
const height = typeof n.style?.height === 'number' ? n.style.height : (n.measured?.height ?? 200)
|
|
return {
|
|
id: n.id,
|
|
type: (data.groupType as string) || 'subnet',
|
|
label: (data.label as string) || 'Group',
|
|
position: n.position,
|
|
properties: {} as DeviceProperties,
|
|
nodeType: 'group',
|
|
style: { width, height },
|
|
}
|
|
}
|
|
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<string, unknown>)?.connectionType as string || 'ethernet',
|
|
speed: (e.data as Record<string, unknown>)?.speed as string || null,
|
|
notes: (e.data as Record<string, unknown>)?.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'
|
|
setIsDragOver(true)
|
|
}, [])
|
|
|
|
const onDragLeave = useCallback((event: React.DragEvent) => {
|
|
const relatedTarget = event.relatedTarget as HTMLElement | null
|
|
if (relatedTarget && (event.currentTarget as HTMLElement).contains(relatedTarget)) return
|
|
setIsDragOver(false)
|
|
}, [])
|
|
|
|
const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
|
|
event.preventDefault()
|
|
const currentNodes = getNodes()
|
|
const isInSelection = currentNodes.find(n => n.id === node.id)?.selected
|
|
if (!isInSelection) {
|
|
setNodes(nds => nds.map(n => ({ ...n, selected: n.id === node.id })))
|
|
setSelectedNodeId(node.id)
|
|
setSelectedEdgeId(null)
|
|
}
|
|
setContextMenu({
|
|
type: 'node',
|
|
position: { x: event.clientX, y: event.clientY },
|
|
nodeId: node.id,
|
|
})
|
|
}, [getNodes, setNodes, setSelectedNodeId, setSelectedEdgeId])
|
|
|
|
const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => {
|
|
event.preventDefault()
|
|
setContextMenu({
|
|
type: 'canvas',
|
|
position: { x: event.clientX, y: event.clientY },
|
|
})
|
|
}, [])
|
|
|
|
const closeContextMenu = useCallback(() => {
|
|
setContextMenu(null)
|
|
}, [])
|
|
|
|
const onDrop = useCallback((event: React.DragEvent) => {
|
|
event.preventDefault()
|
|
setIsDragOver(false)
|
|
|
|
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
|
|
|
|
// Handle device drops
|
|
const deviceRaw = event.dataTransfer.getData('application/reactflow-device')
|
|
if (deviceRaw) {
|
|
const { slug, label, category } = JSON.parse(deviceRaw) as { slug: string; label: string; category: string }
|
|
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)
|
|
return
|
|
}
|
|
|
|
// Handle group drops
|
|
const groupRaw = event.dataTransfer.getData('application/reactflow-group')
|
|
if (groupRaw) {
|
|
const { slug, label } = JSON.parse(groupRaw) as { slug: string; label: string }
|
|
const newNode: Node = {
|
|
id: `group-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
type: 'group',
|
|
position,
|
|
style: { width: 300, height: 200 },
|
|
data: {
|
|
label,
|
|
groupType: slug,
|
|
},
|
|
}
|
|
setNodes(nds => [...nds, newNode])
|
|
setIsDirty(true)
|
|
}
|
|
}, [setNodes, screenToFlowPosition])
|
|
|
|
const handleNodeUpdate = useCallback((nodeId: string, updates: Partial<DeviceNodeData>) => {
|
|
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<DiagramEdge>) => {
|
|
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 } : {}),
|
|
},
|
|
}
|
|
}))
|
|
setIsDirty(true)
|
|
}, [setEdges])
|
|
|
|
const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => {
|
|
setEdges(eds => eds.map(e => {
|
|
if (e.id !== edgeId) return e
|
|
return { ...e, type: edgeType }
|
|
}))
|
|
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))
|
|
setSelectedNodeId(null)
|
|
setIsDirty(true)
|
|
}, [setNodes, setEdges])
|
|
|
|
const handleDeleteEdge = useCallback((edgeId: string) => {
|
|
setEdges(eds => eds.filter(e => e.id !== edgeId))
|
|
setSelectedEdgeId(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 (
|
|
<div className="flex h-full items-center justify-center">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent border-t-transparent" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<DiagramHeader
|
|
name={name}
|
|
clientName={clientName}
|
|
isSaving={isSaving}
|
|
lastSavedAt={lastSavedAt}
|
|
diagramId={diagramId}
|
|
onNameChange={n => { setName(n); setIsDirty(true) }}
|
|
onSave={handleSave}
|
|
onExportPng={handleExportPng}
|
|
onExportPdf={handleExportPdf}
|
|
onExportJson={handleExportJson}
|
|
/>
|
|
<div className="flex flex-1 min-h-0">
|
|
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
|
|
<div className="flex flex-1 flex-col min-h-0">
|
|
<div className="flex-1 min-h-0" ref={canvasRef}>
|
|
<NetworkCanvas
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={handleNodesChange}
|
|
onEdgesChange={handleEdgesChange}
|
|
onConnect={onConnect}
|
|
onNodeSelect={setSelectedNodeId}
|
|
onEdgeSelect={setSelectedEdgeId}
|
|
onDrop={onDrop}
|
|
onDragOver={onDragOver}
|
|
onDragLeave={onDragLeave}
|
|
isDragOver={isDragOver}
|
|
onNodeContextMenu={handleNodeContextMenu}
|
|
onPaneContextMenu={handlePaneContextMenu}
|
|
onPaneClick={closeContextMenu}
|
|
/>
|
|
</div>
|
|
<AIAssistPanel
|
|
onGenerate={handleAIGenerate}
|
|
getExistingBounds={getExistingBounds}
|
|
hasNodes={nodes.length > 0}
|
|
/>
|
|
</div>
|
|
<PropertiesPanel
|
|
selectedNode={selectedNode}
|
|
selectedEdge={selectedEdge}
|
|
onNodeUpdate={handleNodeUpdate}
|
|
onEdgeUpdate={handleEdgeUpdate}
|
|
onEdgeTypeChange={handleEdgeTypeChange}
|
|
onDeleteNode={handleDeleteNode}
|
|
onDeleteEdge={handleDeleteEdge}
|
|
/>
|
|
</div>
|
|
{contextMenu && (
|
|
<ContextMenu
|
|
position={contextMenu.position}
|
|
actions={
|
|
contextMenu.type === 'node'
|
|
? getNodeMenuActions({
|
|
onCopy: copyNodes,
|
|
onDuplicate: duplicateNodes,
|
|
onDelete: deleteSelected,
|
|
})
|
|
: getCanvasMenuActions({
|
|
onPaste: pasteNodes,
|
|
onSelectAll: selectAll,
|
|
onFitView: () => fitView({ padding: 0.2 }),
|
|
hasClipboard: hasClipboard(),
|
|
})
|
|
}
|
|
onClose={closeContextMenu}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function DiagramEditor() {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<DiagramEditorInner />
|
|
</ReactFlowProvider>
|
|
)
|
|
}
|