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
+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)
})
})