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 <[email protected]>
This commit is contained in:
@@ -28,6 +28,7 @@ describe('aggregateCfdmServices', () => {
|
|||||||
fqdn: 'vpn-a.example.com',
|
fqdn: 'vpn-a.example.com',
|
||||||
vpsId: 'vps-1',
|
vpsId: 'vps-1',
|
||||||
matchStatus: 'matched',
|
matchStatus: 'matched',
|
||||||
|
targetIps: JSON.stringify(['203.0.113.10']),
|
||||||
lbMode: 'failover',
|
lbMode: 'failover',
|
||||||
}),
|
}),
|
||||||
domain({
|
domain({
|
||||||
@@ -37,6 +38,7 @@ describe('aggregateCfdmServices', () => {
|
|||||||
fqdn: 'vpn-b.example.com',
|
fqdn: 'vpn-b.example.com',
|
||||||
vpsId: 'vps-2',
|
vpsId: 'vps-2',
|
||||||
matchStatus: 'matched',
|
matchStatus: 'matched',
|
||||||
|
targetIps: JSON.stringify(['203.0.113.20']),
|
||||||
lbMode: 'failover',
|
lbMode: 'failover',
|
||||||
}),
|
}),
|
||||||
domain({
|
domain({
|
||||||
@@ -65,7 +67,32 @@ describe('aggregateCfdmServices', () => {
|
|||||||
const dns = services.find((s) => s.serviceId === 11)
|
const dns = services.find((s) => s.serviceId === 11)
|
||||||
expect(dns?.matchedVpsIds).toEqual([])
|
expect(dns?.matchedVpsIds).toEqual([])
|
||||||
expect(dns?.unmatchedIps).toEqual(['198.51.100.1'])
|
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', () => {
|
it('lists services a VPS belongs to', () => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function aggregateCfdmServices(domains: VpsDomain[]): CfdmTopologyService
|
|||||||
lbMode?: TopologyLbMode
|
lbMode?: TopologyLbMode
|
||||||
fqdns: Set<string>
|
fqdns: Set<string>
|
||||||
matchedVpsIds: Set<string>
|
matchedVpsIds: Set<string>
|
||||||
|
originIps: Set<string>
|
||||||
unmatchedIps: Set<string>
|
unmatchedIps: Set<string>
|
||||||
}
|
}
|
||||||
>()
|
>()
|
||||||
@@ -45,12 +46,14 @@ export function aggregateCfdmServices(domains: VpsDomain[]): CfdmTopologyService
|
|||||||
slug: row.serviceSlug,
|
slug: row.serviceSlug,
|
||||||
fqdns: new Set(),
|
fqdns: new Set(),
|
||||||
matchedVpsIds: new Set(),
|
matchedVpsIds: new Set(),
|
||||||
|
originIps: new Set(),
|
||||||
unmatchedIps: new Set(),
|
unmatchedIps: new Set(),
|
||||||
}
|
}
|
||||||
byService.set(row.cfdmServiceId, bucket)
|
byService.set(row.cfdmServiceId, bucket)
|
||||||
}
|
}
|
||||||
if (row.fqdn) bucket.fqdns.add(row.fqdn)
|
if (row.fqdn) bucket.fqdns.add(row.fqdn)
|
||||||
if (isTopologyLbMode(row.lbMode)) bucket.lbMode = row.lbMode
|
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') {
|
if (row.vpsId && row.matchStatus === 'matched') {
|
||||||
bucket.matchedVpsIds.add(row.vpsId)
|
bucket.matchedVpsIds.add(row.vpsId)
|
||||||
} else {
|
} else {
|
||||||
@@ -63,7 +66,7 @@ export function aggregateCfdmServices(domains: VpsDomain[]): CfdmTopologyService
|
|||||||
serviceId,
|
serviceId,
|
||||||
name: b.name,
|
name: b.name,
|
||||||
slug: b.slug,
|
slug: b.slug,
|
||||||
lbMode: b.lbMode,
|
lbMode: b.originIps.size >= 2 ? b.lbMode : undefined,
|
||||||
fqdns: [...b.fqdns].sort(),
|
fqdns: [...b.fqdns].sort(),
|
||||||
matchedVpsIds: [...b.matchedVpsIds],
|
matchedVpsIds: [...b.matchedVpsIds],
|
||||||
unmatchedIps: [...b.unmatchedIps],
|
unmatchedIps: [...b.unmatchedIps],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { memo } from 'react'
|
|||||||
import {
|
import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
|
getBezierPath,
|
||||||
getSmoothStepPath,
|
getSmoothStepPath,
|
||||||
type Edge,
|
type Edge,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
@@ -30,20 +31,22 @@ function TopologyEdgeComponent(props: EdgeProps<Edge<TopologyEdgeData>>) {
|
|||||||
data,
|
data,
|
||||||
} = props
|
} = 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,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
sourcePosition,
|
sourcePosition,
|
||||||
targetX,
|
targetX,
|
||||||
targetY,
|
targetY,
|
||||||
targetPosition,
|
targetPosition,
|
||||||
})
|
}
|
||||||
|
const [edgePath, labelX, labelY] = membership
|
||||||
const relation = data?.relation ?? 'network'
|
? getBezierPath(pathParams)
|
||||||
const title = data?.label?.trim() ?? ''
|
: getSmoothStepPath(pathParams)
|
||||||
const protocol = data?.protocol?.trim() ?? ''
|
|
||||||
const tunnelIps = formatEdgeTunnelIps(data ?? {})
|
|
||||||
const membership = relation === 'membership'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { TopologyFlowNode, TopologyNodeType } from './types'
|
|||||||
|
|
||||||
const GROUP_Z = -1
|
const GROUP_Z = -1
|
||||||
const CONTENT_Z = 1
|
const CONTENT_Z = 1
|
||||||
|
const SERVICE_Z = 3
|
||||||
|
|
||||||
const ATTACHABLE = new Set<TopologyNodeType>(['vps', 'shape', 'note', 'service'])
|
const ATTACHABLE = new Set<TopologyNodeType>(['vps', 'shape', 'note', 'service'])
|
||||||
|
|
||||||
@@ -16,10 +17,10 @@ export function normalizeGroupLayers(nodes: TopologyFlowNode[]): TopologyFlowNod
|
|||||||
}
|
}
|
||||||
// extent: 'parent' блокирует вытаскивание — не используем; parentId достаточно для «прилипания»
|
// extent: 'parent' блокирует вытаскивание — не используем; parentId достаточно для «прилипания»
|
||||||
const cleared = n.extent != null ? { ...n, extent: undefined } : n
|
const cleared = n.extent != null ? { ...n, extent: undefined } : n
|
||||||
if (cleared.zIndex != null && cleared.zIndex < CONTENT_Z) {
|
if (cleared.type === 'service') {
|
||||||
return { ...cleared, zIndex: CONTENT_Z }
|
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 { memo } from 'react'
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
import { type NodeProps } from '@xyflow/react'
|
||||||
import { NetworkIcon } from 'lucide-react'
|
import { NetworkIcon } from 'lucide-react'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { lbModeBadgeVariant, lbModeLabel, type ServiceNodeData } from '../types'
|
import { lbModeBadgeVariant, lbModeLabel, type ServiceNodeData } from '../types'
|
||||||
|
import { NodeSideHandles } from './node-side-handles'
|
||||||
|
|
||||||
function ServiceNodeComponent({ data, selected }: NodeProps & { data: ServiceNodeData }) {
|
function ServiceNodeComponent({ data, selected }: NodeProps & { data: ServiceNodeData }) {
|
||||||
return (
|
return (
|
||||||
@@ -13,11 +14,7 @@ function ServiceNodeComponent({ data, selected }: NodeProps & { data: ServiceNod
|
|||||||
selected ? 'border-primary ring-2 ring-primary/20' : 'border-border',
|
selected ? 'border-primary ring-2 ring-primary/20' : 'border-border',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Handle
|
<NodeSideHandles />
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="!size-2.5 !border-background !bg-muted-foreground"
|
|
||||||
/>
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||||
<NetworkIcon className="size-4 text-muted-foreground" />
|
<NetworkIcon className="size-4 text-muted-foreground" />
|
||||||
@@ -38,11 +35,6 @@ function ServiceNodeComponent({ data, selected }: NodeProps & { data: ServiceNod
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="!size-2.5 !border-background !bg-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useMemo } from 'react'
|
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 { ServerIcon } from 'lucide-react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
@@ -11,6 +11,7 @@ import { resolveCountryCode, vpsStatusLabel } from '@/lib/format'
|
|||||||
import type { Vps } from '@/types/entities'
|
import type { Vps } from '@/types/entities'
|
||||||
import { aggregateCfdmServices, servicesForVps } from '../cfdm-services'
|
import { aggregateCfdmServices, servicesForVps } from '../cfdm-services'
|
||||||
import { type VpsNodeData, vpsSpecsLine } from '../types'
|
import { type VpsNodeData, vpsSpecsLine } from '../types'
|
||||||
|
import { NodeSideHandles } from './node-side-handles'
|
||||||
|
|
||||||
function formatRate(vps: Vps): string | null {
|
function formatRate(vps: Vps): string | null {
|
||||||
if (vps.tariffType === 'monthly' && vps.monthlyRate != null) {
|
if (vps.tariffType === 'monthly' && vps.monthlyRate != null) {
|
||||||
@@ -56,11 +57,7 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData })
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Handle
|
<NodeSideHandles />
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="!size-2.5 !border-background !bg-muted-foreground"
|
|
||||||
/>
|
|
||||||
{vps?.ip ? (
|
{vps?.ip ? (
|
||||||
<div className="mb-1 font-mono text-[10px] text-muted-foreground">{vps.ip}</div>
|
<div className="mb-1 font-mono text-[10px] text-muted-foreground">{vps.ip}</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -109,11 +106,6 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData })
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="!size-2.5 !border-background !bg-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ describe('placeCfdmService', () => {
|
|||||||
expect(edges.every((e) => e.data?.relation === 'membership')).toBe(true)
|
expect(edges.every((e) => e.data?.relation === 'membership')).toBe(true)
|
||||||
expect(edges).toHaveLength(2)
|
expect(edges).toHaveLength(2)
|
||||||
expect(edges.every((e) => e.source === service!.id)).toBe(true)
|
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', () => {
|
it('does not duplicate a service already on the canvas', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { CfdmTopologyService } from './cfdm-services'
|
import type { CfdmTopologyService } from './cfdm-services'
|
||||||
import { serviceFqdnMeta } from './cfdm-services'
|
import { serviceFqdnMeta } from './cfdm-services'
|
||||||
import { createMembershipEdge } from './edge-utils'
|
import { createMembershipEdge } from './edge-utils'
|
||||||
|
import { applyMembershipHandles } from './membership-handles'
|
||||||
import { detachNodeFromGroup, normalizeGroupLayers, sortParentsFirst } from './group-utils'
|
import { detachNodeFromGroup, normalizeGroupLayers, sortParentsFirst } from './group-utils'
|
||||||
import {
|
import {
|
||||||
isGroupNodeData,
|
isGroupNodeData,
|
||||||
@@ -106,7 +107,7 @@ export function placeCfdmService(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
nodes: normalizeGroupLayers(sortParentsFirst(nextNodes)),
|
nodes: normalizeGroupLayers(sortParentsFirst(nextNodes)),
|
||||||
edges: nextEdges,
|
edges: applyMembershipHandles(nextEdges, nextNodes),
|
||||||
alreadyOnCanvas: false,
|
alreadyOnCanvas: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,8 +179,12 @@ export function migrateCfdmGroupsToServices(
|
|||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
const compacted = nodes.map(compactServiceNode)
|
const compacted = nodes.map(compactServiceNode)
|
||||||
const withMeta = applyServiceFqdns(compacted, services)
|
const withMeta = applyServiceFqdns(compacted, services)
|
||||||
const changed = compacted.some((n, i) => n !== nodes[i]) || withMeta !== compacted
|
const nextEdges = applyMembershipHandles(edges, withMeta)
|
||||||
return { nodes: withMeta, edges, changed }
|
const changed =
|
||||||
|
compacted.some((n, i) => n !== nodes[i]) ||
|
||||||
|
withMeta !== compacted ||
|
||||||
|
nextEdges !== edges
|
||||||
|
return { nodes: withMeta, edges: nextEdges, changed }
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextNodes = nodes
|
let nextNodes = nodes
|
||||||
@@ -205,7 +210,7 @@ export function migrateCfdmGroupsToServices(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
nodes: normalizeGroupLayers(sortParentsFirst(nextNodes)),
|
nodes: normalizeGroupLayers(sortParentsFirst(nextNodes)),
|
||||||
edges: nextEdges,
|
edges: applyMembershipHandles(nextEdges, nextNodes),
|
||||||
changed,
|
changed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
Background,
|
Background,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
|
ConnectionMode,
|
||||||
MarkerType,
|
MarkerType,
|
||||||
addEdge,
|
addEdge,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
@@ -41,6 +42,7 @@ import { VpsDetailSheet } from './vps-detail-sheet'
|
|||||||
import { ElementEditSheet, type EditableElement } from './element-edit-sheet'
|
import { ElementEditSheet, type EditableElement } from './element-edit-sheet'
|
||||||
import { EdgeEditSheet } from './edge-edit-sheet'
|
import { EdgeEditSheet } from './edge-edit-sheet'
|
||||||
import { applyEdgeVisuals, createConnectedEdge, isMembershipEdge } from './edge-utils'
|
import { applyEdgeVisuals, createConnectedEdge, isMembershipEdge } from './edge-utils'
|
||||||
|
import { applyMembershipHandles, clusterIdsForService } from './membership-handles'
|
||||||
import { aggregateCfdmServices, type CfdmTopologyService } from './cfdm-services'
|
import { aggregateCfdmServices, type CfdmTopologyService } from './cfdm-services'
|
||||||
import {
|
import {
|
||||||
applyServiceFqdns,
|
applyServiceFqdns,
|
||||||
@@ -102,7 +104,7 @@ function TopologyEditorInner({
|
|||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const colorMode = resolvedTheme === 'dark' ? 'dark' : 'light'
|
const colorMode = resolvedTheme === 'dark' ? 'dark' : 'light'
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
const { screenToFlowPosition, fitView, zoomIn, zoomOut, getViewport, setViewport } =
|
const { screenToFlowPosition, fitView, zoomIn, zoomOut, getViewport, setViewport, getNodes, getEdges } =
|
||||||
useReactFlow()
|
useReactFlow()
|
||||||
const { data: snapshot } = useQuery(snapshotQueryOptions())
|
const { data: snapshot } = useQuery(snapshotQueryOptions())
|
||||||
const cfdmServices = useMemo(
|
const cfdmServices = useMemo(
|
||||||
@@ -139,8 +141,9 @@ function TopologyEditorInner({
|
|||||||
cfdmServicesRef.current,
|
cfdmServicesRef.current,
|
||||||
)
|
)
|
||||||
const withFqdn = applyServiceFqdns(migrated.nodes, cfdmServicesRef.current)
|
const withFqdn = applyServiceFqdns(migrated.nodes, cfdmServicesRef.current)
|
||||||
|
const withHandles = applyMembershipHandles(migrated.edges, withFqdn)
|
||||||
setNodes(() => normalizeGroupLayers(sortParentsFirst(withFqdn)))
|
setNodes(() => normalizeGroupLayers(sortParentsFirst(withFqdn)))
|
||||||
setEdges(() => normalizeEdges(migrated.edges))
|
setEdges(() => normalizeEdges(withHandles))
|
||||||
setEditElement(null)
|
setEditElement(null)
|
||||||
setElementOpen(false)
|
setElementOpen(false)
|
||||||
setEditEdgeId(null)
|
setEditEdgeId(null)
|
||||||
@@ -179,6 +182,10 @@ function TopologyEditorInner({
|
|||||||
return () => window.clearTimeout(t)
|
return () => window.clearTimeout(t)
|
||||||
}, [nodes, edges, emitSave, locked])
|
}, [nodes, edges, emitSave, locked])
|
||||||
|
|
||||||
|
const syncMembershipHandles = useCallback(() => {
|
||||||
|
setEdges((eds) => applyMembershipHandles(eds, getNodes() as FlowNode[]))
|
||||||
|
}, [getNodes, setEdges])
|
||||||
|
|
||||||
const onNodesChange: OnNodesChange<FlowNode> = useCallback(
|
const onNodesChange: OnNodesChange<FlowNode> = useCallback(
|
||||||
(changes) => {
|
(changes) => {
|
||||||
if (locked) return
|
if (locked) return
|
||||||
@@ -192,8 +199,13 @@ function TopologyEditorInner({
|
|||||||
setNodes((ns) => normalizeGroupLayers(ns))
|
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(
|
const onEdgesChange: OnEdgesChange<FlowEdge> = useCallback(
|
||||||
@@ -223,6 +235,11 @@ function TopologyEditorInner({
|
|||||||
[locked, setEdges],
|
[locked, setEdges],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onNodeDrag: OnNodeDrag<FlowNode> = useCallback(() => {
|
||||||
|
if (locked) return
|
||||||
|
syncMembershipHandles()
|
||||||
|
}, [locked, syncMembershipHandles])
|
||||||
|
|
||||||
const onNodeDragStop: OnNodeDrag<FlowNode> = useCallback(
|
const onNodeDragStop: OnNodeDrag<FlowNode> = useCallback(
|
||||||
(_e, node) => {
|
(_e, node) => {
|
||||||
if (locked) return
|
if (locked) return
|
||||||
@@ -237,8 +254,9 @@ function TopologyEditorInner({
|
|||||||
const updated = ns.map((n) => (n.id === next.id ? next : n))
|
const updated = ns.map((n) => (n.id === next.id ? next : n))
|
||||||
return normalizeGroupLayers(sortParentsFirst(updated))
|
return normalizeGroupLayers(sortParentsFirst(updated))
|
||||||
})
|
})
|
||||||
|
syncMembershipHandles()
|
||||||
},
|
},
|
||||||
[locked, setNodes],
|
[locked, setNodes, syncMembershipHandles],
|
||||||
)
|
)
|
||||||
|
|
||||||
const existingVpsIds = useMemo(() => {
|
const existingVpsIds = useMemo(() => {
|
||||||
@@ -381,6 +399,16 @@ function TopologyEditorInner({
|
|||||||
setElementOpen(true)
|
setElementOpen(true)
|
||||||
return
|
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)) {
|
if (node.type === 'service' && isServiceNodeData(node.data)) {
|
||||||
setEditElement({ kind: 'service', id: node.id, data: node.data })
|
setEditElement({ kind: 'service', id: node.id, data: node.data })
|
||||||
setElementOpen(true)
|
setElementOpen(true)
|
||||||
@@ -457,7 +485,9 @@ function TopologyEditorInner({
|
|||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
|
onNodeDoubleClick={onNodeDoubleClick}
|
||||||
onEdgeClick={onEdgeClick}
|
onEdgeClick={onEdgeClick}
|
||||||
|
onNodeDrag={onNodeDrag}
|
||||||
onNodeDragStop={onNodeDragStop}
|
onNodeDragStop={onNodeDragStop}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
@@ -469,6 +499,8 @@ function TopologyEditorInner({
|
|||||||
}}
|
}}
|
||||||
nodeTypes={topologyNodeTypes}
|
nodeTypes={topologyNodeTypes}
|
||||||
edgeTypes={topologyEdgeTypes}
|
edgeTypes={topologyEdgeTypes}
|
||||||
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
elevateNodesOnSelect={false}
|
||||||
nodesDraggable={!locked}
|
nodesDraggable={!locked}
|
||||||
nodesConnectable={!locked}
|
nodesConnectable={!locked}
|
||||||
elementsSelectable={!locked}
|
elementsSelectable={!locked}
|
||||||
|
|||||||
Reference in New Issue
Block a user