fix(topology): спрямить HA-линии и не показывать RR без пула
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:
Denozordec
2026-09-01 15:09:19 +07:00
co-authored by Cursor
parent 5a61ce33b4
commit a64d8b9755
12 changed files with 282 additions and 43 deletions
@@ -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}