Compare commits

..

3 Commits

9 changed files with 745 additions and 3 deletions
+8
View File
@@ -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",
+2
View File
@@ -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",
+24 -1
View File
@@ -5,6 +5,8 @@ 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 { parseYamlToCanvas } from '@/utils/importYaml'
import { TooltipProvider } from '@/components/ui/tooltip'
import { Toaster } from '@/components/ui/sonner'
import { toast } from 'sonner'
@@ -32,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<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme } = useThemeStore()
@@ -371,6 +373,25 @@ 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 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<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return }
@@ -449,6 +470,8 @@ export default function App() {
onRedo={redo}
onShortcuts={() => setShortcutsOpen(true)}
onExportMd={handleExportMd}
onExportYaml={handleExportYaml}
onImportYaml={handleImportYaml}
/>
<div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
+31 -2
View File
@@ -1,4 +1,5 @@
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2 } 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'
@@ -12,10 +13,25 @@ interface ToolbarProps {
onRedo: () => void
onShortcuts: () => void
onExportMd: () => void
onExportYaml: () => void
onImportYaml: (content: string) => 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, onImportYaml }: ToolbarProps) {
const { hasUnsavedChanges, past, future } = useCanvasStore()
const fileInputRef = useRef<HTMLInputElement>(null)
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
@@ -46,12 +62,25 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
<Palette size={14} /> Style
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
<Upload size={14} /> Import
</Button>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
className="hidden"
onChange={handleFileChange}
/>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Export as PNG">
<Download size={14} /> Export
</Button>
<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>
+25
View File
@@ -0,0 +1,25 @@
import type { NodeType, EdgeType, CheckMethod } 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?: CheckMethod
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,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<NodeData>[]) =>
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<NodeData>[] = []
const emptyEdges: Edge<EdgeData>[] = []
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<NodeData>[] = [{
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<NodeData>[] = [
{ 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<EdgeData>[] = [{
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()
})
})
+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)
}
+159
View File
@@ -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<NodeData>[],
existingEdges: Edge<EdgeData>[],
): { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[]; 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<string, string>()
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<NodeData>[] = []
const yamlNodes: YamlNode[] = []
for (const entry of entries) {
const raw = entry as Record<string, unknown>
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<EdgeData>[] = []
// Track edge pairs to deduplicate (store as "sourceId|targetId")
const edgePairs = new Set<string>(
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 }
}