feat: arrowhead endpoints for edges + fix parallel edges not rendering

Add optional filled-triangle arrowheads at either end of an edge,
independently toggleable per edge (EdgeModal) and as per-edge-type
defaults (CustomStyleModal). Arrowheads are custom inline <marker> defs
filled with the live stroke colour so they recolour reactively with
custom_color / vlan / selected state. Persisted frontend (serializer)
and backend (edge columns + schemas + runtime migration).

Also fix two dedupe layers that silently dropped legitimate parallel
links between the same two devices:
- store: React Flow addEdge() connectionExists dropped a second edge
  with matching source+target when handles were null/equal. Build the
  edge with a unique id and append directly.
- render: rewireEdgesForCollapse deduped ALL edges by src->tgt key even
  when nothing was collapsed, filtering real parallel edges out of the
  visible set. Restrict the anti-mesh dedupe to rewired collapse stubs.

Tests: marker render, per-edge/per-type UI, store apply, serializer
round-trip, backend edge/canvas persistence, parallel-edge regressions.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-07-05 01:16:26 +02:00
parent ae2d3e1eab
commit 1cf525844b
20 changed files with 371 additions and 14 deletions
@@ -332,6 +332,25 @@ describe('canvasStore', () => {
expect(edges[0].data?.animated).toBe('snake')
})
it('onConnect allows multiple edges between the same two nodes (no dedupe)', () => {
// Regression: React Flow addEdge() dropped a second edge between the same
// source+target when handles were null/equal. A homelab has multiple links
// between two devices, so every connect must add an edge.
useCanvasStore.getState().onConnect({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null })
useCanvasStore.getState().onConnect({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null })
const { edges } = useCanvasStore.getState()
expect(edges).toHaveLength(2)
expect(edges[0].id).not.toBe(edges[1].id)
})
it('onConnect preserves arrow markers from edge data', () => {
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', marker_start: true, marker_end: true })
useCanvasStore.getState().onConnect(conn)
const { edges } = useCanvasStore.getState()
expect(edges[0].data?.marker_start).toBe(true)
expect(edges[0].data?.marker_end).toBe(true)
})
it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => {
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }, { type: 'cluster' })
useCanvasStore.getState().onConnect(conn)
@@ -1301,14 +1320,17 @@ describe('canvasStore — custom style apply', () => {
const e2: Edge<EdgeData> = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } }
useCanvasStore.setState({ nodes: [], edges: [e1, e2] })
useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow' })
useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow', arrowStart: true, arrowEnd: true })
const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')!
expect(updated1.data?.custom_color).toBe('#00ff00')
expect(updated1.data?.path_style).toBe('smooth')
expect(updated1.data?.animated).toBe('flow')
expect(updated1.data?.marker_start).toBe(true)
expect(updated1.data?.marker_end).toBe(true)
expect(updated2.data?.custom_color).toBeUndefined()
expect(updated2.data?.marker_end).toBeUndefined()
})
it('applyAllCustomStyles applies all defined types', () => {
@@ -1322,7 +1344,7 @@ describe('canvasStore — custom style apply', () => {
proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 },
},
edges: {
ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none' },
ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none', arrowStart: false, arrowEnd: true },
},
})
@@ -1332,6 +1354,8 @@ describe('canvasStore — custom style apply', () => {
expect(np.data.custom_colors?.border).toBe('#ff6e00')
expect(ns.data.custom_colors?.border).toBeUndefined()
expect(e.data?.custom_color).toBe('#aabbcc')
expect(e.data?.marker_end).toBe(true)
expect(e.data?.marker_start).toBe(false)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
@@ -39,7 +39,7 @@ describe('themeStore', () => {
it('setCustomStyle replaces the entire definition', () => {
const def: CustomStyleDef = {
nodes: { server: { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#000000', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 200, height: 80 } },
edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none' } },
edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none', arrowStart: false, arrowEnd: false } },
}
useThemeStore.getState().setCustomStyle(def)
expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000')
+19 -8
View File
@@ -7,7 +7,6 @@ import {
type Connection,
applyNodeChanges,
applyEdgeChanges,
addEdge,
} from '@xyflow/react'
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types'
import { generateUUID } from '@/utils/uuid'
@@ -268,14 +267,22 @@ export const useCanvasStore = create<CanvasState>((set) => ({
set((state) => {
const extra = connection as Connection & Partial<EdgeData>
const edgeType = extra.type ?? 'ethernet'
// Build the edge with our own unique id and append directly instead of
// React Flow's addEdge(): addEdge silently drops any new edge whose
// source+target already match an existing edge when handles are null/equal
// (connectionExists dedupe). A homelab legitimately has multiple links
// between the same two devices, so we allow them.
const newEdge: Edge<EdgeData> = {
id: `edge-${generateUUID()}`,
source: connection.source,
target: connection.target,
sourceHandle: normalizeHandle(extra.sourceHandle),
targetHandle: normalizeHandle(extra.targetHandle),
type: edgeType,
data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated, marker_start: extra.marker_start, marker_end: extra.marker_end },
}
return {
edges: addEdge({
...connection,
sourceHandle: normalizeHandle(extra.sourceHandle),
targetHandle: normalizeHandle(extra.targetHandle),
type: edgeType,
data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated },
}, state.edges),
edges: [...state.edges, newEdge],
hasUnsavedChanges: true,
}
}),
@@ -816,6 +823,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
custom_color: applyOpacity(style.color, style.opacity),
path_style: style.pathStyle,
animated: style.animated,
marker_start: style.arrowStart,
marker_end: style.arrowEnd,
} as EdgeData,
}
}),
@@ -854,6 +863,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
custom_color: applyOpacity(style.color, style.opacity),
path_style: style.pathStyle,
animated: style.animated,
marker_start: style.arrowStart,
marker_end: style.arrowEnd,
} as EdgeData,
}
})