fix: cluster edges render on left/right handles from approve flow
Cluster edges created via the pending -> approve path rendered on the top
handle instead of left/right, because the edge and its endpoints lost their
handle information on the way to the canvas.
- Approve resolver (scan.py) now returns each edge's type + source/target
handle. Handle IDs are the bare slot-0 side names ('right'/'left'), the
canonical stored form React Flow resolves to the correct side; a '-t' target
id fails to resolve and falls back to the top handle.
- Frontend injectAutoEdges no longer hardcodes iot/bottom/top-t. It injects
each edge with its real type + handles and bumps the referenced nodes'
left/right handle counts (which default to 0) so the cluster endpoints exist.
Logic extracted to a pure, tested util (applyAutoEdges).
- clusterEdges direct-import path uses the bare 'left' target to match.
Tests: new autoEdges unit tests; updated backend handle assertions.
ha-relevant: maybe
This commit is contained in:
@@ -88,7 +88,7 @@ export const scanApi = {
|
||||
approved: boolean
|
||||
node_id: string
|
||||
edges_created: number
|
||||
edges: { id: string; source: string; target: string }[]
|
||||
edges: { id: string; source: string; target: string; type?: string; source_handle?: string | null; target_handle?: string | null }[]
|
||||
}>(`/scan/pending/${id}/approve`, nodeData),
|
||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||
@@ -98,7 +98,7 @@ export const scanApi = {
|
||||
node_ids: string[]
|
||||
device_ids: string[]
|
||||
edges_created: number
|
||||
edges: { id: string; source: string; target: string }[]
|
||||
edges: { id: string; source: string; target: string; type?: string; source_handle?: string | null; target_handle?: string | null }[]
|
||||
skipped: number
|
||||
}>('/scan/pending/bulk-approve', { device_ids: ids, design_id: designId ?? undefined }),
|
||||
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { toast } from 'sonner'
|
||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
import type { NodeType, ServiceInfo } from '@/types'
|
||||
import { applyAutoEdges, type AutoEdge } from '@/utils/autoEdges'
|
||||
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
||||
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
|
||||
import { buildMacProperty } from '@/utils/macProperty'
|
||||
@@ -100,21 +101,10 @@ function deviceLabel(d: PendingDevice): string {
|
||||
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
|
||||
}
|
||||
|
||||
function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) {
|
||||
function injectAutoEdges(edges: AutoEdge[] | undefined) {
|
||||
if (!edges || edges.length === 0) return
|
||||
useCanvasStore.setState((state) => ({
|
||||
edges: [
|
||||
...state.edges,
|
||||
...edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: 'bottom',
|
||||
targetHandle: 'top-t',
|
||||
type: 'iot',
|
||||
data: { type: 'iot' as const },
|
||||
})),
|
||||
],
|
||||
...applyAutoEdges(state.nodes, state.edges, edges),
|
||||
hasUnsavedChanges: true,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -9,9 +9,12 @@
|
||||
*/
|
||||
import type { ProxmoxNode } from './types'
|
||||
|
||||
/** Source/target handle IDs for a cluster link (see handleUtils.handleId). */
|
||||
/** Source/target handle IDs for a cluster link (see handleUtils.handleId).
|
||||
* Both are the bare slot-0 side names — the canonical stored form. React Flow
|
||||
* resolves a bare id to that side; a '-t' target id fails to resolve and falls
|
||||
* back to the top handle (which is why the target must be 'left', not 'left-t'). */
|
||||
export const CLUSTER_SOURCE_HANDLE = 'right'
|
||||
export const CLUSTER_TARGET_HANDLE = 'left-t'
|
||||
export const CLUSTER_TARGET_HANDLE = 'left'
|
||||
|
||||
export interface ClusterEdgeSpec {
|
||||
source: string
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { applyAutoEdges, handleSide, type AutoEdge } from '../autoEdges'
|
||||
import type { Edge, Node } from '@xyflow/react'
|
||||
import type { EdgeData, NodeData } from '@/types'
|
||||
|
||||
function node(id: string, data: Partial<NodeData> = {}): Node<NodeData> {
|
||||
return {
|
||||
id,
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: id, type: 'proxmox', status: 'online', services: [], ...data },
|
||||
}
|
||||
}
|
||||
|
||||
describe('handleSide', () => {
|
||||
it('maps handle ids (with -t / slot suffixes) to their side', () => {
|
||||
expect(handleSide('left-t')).toBe('left')
|
||||
expect(handleSide('right')).toBe('right')
|
||||
expect(handleSide('bottom-2')).toBe('bottom')
|
||||
expect(handleSide('top')).toBe('top')
|
||||
expect(handleSide(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyAutoEdges', () => {
|
||||
it('injects a cluster edge with its handles and grants left/right handles', () => {
|
||||
const nodes = [node('a'), node('b')]
|
||||
const auto: AutoEdge[] = [
|
||||
{ id: 'e1', source: 'a', target: 'b', type: 'cluster', source_handle: 'right', target_handle: 'left' },
|
||||
]
|
||||
const res = applyAutoEdges(nodes, [], auto)
|
||||
|
||||
// Edge keeps its cluster type + right→left handles (not the iot default).
|
||||
expect(res.edges).toHaveLength(1)
|
||||
expect(res.edges[0]).toMatchObject({ type: 'cluster', sourceHandle: 'right', targetHandle: 'left' })
|
||||
// Source node gained a right handle; target node gained a left handle.
|
||||
expect(res.nodes.find((n) => n.id === 'a')!.data.right_handles).toBe(1)
|
||||
expect(res.nodes.find((n) => n.id === 'b')!.data.left_handles).toBe(1)
|
||||
})
|
||||
|
||||
it('does not lower an existing higher handle count', () => {
|
||||
const nodes = [node('a', { right_handles: 3 }), node('b')]
|
||||
const auto: AutoEdge[] = [
|
||||
{ id: 'e1', source: 'a', target: 'b', type: 'cluster', source_handle: 'right', target_handle: 'left' },
|
||||
]
|
||||
const res = applyAutoEdges(nodes, [], auto)
|
||||
expect(res.nodes.find((n) => n.id === 'a')!.data.right_handles).toBe(3)
|
||||
})
|
||||
|
||||
it('defaults to an iot bottom→top edge and bumps no handles', () => {
|
||||
const nodes = [node('a'), node('b')]
|
||||
const auto: AutoEdge[] = [{ id: 'e1', source: 'a', target: 'b' }]
|
||||
const res = applyAutoEdges(nodes, [], auto)
|
||||
expect(res.edges[0]).toMatchObject({ type: 'iot', sourceHandle: 'bottom', targetHandle: 'top' })
|
||||
// top/bottom always exist — nodes are returned untouched.
|
||||
expect(res.nodes).toBe(nodes)
|
||||
})
|
||||
|
||||
it('appends to existing edges rather than replacing them', () => {
|
||||
const existing = [{ id: 'x', source: 'a', target: 'b' }] as Edge<EdgeData>[]
|
||||
const res = applyAutoEdges([node('a'), node('b')], existing, [
|
||||
{ id: 'e1', source: 'a', target: 'b', type: 'iot' },
|
||||
])
|
||||
expect(res.edges.map((e) => e.id)).toEqual(['x', 'e1'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Apply server-created "auto" edges (from scan/import approve) to the canvas.
|
||||
*
|
||||
* The approve endpoints create edges server-side and return them with their
|
||||
* type + handle IDs. We must inject them faithfully: a Proxmox cluster edge
|
||||
* keeps its right→left handles, mesh links stay iot bottom→top. Left/right
|
||||
* handles default to 0, so a node referenced on its left/right side must be
|
||||
* granted that side's connection point or the edge endpoint won't exist and
|
||||
* React Flow falls back to the top handle.
|
||||
*/
|
||||
import type { Edge, Node } from '@xyflow/react'
|
||||
import type { EdgeData, EdgeType, NodeData } from '@/types'
|
||||
import { normalizeHandle, handleCountField, type Side } from '@/utils/handleUtils'
|
||||
|
||||
export interface AutoEdge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
type?: string
|
||||
source_handle?: string | null
|
||||
target_handle?: string | null
|
||||
}
|
||||
|
||||
/** Side a handle id sits on ('left-t' → 'left', 'bottom-2' → 'bottom'). */
|
||||
export function handleSide(h: string | null | undefined): Side | null {
|
||||
const bare = normalizeHandle(h)
|
||||
const m = bare?.match(/^(top|bottom|left|right)/)
|
||||
return m ? (m[1] as Side) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transform: given current nodes + edges and the server auto-edges,
|
||||
* return the next nodes (with left/right handle counts bumped where an edge
|
||||
* needs them) and edges (with the injected edges appended).
|
||||
*/
|
||||
export function applyAutoEdges(
|
||||
nodes: Node<NodeData>[],
|
||||
edges: Edge<EdgeData>[],
|
||||
autoEdges: AutoEdge[],
|
||||
): { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] } {
|
||||
// node id → left/right sides that need at least one handle.
|
||||
const bumps = new Map<string, Set<Side>>()
|
||||
const mark = (id: string, side: Side | null) => {
|
||||
if (!side || side === 'top' || side === 'bottom') return // always exist
|
||||
const set = bumps.get(id) ?? new Set<Side>()
|
||||
set.add(side)
|
||||
bumps.set(id, set)
|
||||
}
|
||||
|
||||
const injected: Edge<EdgeData>[] = autoEdges.map((e) => {
|
||||
const type = (e.type ?? 'iot') as EdgeType
|
||||
const sourceHandle = e.source_handle ?? 'bottom'
|
||||
const targetHandle = e.target_handle ?? 'top'
|
||||
mark(e.source, handleSide(sourceHandle))
|
||||
mark(e.target, handleSide(targetHandle))
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
type,
|
||||
data: { type } as EdgeData,
|
||||
}
|
||||
})
|
||||
|
||||
const nextNodes = bumps.size === 0 ? nodes : nodes.map((n) => {
|
||||
const sides = bumps.get(n.id)
|
||||
if (!sides) return n
|
||||
const data = { ...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: nextNodes, edges: [...edges, ...injected] }
|
||||
}
|
||||
Reference in New Issue
Block a user