feat: add YAML export — serialize canvas nodes/edges to downloadable .yaml file
This commit is contained in:
Generated
+8
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<HTMLElement>('.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}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||
|
||||
@@ -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,
|
||||
<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
|
||||
</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 (?)">
|
||||
<HelpCircle size={14} />
|
||||
</Button>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user