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:
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user