feat(network): draw.io XML import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, Share2 } from 'lucide-react'
|
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, Share2, Upload } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export type InteractionMode = 'select' | 'pan'
|
export type InteractionMode = 'select' | 'pan'
|
||||||
@@ -19,6 +19,7 @@ interface DiagramHeaderProps {
|
|||||||
onExportPdf: () => void
|
onExportPdf: () => void
|
||||||
onExportJson: () => void
|
onExportJson: () => void
|
||||||
onExportDrawio: () => void
|
onExportDrawio: () => void
|
||||||
|
onImportDrawio: () => void
|
||||||
onUndo: () => void
|
onUndo: () => void
|
||||||
onRedo: () => void
|
onRedo: () => void
|
||||||
canUndo: boolean
|
canUndo: boolean
|
||||||
@@ -41,6 +42,7 @@ export function DiagramHeader({
|
|||||||
onExportPdf,
|
onExportPdf,
|
||||||
onExportJson,
|
onExportJson,
|
||||||
onExportDrawio,
|
onExportDrawio,
|
||||||
|
onImportDrawio,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
canUndo,
|
canUndo,
|
||||||
@@ -199,6 +201,14 @@ export function DiagramHeader({
|
|||||||
{isSaving ? 'Saving...' : 'Save'}
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onImportDrawio}
|
||||||
|
className="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-primary hover:border-hover"
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="relative" ref={exportMenuRef}>
|
<div className="relative" ref={exportMenuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowExportMenu(prev => !prev)}
|
onClick={() => setShowExportMenu(prev => !prev)}
|
||||||
|
|||||||
142
frontend/src/lib/drawio-import.ts
Normal file
142
frontend/src/lib/drawio-import.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import type { DiagramNode, DiagramEdge } from '@/types/network-diagram'
|
||||||
|
|
||||||
|
// Maps draw.io shape identifiers (substrings of style) → our device slugs
|
||||||
|
const DRAWIO_SHAPE_TO_SLUG: Array<[string, string]> = [
|
||||||
|
['cisco.routers.router', 'router'],
|
||||||
|
['cisco.routers', 'router'],
|
||||||
|
['cisco.switches.layer_3_switch', 'switch'],
|
||||||
|
['cisco.switches.workgroup_switch', 'switch'],
|
||||||
|
['cisco.switches', 'switch'],
|
||||||
|
['cisco.firewalls', 'firewall'],
|
||||||
|
['cisco.servers', 'server'],
|
||||||
|
['cisco.computers_and_peripherals.laptop', 'laptop'],
|
||||||
|
['cisco.computers_and_peripherals.ip_phone', 'phone'],
|
||||||
|
['cisco.computers_and_peripherals.pc', 'workstation'],
|
||||||
|
['cisco.computers_and_peripherals.printer', 'printer'],
|
||||||
|
['cisco.misc.access_point', 'access-point'],
|
||||||
|
['cisco.misc.cloud', 'cloud'],
|
||||||
|
['cisco.storage', 'nas'],
|
||||||
|
['shape=router', 'router'],
|
||||||
|
['shape=server', 'server'],
|
||||||
|
['shape=firewall', 'firewall'],
|
||||||
|
['shape=cloud', 'cloud'],
|
||||||
|
]
|
||||||
|
|
||||||
|
function styleToSlug(style: string): string {
|
||||||
|
const lower = style.toLowerCase()
|
||||||
|
for (const [pattern, slug] of DRAWIO_SHAPE_TO_SLUG) {
|
||||||
|
if (lower.includes(pattern)) return slug
|
||||||
|
}
|
||||||
|
return 'server'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroup(style: string): boolean {
|
||||||
|
return style.includes('swimlane') || style.includes('container') || style.includes('group')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrawioImportResult {
|
||||||
|
nodes: DiagramNode[]
|
||||||
|
edges: DiagramEdge[]
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDrawioXml(xmlString: string): DrawioImportResult {
|
||||||
|
const parser = new DOMParser()
|
||||||
|
const doc = parser.parseFromString(xmlString, 'application/xml')
|
||||||
|
|
||||||
|
const parseError = doc.querySelector('parsererror')
|
||||||
|
if (parseError) {
|
||||||
|
throw new Error('Invalid draw.io XML: ' + parseError.textContent?.slice(0, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = Array.from(doc.querySelectorAll('mxCell'))
|
||||||
|
const warnings: string[] = []
|
||||||
|
const nodes: DiagramNode[] = []
|
||||||
|
const edges: DiagramEdge[] = []
|
||||||
|
|
||||||
|
const geoMap = new Map<string, { x: number; y: number; width: number; height: number }>()
|
||||||
|
for (const cell of cells) {
|
||||||
|
const geo = cell.querySelector('mxGeometry')
|
||||||
|
if (geo) {
|
||||||
|
geoMap.set(cell.getAttribute('id') ?? '', {
|
||||||
|
x: parseFloat(geo.getAttribute('x') ?? '0'),
|
||||||
|
y: parseFloat(geo.getAttribute('y') ?? '0'),
|
||||||
|
width: parseFloat(geo.getAttribute('width') ?? '120'),
|
||||||
|
height: parseFloat(geo.getAttribute('height') ?? '120'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupIds = new Set<string>()
|
||||||
|
|
||||||
|
for (const cell of cells) {
|
||||||
|
const id = cell.getAttribute('id') ?? ''
|
||||||
|
if (id === '0' || id === '1') continue
|
||||||
|
|
||||||
|
const isEdge = cell.getAttribute('edge') === '1'
|
||||||
|
const isVertex = cell.getAttribute('vertex') === '1'
|
||||||
|
const style = cell.getAttribute('style') ?? ''
|
||||||
|
const value = cell.getAttribute('value') ?? ''
|
||||||
|
const parent = cell.getAttribute('parent') ?? '1'
|
||||||
|
const geo = geoMap.get(id)
|
||||||
|
|
||||||
|
if (isEdge) {
|
||||||
|
const source = cell.getAttribute('source') ?? ''
|
||||||
|
const target = cell.getAttribute('target') ?? ''
|
||||||
|
if (!source || !target) {
|
||||||
|
warnings.push(`Edge "${id}" skipped — missing source or target`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
edges.push({
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
label: value || null,
|
||||||
|
connectionType: 'ethernet',
|
||||||
|
speed: null,
|
||||||
|
notes: null,
|
||||||
|
routing: null,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVertex && geo) {
|
||||||
|
if (isGroup(style)) {
|
||||||
|
groupIds.add(id)
|
||||||
|
nodes.push({
|
||||||
|
id,
|
||||||
|
type: 'subnet',
|
||||||
|
label: value || 'Group',
|
||||||
|
position: { x: geo.x, y: geo.y },
|
||||||
|
properties: {
|
||||||
|
hostname: null, ip: null, subnet: null, vendor: null,
|
||||||
|
model: null, role: null, vlan: null, notes: null, status: 'unknown',
|
||||||
|
},
|
||||||
|
nodeType: 'group',
|
||||||
|
style: { width: geo.width, height: geo.height },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const slug = styleToSlug(style)
|
||||||
|
const parentId = parent !== '1' && groupIds.has(parent) ? parent : undefined
|
||||||
|
nodes.push({
|
||||||
|
id,
|
||||||
|
type: slug,
|
||||||
|
label: value || slug,
|
||||||
|
position: { x: geo.x, y: geo.y },
|
||||||
|
properties: {
|
||||||
|
hostname: null, ip: null, subnet: null, vendor: null,
|
||||||
|
model: null, role: null, vlan: null, notes: null, status: 'unknown',
|
||||||
|
},
|
||||||
|
...(parentId ? { parentId } : {}),
|
||||||
|
style: { width: geo.width, height: geo.height },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
warnings.push('No nodes were found in this draw.io file. Only basic shapes and Cisco stencil shapes are supported.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes, edges, warnings }
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
|
|||||||
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { exportToDrawio } from '@/lib/drawio-export'
|
import { exportToDrawio } from '@/lib/drawio-export'
|
||||||
|
import { parseDrawioXml } from '@/lib/drawio-import'
|
||||||
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types'
|
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types'
|
||||||
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
|
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ function DiagramEditorInner() {
|
|||||||
const [interactionMode, setInteractionMode] = useState<InteractionMode>('select')
|
const [interactionMode, setInteractionMode] = useState<InteractionMode>('select')
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null)
|
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const drawioImportRef = useRef<HTMLInputElement>(null)
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
||||||
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(null)
|
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -745,6 +747,39 @@ function DiagramEditorInner() {
|
|||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}, [nodes, edges, getNodes, name])
|
}, [nodes, edges, getNodes, name])
|
||||||
|
|
||||||
|
const handleImportDrawio = useCallback(() => {
|
||||||
|
drawioImportRef.current?.click()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrawioFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
e.target.value = ''
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const { nodes: importedNodes, edges: importedEdges, warnings } = parseDrawioXml(text)
|
||||||
|
const importPayload = {
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
name: file.name.replace(/\.drawio$/i, '') || 'Imported Diagram',
|
||||||
|
client_name: null,
|
||||||
|
description: null,
|
||||||
|
nodes: importedNodes,
|
||||||
|
edges: importedEdges,
|
||||||
|
}
|
||||||
|
const result = await networkDiagramsApi.importJson(importPayload)
|
||||||
|
const allWarnings = [...warnings, ...result.warnings]
|
||||||
|
if (allWarnings.length > 0) {
|
||||||
|
toast.warning(`Imported with ${allWarnings.length} warning(s): ${allWarnings[0]}`)
|
||||||
|
} else {
|
||||||
|
toast.success('draw.io file imported successfully')
|
||||||
|
}
|
||||||
|
navigate(`/network-diagrams/${result.diagram.id}`)
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
toast.error(`Import failed: ${msg}`)
|
||||||
|
}
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
@@ -769,6 +804,7 @@ function DiagramEditorInner() {
|
|||||||
onExportPdf={handleExportPdf}
|
onExportPdf={handleExportPdf}
|
||||||
onExportJson={handleExportJson}
|
onExportJson={handleExportJson}
|
||||||
onExportDrawio={handleExportDrawio}
|
onExportDrawio={handleExportDrawio}
|
||||||
|
onImportDrawio={handleImportDrawio}
|
||||||
onUndo={undo}
|
onUndo={undo}
|
||||||
onRedo={redo}
|
onRedo={redo}
|
||||||
canUndo={canUndo}
|
canUndo={canUndo}
|
||||||
@@ -896,6 +932,13 @@ function DiagramEditorInner() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<input
|
||||||
|
ref={drawioImportRef}
|
||||||
|
type="file"
|
||||||
|
accept=".drawio,.xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleDrawioFileChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export default function NetworkDiagramsPage() {
|
|||||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
|
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
|
||||||
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
|
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
|
||||||
const clientDropdownRef = useRef<HTMLDivElement>(null)
|
const clientDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const drawioListImportRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clientDropdownOpen) return
|
if (!clientDropdownOpen) return
|
||||||
@@ -129,6 +130,35 @@ export default function NetworkDiagramsPage() {
|
|||||||
input.click()
|
input.click()
|
||||||
}, [navigate])
|
}, [navigate])
|
||||||
|
|
||||||
|
const handleListDrawioImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
e.target.value = ''
|
||||||
|
try {
|
||||||
|
const { parseDrawioXml } = await import('@/lib/drawio-import')
|
||||||
|
const text = await file.text()
|
||||||
|
const { nodes: importedNodes, edges: importedEdges, warnings } = parseDrawioXml(text)
|
||||||
|
const result = await networkDiagramsApi.importJson({
|
||||||
|
schemaVersion: 1,
|
||||||
|
name: file.name.replace(/\.drawio$/i, '') || 'Imported Diagram',
|
||||||
|
client_name: null,
|
||||||
|
description: null,
|
||||||
|
nodes: importedNodes,
|
||||||
|
edges: importedEdges,
|
||||||
|
})
|
||||||
|
const allWarnings = [...warnings, ...result.warnings]
|
||||||
|
if (allWarnings.length > 0) {
|
||||||
|
toast.warning(`Imported with ${allWarnings.length} warning(s)`)
|
||||||
|
} else {
|
||||||
|
toast.success('Imported successfully')
|
||||||
|
}
|
||||||
|
navigate(`/network-diagrams/${result.diagram.id}`)
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
toast.error(`Import failed: ${msg}`)
|
||||||
|
}
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
}
|
}
|
||||||
@@ -148,6 +178,20 @@ export default function NetworkDiagramsPage() {
|
|||||||
<Upload size={14} />
|
<Upload size={14} />
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
|
<input
|
||||||
|
ref={drawioListImportRef}
|
||||||
|
type="file"
|
||||||
|
accept=".drawio,.xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleListDrawioImport}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => drawioListImportRef.current?.click()}
|
||||||
|
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
Import draw.io
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/network-diagrams/new')}
|
onClick={() => navigate('/network-diagrams/new')}
|
||||||
className="flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
className="flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
||||||
|
|||||||
Reference in New Issue
Block a user