fix: replace Proxmox cluster handles with configurable side points

The Proxmox node had two always-on cluster-left/cluster-right handles that
rendered independently of the new per-side connection-point config — so a
configured side stacked on top of the forced handle (e.g. 0 config still showed
1; 2 config left the forced one in the middle).

- ProxmoxGroupNode: remove the always-on cluster handles in both container and
  non-container modes; cluster links now use the normal per-side points
- add migrateClusterHandles(nodes, edges): on load, remap any edge still bound
  to cluster-left/right onto the left/right slot-0 point and set that node's
  side count to at least 1, so existing links survive (edge 'cluster' type/style
  kept). Applied on API load, standalone load, LiveView, and YAML import
- App: new cluster links are made by choosing the Cluster edge type in the edge
  modal (drop the cluster-handle auto-default)

ha-relevant: yes
This commit is contained in:
Pouzor
2026-07-04 01:35:28 +02:00
parent 6302c43e06
commit 1479c777d0
8 changed files with 130 additions and 59 deletions
+7 -9
View File
@@ -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
}
/>
<EdgeModal
+3 -2
View File
@@ -27,7 +27,7 @@ import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { deserializeApiNode, deserializeApiEdge, migrateClusterHandles, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
import { liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
@@ -88,10 +88,11 @@ function LiveViewCanvas() {
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
loadCanvas(
const migrated = migrateClusterHandles(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
)
loadCanvas(migrated.nodes, migrated.edges)
setViewState('ready')
})
.catch((err) => {
@@ -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<Node<NodeData>>) {
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 (
<>
<BaseNode {...props} icon={Layers} />
<Handle
type="source"
position={Position.Left}
id="cluster-left"
title="Same cluster"
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
/>
<Handle
type="source"
position={Position.Right}
id="cluster-right"
title="Same cluster"
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
/>
</>
)
return <BaseNode {...props} icon={Layers} />
}
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<Node<NodeData>>) {
handleBorder={theme.colors.handleBorder}
labelColor={theme.colors.nodeSubtextColor}
/>
{/* Cluster handles */}
<Handle
type="source"
position={Position.Left}
id="cluster-left"
title="Same cluster"
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
/>
<Handle
type="source"
position={Position.Right}
id="cluster-right"
title="Same cluster"
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
/>
</>
)
}
@@ -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)
})
})
@@ -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')
})
})
@@ -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', () => {
+50 -1
View File
@@ -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<EdgeData> {
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<string, Side> = {
'cluster-left': 'left',
'cluster-right': 'right',
}
export function migrateClusterHandles(
nodes: Node<NodeData>[],
edges: Edge<EdgeData>[],
): { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] } {
// nodeId → sides that need at least one connection point after remap.
const needed = new Map<string, Set<Side>>()
const mark = (id: string, side: Side) => {
const set = needed.get(id) ?? new Set<Side>()
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 }
}
+6 -1
View File
@@ -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 }
}