From e7ab9a1d7aea6e83e8b76fd652ac5b40051a4fe6 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 23 Mar 2026 21:34:26 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20add=20YAML=20import=20=E2=80=94=20merge?= =?UTF-8?q?=20nodes/edges=20from=20.yaml=20file=20into=20canvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 16 +- frontend/src/components/panels/Toolbar.tsx | 29 ++- frontend/src/types/yaml.ts | 8 +- .../src/utils/__tests__/importYaml.test.ts | 230 ++++++++++++++++++ frontend/src/utils/importYaml.ts | 159 ++++++++++++ 5 files changed, 435 insertions(+), 7 deletions(-) create mode 100644 frontend/src/utils/__tests__/importYaml.test.ts create mode 100644 frontend/src/utils/importYaml.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 328a227..feac20a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { generateUUID } from '@/utils/uuid' import { generateMarkdownTable } from '@/utils/exportMarkdown' import { exportToPng } from '@/utils/export' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' +import { parseYamlToCanvas } from '@/utils/importYaml' import { TooltipProvider } from '@/components/ui/tooltip' import { Toaster } from '@/components/ui/sonner' import { toast } from 'sonner' @@ -33,7 +34,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' export default function App() { - const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() + const { loadCanvas, markSaved, markUnsaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme } = useThemeStore() @@ -379,6 +380,18 @@ export default function App() { toast.success('Canvas exported as YAML') }, [nodes, edges]) + const handleImportYaml = useCallback((content: string) => { + try { + const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges) + snapshotHistory() + loadCanvas(merged, mergedEdges) + markUnsaved() + toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`) + } catch (err) { + toast.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`) + } + }, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved]) + const handleExport = useCallback(async () => { const el = canvasRef.current?.querySelector('.react-flow') if (!el) { toast.error('Canvas not ready'); return } @@ -458,6 +471,7 @@ export default function App() { onShortcuts={() => setShortcutsOpen(true)} onExportMd={handleExportMd} onExportYaml={handleExportYaml} + onImportYaml={handleImportYaml} />
diff --git a/frontend/src/components/panels/Toolbar.tsx b/frontend/src/components/panels/Toolbar.tsx index 656e64e..89ca0a3 100644 --- a/frontend/src/components/panels/Toolbar.tsx +++ b/frontend/src/components/panels/Toolbar.tsx @@ -1,4 +1,5 @@ -import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown } from 'lucide-react' +import { useRef } from 'react' +import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload } from 'lucide-react' import { Button } from '@/components/ui/button' import { Logo } from '@/components/ui/Logo' import { useCanvasStore } from '@/stores/canvasStore' @@ -13,10 +14,24 @@ interface ToolbarProps { onShortcuts: () => void onExportMd: () => void onExportYaml: () => void + onImportYaml: (content: string) => void } -export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml }: ToolbarProps) { +export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml }: ToolbarProps) { const { hasUnsavedChanges, past, future } = useCanvasStore() + const fileInputRef = useRef(null) + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = (ev) => { + const content = ev.target?.result + if (typeof content === 'string') onImportYaml(content) + } + reader.readAsText(file) + e.target.value = '' + } return (
@@ -47,6 +62,16 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, + + diff --git a/frontend/src/types/yaml.ts b/frontend/src/types/yaml.ts index 5896664..f73c3e2 100644 --- a/frontend/src/types/yaml.ts +++ b/frontend/src/types/yaml.ts @@ -1,9 +1,9 @@ -import type { EdgeType, NodeType } from '@/types' +import type { NodeType, EdgeType, CheckMethod } from '@/types' export interface YamlNodeConnection { label: string - linkType: EdgeType - linkLabel: string + linkType?: EdgeType + linkLabel?: string } export interface YamlNode { @@ -12,7 +12,7 @@ export interface YamlNode { label: string hostname?: string ipAddress?: string - checkMethod?: string + checkMethod?: CheckMethod checkTarget?: string notes?: string parent?: YamlNodeConnection diff --git a/frontend/src/utils/__tests__/importYaml.test.ts b/frontend/src/utils/__tests__/importYaml.test.ts new file mode 100644 index 0000000..76aad23 --- /dev/null +++ b/frontend/src/utils/__tests__/importYaml.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi } from 'vitest' +import { parseYamlToCanvas } from '../importYaml' +import type { Node, Edge } from '@xyflow/react' +import type { NodeData, EdgeData } from '@/types' + +// Mock dagre layout to return nodes with predictable positions +vi.mock('../layout', () => ({ + applyDagreLayout: (nodes: Node[]) => + nodes.map((n, i) => ({ ...n, position: { x: i * 200, y: 0 } })), +})) + +// Mock uuid to return deterministic ids +let uuidCounter = 0 +vi.mock('../uuid', () => ({ + generateUUID: () => `test-uuid-${++uuidCounter}`, +})) + +beforeEach(() => { + uuidCounter = 0 +}) + +const empty: Node[] = [] +const emptyEdges: Edge[] = [] + +describe('parseYamlToCanvas', () => { + it('parses a minimal node (only nodeType + label)', () => { + const yaml = ` +- nodeType: server + label: "My Server" +` + const { nodes, edges, imported } = parseYamlToCanvas(yaml, empty, emptyEdges) + expect(imported).toBe(1) + expect(nodes).toHaveLength(1) + expect(nodes[0].data.label).toBe('My Server') + expect(nodes[0].data.type).toBe('server') + expect(nodes[0].data.status).toBe('unknown') + expect(edges).toHaveLength(0) + }) + + it('parses all scalar fields', () => { + const yaml = ` +- nodeType: proxmox + label: "PVE1" + hostname: "pve1.local" + ipAddress: "192.168.1.10" + checkMethod: ping + checkTarget: "192.168.1.10" + notes: "main host" + nodeIcon: "custom-icon" + cpuModel: "Intel Xeon" + cpuCore: 16 + ram: 64 + disk: 2000 +` + const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges) + const d = nodes[0].data + expect(d.hostname).toBe('pve1.local') + expect(d.ip).toBe('192.168.1.10') + expect(d.check_method).toBe('ping') + expect(d.check_target).toBe('192.168.1.10') + expect(d.notes).toBe('main host') + expect(d.custom_icon).toBe('custom-icon') + expect(d.cpu_model).toBe('Intel Xeon') + expect(d.cpu_count).toBe(16) + expect(d.ram_gb).toBe(64) + expect(d.disk_gb).toBe(2000) + expect(d.show_hardware).toBe(true) + }) + + it('sets show_hardware only when hardware fields present', () => { + const yaml = `- nodeType: server\n label: "NoHW"\n` + const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges) + expect(nodes[0].data.show_hardware).toBeUndefined() + }) + + it('parent relationship sets parentId and creates an edge', () => { + const yaml = ` +- nodeType: proxmox + label: "PVE1" +- nodeType: vm + label: "VM1" + parent: + label: "PVE1" + linkType: virtual + linkLabel: "hosted" +` + const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges) + const vm = nodes.find((n) => n.data.label === 'VM1')! + const pve = nodes.find((n) => n.data.label === 'PVE1')! + expect(vm.parentId).toBe(pve.id) + expect(vm.data.parent_id).toBe(pve.id) + expect(vm.extent).toBe('parent') + expect(edges).toHaveLength(1) + expect(edges[0].source).toBe(pve.id) + expect(edges[0].target).toBe(vm.id) + expect(edges[0].type).toBe('virtual') + expect(edges[0].data?.label).toBe('hosted') + }) + + it('clusterR creates an edge from this node to target', () => { + const yaml = ` +- nodeType: proxmox + label: "PVE1" + clusterR: + label: "PVE2" + linkType: ethernet + linkLabel: "10GbE" +- nodeType: proxmox + label: "PVE2" +` + const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges) + const pve1 = nodes.find((n) => n.data.label === 'PVE1')! + const pve2 = nodes.find((n) => n.data.label === 'PVE2')! + expect(edges).toHaveLength(1) + expect(edges[0].source).toBe(pve1.id) + expect(edges[0].target).toBe(pve2.id) + expect(edges[0].type).toBe('ethernet') + }) + + it('clusterL creates an edge from referenced node to this node', () => { + const yaml = ` +- nodeType: proxmox + label: "PVE1" +- nodeType: proxmox + label: "PVE2" + clusterL: + label: "PVE1" + linkType: cluster + linkLabel: "" +` + const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges) + const pve1 = nodes.find((n) => n.data.label === 'PVE1')! + const pve2 = nodes.find((n) => n.data.label === 'PVE2')! + expect(edges).toHaveLength(1) + expect(edges[0].source).toBe(pve1.id) + expect(edges[0].target).toBe(pve2.id) + }) + + it('deduplicates edges when clusterR on A and clusterL on B point to each other', () => { + const yaml = ` +- nodeType: proxmox + label: "PVE1" + clusterR: + label: "PVE2" + linkType: ethernet +- nodeType: proxmox + label: "PVE2" + clusterL: + label: "PVE1" + linkType: ethernet +` + const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges) + expect(edges).toHaveLength(1) + }) + + it('skips nodes with same label as existing canvas nodes', () => { + const existing: Node[] = [{ + id: 'existing-1', + type: 'server', + position: { x: 0, y: 0 }, + data: { label: 'ExistingServer', type: 'server', status: 'online', services: [] }, + }] + const yaml = ` +- nodeType: server + label: "ExistingServer" +- nodeType: router + label: "NewRouter" +` + const { nodes, imported } = parseYamlToCanvas(yaml, existing, emptyEdges) + expect(imported).toBe(1) + expect(nodes.filter((n) => n.data.label === 'ExistingServer')).toHaveLength(1) + expect(nodes.filter((n) => n.data.label === 'NewRouter')).toHaveLength(1) + }) + + it('merges with existing edges without duplicating', () => { + const existing: Node[] = [ + { id: 'a', type: 'server', position: { x: 0, y: 0 }, data: { label: 'A', type: 'server', status: 'online', services: [] } }, + { id: 'b', type: 'server', position: { x: 0, y: 0 }, data: { label: 'B', type: 'server', status: 'online', services: [] } }, + ] + const existingEdge: Edge[] = [{ + id: 'e1', source: 'a', target: 'b', type: 'ethernet', + data: { type: 'ethernet' }, + }] + const yaml = ` +- nodeType: server + label: "A" + clusterR: + label: "B" + linkType: ethernet +` + // A already exists so it's skipped, no new edge created + const { edges } = parseYamlToCanvas(yaml, existing, existingEdge) + expect(edges).toHaveLength(1) + }) + + it('throws on invalid YAML', () => { + expect(() => parseYamlToCanvas('{invalid: [yaml', empty, emptyEdges)).toThrow() + }) + + it('throws when YAML is not an array', () => { + const yaml = `nodeType: server\nlabel: oops\n` + expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/list/) + }) + + it('throws when nodeType is missing', () => { + const yaml = `- label: "Missing type"\n` + expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/nodeType/) + }) + + it('throws when label is missing', () => { + const yaml = `- nodeType: server\n` + expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/label/) + }) + + it('warns and skips unknown parent label without crashing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const yaml = ` +- nodeType: vm + label: "OrphanVM" + parent: + label: "NonexistentHost" + linkType: virtual +` + const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges) + expect(nodes).toHaveLength(1) + expect(edges).toHaveLength(0) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NonexistentHost')) + warnSpy.mockRestore() + }) +}) diff --git a/frontend/src/utils/importYaml.ts b/frontend/src/utils/importYaml.ts new file mode 100644 index 0000000..8406ed4 --- /dev/null +++ b/frontend/src/utils/importYaml.ts @@ -0,0 +1,159 @@ +import yaml from 'js-yaml' +import type { Node, Edge } from '@xyflow/react' +import type { NodeData, EdgeData } from '@/types' +import type { YamlNode, YamlNodeConnection } from '@/types/yaml' +import { generateUUID } from '@/utils/uuid' +import { applyDagreLayout } from '@/utils/layout' + +/** + * Parse a YAML string and merge the resulting nodes/edges into the existing canvas. + * - Nodes with the same label as an existing node are skipped (no duplicates). + * - Positions are computed via dagre auto-layout over the full merged set. + */ +export function parseYamlToCanvas( + yamlString: string, + existingNodes: Node[], + existingEdges: Edge[], +): { nodes: Node[]; edges: Edge[]; imported: number } { + const raw = yaml.load(yamlString) + + if (!Array.isArray(raw)) { + throw new Error('YAML must be a list of node objects (top-level array)') + } + + const entries = raw as unknown[] + + // Build lookup: label → existing node id (existing canvas + nodes being added) + const labelToId = new Map() + for (const n of existingNodes) { + labelToId.set(n.data.label, n.id) + } + + // First pass: validate and create nodes (without positions — dagre will assign them) + const newNodes: Node[] = [] + const yamlNodes: YamlNode[] = [] + + for (const entry of entries) { + const raw = entry as Record + + if (!raw.nodeType || typeof raw.nodeType !== 'string') { + throw new Error(`Each YAML entry must have a "nodeType" string field`) + } + if (!raw.label || typeof raw.label !== 'string') { + throw new Error(`Each YAML entry must have a "label" string field`) + } + + const yn = raw as unknown as YamlNode + + // Skip if a node with this label already exists on the canvas + if (labelToId.has(yn.label)) { + console.warn(`[importYaml] Skipping duplicate label: "${yn.label}"`) + continue + } + + const id = generateUUID() + labelToId.set(yn.label, id) + + const hasHardware = !!(yn.cpuModel || yn.cpuCore || yn.ram || yn.disk) + + const data: NodeData = { + label: yn.label, + type: yn.nodeType, + status: 'unknown', + services: [], + ...(yn.hostname ? { hostname: yn.hostname } : {}), + ...(yn.ipAddress ? { ip: yn.ipAddress } : {}), + ...(yn.checkMethod ? { check_method: yn.checkMethod } : {}), + ...(yn.checkTarget ? { check_target: yn.checkTarget } : {}), + ...(yn.notes ? { notes: yn.notes } : {}), + ...(yn.nodeIcon ? { custom_icon: yn.nodeIcon } : {}), + ...(yn.cpuModel ? { cpu_model: yn.cpuModel } : {}), + ...(yn.cpuCore ? { cpu_count: yn.cpuCore } : {}), + ...(yn.ram ? { ram_gb: yn.ram } : {}), + ...(yn.disk ? { disk_gb: yn.disk } : {}), + ...(hasHardware ? { show_hardware: true } : {}), + } + + newNodes.push({ + id, + type: yn.nodeType, + position: { x: 0, y: 0 }, + data, + }) + + yamlNodes.push(yn) + } + + // Second pass: apply parent relationships (parentId / parent_id) + const newEdges: Edge[] = [] + // Track edge pairs to deduplicate (store as "sourceId|targetId") + const edgePairs = new Set( + existingEdges.map((e) => `${e.source}|${e.target}`) + ) + + function addEdgeIfNew( + sourceId: string, + targetId: string, + conn: YamlNodeConnection, + ) { + const key = `${sourceId}|${targetId}` + const reverseKey = `${targetId}|${sourceId}` + if (edgePairs.has(key) || edgePairs.has(reverseKey)) return + edgePairs.add(key) + const edgeType = conn.linkType ?? 'ethernet' + newEdges.push({ + id: generateUUID(), + source: sourceId, + target: targetId, + type: edgeType, + data: { + type: edgeType, + ...(conn.linkLabel ? { label: conn.linkLabel } : {}), + }, + }) + } + + for (let i = 0; i < newNodes.length; i++) { + const node = newNodes[i] + const yn = yamlNodes[i] + + if (yn.parent) { + const parentId = labelToId.get(yn.parent.label) + if (!parentId) { + console.warn(`[importYaml] parent label not found: "${yn.parent.label}" — skipping relationship`) + } else { + // Set React Flow parentId for nesting + node.data = { ...node.data, parent_id: parentId } + node.parentId = parentId + node.extent = 'parent' + // Also create an edge + addEdgeIfNew(parentId, node.id, yn.parent) + } + } + + if (yn.clusterR) { + const targetId = labelToId.get(yn.clusterR.label) + if (!targetId) { + console.warn(`[importYaml] clusterR label not found: "${yn.clusterR.label}" — skipping`) + } else { + addEdgeIfNew(node.id, targetId, yn.clusterR) + } + } + + if (yn.clusterL) { + const sourceId = labelToId.get(yn.clusterL.label) + if (!sourceId) { + console.warn(`[importYaml] clusterL label not found: "${yn.clusterL.label}" — skipping`) + } else { + addEdgeIfNew(sourceId, node.id, yn.clusterL) + } + } + } + + // Merge and apply layout + const mergedNodes = [...existingNodes, ...newNodes] + const mergedEdges = [...existingEdges, ...newEdges] + const laidOut = applyDagreLayout(mergedNodes, mergedEdges) + + return { nodes: laidOut, edges: mergedEdges, imported: newNodes.length } +}