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:
Pouzor
2026-07-06 11:13:32 +02:00
parent 9670d0a86a
commit 05ef746f22
8 changed files with 176 additions and 24 deletions
+18 -5
View File
@@ -642,16 +642,20 @@ async def _resolve_pending_links_for_ieee(
if edge_design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
edge_design_id = first.id if first else None
# Edge shape by link source:
# Edge shape by link source. Handle IDs are the *bare* slot-0 side names
# (the canonical stored form — the save path normalizes '<side>-t' → the
# bare source id, and React Flow resolves the bare id to that side). A
# '-t' target id does not resolve here and RF falls back to the top
# handle, so never emit one.
# proxmox → 'virtual' host→guest, vertical (bottom → top)
# proxmox_cluster → 'cluster' host↔host, horizontal (right → left)
# anything else → 'iot' mesh link, vertical
if link.discovery_source == "proxmox":
edge_type, src_handle, tgt_handle = "virtual", "bottom", "top-t"
edge_type, src_handle, tgt_handle = "virtual", "bottom", "top"
elif link.discovery_source == "proxmox_cluster":
edge_type, src_handle, tgt_handle = "cluster", "right", "left-t"
edge_type, src_handle, tgt_handle = "cluster", "right", "left"
else:
edge_type, src_handle, tgt_handle = "iot", "bottom", "top-t"
edge_type, src_handle, tgt_handle = "iot", "bottom", "top"
edge = Edge(
source=src_id,
target=tgt_id,
@@ -663,7 +667,16 @@ async def _resolve_pending_links_for_ieee(
db.add(edge)
await db.flush()
existing_pairs.add((src_id, tgt_id))
created.append({"id": edge.id, "source": src_id, "target": tgt_id})
# Return the edge's type + handles so the client injects it faithfully
# (a cluster edge must keep its right→left handles, not the iot default).
created.append({
"id": edge.id,
"source": src_id,
"target": tgt_id,
"type": edge_type,
"source_handle": src_handle,
"target_handle": tgt_handle,
})
await db.delete(link)
return created
+3 -1
View File
@@ -265,7 +265,9 @@ async def test_cluster_link_resolves_to_cluster_edge(db_session) -> None:
edge = (await db_session.execute(select(Edge))).scalars().one()
assert edge.type == "cluster"
assert edge.source_handle == "right"
assert edge.target_handle == "left-t"
# Bare side name (canonical) so React Flow resolves it to the left side;
# a '-t' target would fall back to the top handle.
assert edge.target_handle == "left"
@pytest.mark.asyncio
+2 -1
View File
@@ -1281,7 +1281,8 @@ async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node(
assert edges[0].source == coord.id
assert edges[0].target == data["node_id"]
assert edges[0].source_handle == "bottom"
assert edges[0].target_handle == "top-t"
# Bare side name (canonical stored form); renders at the top like before.
assert edges[0].target_handle == "top"
assert edges[0].type == "iot"
+2 -2
View File
@@ -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'])
})
})
+78
View File
@@ -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] }
}