diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1f2b562..2ae06e2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,12 +13,14 @@ "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", + "@types/js-yaml": "^4.0.9", "@xyflow/react": "^12.10.1", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dagre": "^0.8.5", "html-to-image": "^1.11.13", + "js-yaml": "^4.1.1", "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -3008,6 +3010,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3c228e1..3c519c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,12 +19,14 @@ "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", + "@types/js-yaml": "^4.0.9", "@xyflow/react": "^12.10.1", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dagre": "^0.8.5", "html-to-image": "^1.11.13", + "js-yaml": "^4.1.1", "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 12a102d..328a227 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { applyDagreLayout } from '@/utils/layout' import { generateUUID } from '@/utils/uuid' import { generateMarkdownTable } from '@/utils/exportMarkdown' import { exportToPng } from '@/utils/export' +import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' import { TooltipProvider } from '@/components/ui/tooltip' import { Toaster } from '@/components/ui/sonner' import { toast } from 'sonner' @@ -371,6 +372,13 @@ export default function App() { toast.success('Markdown table copied to clipboard') }, [nodes]) + const handleExportYaml = useCallback(() => { + if (nodes.length === 0) { toast.error('No nodes to export'); return } + const content = exportCanvasToYaml(nodes, edges) + downloadYaml(content) + toast.success('Canvas exported as YAML') + }, [nodes, edges]) + const handleExport = useCallback(async () => { const el = canvasRef.current?.querySelector('.react-flow') if (!el) { toast.error('Canvas not ready'); return } @@ -449,6 +457,7 @@ export default function App() { onRedo={redo} onShortcuts={() => setShortcutsOpen(true)} onExportMd={handleExportMd} + onExportYaml={handleExportYaml} />
diff --git a/frontend/src/components/panels/Toolbar.tsx b/frontend/src/components/panels/Toolbar.tsx index f7763cb..656e64e 100644 --- a/frontend/src/components/panels/Toolbar.tsx +++ b/frontend/src/components/panels/Toolbar.tsx @@ -1,4 +1,4 @@ -import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2 } from 'lucide-react' +import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown } from 'lucide-react' import { Button } from '@/components/ui/button' import { Logo } from '@/components/ui/Logo' import { useCanvasStore } from '@/stores/canvasStore' @@ -12,9 +12,10 @@ interface ToolbarProps { onRedo: () => void onShortcuts: () => void onExportMd: () => void + onExportYaml: () => void } -export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd }: ToolbarProps) { +export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml }: ToolbarProps) { const { hasUnsavedChanges, past, future } = useCanvasStore() return ( @@ -52,6 +53,9 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, + diff --git a/frontend/src/types/yaml.ts b/frontend/src/types/yaml.ts new file mode 100644 index 0000000..5896664 --- /dev/null +++ b/frontend/src/types/yaml.ts @@ -0,0 +1,25 @@ +import type { EdgeType, NodeType } from '@/types' + +export interface YamlNodeConnection { + label: string + linkType: EdgeType + linkLabel: string +} + +export interface YamlNode { + nodeType: NodeType + nodeIcon?: string + label: string + hostname?: string + ipAddress?: string + checkMethod?: string + checkTarget?: string + notes?: string + parent?: YamlNodeConnection + clusterR?: YamlNodeConnection + clusterL?: YamlNodeConnection + cpuModel?: string + cpuCore?: number + ram?: number + disk?: number +} diff --git a/frontend/src/utils/__tests__/exportYaml.test.ts b/frontend/src/utils/__tests__/exportYaml.test.ts new file mode 100644 index 0000000..60c5d57 --- /dev/null +++ b/frontend/src/utils/__tests__/exportYaml.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest' +import { exportCanvasToYaml } from '../exportYaml' +import type { Node, Edge } from '@xyflow/react' +import type { NodeData, EdgeData } from '@/types' +import yaml from 'js-yaml' + +const makeNode = (overrides: Partial = {}, id = '1', parentId?: string): Node => ({ + id, + type: overrides.type ?? 'server', + position: { x: 0, y: 0 }, + parentId, + data: { label: 'Test', type: 'server', status: 'online', services: [], ...overrides }, +}) + +const makeEdge = (id: string, source: string, target: string, data: Partial = {}): Edge => ({ + id, + source, + target, + data: { type: 'ethernet', ...data } as EdgeData, +}) + +describe('exportCanvasToYaml', () => { + it('serializes a simple node with basic fields', () => { + const nodes = [makeNode({ label: 'My Server', type: 'server', ip: '192.168.1.10', hostname: 'srv.local' })] + const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[] + expect(result).toHaveLength(1) + const entry = result[0] as Record + expect(entry.nodeType).toBe('server') + expect(entry.label).toBe('My Server') + expect(entry.ipAddress).toBe('192.168.1.10') + expect(entry.hostname).toBe('srv.local') + }) + + it('omits empty/null/undefined optional fields', () => { + const nodes = [makeNode({ label: 'Router', type: 'router', hostname: undefined, ip: undefined, notes: undefined })] + const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[] + const entry = result[0] as Record + expect(entry).not.toHaveProperty('hostname') + expect(entry).not.toHaveProperty('ipAddress') + expect(entry).not.toHaveProperty('notes') + }) + + it('omits hardware specs when zero or falsy', () => { + const nodes = [makeNode({ label: 'Server', type: 'server', cpu_count: 0, ram_gb: 0, disk_gb: 0 })] + const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[] + const entry = result[0] as Record + expect(entry).not.toHaveProperty('cpuCore') + expect(entry).not.toHaveProperty('ram') + expect(entry).not.toHaveProperty('disk') + }) + + it('includes hardware specs when non-zero', () => { + const nodes = [makeNode({ label: 'Server', type: 'server', cpu_count: 16, ram_gb: 64, disk_gb: 2000, cpu_model: 'Intel Xeon' })] + const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[] + const entry = result[0] as Record + expect(entry.cpuCore).toBe(16) + expect(entry.ram).toBe(64) + expect(entry.disk).toBe(2000) + expect(entry.cpuModel).toBe('Intel Xeon') + }) + + it('serializes parent relationship from parentId', () => { + const parent = makeNode({ label: 'Proxmox1', type: 'proxmox' }, 'pve1') + const child = makeNode({ label: 'VM1', type: 'vm' }, 'vm1', 'pve1') + const edge = makeEdge('e1', 'pve1', 'vm1', { type: 'virtual' }) + const result = yaml.load(exportCanvasToYaml([parent, child], [edge])) as object[] + const childEntry = (result as Record[]).find((e) => e.label === 'VM1')! + expect(childEntry.parent).toEqual({ label: 'Proxmox1', linkType: 'virtual', linkLabel: '' }) + }) + + it('serializes clusterR edge on source node', () => { + const nodeA = makeNode({ label: 'NodeA', type: 'proxmox' }, 'a') + const nodeB = makeNode({ label: 'NodeB', type: 'proxmox' }, 'b') + const edge = makeEdge('e1', 'a', 'b', { type: 'ethernet', label: '10GbE' }) + const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record[] + const entryA = result.find((e) => e.label === 'NodeA')! + expect(entryA.clusterR).toEqual({ label: 'NodeB', linkType: 'ethernet', linkLabel: '10GbE' }) + }) + + it('does not duplicate an edge as both clusterR and clusterL', () => { + const nodeA = makeNode({ label: 'NodeA', type: 'proxmox' }, 'a') + const nodeB = makeNode({ label: 'NodeB', type: 'proxmox' }, 'b') + const edge = makeEdge('e1', 'a', 'b', { type: 'ethernet' }) + const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record[] + const entryA = result.find((e) => e.label === 'NodeA')! + const entryB = result.find((e) => e.label === 'NodeB')! + // clusterR on A and clusterL on B would duplicate — only one side should have it + const hasClusterR = 'clusterR' in entryA + const hasClusterL = 'clusterL' in entryB + expect(hasClusterR && hasClusterL).toBe(false) + }) + + it('excludes groupRect nodes from output', () => { + const nodes = [ + makeNode({ label: 'Zone', type: 'groupRect' }, '1'), + makeNode({ label: 'Server', type: 'server' }, '2'), + ] + const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[] + expect(result).toHaveLength(1) + expect((result[0] as Record).label).toBe('Server') + }) + + it('roundtrip: all non-empty fields appear in YAML output', () => { + const nodes = [makeNode({ + label: 'Full Node', + type: 'server', + ip: '10.0.0.1', + hostname: 'full.local', + check_method: 'ping', + check_target: '10.0.0.1', + notes: 'test notes', + cpu_model: 'AMD EPYC', + cpu_count: 32, + ram_gb: 128, + disk_gb: 4000, + custom_icon: 'star', + })] + const yamlStr = exportCanvasToYaml(nodes, []) + expect(yamlStr).toContain('Full Node') + expect(yamlStr).toContain('10.0.0.1') + expect(yamlStr).toContain('full.local') + expect(yamlStr).toContain('ping') + expect(yamlStr).toContain('test notes') + expect(yamlStr).toContain('AMD EPYC') + expect(yamlStr).toContain('32') + expect(yamlStr).toContain('128') + expect(yamlStr).toContain('4000') + expect(yamlStr).toContain('star') + }) +}) diff --git a/frontend/src/utils/exportYaml.ts b/frontend/src/utils/exportYaml.ts new file mode 100644 index 0000000..a63a345 --- /dev/null +++ b/frontend/src/utils/exportYaml.ts @@ -0,0 +1,136 @@ +import type { Node, Edge } from '@xyflow/react' +import type { NodeData, EdgeData, EdgeType } from '@/types' +import type { YamlNode, YamlNodeConnection } from '@/types/yaml' +import yaml from 'js-yaml' + +/** Build a map of node id → label for edge resolution */ +function buildIdToLabel(nodes: Node[]): Map { + const m = new Map() + for (const n of nodes) m.set(n.id, n.data.label) + return m +} + +function makeConnection(targetLabel: string, edgeType: EdgeType, edgeLabel: string | undefined): YamlNodeConnection { + return { + label: targetLabel, + linkType: edgeType, + linkLabel: edgeLabel ?? '', + } +} + +/** + * Serialize React Flow canvas state to a YAML string. + * Each node becomes one entry; edges are embedded as parent/clusterR/clusterL sub-objects. + * Edge deduplication: each edge is written on exactly one side (source as clusterR, target as clusterL) + * unless the edge type is 'virtual' or there is a parentId relationship, in which case + * it becomes the 'parent' field of the child node. + */ +export function exportCanvasToYaml(nodes: Node[], edges: Edge[]): string { + const idToLabel = buildIdToLabel(nodes) + + // Build per-node edge maps (id → connections) + // We use a Set to track already-serialized edge ids (deduplication). + const serializedEdges = new Set() + + // Index edges by source and target for quick lookup + const edgesBySource = new Map[]>() + const edgesByTarget = new Map[]>() + for (const e of edges) { + if (!edgesBySource.has(e.source)) edgesBySource.set(e.source, []) + edgesBySource.get(e.source)!.push(e) + if (!edgesByTarget.has(e.target)) edgesByTarget.set(e.target, []) + edgesByTarget.get(e.target)!.push(e) + } + + const yamlNodes: YamlNode[] = [] + + for (const node of nodes) { + const d = node.data + + // Skip groupRect nodes — they are canvas decoration only + if (d.type === 'groupRect') continue + + const entry: YamlNode = { + nodeType: d.type, + label: d.label, + } + + if (d.custom_icon) entry.nodeIcon = d.custom_icon + if (d.hostname) entry.hostname = d.hostname + if (d.ip) entry.ipAddress = d.ip + if (d.check_method && d.check_method !== 'none') entry.checkMethod = d.check_method + if (d.check_target) entry.checkTarget = d.check_target + if (d.notes) entry.notes = d.notes + + // Hardware specs — omit zero values + if (d.cpu_model) entry.cpuModel = d.cpu_model + if (d.cpu_count && d.cpu_count > 0) entry.cpuCore = d.cpu_count + if (d.ram_gb && d.ram_gb > 0) entry.ram = d.ram_gb + if (d.disk_gb && d.disk_gb > 0) entry.disk = d.disk_gb + + // Parent relationship: if this node has a parentId in React Flow, + // encode it as a 'parent' connection using any virtual edge between them. + if (node.parentId) { + const parentLabel = idToLabel.get(node.parentId) ?? node.parentId + // Find an edge between parent and this node (either direction) + const parentEdges = [ + ...(edgesBySource.get(node.parentId) ?? []).filter((e) => e.target === node.id), + ...(edgesByTarget.get(node.parentId) ?? []).filter((e) => e.source === node.id), + ] + const pEdge = parentEdges[0] + const linkType: EdgeType = (pEdge?.data?.type as EdgeType) ?? 'virtual' + const linkLabel = pEdge?.data?.label ?? '' + entry.parent = { label: parentLabel, linkType, linkLabel: linkLabel as string } + if (pEdge) serializedEdges.add(pEdge.id) + } + + // Non-parent edges: serialize as clusterR (source side) or clusterL (target side). + // We process source edges as clusterR on this node; target edges as clusterL on this node, + // but only if the edge hasn't been serialized yet (deduplication: source wins). + const sourceEdgesForNode = (edgesBySource.get(node.id) ?? []).filter( + (e) => !serializedEdges.has(e.id) && e.target !== node.parentId && e.source !== node.parentId, + ) + for (const e of sourceEdgesForNode) { + const targetLabel = idToLabel.get(e.target) + if (!targetLabel) continue + const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet' + const edgeLabel = e.data?.label as string | undefined + if (!entry.clusterR) { + entry.clusterR = makeConnection(targetLabel, edgeType, edgeLabel) + } + // Only first clusterR wins per node; mark all source edges as serialized + serializedEdges.add(e.id) + } + + const targetEdgesForNode = (edgesByTarget.get(node.id) ?? []).filter( + (e) => !serializedEdges.has(e.id) && e.source !== node.parentId && e.target !== node.parentId, + ) + for (const e of targetEdgesForNode) { + const sourceLabel = idToLabel.get(e.source) + if (!sourceLabel) continue + const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet' + const edgeLabel = e.data?.label as string | undefined + if (!entry.clusterL) { + entry.clusterL = makeConnection(sourceLabel, edgeType, edgeLabel) + } + serializedEdges.add(e.id) + } + + yamlNodes.push(entry) + } + + return yaml.dump(yamlNodes, { lineWidth: -1, noRefs: true }) +} + +/** Trigger a browser file download with the given YAML content */ +export function downloadYaml(content: string, filename = 'homelable-export.yaml'): void { + const blob = new Blob([content], { type: 'text/yaml;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +}