From 2a4220b4963ca194ef4e3a890801ee0ac88c219b Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 14 Apr 2026 01:25:49 +0000 Subject: [PATCH] feat(network): draw.io XML export Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/network/DiagramHeader.tsx | 10 +- frontend/src/lib/drawio-export.ts | 99 +++++++++++++++++++ .../pages/NetworkDiagrams/DiagramEditor.tsx | 17 ++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/drawio-export.ts diff --git a/frontend/src/components/network/DiagramHeader.tsx b/frontend/src/components/network/DiagramHeader.tsx index f3f7e830..ab3d83f8 100644 --- a/frontend/src/components/network/DiagramHeader.tsx +++ b/frontend/src/components/network/DiagramHeader.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useRef, useEffect } from 'react' 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' export type InteractionMode = 'select' | 'pan' @@ -18,6 +18,7 @@ interface DiagramHeaderProps { onExportSvg: () => void onExportPdf: () => void onExportJson: () => void + onExportDrawio: () => void onUndo: () => void onRedo: () => void canUndo: boolean @@ -39,6 +40,7 @@ export function DiagramHeader({ onExportSvg, onExportPdf, onExportJson, + onExportDrawio, onUndo, onRedo, canUndo, @@ -233,6 +235,12 @@ export function DiagramHeader({ Export JSON )} + )} diff --git a/frontend/src/lib/drawio-export.ts b/frontend/src/lib/drawio-export.ts new file mode 100644 index 00000000..4f7b03cc --- /dev/null +++ b/frontend/src/lib/drawio-export.ts @@ -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 = { + '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, '"') +} + +export function exportToDrawio(nodes: Node[], edges: Edge[]): string { + const cells: string[] = [ + '', + '', + ] + + 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( + `` + + `` + + ``, + ) + } 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( + `` + + `` + + ``, + ) + } + } + + for (const edge of edges) { + const label = typeof edge.label === 'string' ? edge.label : '' + cells.push( + `` + + `` + + ``, + ) + } + + const xml = + `\n` + + `\n` + + cells.join('\n') + + `\n` + + return xml +} diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 0d000f98..abe302f8 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -25,6 +25,7 @@ import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel' import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt' import { networkDiagramsApi, deviceTypesApi } from '@/api' import { toast } from '@/lib/toast' +import { exportToDrawio } from '@/lib/drawio-export' import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types' import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode' @@ -729,6 +730,21 @@ function DiagramEditorInner() { } }, [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) { return (
@@ -752,6 +768,7 @@ function DiagramEditorInner() { onExportSvg={handleExportSvg} onExportPdf={handleExportPdf} onExportJson={handleExportJson} + onExportDrawio={handleExportDrawio} onUndo={undo} onRedo={redo} canUndo={canUndo}