diff --git a/web/WEB.md b/web/WEB.md index 974a0c0..12f0561 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -127,12 +127,12 @@ src/ - 三层库存:**运维** `managed_ne`(凭据/采集/WebCRT)· **EMS** `ume_inventory_ne` · **拓扑投影** `topo_fabric_node`(上图/LLDP/分类)。Fabric 由 ensure 按需创建,与运维表不是同一张表。 - 删除运维网元(手工 / WebCRT / `ume_sync` ManagedNE 同一路径):先解绑 `fabric.managed_ne_id`(及 view membership)。若该 Fabric 节点同时仍绑 UME → 变为 UME-only,**保留**节点与直连边;若解绑后已无 managed 且无 UME(完全孤儿)→ **硬删** Fabric 节点并级联直连边 / placements。UME 库存 reconcile 对称解绑 `ume_ne_id`,同样在完全孤儿时 purge。历史悬空引用与孤儿 GC:`POST /fabric/reconcile-links`(`cleanup-duplicates` 先 merge/absorb 再扫孤儿);后台默认每 6h 跑一次(`fabric_reconcile_scheduler_*`,可关)。 -- 画布:移出本图仅影响 placement(保存落库);删边本地排队、**保存**才删 Fabric(Undo 仅保存前有效);topology/lldp 占位可右键硬删(ManagedNE+Fabric+边)。未匹配且未建占位的对端不上 Fabric 边。 +- 画布:移出本图仅影响 placement(保存落库);连线/删边本地排队、**保存**才写/删 Fabric(Undo 仅保存前有效);topology/lldp 占位可右键硬删(ManagedNE+Fabric+边)。未匹配且未建占位的对端不上 Fabric 边。 - 事实库:`topo_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP) - 站点树:`topo_folder`(系统隐藏 `root`;用户新建站点/区域,无默认「未分区」) - 拓扑图:`topo_view.kind=physical|custom`(同站点下平级;建区域后需手工/MCP 建画布)+ `topo_view_node` - 边界:图 `filter.membership`(max_nodes / expand_hops / frozen);`project-neighbors` / `populate` 不得无界灌全网。画布「投影邻居 / 发现后投影」默认 `dry_run`:只进本地脏图,**保存**时 `addTopologyViewNodes` 才落库。 -- 连线模式:Visio 式四边锚点拖拽连线(非点选两点);默认折线 `smoothstep`。 +- 连线模式:网元中心锚点拖拽连线;默认直线 `straight`(与发现边同一附着点)。 - API:`/v1/topology/tree`、`/folders*`、`/fabric/*`、`/views*`(含 `populate`、`kind`) - 前端:左侧站点→物理/自定义图;右侧目录浏览,打开本图进设备画布;「添加网元」支持勾选批量上图(也可拖放单台) - MCP:以 `queryTopologyEdges` 为主查询 Fabric;画布编辑走 Web diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 35d8802..edb86a8 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1622,7 +1622,7 @@ const en = { removeStaleHint: "Queue delete of red missing edges (applies on Save); scheduled collect also purges after 4 consecutive misses", staleRemoved: "Queued {{count}} missing edge delete(s) — Save to apply", - connectHint: "Connect mode: drag from a node's side anchors to the target NE (Esc or Select to exit)", + connectHint: "Connect mode: drag from the NE center anchor to the target (Esc or Select to exit)", }, topoClassify: { title: "Classify & slices", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 370976c..49319e4 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1608,7 +1608,7 @@ const zh = { removeStale: "清除未发现 ({{count}})", removeStaleHint: "标记删除红色未发现链路(保存后生效);周期采集连续 4 次未发现也会自动清理", staleRemoved: "已标记删除 {{count}} 条未发现链路(保存后生效)", - connectHint: "连线模式:从节点四边锚点拖到目标网元(Esc 或切回选择退出)", + connectHint: "连线模式:从网元中心锚点拖到目标网元(Esc 或切回选择退出)", }, topoClassify: { title: "分类与切片", diff --git a/web/src/index.css b/web/src/index.css index 936c405..4a96725 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -6339,11 +6339,15 @@ pre { } .topo-node.is-connect-mode .topo-node__handle { - width: 12px !important; - height: 12px !important; - opacity: 1 !important; - background: #0ea5e9 !important; - border: 2px solid #fff !important; + width: 28px !important; + height: 28px !important; + min-width: 28px !important; + min-height: 28px !important; + opacity: 0.9 !important; + background: rgba(14, 165, 233, 0.35) !important; + border: 2px solid #38bdf8 !important; + border-radius: 50% !important; + z-index: 5; } .react-flow__selection { @@ -7042,76 +7046,16 @@ pre { .topo-node.is-connect-mode .topo-node__handle { pointer-events: all; - opacity: 1 !important; - width: 12px !important; - height: 12px !important; - min-width: 12px !important; - min-height: 12px !important; - background: #f8fafc !important; - border: 2px solid #38bdf8 !important; - border-radius: 2px !important; - box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.35); - z-index: 5; } -.topo-node.is-connect-mode .topo-node__handle:hover { - background: #38bdf8 !important; - border-color: #e0f2fe !important; - transform: scale(1.15); -} - -.topo-node__handle--n { +.topo-node__handle--center { left: 50% !important; - top: 0 !important; - right: auto !important; - bottom: auto !important; - transform: translate(-50%, -50%) !important; -} - -.topo-node__handle--e { - left: auto !important; - top: 50% !important; - right: 0 !important; - bottom: auto !important; - transform: translate(50%, -50%) !important; -} - -.topo-node__handle--s { - left: 50% !important; - top: auto !important; - right: auto !important; - bottom: 0 !important; - transform: translate(-50%, 50%) !important; -} - -.topo-node__handle--w { - left: 0 !important; top: 50% !important; right: auto !important; bottom: auto !important; transform: translate(-50%, -50%) !important; } -.topo-node.is-connect-mode .topo-node__handle--n:hover, -.topo-node.is-connect-mode .topo-node__handle--e:hover, -.topo-node.is-connect-mode .topo-node__handle--s:hover, -.topo-node.is-connect-mode .topo-node__handle--w:hover { - /* keep side translate while scaling */ -} - -.topo-node.is-connect-mode .topo-node__handle--n:hover { - transform: translate(-50%, -50%) scale(1.15) !important; -} -.topo-node.is-connect-mode .topo-node__handle--e:hover { - transform: translate(50%, -50%) scale(1.15) !important; -} -.topo-node.is-connect-mode .topo-node__handle--s:hover { - transform: translate(-50%, 50%) scale(1.15) !important; -} -.topo-node.is-connect-mode .topo-node__handle--w:hover { - transform: translate(-50%, -50%) scale(1.15) !important; -} - .topo-node.is-selected .topo-node__glyph { outline: 2px solid #60a5fa; outline-offset: 3px; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 58b53d9..581301a 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -284,6 +284,7 @@ type HistorySnap = { nodes: Node[]; edges: Edge[]; pendingEdgeDeletes: string[]; + pendingEdgeCreates: string[]; }; type PaletteSource = "managed" | "ume"; @@ -330,6 +331,15 @@ function newId(): string { return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`; } +/** Client-only edge id until Save calls createFabricManualEdge. */ +function newLocalEdgeId(): string { + return `local:${newId()}`; +} + +function isLocalPendingEdgeId(id: string): boolean { + return String(id || "").startsWith("local:"); +} + /** * Icon tone for /topo/ne-router.png (base = blue). * Unmanaged (no managed/ume) and unknown vendors → gray. @@ -431,12 +441,6 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps
- {handlePositions.map(({ id, position }) => ( - - ))} + {/* Single center anchors on the router glyph — same attachment as discovered edges. */} + + {showBadge ? ( @@ -743,13 +750,11 @@ function graphToFlow( position: { x: n.x || 0, y: n.y || 0 }, width: TOPO_NODE_W, height: TOPO_NODE_H, - // Predetermined handles must match DOM anchors on the router glyph (56×56, top-centered). - // ConnectionMode.Loose treats these source handles as connectable targets too. + // Predetermined handles must match DOM anchors (icon center), not the 160px box edges — + // otherwise edges float in the gap and never touch the router glyph. handles: [ - { type: "source", position: Position.Top, id: "n", x: TOPO_HANDLE_X, y: 0 }, - { type: "source", position: Position.Right, id: "e", x: TOPO_HANDLE_X + TOPO_ICON / 2, y: TOPO_HANDLE_Y }, - { type: "source", position: Position.Bottom, id: "s", x: TOPO_HANDLE_X, y: TOPO_ICON }, - { type: "source", position: Position.Left, id: "w", x: TOPO_HANDLE_X - TOPO_ICON / 2, y: TOPO_HANDLE_Y }, + { type: "target", position: Position.Left, x: TOPO_HANDLE_X, y: TOPO_HANDLE_Y }, + { type: "source", position: Position.Right, x: TOPO_HANDLE_X, y: TOPO_HANDLE_Y }, ], data: { label: n.label || n.name || n.ip || n.fabric_node_id, @@ -779,7 +784,7 @@ function graphToFlow( id: e.id, source: e.a_node_id, target: e.b_node_id, - type: "smoothstep", + type: "straight", label: label || undefined, animated: false, data, @@ -944,6 +949,8 @@ export function TopologyPage() { const historyLockRef = useRef(false); /** Fabric edge ids removed locally; flushed on Save (not when only removing nodes from view). */ const pendingEdgeDeletesRef = useRef>(new Set()); + /** Local `local:*` edge ids added on canvas; flushed on Save via createFabricManualEdge. */ + const pendingEdgeCreatesRef = useRef>(new Set()); const connectClickRef = useRef(null); const canUndo = historyTick >= 0 && historyRef.current.length > 0; const canRedo = historyTick >= 0 && redoRef.current.length > 0; @@ -1289,6 +1296,7 @@ export function TopologyPage() { historyRef.current = []; redoRef.current = []; pendingEdgeDeletesRef.current = new Set(); + pendingEdgeCreatesRef.current = new Set(); clearDirty(); bumpHistory(); needsInitialFitRef.current = true; @@ -1330,6 +1338,7 @@ export function TopologyPage() { nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), edges: edges.map((e) => ({ ...e })), pendingEdgeDeletes: [...pendingEdgeDeletesRef.current], + pendingEdgeCreates: [...pendingEdgeCreatesRef.current], }, ]; redoRef.current = []; @@ -1343,11 +1352,13 @@ export function TopologyPage() { nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), edges: edges.map((e) => ({ ...e })), pendingEdgeDeletes: [...pendingEdgeDeletesRef.current], + pendingEdgeCreates: [...pendingEdgeCreatesRef.current], }); historyLockRef.current = true; setNodes(prev.nodes); setEdges(prev.edges); pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes); + pendingEdgeCreatesRef.current = new Set(prev.pendingEdgeCreates || []); markDirty(); bumpHistory(); historyLockRef.current = false; @@ -1360,11 +1371,13 @@ export function TopologyPage() { nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), edges: edges.map((e) => ({ ...e })), pendingEdgeDeletes: [...pendingEdgeDeletesRef.current], + pendingEdgeCreates: [...pendingEdgeCreatesRef.current], }); historyLockRef.current = true; setNodes(next.nodes); setEdges(next.edges); pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes); + pendingEdgeCreatesRef.current = new Set(next.pendingEdgeCreates || []); markDirty(); bumpHistory(); historyLockRef.current = false; @@ -1388,7 +1401,10 @@ export function TopologyPage() { if (opts?.persist && mapId) { try { const graph = await patchTopologyPositions(mapId, flowToPositions(next)); - if (pendingEdgeDeletesRef.current.size === 0) { + if ( + pendingEdgeDeletesRef.current.size === 0 && + pendingEdgeCreatesRef.current.size === 0 + ) { clearDirty(); } queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); @@ -1538,11 +1554,24 @@ export function TopologyPage() { const saveMut = useMutation({ mutationFn: async () => { if (!mapId) throw new Error(t("topology.selectMap")); - const pendingEdges = [...pendingEdgeDeletesRef.current]; + const createIds = [...pendingEdgeCreatesRef.current]; + for (const id of createIds) { + const e = edges.find((x) => x.id === id); + if (!e) continue; + const d = (e.data || {}) as EdgeStyleData; + await createFabricManualEdge({ + a_node_id: e.source, + b_node_id: e.target, + a_port: String(d.source_port || ""), + b_port: String(d.target_port || ""), + }); + } + pendingEdgeCreatesRef.current.clear(); + const pendingEdges = [...pendingEdgeDeletesRef.current].filter((id) => !isLocalPendingEdgeId(id)); if (pendingEdges.length) { await deleteFabricEdges(pendingEdges); - pendingEdgeDeletesRef.current.clear(); } + pendingEdgeDeletesRef.current.clear(); const serverIds = (graphQuery.data?.nodes || []) .map((n) => n.fabric_node_id) .filter(Boolean); @@ -1601,11 +1630,26 @@ export function TopologyPage() { }); try { if (dirtyRef.current) { - const pendingEdges = [...pendingEdgeDeletesRef.current]; + const createIds = [...pendingEdgeCreatesRef.current]; + for (const id of createIds) { + const e = edges.find((x) => x.id === id); + if (!e) continue; + const d = (e.data || {}) as EdgeStyleData; + await createFabricManualEdge({ + a_node_id: e.source, + b_node_id: e.target, + a_port: String(d.source_port || ""), + b_port: String(d.target_port || ""), + }); + } + pendingEdgeCreatesRef.current.clear(); + const pendingEdges = [...pendingEdgeDeletesRef.current].filter( + (id) => !isLocalPendingEdgeId(id), + ); if (pendingEdges.length) { await deleteFabricEdges(pendingEdges); - pendingEdgeDeletesRef.current.clear(); } + pendingEdgeDeletesRef.current.clear(); const serverIds = (graphQuery.data?.nodes || []) .map((n) => n.fabric_node_id) .filter(Boolean); @@ -1831,29 +1875,40 @@ export function TopologyPage() { const onConnect = useCallback( (connection: Connection) => { if (!mapId || !isValidConnection(connection)) return; + const source = String(connection.source || ""); + const target = String(connection.target || ""); + if (!source || !target) return; pushHistory(); connectClickRef.current = null; - void (async () => { - try { - await createFabricManualEdge({ - a_node_id: String(connection.source || ""), - b_node_id: String(connection.target || ""), - a_port: "", - b_port: "", - }); - const graph = await fetchTopologyGraph(mapId); - queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); - historyLockRef.current = true; - applyViewGraph(graph, edgeDefaults, setNodes, setEdges); - historyLockRef.current = false; - pendingEdgeDeletesRef.current = new Set(); - clearDirty(); - } catch (err) { - showError(String(err)); - } - })(); + const id = newLocalEdgeId(); + const data: EdgeStyleData = { + source: "manual", + source_port: "", + target_port: "", + stroke_color: "", + stroke_width: 0, + line_style: "", + discovered_at: null, + }; + const edge = withEdgeVisual( + { + id, + source, + target, + // Center anchors only — omit handle ids so attach matches discovered edges after Save. + sourceHandle: null, + targetHandle: null, + type: "straight", + animated: false, + data, + }, + edgeDefaults, + ); + pendingEdgeCreatesRef.current.add(id); + setEdges((es) => [...es, edge]); + markDirty(); }, - [setEdges, setNodes, pushHistory, edgeDefaults, isValidConnection, mapId, queryClient, clearDirty, showError], + [mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty], ); const addPaletteItems = useCallback( @@ -2110,7 +2165,13 @@ export function TopologyPage() { if (!window.confirm(confirmMsg)) return false; } pushHistory(); - for (const id of edgeIds) pendingEdgeDeletesRef.current.add(id); + for (const id of edgeIds) { + if (pendingEdgeCreatesRef.current.has(id) || isLocalPendingEdgeId(id)) { + pendingEdgeCreatesRef.current.delete(id); + } else { + pendingEdgeDeletesRef.current.add(id); + } + } const idSet = new Set(edgeIds); setEdges((es) => es.filter((e) => !idSet.has(e.id))); setSelectedEdgeId((cur) => (cur && idSet.has(cur) ? null : cur)); @@ -2143,7 +2204,13 @@ export function TopologyPage() { if (!window.confirm(msg)) return; pushHistory(); const nodeSet = new Set(nodeIds); - for (const id of edgeIds) pendingEdgeDeletesRef.current.add(id); + for (const id of edgeIds) { + if (pendingEdgeCreatesRef.current.has(id) || isLocalPendingEdgeId(id)) { + pendingEdgeCreatesRef.current.delete(id); + } else { + pendingEdgeDeletesRef.current.add(id); + } + } setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id))); setEdges((es) => es.filter( @@ -2170,7 +2237,14 @@ export function TopologyPage() { const nodeSet = new Set(nodeIds); setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id))); // Drop incident edges from the canvas only — do not queue Fabric deletes. - setEdges((es) => es.filter((e) => !nodeSet.has(e.source) && !nodeSet.has(e.target))); + setEdges((es) => { + const next = es.filter((e) => !nodeSet.has(e.source) && !nodeSet.has(e.target)); + for (const e of es) { + if (next.some((x) => x.id === e.id)) continue; + pendingEdgeCreatesRef.current.delete(e.id); + } + return next; + }); setSelectedEdgeId(null); markDirty(); }, [ @@ -2322,7 +2396,14 @@ export function TopologyPage() { pushHistory(); closeCtxMenu(); setNodes((ns) => ns.filter((n) => n.id !== nodeId)); - setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId)); + setEdges((es) => { + const next = es.filter((e) => e.source !== nodeId && e.target !== nodeId); + for (const e of es) { + if (next.some((x) => x.id === e.id)) continue; + pendingEdgeCreatesRef.current.delete(e.id); + } + return next; + }); markDirty(); }; @@ -2337,11 +2418,26 @@ export function TopologyPage() { closeCtxMenu(); try { if (dirtyRef.current) { - const pendingEdges = [...pendingEdgeDeletesRef.current]; + const createIds = [...pendingEdgeCreatesRef.current]; + for (const id of createIds) { + const e = edges.find((x) => x.id === id); + if (!e || e.source === nodeId || e.target === nodeId) continue; + const d = (e.data || {}) as EdgeStyleData; + await createFabricManualEdge({ + a_node_id: e.source, + b_node_id: e.target, + a_port: String(d.source_port || ""), + b_port: String(d.target_port || ""), + }); + } + pendingEdgeCreatesRef.current.clear(); + const pendingEdges = [...pendingEdgeDeletesRef.current].filter( + (id) => !isLocalPendingEdgeId(id), + ); if (pendingEdges.length) { await deleteFabricEdges(pendingEdges); - pendingEdgeDeletesRef.current.clear(); } + pendingEdgeDeletesRef.current.clear(); const serverIds = (graphQuery.data?.nodes || []) .map((n) => n.fabric_node_id) .filter(Boolean); @@ -2366,6 +2462,7 @@ export function TopologyPage() { applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos); historyLockRef.current = false; pendingEdgeDeletesRef.current = new Set(); + pendingEdgeCreatesRef.current = new Set(); historyRef.current = []; redoRef.current = []; bumpHistory(); @@ -2378,6 +2475,7 @@ export function TopologyPage() { [ mapId, nodes, + edges, graphQuery.data, closeCtxMenu, queryClient, @@ -2787,8 +2885,11 @@ export function TopologyPage() { const onNodeClick = useCallback( (e: React.MouseEvent, node: Node) => { setCtxMenu(null); - // Connect mode uses Visio-style drag from handles (onConnect), not click-click. - if (toolMode === "connect") return; + // Connect mode: drag from center handle (onConnect). Clicks only select the NE. + if (toolMode === "connect") { + focusNode(node.id, false); + return; + } focusNode(node.id, e.shiftKey || e.metaKey || e.ctrlKey); }, [toolMode, focusNode], @@ -4137,9 +4238,9 @@ export function TopologyPage() { edgeTypes={edgeTypes} onlyRenderVisibleElements connectionMode={ConnectionMode.Loose} - connectionLineType={ConnectionLineType.SmoothStep} + connectionLineType={ConnectionLineType.Straight} connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }} - defaultEdgeOptions={{ type: "smoothstep", labelShowBg: false }} + defaultEdgeOptions={{ type: "straight", labelShowBg: false }} proOptions={{ hideAttribution: true }} minZoom={0.05} maxZoom={4} diff --git a/web/src/pages/topology/ParallelEdge.tsx b/web/src/pages/topology/ParallelEdge.tsx index f02ca55..b7aa53b 100644 --- a/web/src/pages/topology/ParallelEdge.tsx +++ b/web/src/pages/topology/ParallelEdge.tsx @@ -1,14 +1,12 @@ -import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from "@xyflow/react"; +import { BaseEdge, EdgeLabelRenderer, getStraightPath, type EdgeProps } from "@xyflow/react"; -/** Offset parallel physical links so they do not stack on one line (Visio-like elbows). */ +/** Offset parallel physical links so they do not stack on one line. */ export function ParallelEdge({ id, sourceX, sourceY, targetX, targetY, - sourcePosition, - targetPosition, style, markerEnd, label, @@ -18,20 +16,17 @@ export function ParallelEdge({ }: EdgeProps) { const index = Number((data as { parallelIndex?: number } | undefined)?.parallelIndex || 0); const count = Math.max(1, Number((data as { parallelCount?: number } | undefined)?.parallelCount || 1)); - const offset = (index - (count - 1) / 2) * 12; + const offset = (index - (count - 1) / 2) * 10; const dx = targetX - sourceX; const dy = targetY - sourceY; const len = Math.hypot(dx, dy) || 1; const ox = (-dy / len) * offset; const oy = (dx / len) * offset; - const [path, labelX, labelY] = getSmoothStepPath({ + const [path, labelX, labelY] = getStraightPath({ sourceX: sourceX + ox, sourceY: sourceY + oy, targetX: targetX + ox, targetY: targetY + oy, - sourcePosition, - targetPosition, - borderRadius: 8, }); return ( <> diff --git a/web/src/pages/topology/linkDisplay.ts b/web/src/pages/topology/linkDisplay.ts index 1dc4cb1..f154b78 100644 --- a/web/src/pages/topology/linkDisplay.ts +++ b/web/src/pages/topology/linkDisplay.ts @@ -116,7 +116,7 @@ export function buildLinkDisplayEdges( const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || ""); out.push({ ...e, - type: count > 1 ? "topoParallel" : e.type || "smoothstep", + type: count > 1 ? "topoParallel" : e.type || "straight", // Never fall back to a stale e.label — it may still contain ports. label: hidePorts ? undefined : portLabel || undefined, data, @@ -137,7 +137,7 @@ export function buildLinkDisplayEdges( const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || ""); out.push({ ...e, - type: "smoothstep", + type: "straight", label: hidePorts ? undefined : portLabel || undefined, data, }); @@ -162,7 +162,7 @@ export function buildLinkDisplayEdges( out.push({ ...primary, id: aggregateIdForPair(primary.source, primary.target), - type: "smoothstep", + type: "straight", label: formatBundleLabel( list.length, { a_port: data.source_port, b_port: data.target_port },