diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 857bb0f..b875f5f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react' import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react' import { type Node } from '@xyflow/react' import { applyDagreLayout } from '@/utils/layout' -import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' +import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, migrateClusterHandles, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { generateUUID } from '@/utils/uuid' import { getCenteredPosition } from '@/utils/viewportCenter' import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent' @@ -125,8 +125,10 @@ export default function App() { .filter((n) => n.type === 'group' || n.container_mode === true) .map((n) => [n.id, true]) ) - const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) - const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) + const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles( + (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)), + (apiEdges as ApiEdge[]).map(deserializeApiEdge), + ) const savedTheme = res.data.viewport?.theme_id if (savedTheme) setTheme(savedTheme) if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) @@ -154,7 +156,8 @@ export default function App() { if (saved.custom_style) setCustomStyle(saved.custom_style) // Floor plans are backend-only; keep the store clear in standalone mode. setFloorMap(null) - loadCanvas(saved.nodes, saved.edges) + const migrated = migrateClusterHandles(saved.nodes, saved.edges) + loadCanvas(migrated.nodes, migrated.edges) } else { setFloorMap(null) loadCanvas(demoNodes, demoEdges) @@ -788,11 +791,6 @@ export default function App() { open={!!pendingConnection} onClose={() => setPendingConnection(null)} onSubmit={handleEdgeConfirm} - initial={ - pendingConnection?.sourceHandle?.includes('cluster') || pendingConnection?.targetHandle?.includes('cluster') - ? { type: 'cluster' } - : undefined - } /> deserializeApiNode(n, proxmoxMap)), (apiEdges as ApiEdge[]).map(deserializeApiEdge), ) + loadCanvas(migrated.nodes, migrated.edges) setViewState('ready') }) .catch((err) => { diff --git a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx index 2c01e99..8d30797 100644 --- a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx +++ b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx @@ -1,5 +1,5 @@ import { createElement, useEffect } from 'react' -import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react' +import { NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react' import { Layers } from 'lucide-react' import type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' @@ -23,34 +23,15 @@ export function ProxmoxGroupNode(props: NodeProps>) { const theme = THEMES[activeTheme] const colors = resolveNodeColors(data, activeTheme) - // Render as a regular node when container mode is disabled + // Render as a regular node when container mode is disabled. Cluster links now + // use the configurable per-side connection points (see BaseNode / SideHandles). if (data.container_mode === false) { - const proxmoxAccent = theme.colors.nodeAccents.proxmox.border - return ( - <> - - - - - ) + return } const statusColor = theme.colors.statusColors[data.status] const isOnline = data.status === 'online' const glow = colors.border - const proxmoxAccent = theme.colors.nodeAccents.proxmox.border const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon) return ( @@ -151,22 +132,6 @@ export function ProxmoxGroupNode(props: NodeProps>) { handleBorder={theme.colors.handleBorder} labelColor={theme.colors.nodeSubtextColor} /> - - {/* Cluster handles */} - - ) } diff --git a/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx b/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx index 156cd0d..d54ecab 100644 --- a/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx +++ b/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx @@ -114,10 +114,20 @@ describe('ProxmoxGroupNode', () => { expect(sourceHandles.length).toBe(1) }) - it('renders cluster handles in both modes', () => { + it('no longer renders the always-on cluster handles (#243)', () => { const { container: groupC } = renderNode({}) - expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2) + expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBe(0) const { container: nodeC } = renderNode({ container_mode: false }) - expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2) + expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBe(0) + }) + + it('renders configurable left/right handles only when counts > 0', () => { + const { container: none } = renderNode({ container_mode: false }) + expect(none.querySelectorAll('.react-flow__handle-left.source').length).toBe(0) + expect(none.querySelectorAll('.react-flow__handle-right.source').length).toBe(0) + + const { container: set } = renderNode({ container_mode: false, left_handles: 1, right_handles: 2 }) + expect(set.querySelectorAll('.react-flow__handle-left.source').length).toBe(1) + expect(set.querySelectorAll('.react-flow__handle-right.source').length).toBe(2) }) }) diff --git a/frontend/src/utils/__tests__/canvasSerializer.test.ts b/frontend/src/utils/__tests__/canvasSerializer.test.ts index 9d483f7..4cd4fdd 100644 --- a/frontend/src/utils/__tests__/canvasSerializer.test.ts +++ b/frontend/src/utils/__tests__/canvasSerializer.test.ts @@ -6,6 +6,7 @@ import { serializeEdge, deserializeApiNode, deserializeApiEdge, + migrateClusterHandles, type ApiNode, type ApiEdge, } from '@/utils/canvasSerializer' @@ -483,3 +484,40 @@ describe('serializeNode — text node roundtrip', () => { expect(restored.data.label).toBe('Hello world') }) }) + +// ── migrateClusterHandles (#243) ────────────────────────────────────────────── + +describe('migrateClusterHandles', () => { + it('remaps cluster-right/left edge handles to right/left slot-0', () => { + const nodes = [makeRfNode({ id: 'p1', type: 'proxmox' }), makeRfNode({ id: 'p2', type: 'proxmox' })] + const edges = [makeRfEdge({ id: 'c1', source: 'p1', target: 'p2', type: 'cluster', sourceHandle: 'cluster-right', targetHandle: 'cluster-left', data: { type: 'cluster' } })] + const out = migrateClusterHandles(nodes, edges) + const e = out.edges.find((x) => x.id === 'c1')! + expect(e.sourceHandle).toBe('right') + expect(e.targetHandle).toBe('left') + expect(e.type).toBe('cluster') // style/type preserved + }) + + it('bumps the connected side to 1 on each proxmox node', () => { + const nodes = [makeRfNode({ id: 'p1', type: 'proxmox' }), makeRfNode({ id: 'p2', type: 'proxmox' })] + const edges = [makeRfEdge({ id: 'c1', source: 'p1', target: 'p2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' })] + const out = migrateClusterHandles(nodes, edges) + expect(out.nodes.find((n) => n.id === 'p1')!.data.right_handles).toBe(1) + expect(out.nodes.find((n) => n.id === 'p2')!.data.left_handles).toBe(1) + }) + + it('does not lower an already-higher side count', () => { + const nodes = [makeRfNode({ id: 'p1', type: 'proxmox', data: { label: 'x', type: 'proxmox', status: 'online', services: [], right_handles: 3 } })] + const edges = [makeRfEdge({ id: 'c1', source: 'p1', target: 'p2', sourceHandle: 'cluster-right' })] + const out = migrateClusterHandles(nodes, edges) + expect(out.nodes[0].data.right_handles).toBe(3) + }) + + it('leaves non-cluster edges and their nodes untouched (identity when nothing to do)', () => { + const nodes = [makeRfNode({ id: 'p1' })] + const edges = [makeRfEdge({ id: 'e1', sourceHandle: 'bottom', targetHandle: 'top' })] + const out = migrateClusterHandles(nodes, edges) + expect(out.nodes).toBe(nodes) + expect(out.edges[0].sourceHandle).toBe('bottom') + }) +}) diff --git a/frontend/src/utils/__tests__/importYaml.test.ts b/frontend/src/utils/__tests__/importYaml.test.ts index 27a4e36..190568e 100644 --- a/frontend/src/utils/__tests__/importYaml.test.ts +++ b/frontend/src/utils/__tests__/importYaml.test.ts @@ -105,7 +105,7 @@ describe('parseYamlToCanvas', () => { expect(edges[0].data?.type).toBe('fibre') }) - it('cluster edges have cluster-right→cluster-left handles', () => { + it('cluster edges are remapped onto right→left connection points (#243)', () => { const yaml = ` - nodeType: proxmox label: "PVE1" @@ -115,10 +115,15 @@ describe('parseYamlToCanvas', () => { - nodeType: proxmox label: "PVE2" ` - const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges) + const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges) expect(edges).toHaveLength(1) - expect(edges[0].sourceHandle).toBe('cluster-right') - expect(edges[0].targetHandle).toBe('cluster-left') + expect(edges[0].sourceHandle).toBe('right') + expect(edges[0].targetHandle).toBe('left') + // Connected sides get a connection point so the link is anchored. + const src = nodes.find((n) => n.id === edges[0].source)! + const tgt = nodes.find((n) => n.id === edges[0].target)! + expect(src.data.right_handles).toBe(1) + expect(tgt.data.left_handles).toBe(1) }) it('parent relationship sets parentId and creates an edge', () => { diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index 3e67b23..01942bd 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -1,6 +1,6 @@ import type { Node, Edge } from '@xyflow/react' import type { NodeData, EdgeData, Waypoint } from '@/types' -import { normalizeHandle, clampHandles } from '@/utils/handleUtils' +import { normalizeHandle, clampHandles, handleId, handleCountField, type Side } from '@/utils/handleUtils' // ── Types ──────────────────────────────────────────────────────────────────── @@ -216,3 +216,52 @@ export function deserializeApiEdge(e: ApiEdge): Edge { data: e as unknown as EdgeData, } } + +// Legacy Proxmox nodes had two always-on cluster handles ('cluster-left' / +// 'cluster-right'). Those are gone — cluster links now use the normal, per-side +// connection points. On load we remap any edge still bound to a cluster handle +// onto the matching left/right slot-0 handle and give that node's side a +// connection point (count → at least 1) so the link survives. The edge's +// 'cluster' type/colour is untouched. +const CLUSTER_HANDLE_SIDE: Record = { + 'cluster-left': 'left', + 'cluster-right': 'right', +} + +export function migrateClusterHandles( + nodes: Node[], + edges: Edge[], +): { nodes: Node[]; edges: Edge[] } { + // nodeId → sides that need at least one connection point after remap. + const needed = new Map>() + const mark = (id: string, side: Side) => { + const set = needed.get(id) ?? new Set() + set.add(side) + needed.set(id, set) + } + + const migratedEdges = edges.map((e) => { + const srcSide = e.sourceHandle ? CLUSTER_HANDLE_SIDE[e.sourceHandle] : undefined + const tgtSide = e.targetHandle ? CLUSTER_HANDLE_SIDE[e.targetHandle] : undefined + if (!srcSide && !tgtSide) return e + const next = { ...e } + if (srcSide) { next.sourceHandle = handleId(srcSide, 0); mark(e.source, srcSide) } + if (tgtSide) { next.targetHandle = handleId(tgtSide, 0); mark(e.target, tgtSide) } + return next + }) + + if (needed.size === 0) return { nodes, edges: migratedEdges } + + const migratedNodes = nodes.map((n) => { + const sides = needed.get(n.id) + if (!sides) return n + const data: NodeData = { ...n.data } + for (const side of sides) { + const field = handleCountField(side) + data[field] = Math.max((data[field] as number | undefined) ?? 0, 1) + } + return { ...n, data } + }) + + return { nodes: migratedNodes, edges: migratedEdges } +} diff --git a/frontend/src/utils/importYaml.ts b/frontend/src/utils/importYaml.ts index 16b7678..11210e8 100644 --- a/frontend/src/utils/importYaml.ts +++ b/frontend/src/utils/importYaml.ts @@ -4,6 +4,7 @@ import type { NodeData, EdgeData } from '@/types' import type { YamlNode, YamlNodeConnection } from '@/types/yaml' import { generateUUID } from '@/utils/uuid' import { applyDagreLayout } from '@/utils/layout' +import { migrateClusterHandles } from '@/utils/canvasSerializer' /** * Parse a YAML string and merge the resulting nodes/edges into the existing canvas. @@ -170,5 +171,9 @@ export function parseYamlToCanvas( const mergedEdges = [...existingEdges, ...newEdges] const laidOut = applyDagreLayout(mergedNodes, mergedEdges) - return { nodes: laidOut, edges: mergedEdges, imported: newNodes.length } + // Cluster links are imported on the legacy 'cluster-left/right' handles; + // remap them to the per-side connection points (and give the side a point). + const migrated = migrateClusterHandles(laidOut, mergedEdges) + + return { nodes: migrated.nodes, edges: migrated.edges, imported: newNodes.length } }