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