fix(topology): спрямить HA-линии и не показывать RR без пула
Docker / build (push) Failing after 19s
Docker / build (push) Failing after 19s
Membership идёт bezier по ближайшей грани; клик по сервису выделяет VPS-членов. Badge HA только при двух уникальных origin IP. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ export function aggregateCfdmServices(domains: VpsDomain[]): CfdmTopologyService
|
||||
lbMode?: TopologyLbMode
|
||||
fqdns: Set<string>
|
||||
matchedVpsIds: Set<string>
|
||||
originIps: Set<string>
|
||||
unmatchedIps: Set<string>
|
||||
}
|
||||
>()
|
||||
@@ -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],
|
||||
|
||||
@@ -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<Edge<TopologyEdgeData>>) {
|
||||
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 (
|
||||
<>
|
||||
|
||||
@@ -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<TopologyNodeType>(['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 }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<string>([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<TopologyEdgeData>[],
|
||||
nodes: TopologyFlowNode[],
|
||||
): Edge<TopologyEdgeData>[] {
|
||||
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
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Handle type="source" position={Position.Top} id="top" className={HANDLE_CLASS} />
|
||||
<Handle type="source" position={Position.Right} id="right" className={HANDLE_CLASS} />
|
||||
<Handle type="source" position={Position.Bottom} id="bottom" className={HANDLE_CLASS} />
|
||||
<Handle type="source" position={Position.Left} id="left" className={HANDLE_CLASS} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!size-2.5 !border-background !bg-muted-foreground"
|
||||
/>
|
||||
<NodeSideHandles />
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<NetworkIcon className="size-4 text-muted-foreground" />
|
||||
@@ -38,11 +35,6 @@ function ServiceNodeComponent({ data, selected }: NodeProps & { data: ServiceNod
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!size-2.5 !border-background !bg-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!size-2.5 !border-background !bg-muted-foreground"
|
||||
/>
|
||||
<NodeSideHandles />
|
||||
{vps?.ip ? (
|
||||
<div className="mb-1 font-mono text-[10px] text-muted-foreground">{vps.ip}</div>
|
||||
) : null}
|
||||
@@ -109,11 +106,6 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData })
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!size-2.5 !border-background !bg-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(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<FlowNode> = 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<FlowEdge> = useCallback(
|
||||
@@ -223,6 +235,11 @@ function TopologyEditorInner({
|
||||
[locked, setEdges],
|
||||
)
|
||||
|
||||
const onNodeDrag: OnNodeDrag<FlowNode> = useCallback(() => {
|
||||
if (locked) return
|
||||
syncMembershipHandles()
|
||||
}, [locked, syncMembershipHandles])
|
||||
|
||||
const onNodeDragStop: OnNodeDrag<FlowNode> = 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}
|
||||
|
||||
Reference in New Issue
Block a user