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 { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
import { type Node } from '@xyflow/react' import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout' 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 { generateUUID } from '@/utils/uuid'
import { getCenteredPosition } from '@/utils/viewportCenter' import { getCenteredPosition } from '@/utils/viewportCenter'
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent' import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
@@ -125,8 +125,10 @@ export default function App() {
.filter((n) => n.type === 'group' || n.container_mode === true) .filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, true]) .map((n) => [n.id, true])
) )
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
)
const savedTheme = res.data.viewport?.theme_id const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme) if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) 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) if (saved.custom_style) setCustomStyle(saved.custom_style)
// Floor plans are backend-only; keep the store clear in standalone mode. // Floor plans are backend-only; keep the store clear in standalone mode.
setFloorMap(null) setFloorMap(null)
loadCanvas(saved.nodes, saved.edges) const migrated = migrateClusterHandles(saved.nodes, saved.edges)
loadCanvas(migrated.nodes, migrated.edges)
} else { } else {
setFloorMap(null) setFloorMap(null)
loadCanvas(demoNodes, demoEdges) loadCanvas(demoNodes, demoEdges)
@@ -788,11 +791,6 @@ export default function App() {
open={!!pendingConnection} open={!!pendingConnection}
onClose={() => setPendingConnection(null)} onClose={() => setPendingConnection(null)}
onSubmit={handleEdgeConfirm} onSubmit={handleEdgeConfirm}
initial={
pendingConnection?.sourceHandle?.includes('cluster') || pendingConnection?.targetHandle?.includes('cluster')
? { type: 'cluster' }
: undefined
}
/> />
<EdgeModal <EdgeModal
+3 -2
View File
@@ -27,7 +27,7 @@ import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes' import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
import { edgeTypes } from '@/components/canvas/edges/edgeTypes' 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 { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
import { liveviewApi } from '@/api/client' import { liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage' import * as standaloneStorage from '@/utils/standaloneStorage'
@@ -88,10 +88,11 @@ function LiveViewCanvas() {
const savedTheme = res.data.viewport?.theme_id const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme) if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
loadCanvas( const migrated = migrateClusterHandles(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)), (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge), (apiEdges as ApiEdge[]).map(deserializeApiEdge),
) )
loadCanvas(migrated.nodes, migrated.edges)
setViewState('ready') setViewState('ready')
}) })
.catch((err) => { .catch((err) => {
@@ -1,5 +1,5 @@
import { createElement, useEffect } from 'react' 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 { Layers } from 'lucide-react'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
@@ -23,34 +23,15 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
const colors = resolveNodeColors(data, 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) { if (data.container_mode === false) {
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border return <BaseNode {...props} icon={Layers} />
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 }}
/>
</>
)
} }
const statusColor = theme.colors.statusColors[data.status] const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online' const isOnline = data.status === 'online'
const glow = colors.border const glow = colors.border
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon) const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
return ( return (
@@ -151,22 +132,6 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
handleBorder={theme.colors.handleBorder} handleBorder={theme.colors.handleBorder}
labelColor={theme.colors.nodeSubtextColor} 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) 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({}) 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 }) 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, serializeEdge,
deserializeApiNode, deserializeApiNode,
deserializeApiEdge, deserializeApiEdge,
migrateClusterHandles,
type ApiNode, type ApiNode,
type ApiEdge, type ApiEdge,
} from '@/utils/canvasSerializer' } from '@/utils/canvasSerializer'
@@ -483,3 +484,40 @@ describe('serializeNode — text node roundtrip', () => {
expect(restored.data.label).toBe('Hello world') 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') 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 = ` const yaml = `
- nodeType: proxmox - nodeType: proxmox
label: "PVE1" label: "PVE1"
@@ -115,10 +115,15 @@ describe('parseYamlToCanvas', () => {
- nodeType: proxmox - nodeType: proxmox
label: "PVE2" label: "PVE2"
` `
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges) const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(edges).toHaveLength(1) expect(edges).toHaveLength(1)
expect(edges[0].sourceHandle).toBe('cluster-right') expect(edges[0].sourceHandle).toBe('right')
expect(edges[0].targetHandle).toBe('cluster-left') 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', () => { 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 { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData, Waypoint } from '@/types' import type { NodeData, EdgeData, Waypoint } from '@/types'
import { normalizeHandle, clampHandles } from '@/utils/handleUtils' import { normalizeHandle, clampHandles, handleId, handleCountField, type Side } from '@/utils/handleUtils'
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -216,3 +216,52 @@ export function deserializeApiEdge(e: ApiEdge): Edge<EdgeData> {
data: e as unknown as 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 type { YamlNode, YamlNodeConnection } from '@/types/yaml'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { applyDagreLayout } from '@/utils/layout' import { applyDagreLayout } from '@/utils/layout'
import { migrateClusterHandles } from '@/utils/canvasSerializer'
/** /**
* Parse a YAML string and merge the resulting nodes/edges into the existing canvas. * 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 mergedEdges = [...existingEdges, ...newEdges]
const laidOut = applyDagreLayout(mergedNodes, mergedEdges) 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 }
} }