feat: add YAML import — merge nodes/edges from .yaml file into canvas

This commit is contained in:
Pouzor
2026-03-23 21:34:26 +01:00
parent d5b67a770c
commit e7ab9a1d7a
5 changed files with 435 additions and 7 deletions
@@ -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()
})
})
+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 }
}