fix: preserve edge connection points in YAML export/import (#208)

YAML round-trip dropped every edge's handles: export never wrote them
and import hardcoded sourceHandle='bottom'/targetHandle='top-t'. After
import all connections collapsed onto slot 0 ("converge at a single
point"). Per-side handle counts were dropped too, so the extra bottom
slots did not even exist to attach to.

- yaml types: add sourceHandle/targetHandle to connections and
  top/bottom/left/rightHandles to nodes
- export: write each edge's real handles + per-side counts (only above
  the side default); orient parent-edge handles parent->child
- import: restore handle counts onto node data and use the stored
  handles, falling back to the legacy defaults for pre-existing YAML

Positions remain dagre-managed (unchanged); this restores connection
points only.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-07-07 11:16:29 +02:00
parent 65a1f49f93
commit 7bc3e5d8a0
5 changed files with 148 additions and 7 deletions
+11
View File
@@ -4,6 +4,10 @@ export interface YamlNodeConnection {
label: string
linkType?: EdgeType
linkLabel?: string
// Connection points (React Flow handle IDs) the edge attaches to. Preserved so
// a manual layout round-trips instead of collapsing every edge onto slot 0.
sourceHandle?: string
targetHandle?: string
}
export interface YamlNode {
@@ -23,4 +27,11 @@ export interface YamlNode {
cpuCore?: number
ram?: number
disk?: number
// Per-side connection-point counts. Only written when a side has more than its
// default (top/bottom 1, left/right 0) so the referenced handle slot exists on
// re-import — without it React Flow falls back to slot 0.
topHandles?: number
bottomHandles?: number
leftHandles?: number
rightHandles?: number
}
@@ -181,4 +181,31 @@ describe('exportCanvasToYaml', () => {
expect(yamlStr).toContain('4000')
expect(yamlStr).toContain('star')
})
// Regression for issue #208: connection points were dropped on export, so
// every edge collapsed onto slot 0 after import.
it('preserves edge connection points (source/target handles) in links', () => {
const sw = makeNode({ label: 'Switch', type: 'switch', bottom_handles: 3 }, 'sw')
const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1')
const edge: Edge<EdgeData> = {
...makeEdge('e1', 'sw', 's1', { type: 'ethernet' }),
sourceHandle: 'bottom-3',
targetHandle: 'top-t',
}
const result = yaml.load(exportCanvasToYaml([sw, s1], [edge])) as Record<string, unknown>[]
const swEntry = result.find((e) => e.label === 'Switch')!
const link = (swEntry.links as Record<string, unknown>[])[0]
expect(link.sourceHandle).toBe('bottom-3')
expect(link.targetHandle).toBe('top-t')
})
it('exports per-side handle counts only when above the side default', () => {
const nodes = [makeNode({ label: 'N', type: 'server', bottom_handles: 4, right_handles: 2, top_handles: 1, left_handles: 0 })]
const entry = (yaml.load(exportCanvasToYaml(nodes, [])) as Record<string, unknown>[])[0]
expect(entry.bottomHandles).toBe(4)
expect(entry.rightHandles).toBe(2)
// top default is 1, left default is 0 → omitted.
expect(entry).not.toHaveProperty('topHandles')
expect(entry).not.toHaveProperty('leftHandles')
})
})
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { parseYamlToCanvas } from '../importYaml'
import { exportCanvasToYaml } from '../exportYaml'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
@@ -306,4 +307,65 @@ describe('parseYamlToCanvas', () => {
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NonexistentHost'))
warnSpy.mockRestore()
})
// Regression for issue #208.
it('restores edge connection points from the YAML link', () => {
const yaml = `
- nodeType: switch
label: "Switch"
bottomHandles: 3
links:
- label: "Server1"
linkType: ethernet
sourceHandle: bottom-3
targetHandle: top-2-t
- nodeType: server
label: "Server1"
`
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(edges).toHaveLength(1)
expect(edges[0].sourceHandle).toBe('bottom-3')
expect(edges[0].targetHandle).toBe('top-2-t')
const sw = nodes.find((n) => n.data.label === 'Switch')!
expect(sw.data.bottom_handles).toBe(3)
})
it('falls back to legacy handles when the YAML link omits them', () => {
const yaml = `
- nodeType: switch
label: "Switch"
links:
- label: "Server1"
linkType: ethernet
- nodeType: server
label: "Server1"
`
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(edges[0].sourceHandle).toBe('bottom')
expect(edges[0].targetHandle).toBe('top-t')
})
it('round-trips connection points through export → import (issue #208)', () => {
const sw: Node<NodeData> = {
id: 'sw', type: 'switch', position: { x: 0, y: 0 },
data: { label: 'Switch', type: 'switch', status: 'online', services: [], bottom_handles: 3 },
}
const s1: Node<NodeData> = {
id: 's1', type: 'server', position: { x: 0, y: 0 },
data: { label: 'Server1', type: 'server', status: 'online', services: [] },
}
const edge: Edge<EdgeData> = {
id: 'e1', source: 'sw', target: 's1',
sourceHandle: 'bottom-3', targetHandle: 'top-t',
data: { type: 'ethernet' } as EdgeData,
}
const yamlStr = exportCanvasToYaml([sw, s1], [edge])
const { nodes, edges } = parseYamlToCanvas(yamlStr, empty, emptyEdges)
expect(edges).toHaveLength(1)
expect(edges[0].sourceHandle).toBe('bottom-3')
expect(edges[0].targetHandle).toBe('top-t')
expect(nodes.find((n) => n.data.label === 'Switch')!.data.bottom_handles).toBe(3)
})
})
+38 -5
View File
@@ -1,6 +1,7 @@
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData, EdgeType } from '@/types'
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
import { SIDES, sideDefault, sideHandleCount } from '@/utils/handleUtils'
import yaml from 'js-yaml'
/** Build a map of node id → label for edge resolution */
@@ -10,12 +11,32 @@ function buildIdToLabel(nodes: Node<NodeData>[]): Map<string, string> {
return m
}
function makeConnection(targetLabel: string, edgeType: EdgeType, edgeLabel: string | undefined): YamlNodeConnection {
return {
function makeConnection(
targetLabel: string,
edgeType: EdgeType,
edgeLabel: string | undefined,
edge?: Edge<EdgeData>,
): YamlNodeConnection {
const conn: YamlNodeConnection = {
label: targetLabel,
linkType: edgeType,
linkLabel: edgeLabel ?? '',
}
// Preserve the exact connection points so the layout round-trips.
if (edge?.sourceHandle) conn.sourceHandle = edge.sourceHandle
if (edge?.targetHandle) conn.targetHandle = edge.targetHandle
return conn
}
/** Write each side's handle count onto the entry when it exceeds the side default. */
function attachHandleCounts(entry: YamlNode, data: NodeData): void {
for (const side of SIDES) {
const count = sideHandleCount(data, side)
if (count > sideDefault(side)) {
entry[`${side}Handles` as 'topHandles' | 'bottomHandles' | 'leftHandles' | 'rightHandles'] =
count
}
}
}
/**
@@ -68,6 +89,9 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
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
// Custom connection-point counts, so imported edges land on real slots.
attachHandleCounts(entry, d)
// 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) {
@@ -81,7 +105,16 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
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)
// Import always rebuilds this edge parent→child, so orient the handles the
// same way (swap when the stored edge runs child→parent).
if (pEdge) {
const parentToChild = pEdge.source === node.parentId
const srcH = parentToChild ? pEdge.sourceHandle : pEdge.targetHandle
const tgtH = parentToChild ? pEdge.targetHandle : pEdge.sourceHandle
if (srcH) entry.parent.sourceHandle = srcH
if (tgtH) entry.parent.targetHandle = tgtH
serializedEdges.add(pEdge.id)
}
}
// Outgoing edges (this node is the source):
@@ -95,7 +128,7 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
if (!targetLabel) continue
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
const edgeLabel = e.data?.label as string | undefined
const conn = makeConnection(targetLabel, edgeType, edgeLabel)
const conn = makeConnection(targetLabel, edgeType, edgeLabel, e)
if (edgeType === 'cluster') {
if (!entry.clusterR) entry.clusterR = conn
} else {
@@ -112,7 +145,7 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
const sourceLabel = idToLabel.get(e.source)
if (!sourceLabel) continue
const edgeLabel = e.data?.label as string | undefined
if (!entry.clusterL) entry.clusterL = makeConnection(sourceLabel, 'cluster', edgeLabel)
if (!entry.clusterL) entry.clusterL = makeConnection(sourceLabel, 'cluster', edgeLabel, e)
serializedEdges.add(e.id)
}
+10 -2
View File
@@ -73,6 +73,12 @@ export function parseYamlToCanvas(
...(yn.ram ? { ram_gb: yn.ram } : {}),
...(yn.disk ? { disk_gb: yn.disk } : {}),
...(hasHardware ? { show_hardware: true } : {}),
// Restore custom connection-point counts so the edges below attach to the
// slots they were exported on instead of collapsing onto slot 0.
...(yn.topHandles ? { top_handles: yn.topHandles } : {}),
...(yn.bottomHandles ? { bottom_handles: yn.bottomHandles } : {}),
...(yn.leftHandles ? { left_handles: yn.leftHandles } : {}),
...(yn.rightHandles ? { right_handles: yn.rightHandles } : {}),
}
newNodes.push({
@@ -104,12 +110,14 @@ export function parseYamlToCanvas(
if (edgePairs.has(key) || edgePairs.has(reverseKey)) return
edgePairs.add(key)
const edgeType = conn.linkType ?? 'ethernet'
// Prefer the exported connection points; fall back to the legacy defaults for
// YAML written before handles were persisted.
newEdges.push({
id: generateUUID(),
source: sourceId,
target: targetId,
sourceHandle,
targetHandle,
sourceHandle: conn.sourceHandle ?? sourceHandle,
targetHandle: conn.targetHandle ?? targetHandle,
type: edgeType,
data: {
type: edgeType,