From a64d8b9755a4b47260e38582bf91b705ccfa2c77 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 1 Sep 2026 15:09:19 +0700 Subject: [PATCH] =?UTF-8?q?fix(topology):=20=D1=81=D0=BF=D1=80=D1=8F=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20HA-=D0=BB=D0=B8=D0=BD=D0=B8=D0=B8=20=D0=B8?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20RR=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D1=83?= =?UTF-8?q?=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Membership идёт bezier по ближайшей грани; клик по сервису выделяет VPS-членов. Badge HA только при двух уникальных origin IP. Co-authored-by: Cursor --- .../components/topology/cfdm-services.test.ts | 29 ++++- .../src/components/topology/cfdm-services.ts | 5 +- .../topology/edges/topology-edge.tsx | 19 ++-- .../src/components/topology/group-utils.ts | 7 +- .../topology/membership-handles.test.ts | 107 ++++++++++++++++++ .../components/topology/membership-handles.ts | 61 ++++++++++ .../topology/nodes/node-side-handles.tsx | 15 +++ .../topology/nodes/service-node.tsx | 14 +-- .../components/topology/nodes/vps-node.tsx | 14 +-- .../topology/place-cfdm-service.test.ts | 1 + .../components/topology/place-cfdm-service.ts | 13 ++- .../components/topology/topology-editor.tsx | 40 ++++++- 12 files changed, 282 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/components/topology/membership-handles.test.ts create mode 100644 apps/web/src/components/topology/membership-handles.ts create mode 100644 apps/web/src/components/topology/nodes/node-side-handles.tsx diff --git a/apps/web/src/components/topology/cfdm-services.test.ts b/apps/web/src/components/topology/cfdm-services.test.ts index bee9a45..1458bf6 100644 --- a/apps/web/src/components/topology/cfdm-services.test.ts +++ b/apps/web/src/components/topology/cfdm-services.test.ts @@ -28,6 +28,7 @@ describe('aggregateCfdmServices', () => { fqdn: 'vpn-a.example.com', vpsId: 'vps-1', matchStatus: 'matched', + targetIps: JSON.stringify(['203.0.113.10']), lbMode: 'failover', }), domain({ @@ -37,6 +38,7 @@ describe('aggregateCfdmServices', () => { fqdn: 'vpn-b.example.com', vpsId: 'vps-2', matchStatus: 'matched', + targetIps: JSON.stringify(['203.0.113.20']), lbMode: 'failover', }), domain({ @@ -65,7 +67,32 @@ describe('aggregateCfdmServices', () => { const dns = services.find((s) => s.serviceId === 11) expect(dns?.matchedVpsIds).toEqual([]) expect(dns?.unmatchedIps).toEqual(['198.51.100.1']) - expect(dns?.lbMode).toBe('round_robin') + expect(dns?.lbMode).toBeUndefined() + }) + + it('omits lbMode when unique origin IPs are below two', () => { + const rows: VpsDomain[] = [ + domain({ + id: 'a', + cfdmServiceId: 10, + cfdmBindingId: 1, + vpsId: 'vps-1', + matchStatus: 'matched', + targetIps: JSON.stringify(['203.0.113.10']), + lbMode: 'round_robin', + }), + domain({ + id: 'b', + cfdmServiceId: 10, + cfdmBindingId: 2, + fqdn: 'vpn-b.example.com', + vpsId: 'vps-2', + matchStatus: 'matched', + targetIps: JSON.stringify(['203.0.113.10']), + lbMode: 'round_robin', + }), + ] + expect(aggregateCfdmServices(rows)[0]?.lbMode).toBeUndefined() }) it('lists services a VPS belongs to', () => { diff --git a/apps/web/src/components/topology/cfdm-services.ts b/apps/web/src/components/topology/cfdm-services.ts index 49a1ede..1291437 100644 --- a/apps/web/src/components/topology/cfdm-services.ts +++ b/apps/web/src/components/topology/cfdm-services.ts @@ -32,6 +32,7 @@ export function aggregateCfdmServices(domains: VpsDomain[]): CfdmTopologyService lbMode?: TopologyLbMode fqdns: Set matchedVpsIds: Set + originIps: Set unmatchedIps: Set } >() @@ -45,12 +46,14 @@ export function aggregateCfdmServices(domains: VpsDomain[]): CfdmTopologyService slug: row.serviceSlug, fqdns: new Set(), matchedVpsIds: new Set(), + originIps: new Set(), unmatchedIps: new Set(), } byService.set(row.cfdmServiceId, bucket) } if (row.fqdn) bucket.fqdns.add(row.fqdn) if (isTopologyLbMode(row.lbMode)) bucket.lbMode = row.lbMode + for (const ip of parseTargetIps(row.targetIps)) bucket.originIps.add(ip) if (row.vpsId && row.matchStatus === 'matched') { bucket.matchedVpsIds.add(row.vpsId) } else { @@ -63,7 +66,7 @@ export function aggregateCfdmServices(domains: VpsDomain[]): CfdmTopologyService serviceId, name: b.name, slug: b.slug, - lbMode: b.lbMode, + lbMode: b.originIps.size >= 2 ? b.lbMode : undefined, fqdns: [...b.fqdns].sort(), matchedVpsIds: [...b.matchedVpsIds], unmatchedIps: [...b.unmatchedIps], diff --git a/apps/web/src/components/topology/edges/topology-edge.tsx b/apps/web/src/components/topology/edges/topology-edge.tsx index 6b59e92..a59cd4d 100644 --- a/apps/web/src/components/topology/edges/topology-edge.tsx +++ b/apps/web/src/components/topology/edges/topology-edge.tsx @@ -2,6 +2,7 @@ import { memo } from 'react' import { BaseEdge, EdgeLabelRenderer, + getBezierPath, getSmoothStepPath, type Edge, type EdgeProps, @@ -30,20 +31,22 @@ function TopologyEdgeComponent(props: EdgeProps>) { data, } = props - const [edgePath, labelX, labelY] = getSmoothStepPath({ + const relation = data?.relation ?? 'network' + const title = data?.label?.trim() ?? '' + const protocol = data?.protocol?.trim() ?? '' + const tunnelIps = formatEdgeTunnelIps(data ?? {}) + const membership = relation === 'membership' + const pathParams = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, - }) - - const relation = data?.relation ?? 'network' - const title = data?.label?.trim() ?? '' - const protocol = data?.protocol?.trim() ?? '' - const tunnelIps = formatEdgeTunnelIps(data ?? {}) - const membership = relation === 'membership' + } + const [edgePath, labelX, labelY] = membership + ? getBezierPath(pathParams) + : getSmoothStepPath(pathParams) return ( <> diff --git a/apps/web/src/components/topology/group-utils.ts b/apps/web/src/components/topology/group-utils.ts index 615add4..ab909ca 100644 --- a/apps/web/src/components/topology/group-utils.ts +++ b/apps/web/src/components/topology/group-utils.ts @@ -2,6 +2,7 @@ import type { TopologyFlowNode, TopologyNodeType } from './types' const GROUP_Z = -1 const CONTENT_Z = 1 +const SERVICE_Z = 3 const ATTACHABLE = new Set(['vps', 'shape', 'note', 'service']) @@ -16,10 +17,10 @@ export function normalizeGroupLayers(nodes: TopologyFlowNode[]): TopologyFlowNod } // extent: 'parent' блокирует вытаскивание — не используем; parentId достаточно для «прилипания» const cleared = n.extent != null ? { ...n, extent: undefined } : n - if (cleared.zIndex != null && cleared.zIndex < CONTENT_Z) { - return { ...cleared, zIndex: CONTENT_Z } + if (cleared.type === 'service') { + return { ...cleared, zIndex: SERVICE_Z } } - return cleared.zIndex == null ? { ...cleared, zIndex: CONTENT_Z } : cleared + return { ...cleared, zIndex: CONTENT_Z } }) } diff --git a/apps/web/src/components/topology/membership-handles.test.ts b/apps/web/src/components/topology/membership-handles.test.ts new file mode 100644 index 0000000..a98af81 --- /dev/null +++ b/apps/web/src/components/topology/membership-handles.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { + applyMembershipHandles, + clusterIdsForService, + pickMembershipHandles, +} from './membership-handles' +import type { TopologyFlowNode } from './types' +import { membershipEdgeData } from './edge-utils' + +describe('pickMembershipHandles', () => { + const origin = { x: 0, y: 0 } + + it('picks bottom→top when VPS is below', () => { + expect(pickMembershipHandles(origin, { x: 0, y: 100 })).toEqual({ + sourceHandle: 'bottom', + targetHandle: 'top', + }) + }) + + it('picks top→bottom when VPS is above', () => { + expect(pickMembershipHandles(origin, { x: 0, y: -100 })).toEqual({ + sourceHandle: 'top', + targetHandle: 'bottom', + }) + }) + + it('picks right→left when VPS is to the right', () => { + expect(pickMembershipHandles(origin, { x: 100, y: 0 })).toEqual({ + sourceHandle: 'right', + targetHandle: 'left', + }) + }) + + it('picks left→right when VPS is to the left', () => { + expect(pickMembershipHandles(origin, { x: -100, y: 0 })).toEqual({ + sourceHandle: 'left', + targetHandle: 'right', + }) + }) + + it('uses vertical on below-right diagonal', () => { + expect(pickMembershipHandles(origin, { x: 10, y: 20 })).toEqual({ + sourceHandle: 'bottom', + targetHandle: 'top', + }) + }) + + it('uses vertical on below-left diagonal', () => { + expect(pickMembershipHandles(origin, { x: -10, y: 20 })).toEqual({ + sourceHandle: 'bottom', + targetHandle: 'top', + }) + }) + + it('uses vertical on above-right diagonal', () => { + expect(pickMembershipHandles(origin, { x: 10, y: -20 })).toEqual({ + sourceHandle: 'top', + targetHandle: 'bottom', + }) + }) + + it('uses vertical on above-left diagonal', () => { + expect(pickMembershipHandles(origin, { x: -10, y: -20 })).toEqual({ + sourceHandle: 'top', + targetHandle: 'bottom', + }) + }) + + it('uses vertical when axes are equal', () => { + expect(pickMembershipHandles(origin, { x: 40, y: 40 })).toEqual({ + sourceHandle: 'bottom', + targetHandle: 'top', + }) + }) +}) + +describe('clusterIdsForService', () => { + it('includes the service and membership VPS only', () => { + const ids = clusterIdsForService('svc-1', [ + { source: 'svc-1', target: 'vps-a', data: { relation: 'membership' } }, + { source: 'svc-1', target: 'vps-b', data: { relation: 'membership' } }, + { source: 'svc-2', target: 'vps-a', data: { relation: 'membership' } }, + { source: 'vps-a', target: 'vps-c', data: { relation: 'network' } }, + ]) + expect(ids.sort()).toEqual(['svc-1', 'vps-a', 'vps-b']) + }) +}) + +describe('applyMembershipHandles', () => { + it('sets handles from node centers', () => { + const nodes: TopologyFlowNode[] = [ + { id: 'svc', type: 'service', position: { x: 0, y: 0 }, data: { label: 'S', cfdmServiceId: 1, fqdn: '' } }, + { id: 'vps', type: 'vps', position: { x: 0, y: 200 }, data: { vpsId: 'v1' } }, + ] + const edges = [ + { + id: 'e1', + source: 'svc', + target: 'vps', + data: membershipEdgeData(), + }, + ] + const next = applyMembershipHandles(edges, nodes) + expect(next[0]?.sourceHandle).toBe('bottom') + expect(next[0]?.targetHandle).toBe('top') + }) +}) diff --git a/apps/web/src/components/topology/membership-handles.ts b/apps/web/src/components/topology/membership-handles.ts new file mode 100644 index 0000000..80c6fe7 --- /dev/null +++ b/apps/web/src/components/topology/membership-handles.ts @@ -0,0 +1,61 @@ +import type { Edge } from '@xyflow/react' +import { getNodeCenterAbsolute } from './group-utils' +import type { TopologyEdgeData, TopologyFlowNode } from './types' + +export type MembershipHandleSide = 'top' | 'right' | 'bottom' | 'left' + +export function pickMembershipHandles( + serviceCenter: { x: number; y: number }, + vpsCenter: { x: number; y: number }, +): { sourceHandle: MembershipHandleSide; targetHandle: MembershipHandleSide } { + const dx = vpsCenter.x - serviceCenter.x + const dy = vpsCenter.y - serviceCenter.y + if (Math.abs(dy) >= Math.abs(dx)) { + if (dy >= 0) return { sourceHandle: 'bottom', targetHandle: 'top' } + return { sourceHandle: 'top', targetHandle: 'bottom' } + } + if (dx >= 0) return { sourceHandle: 'right', targetHandle: 'left' } + return { sourceHandle: 'left', targetHandle: 'right' } +} + +export function clusterIdsForService( + serviceNodeId: string, + edges: readonly { source: string; target: string; data?: { relation?: string } }[], +): string[] { + const ids = new Set([serviceNodeId]) + for (const edge of edges) { + if (edge.data?.relation !== 'membership') continue + if (edge.source === serviceNodeId) ids.add(edge.target) + } + return [...ids] +} + +export function applyMembershipHandles( + edges: Edge[], + nodes: TopologyFlowNode[], +): Edge[] { + let changed = false + const next = edges.map((edge) => { + if (edge.data?.relation !== 'membership') return edge + const source = nodes.find((n) => n.id === edge.source) + const target = nodes.find((n) => n.id === edge.target) + if (!source || !target) return edge + const handles = pickMembershipHandles( + getNodeCenterAbsolute(source, nodes), + getNodeCenterAbsolute(target, nodes), + ) + if ( + edge.sourceHandle === handles.sourceHandle && + edge.targetHandle === handles.targetHandle + ) { + return edge + } + changed = true + return { + ...edge, + sourceHandle: handles.sourceHandle, + targetHandle: handles.targetHandle, + } + }) + return changed ? next : edges +} diff --git a/apps/web/src/components/topology/nodes/node-side-handles.tsx b/apps/web/src/components/topology/nodes/node-side-handles.tsx new file mode 100644 index 0000000..849a2c4 --- /dev/null +++ b/apps/web/src/components/topology/nodes/node-side-handles.tsx @@ -0,0 +1,15 @@ +import { Handle, Position } from '@xyflow/react' + +const HANDLE_CLASS = '!size-2.5 !border-background !bg-muted-foreground' + +/** Четыре стороны; ConnectionMode.Loose позволяет стартовать и заканчивать на любой. */ +export function NodeSideHandles() { + return ( + <> + + + + + + ) +} diff --git a/apps/web/src/components/topology/nodes/service-node.tsx b/apps/web/src/components/topology/nodes/service-node.tsx index 3aa96dc..d13e593 100644 --- a/apps/web/src/components/topology/nodes/service-node.tsx +++ b/apps/web/src/components/topology/nodes/service-node.tsx @@ -1,9 +1,10 @@ import { memo } from 'react' -import { Handle, Position, type NodeProps } from '@xyflow/react' +import { type NodeProps } from '@xyflow/react' import { NetworkIcon } from 'lucide-react' import { cn } from '@cfdm/ui/lib/utils' import { Badge } from '@/components/reui/badge' import { lbModeBadgeVariant, lbModeLabel, type ServiceNodeData } from '../types' +import { NodeSideHandles } from './node-side-handles' function ServiceNodeComponent({ data, selected }: NodeProps & { data: ServiceNodeData }) { return ( @@ -13,11 +14,7 @@ function ServiceNodeComponent({ data, selected }: NodeProps & { data: ServiceNod selected ? 'border-primary ring-2 ring-primary/20' : 'border-border', )} > - +
@@ -38,11 +35,6 @@ function ServiceNodeComponent({ data, selected }: NodeProps & { data: ServiceNod ) : null}
- ) } diff --git a/apps/web/src/components/topology/nodes/vps-node.tsx b/apps/web/src/components/topology/nodes/vps-node.tsx index 67bb55d..c7836ba 100644 --- a/apps/web/src/components/topology/nodes/vps-node.tsx +++ b/apps/web/src/components/topology/nodes/vps-node.tsx @@ -1,5 +1,5 @@ import { memo, useMemo } from 'react' -import { Handle, Position, type NodeProps } from '@xyflow/react' +import { type NodeProps } from '@xyflow/react' import { ServerIcon } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import { cn } from '@cfdm/ui/lib/utils' @@ -11,6 +11,7 @@ import { resolveCountryCode, vpsStatusLabel } from '@/lib/format' import type { Vps } from '@/types/entities' import { aggregateCfdmServices, servicesForVps } from '../cfdm-services' import { type VpsNodeData, vpsSpecsLine } from '../types' +import { NodeSideHandles } from './node-side-handles' function formatRate(vps: Vps): string | null { if (vps.tariffType === 'monthly' && vps.monthlyRate != null) { @@ -56,11 +57,7 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData }) /> ) : null} - + {vps?.ip ? (
{vps.ip}
) : null} @@ -109,11 +106,6 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData }) ) : null} - ) } diff --git a/apps/web/src/components/topology/place-cfdm-service.test.ts b/apps/web/src/components/topology/place-cfdm-service.test.ts index c6e2a8c..6b6d809 100644 --- a/apps/web/src/components/topology/place-cfdm-service.test.ts +++ b/apps/web/src/components/topology/place-cfdm-service.test.ts @@ -57,6 +57,7 @@ describe('placeCfdmService', () => { expect(edges.every((e) => e.data?.relation === 'membership')).toBe(true) expect(edges).toHaveLength(2) expect(edges.every((e) => e.source === service!.id)).toBe(true) + expect(edges.every((e) => e.sourceHandle && e.targetHandle)).toBe(true) }) it('does not duplicate a service already on the canvas', () => { diff --git a/apps/web/src/components/topology/place-cfdm-service.ts b/apps/web/src/components/topology/place-cfdm-service.ts index 4851a2b..1fa339f 100644 --- a/apps/web/src/components/topology/place-cfdm-service.ts +++ b/apps/web/src/components/topology/place-cfdm-service.ts @@ -1,6 +1,7 @@ import type { CfdmTopologyService } from './cfdm-services' import { serviceFqdnMeta } from './cfdm-services' import { createMembershipEdge } from './edge-utils' +import { applyMembershipHandles } from './membership-handles' import { detachNodeFromGroup, normalizeGroupLayers, sortParentsFirst } from './group-utils' import { isGroupNodeData, @@ -106,7 +107,7 @@ export function placeCfdmService( return { nodes: normalizeGroupLayers(sortParentsFirst(nextNodes)), - edges: nextEdges, + edges: applyMembershipHandles(nextEdges, nextNodes), alreadyOnCanvas: false, } } @@ -178,8 +179,12 @@ export function migrateCfdmGroupsToServices( if (groups.length === 0) { const compacted = nodes.map(compactServiceNode) const withMeta = applyServiceFqdns(compacted, services) - const changed = compacted.some((n, i) => n !== nodes[i]) || withMeta !== compacted - return { nodes: withMeta, edges, changed } + const nextEdges = applyMembershipHandles(edges, withMeta) + const changed = + compacted.some((n, i) => n !== nodes[i]) || + withMeta !== compacted || + nextEdges !== edges + return { nodes: withMeta, edges: nextEdges, changed } } let nextNodes = nodes @@ -205,7 +210,7 @@ export function migrateCfdmGroupsToServices( return { nodes: normalizeGroupLayers(sortParentsFirst(nextNodes)), - edges: nextEdges, + edges: applyMembershipHandles(nextEdges, nextNodes), changed, } } diff --git a/apps/web/src/components/topology/topology-editor.tsx b/apps/web/src/components/topology/topology-editor.tsx index 88c4a1e..520f388 100644 --- a/apps/web/src/components/topology/topology-editor.tsx +++ b/apps/web/src/components/topology/topology-editor.tsx @@ -12,6 +12,7 @@ import { ReactFlowProvider, Background, BackgroundVariant, + ConnectionMode, MarkerType, addEdge, useEdgesState, @@ -41,6 +42,7 @@ import { VpsDetailSheet } from './vps-detail-sheet' import { ElementEditSheet, type EditableElement } from './element-edit-sheet' import { EdgeEditSheet } from './edge-edit-sheet' import { applyEdgeVisuals, createConnectedEdge, isMembershipEdge } from './edge-utils' +import { applyMembershipHandles, clusterIdsForService } from './membership-handles' import { aggregateCfdmServices, type CfdmTopologyService } from './cfdm-services' import { applyServiceFqdns, @@ -102,7 +104,7 @@ function TopologyEditorInner({ const { resolvedTheme } = useTheme() const colorMode = resolvedTheme === 'dark' ? 'dark' : 'light' const wrapperRef = useRef(null) - const { screenToFlowPosition, fitView, zoomIn, zoomOut, getViewport, setViewport } = + const { screenToFlowPosition, fitView, zoomIn, zoomOut, getViewport, setViewport, getNodes, getEdges } = useReactFlow() const { data: snapshot } = useQuery(snapshotQueryOptions()) const cfdmServices = useMemo( @@ -139,8 +141,9 @@ function TopologyEditorInner({ cfdmServicesRef.current, ) const withFqdn = applyServiceFqdns(migrated.nodes, cfdmServicesRef.current) + const withHandles = applyMembershipHandles(migrated.edges, withFqdn) setNodes(() => normalizeGroupLayers(sortParentsFirst(withFqdn))) - setEdges(() => normalizeEdges(migrated.edges)) + setEdges(() => normalizeEdges(withHandles)) setEditElement(null) setElementOpen(false) setEditEdgeId(null) @@ -179,6 +182,10 @@ function TopologyEditorInner({ return () => window.clearTimeout(t) }, [nodes, edges, emitSave, locked]) + const syncMembershipHandles = useCallback(() => { + setEdges((eds) => applyMembershipHandles(eds, getNodes() as FlowNode[])) + }, [getNodes, setEdges]) + const onNodesChange: OnNodesChange = useCallback( (changes) => { if (locked) return @@ -192,8 +199,13 @@ function TopologyEditorInner({ setNodes((ns) => normalizeGroupLayers(ns)) }) } + if (changes.some((c) => c.type === 'dimensions')) { + queueMicrotask(() => { + setEdges((eds) => applyMembershipHandles(eds, getNodes() as FlowNode[])) + }) + } }, - [locked, onNodesChangeBase, setNodes], + [locked, onNodesChangeBase, setNodes, setEdges, getNodes], ) const onEdgesChange: OnEdgesChange = useCallback( @@ -223,6 +235,11 @@ function TopologyEditorInner({ [locked, setEdges], ) + const onNodeDrag: OnNodeDrag = useCallback(() => { + if (locked) return + syncMembershipHandles() + }, [locked, syncMembershipHandles]) + const onNodeDragStop: OnNodeDrag = useCallback( (_e, node) => { if (locked) return @@ -237,8 +254,9 @@ function TopologyEditorInner({ const updated = ns.map((n) => (n.id === next.id ? next : n)) return normalizeGroupLayers(sortParentsFirst(updated)) }) + syncMembershipHandles() }, - [locked, setNodes], + [locked, setNodes, syncMembershipHandles], ) const existingVpsIds = useMemo(() => { @@ -381,6 +399,16 @@ function TopologyEditorInner({ setElementOpen(true) return } + if (node.type === 'service' && isServiceNodeData(node.data)) { + const serviceId = node.id + queueMicrotask(() => { + const ids = new Set(clusterIdsForService(serviceId, getEdges())) + setNodes((ns) => ns.map((n) => ({ ...n, selected: ids.has(n.id) }))) + }) + } + } + + function onNodeDoubleClick(_e: ReactMouseEvent, node: FlowNode) { if (node.type === 'service' && isServiceNodeData(node.data)) { setEditElement({ kind: 'service', id: node.id, data: node.data }) setElementOpen(true) @@ -457,7 +485,9 @@ function TopologyEditorInner({ onEdgesChange={onEdgesChange} onConnect={onConnect} onNodeClick={onNodeClick} + onNodeDoubleClick={onNodeDoubleClick} onEdgeClick={onEdgeClick} + onNodeDrag={onNodeDrag} onNodeDragStop={onNodeDragStop} onDrop={onDrop} onDragOver={onDragOver} @@ -469,6 +499,8 @@ function TopologyEditorInner({ }} nodeTypes={topologyNodeTypes} edgeTypes={topologyEdgeTypes} + connectionMode={ConnectionMode.Loose} + elevateNodesOnSelect={false} nodesDraggable={!locked} nodesConnectable={!locked} elementsSelectable={!locked}