feat: add DiagramEditor page assembling all panels with auto-save and AI generation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
432
frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
Normal file
432
frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
Normal file
@@ -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<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([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
|
||||
const [selectedEdge, setSelectedEdge] = useState<Edge | 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)
|
||||
|
||||
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<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'
|
||||
}, [])
|
||||
|
||||
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<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 } : {}),
|
||||
},
|
||||
}
|
||||
}))
|
||||
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 (
|
||||
<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">
|
||||
<NetworkCanvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeSelect={setSelectedNode}
|
||||
onEdgeSelect={setSelectedEdge}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
/>
|
||||
</div>
|
||||
<AIAssistPanel
|
||||
onGenerate={handleAIGenerate}
|
||||
getExistingBounds={getExistingBounds}
|
||||
hasNodes={nodes.length > 0}
|
||||
/>
|
||||
</div>
|
||||
<PropertiesPanel
|
||||
selectedNode={selectedNode}
|
||||
selectedEdge={selectedEdge}
|
||||
onNodeUpdate={handleNodeUpdate}
|
||||
onEdgeUpdate={handleEdgeUpdate}
|
||||
onDeleteNode={handleDeleteNode}
|
||||
onDeleteEdge={handleDeleteEdge}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DiagramEditor() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<DiagramEditorInner />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user