From a43ffb813e03508d76f94959a54e7546817a0962 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Tue, 24 Mar 2026 01:25:26 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20export=20all=20edges=20via=20links=20arr?= =?UTF-8?q?ay=20=E2=80=94=20clusterR/clusterL=20reserved=20for=20cluster-t?= =?UTF-8?q?ype=20edges=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/types/yaml.ts | 1 + .../src/utils/__tests__/exportYaml.test.ts | 71 +++++++++++++++---- .../src/utils/__tests__/importYaml.test.ts | 26 +++++++ frontend/src/utils/exportYaml.ts | 32 ++++----- frontend/src/utils/importYaml.ts | 11 +++ 5 files changed, 112 insertions(+), 29 deletions(-) diff --git a/frontend/src/types/yaml.ts b/frontend/src/types/yaml.ts index f73c3e2..f5ee2f9 100644 --- a/frontend/src/types/yaml.ts +++ b/frontend/src/types/yaml.ts @@ -15,6 +15,7 @@ export interface YamlNode { checkMethod?: CheckMethod checkTarget?: string notes?: string + links?: YamlNodeConnection[] parent?: YamlNodeConnection clusterR?: YamlNodeConnection clusterL?: YamlNodeConnection diff --git a/frontend/src/utils/__tests__/exportYaml.test.ts b/frontend/src/utils/__tests__/exportYaml.test.ts index 60c5d57..870fe44 100644 --- a/frontend/src/utils/__tests__/exportYaml.test.ts +++ b/frontend/src/utils/__tests__/exportYaml.test.ts @@ -68,26 +68,71 @@ describe('exportCanvasToYaml', () => { 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' }) + it('serializes cluster-type edge as clusterR on source node', () => { + const nodeA = makeNode({ label: 'PVE1', type: 'proxmox' }, 'a') + const nodeB = makeNode({ label: 'PVE2', type: 'proxmox' }, 'b') + const edge = makeEdge('e1', 'a', 'b', { type: 'cluster', label: '10GbE' }) const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record[] - const entryA = result.find((e) => e.label === 'NodeA')! - expect(entryA.clusterR).toEqual({ label: 'NodeB', linkType: 'ethernet', linkLabel: '10GbE' }) + const entryA = result.find((e) => e.label === 'PVE1')! + expect(entryA.clusterR).toEqual({ label: 'PVE2', linkType: 'cluster', linkLabel: '10GbE' }) + expect(entryA).not.toHaveProperty('links') }) - 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') + it('serializes cluster-type incoming edge as clusterL on target node', () => { + const nodeA = makeNode({ label: 'PVE1', type: 'proxmox' }, 'a') + const nodeB = makeNode({ label: 'PVE2', type: 'proxmox' }, 'b') + const edge = makeEdge('e1', 'a', 'b', { type: 'cluster' }) + const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record[] + const entryA = result.find((e) => e.label === 'PVE1')! + const entryB = result.find((e) => e.label === 'PVE2')! + // edge serialized as clusterR on A — should NOT also appear as clusterL on B + expect(entryA.clusterR).toBeDefined() + expect(entryB).not.toHaveProperty('clusterL') + }) + + it('serializes regular ethernet edge in links array on source node', () => { + const nodeA = makeNode({ label: 'Switch', type: 'switch' }, 'sw') + const nodeB = makeNode({ label: 'Server1', type: 'server' }, 's1') + const edge = makeEdge('e1', 'sw', 's1', { type: 'ethernet', label: 'eth0' }) + const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record[] + const entryA = result.find((e) => e.label === 'Switch')! + const entryB = result.find((e) => e.label === 'Server1')! + expect(entryA.links).toEqual([{ label: 'Server1', linkType: 'ethernet', linkLabel: 'eth0' }]) + expect(entryB).not.toHaveProperty('links') + expect(entryA).not.toHaveProperty('clusterR') + }) + + it('serializes multiple outgoing edges as links array', () => { + const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw') + const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1') + const s2 = makeNode({ label: 'Server2', type: 'server' }, 's2') + const s3 = makeNode({ label: 'Server3', type: 'server' }, 's3') + const edges = [ + makeEdge('e1', 'sw', 's1', { type: 'ethernet' }), + makeEdge('e2', 'sw', 's2', { type: 'ethernet' }), + makeEdge('e3', 'sw', 's3', { type: 'wifi' }), + ] + const result = yaml.load(exportCanvasToYaml([sw, s1, s2, s3], edges)) as Record[] + const swEntry = result.find((e) => e.label === 'Switch')! + const links = swEntry.links as Record[] + expect(links).toHaveLength(3) + expect(links.map((l) => l.label)).toEqual(expect.arrayContaining(['Server1', 'Server2', 'Server3'])) + // Servers should have no links (edges are on source side) + for (const label of ['Server1', 'Server2', 'Server3']) { + const entry = result.find((e) => e.label === label)! + expect(entry).not.toHaveProperty('links') + } + }) + + it('does not duplicate a links edge on the target node', () => { + const nodeA = makeNode({ label: 'NodeA', type: 'server' }, 'a') + const nodeB = makeNode({ label: 'NodeB', type: 'server' }, 'b') const edge = makeEdge('e1', 'a', 'b', { type: 'ethernet' }) const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record[] 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) + expect(entryA.links).toHaveLength(1) + expect(entryB).not.toHaveProperty('links') }) it('excludes groupRect nodes from output', () => { diff --git a/frontend/src/utils/__tests__/importYaml.test.ts b/frontend/src/utils/__tests__/importYaml.test.ts index 76aad23..2f69f68 100644 --- a/frontend/src/utils/__tests__/importYaml.test.ts +++ b/frontend/src/utils/__tests__/importYaml.test.ts @@ -136,6 +136,32 @@ describe('parseYamlToCanvas', () => { expect(edges[0].target).toBe(pve2.id) }) + it('links array creates multiple edges from this node', () => { + const yaml = ` +- nodeType: switch + label: "Switch" + links: + - label: "Server1" + linkType: ethernet + - label: "Server2" + linkType: ethernet + - label: "Server3" + linkType: wifi +- nodeType: server + label: "Server1" +- nodeType: server + label: "Server2" +- nodeType: server + label: "Server3" +` + const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges) + const sw = nodes.find((n) => n.data.label === 'Switch')! + expect(edges).toHaveLength(3) + expect(edges.every((e) => e.source === sw.id)).toBe(true) + const targets = edges.map((e) => nodes.find((n) => n.id === e.target)!.data.label) + expect(targets).toEqual(expect.arrayContaining(['Server1', 'Server2', 'Server3'])) + }) + it('deduplicates edges when clusterR on A and clusterL on B point to each other', () => { const yaml = ` - nodeType: proxmox diff --git a/frontend/src/utils/exportYaml.ts b/frontend/src/utils/exportYaml.ts index a63a345..bbe3ae4 100644 --- a/frontend/src/utils/exportYaml.ts +++ b/frontend/src/utils/exportYaml.ts @@ -84,35 +84,35 @@ export function exportCanvasToYaml(nodes: Node[], edges: Edge !serializedEdges.has(e.id) && e.target !== node.parentId && e.source !== node.parentId, + // Outgoing edges (this node is the source): + // - cluster type → clusterR (Proxmox cluster link, directional) + // - everything else → links array (supports multiple connections) + const outgoingEdges = (edgesBySource.get(node.id) ?? []).filter( + (e) => !serializedEdges.has(e.id) && e.target !== node.parentId, ) - for (const e of sourceEdgesForNode) { + for (const e of outgoingEdges) { 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) + const conn = makeConnection(targetLabel, edgeType, edgeLabel) + if (edgeType === 'cluster') { + if (!entry.clusterR) entry.clusterR = conn + } else { + entry.links = [...(entry.links ?? []), conn] } - // 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, + // Incoming cluster edges not yet serialized → clusterL + const incomingClusterEdges = (edgesByTarget.get(node.id) ?? []).filter( + (e) => !serializedEdges.has(e.id) && (e.data?.type as EdgeType) === 'cluster', ) - for (const e of targetEdgesForNode) { + for (const e of incomingClusterEdges) { 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) - } + if (!entry.clusterL) entry.clusterL = makeConnection(sourceLabel, 'cluster', edgeLabel) serializedEdges.add(e.id) } diff --git a/frontend/src/utils/importYaml.ts b/frontend/src/utils/importYaml.ts index 8406ed4..fdd7241 100644 --- a/frontend/src/utils/importYaml.ts +++ b/frontend/src/utils/importYaml.ts @@ -131,6 +131,17 @@ export function parseYamlToCanvas( } } + if (yn.links) { + for (const link of yn.links) { + const targetId = labelToId.get(link.label) + if (!targetId) { + console.warn(`[importYaml] links label not found: "${link.label}" — skipping`) + } else { + addEdgeIfNew(node.id, targetId, link) + } + } + } + if (yn.clusterR) { const targetId = labelToId.get(yn.clusterR.label) if (!targetId) {