feat(network): draw.io XML export
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 } from 'lucide-react'
|
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, Share2 } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export type InteractionMode = 'select' | 'pan'
|
export type InteractionMode = 'select' | 'pan'
|
||||||
@@ -18,6 +18,7 @@ interface DiagramHeaderProps {
|
|||||||
onExportSvg: () => void
|
onExportSvg: () => void
|
||||||
onExportPdf: () => void
|
onExportPdf: () => void
|
||||||
onExportJson: () => void
|
onExportJson: () => void
|
||||||
|
onExportDrawio: () => void
|
||||||
onUndo: () => void
|
onUndo: () => void
|
||||||
onRedo: () => void
|
onRedo: () => void
|
||||||
canUndo: boolean
|
canUndo: boolean
|
||||||
@@ -39,6 +40,7 @@ export function DiagramHeader({
|
|||||||
onExportSvg,
|
onExportSvg,
|
||||||
onExportPdf,
|
onExportPdf,
|
||||||
onExportJson,
|
onExportJson,
|
||||||
|
onExportDrawio,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
canUndo,
|
canUndo,
|
||||||
@@ -233,6 +235,12 @@ export function DiagramHeader({
|
|||||||
<FileJson size={12} /> Export JSON
|
<FileJson size={12} /> Export JSON
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { onExportDrawio(); setShowExportMenu(false) }}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||||
|
>
|
||||||
|
<Share2 size={12} /> Export draw.io
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
99
frontend/src/lib/drawio-export.ts
Normal file
99
frontend/src/lib/drawio-export.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
|
||||||
|
import type { GroupNodeData } from '@/types/network-diagram'
|
||||||
|
|
||||||
|
// Maps our device slugs to draw.io Cisco stencil shape styles
|
||||||
|
const SLUG_TO_DRAWIO_STYLE: Record<string, string> = {
|
||||||
|
'router': 'shape=mxgraph.cisco.routers.router;',
|
||||||
|
'switch': 'shape=mxgraph.cisco.switches.layer_3_switch;',
|
||||||
|
'access-point': 'shape=mxgraph.cisco.misc.access_point;',
|
||||||
|
'load-balancer': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||||
|
'firewall': 'shape=mxgraph.cisco.firewalls.firewall;',
|
||||||
|
'badge-reader': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||||
|
'server': 'shape=mxgraph.cisco.servers.standard_server;',
|
||||||
|
'vm': 'shape=mxgraph.cisco.servers.standard_server;',
|
||||||
|
'container': 'shape=mxgraph.cisco.servers.standard_server;',
|
||||||
|
'nas': 'shape=mxgraph.cisco.storage.tape_storage_library;',
|
||||||
|
'san': 'shape=mxgraph.cisco.storage.tape_storage_library;',
|
||||||
|
'cloud-storage': 'shape=mxgraph.cisco.misc.cloud;',
|
||||||
|
'cloud': 'shape=mxgraph.cisco.misc.cloud;',
|
||||||
|
'aws': 'shape=mxgraph.cisco.misc.cloud;',
|
||||||
|
'azure': 'shape=mxgraph.cisco.misc.cloud;',
|
||||||
|
'gcp': 'shape=mxgraph.cisco.misc.cloud;',
|
||||||
|
'isp': 'shape=mxgraph.cisco.misc.cloud;',
|
||||||
|
'workstation': 'shape=mxgraph.cisco.computers_and_peripherals.pc;',
|
||||||
|
'laptop': 'shape=mxgraph.cisco.computers_and_peripherals.laptop;',
|
||||||
|
'tablet': 'shape=mxgraph.cisco.computers_and_peripherals.laptop;',
|
||||||
|
'phone': 'shape=mxgraph.cisco.computers_and_peripherals.ip_phone;',
|
||||||
|
'printer': 'shape=mxgraph.cisco.computers_and_peripherals.printer;',
|
||||||
|
'ups': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||||
|
'pdu': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||||
|
'rack': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||||
|
'patch-panel': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||||
|
'camera': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||||
|
'nvr': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||||
|
'iot': 'shape=mxgraph.cisco.misc.generic_building;',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_NODE_STYLE =
|
||||||
|
'sketch=0;html=1;pointerEvents=1;dashed=0;fillColor=#036897;strokeColor=#ffffff;strokeWidth=2;verticalLabelPosition=bottom;verticalAlign=top;align=center;outlineConnect=0;'
|
||||||
|
const GROUP_STYLE =
|
||||||
|
'swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;collapsible=0;marginBottom=0;swimlaneHead=0;fillColor=none;'
|
||||||
|
|
||||||
|
function esc(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportToDrawio(nodes: Node[], edges: Edge[]): string {
|
||||||
|
const cells: string[] = [
|
||||||
|
'<mxCell id="0"/>',
|
||||||
|
'<mxCell id="1" parent="0"/>',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const w = typeof node.style?.width === 'number' ? node.style.width : (node.measured?.width ?? 120)
|
||||||
|
const h = typeof node.style?.height === 'number' ? node.style.height : (node.measured?.height ?? 120)
|
||||||
|
const x = node.position.x
|
||||||
|
const y = node.position.y
|
||||||
|
const parentId = node.parentId ?? '1'
|
||||||
|
|
||||||
|
if (node.type === 'group') {
|
||||||
|
const gd = node.data as GroupNodeData
|
||||||
|
cells.push(
|
||||||
|
`<mxCell id="${esc(node.id)}" value="${esc(gd.label ?? '')}" style="${GROUP_STYLE}" vertex="1" parent="${esc(parentId)}">` +
|
||||||
|
`<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/>` +
|
||||||
|
`</mxCell>`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const dd = node.data as DeviceNodeData
|
||||||
|
const slug = dd.deviceType ?? 'server'
|
||||||
|
const shapeStyle = SLUG_TO_DRAWIO_STYLE[slug] ?? 'rounded=1;whiteSpace=wrap;html=1;'
|
||||||
|
cells.push(
|
||||||
|
`<mxCell id="${esc(node.id)}" value="${esc(dd.label ?? '')}" style="${shapeStyle}${BASE_NODE_STYLE}" vertex="1" parent="${esc(parentId)}">` +
|
||||||
|
`<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/>` +
|
||||||
|
`</mxCell>`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const label = typeof edge.label === 'string' ? edge.label : ''
|
||||||
|
cells.push(
|
||||||
|
`<mxCell id="${esc(edge.id)}" value="${esc(label)}" style="edgeStyle=orthogonalEdgeStyle;html=1;" edge="1" source="${esc(edge.source)}" target="${esc(edge.target)}" parent="1">` +
|
||||||
|
`<mxGeometry relative="1" as="geometry"/>` +
|
||||||
|
`</mxCell>`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml =
|
||||||
|
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||||
|
`<mxGraphModel><root>\n` +
|
||||||
|
cells.join('\n') +
|
||||||
|
`\n</root></mxGraphModel>`
|
||||||
|
|
||||||
|
return xml
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
|||||||
import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
|
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 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'
|
||||||
|
|
||||||
@@ -729,6 +730,21 @@ function DiagramEditorInner() {
|
|||||||
}
|
}
|
||||||
}, [diagramId, name])
|
}, [diagramId, name])
|
||||||
|
|
||||||
|
const handleExportDrawio = useCallback(() => {
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
toast.warning('Add some devices to the diagram before exporting')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const xml = exportToDrawio(getNodes(), edges)
|
||||||
|
const blob = new Blob([xml], { type: 'application/xml' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.drawio`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}, [nodes, edges, getNodes, name])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
@@ -752,6 +768,7 @@ function DiagramEditorInner() {
|
|||||||
onExportSvg={handleExportSvg}
|
onExportSvg={handleExportSvg}
|
||||||
onExportPdf={handleExportPdf}
|
onExportPdf={handleExportPdf}
|
||||||
onExportJson={handleExportJson}
|
onExportJson={handleExportJson}
|
||||||
|
onExportDrawio={handleExportDrawio}
|
||||||
onUndo={undo}
|
onUndo={undo}
|
||||||
onRedo={redo}
|
onRedo={redo}
|
||||||
canUndo={canUndo}
|
canUndo={canUndo}
|
||||||
|
|||||||
Reference in New Issue
Block a user