feat: add YAML export — serialize canvas nodes/edges to downloadable .yaml file

This commit is contained in:
Pouzor
2026-03-23 21:59:04 +01:00
parent d9787fdcbb
commit 2e49c14028
7 changed files with 316 additions and 2 deletions
+8
View File
@@ -13,12 +13,14 @@
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"js-yaml": "^4.1.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -3008,6 +3010,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+2
View File
@@ -19,12 +19,14 @@
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"js-yaml": "^4.1.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
+9
View File
@@ -5,6 +5,7 @@ import { applyDagreLayout } from '@/utils/layout'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { generateMarkdownTable } from '@/utils/exportMarkdown' import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { exportToPng } from '@/utils/export' import { exportToPng } from '@/utils/export'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -371,6 +372,13 @@ export default function App() {
toast.success('Markdown table copied to clipboard') toast.success('Markdown table copied to clipboard')
}, [nodes]) }, [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 handleExport = useCallback(async () => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow') const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return } if (!el) { toast.error('Canvas not ready'); return }
@@ -449,6 +457,7 @@ export default function App() {
onRedo={redo} onRedo={redo}
onShortcuts={() => setShortcutsOpen(true)} onShortcuts={() => setShortcutsOpen(true)}
onExportMd={handleExportMd} onExportMd={handleExportMd}
onExportYaml={handleExportYaml}
/> />
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full"> <div ref={canvasRef} className="flex-1 min-w-0 h-full">
+6 -2
View File
@@ -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 { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/Logo' import { Logo } from '@/components/ui/Logo'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
@@ -12,9 +12,10 @@ interface ToolbarProps {
onRedo: () => void onRedo: () => void
onShortcuts: () => void onShortcuts: () => void
onExportMd: () => 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() const { hasUnsavedChanges, past, future } = useCanvasStore()
return ( return (
@@ -52,6 +53,9 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table">
<Table2 size={14} /> MD <Table2 size={14} /> MD
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML">
<FileDown size={14} /> YAML
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
<HelpCircle size={14} /> <HelpCircle size={14} />
</Button> </Button>
+25
View File
@@ -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
}
@@ -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<NodeData> = {}, id = '1', parentId?: string): Node<NodeData> => ({
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<EdgeData> = {}): Edge<EdgeData> => ({
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>[]).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<string, unknown>[]
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<string, unknown>[]
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<string, unknown>).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')
})
})
+136
View File
@@ -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<NodeData>[]): Map<string, string> {
const m = new Map<string, string>()
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<NodeData>[], edges: Edge<EdgeData>[]): 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<string>()
// Index edges by source and target for quick lookup
const edgesBySource = new Map<string, Edge<EdgeData>[]>()
const edgesByTarget = new Map<string, Edge<EdgeData>[]>()
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)
}