feat(topology): вынести сервис CFDM в отдельный узел со связями членства
Docker / build (push) Failing after 23s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-09-01 14:20:09 +07:00
co-authored by Cursor
parent 4dc5609f17
commit 5a61ce33b4
14 changed files with 600 additions and 151 deletions
@@ -110,7 +110,7 @@ export function AddVpsSheet({
title={tab === 'cfdm' ? 'Добавить сервис CFDM' : 'Добавить VPS на схему'}
description={
tab === 'cfdm'
? 'Серверы сервиса появятся в группе. Тип резервирования — из CFDM.'
? 'Сервис появится как узел. Серверы свяжутся пунктиром членства — один VPS может быть в нескольких сервисах.'
: 'Сервер появится на канве. Позицию и связи можно настроить вручную.'
}
submitLabel={`Добавить${submitCount ? ` (${submitCount})` : ''}`}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { VpsDomain } from '@/types/entities'
import { aggregateCfdmServices } from './cfdm-services'
import { aggregateCfdmServices, servicesForVps } from './cfdm-services'
function domain(partial: Partial<VpsDomain> & Pick<VpsDomain, 'id' | 'cfdmServiceId'>): VpsDomain {
return {
@@ -68,6 +68,32 @@ describe('aggregateCfdmServices', () => {
expect(dns?.lbMode).toBe('round_robin')
})
it('lists services a VPS belongs to', () => {
const rows: VpsDomain[] = [
domain({
id: 'a',
cfdmServiceId: 10,
cfdmBindingId: 1,
vpsId: 'vps-1',
matchStatus: 'matched',
lbMode: 'failover',
}),
domain({
id: 'b',
cfdmServiceId: 11,
cfdmBindingId: 3,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: 'ns.example.com',
vpsId: 'vps-1',
matchStatus: 'matched',
lbMode: 'round_robin',
}),
]
const chips = servicesForVps(aggregateCfdmServices(rows), 'vps-1')
expect(chips.map((c) => c.name).sort()).toEqual(['DNS', 'VPN'])
})
it('deduplicates the same vpsId across bindings of one service', () => {
const rows: VpsDomain[] = [
domain({
@@ -70,3 +70,21 @@ export function aggregateCfdmServices(domains: VpsDomain[]): CfdmTopologyService
}))
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
}
export function serviceFqdnMeta(service: Pick<CfdmTopologyService, 'fqdns'>): {
fqdn: string
extraFqdns: string[]
} {
const [primary, ...rest] = service.fqdns
return { fqdn: primary ?? '', extraFqdns: rest }
}
/** Сервисы CFDM, в которых состоит данный VPS (для чипов на карточке). */
export function servicesForVps(
services: CfdmTopologyService[],
vpsId: string,
): Pick<CfdmTopologyService, 'serviceId' | 'name' | 'lbMode'>[] {
return services
.filter((s) => s.matchedVpsIds.includes(vpsId))
.map((s) => ({ serviceId: s.serviceId, name: s.name, lbMode: s.lbMode }))
}
@@ -47,3 +47,37 @@ export function createConnectedEdge(connection: {
data,
)
}
export function membershipEdgeData(): TopologyEdgeData {
return {
relation: 'membership',
label: '',
lineStyle: 'dashed',
direction: 'none',
protocol: '',
localIp: '',
remoteIp: '',
notes: '',
}
}
export function createMembershipEdge(
serviceNodeId: string,
vpsNodeId: string,
): Edge<TopologyEdgeData> {
const data = membershipEdgeData()
return applyEdgeVisuals(
{
id: `e-membership-${serviceNodeId}-${vpsNodeId}`,
source: serviceNodeId,
target: vpsNodeId,
data,
deletable: false,
},
data,
)
}
export function isMembershipEdge(edge: Edge<TopologyEdgeData>): boolean {
return edge.data?.relation === 'membership'
}
@@ -43,6 +43,7 @@ function TopologyEdgeComponent(props: EdgeProps<Edge<TopologyEdgeData>>) {
const title = data?.label?.trim() ?? ''
const protocol = data?.protocol?.trim() ?? ''
const tunnelIps = formatEdgeTunnelIps(data ?? {})
const membership = relation === 'membership'
return (
<>
@@ -56,6 +57,7 @@ function TopologyEdgeComponent(props: EdgeProps<Edge<TopologyEdgeData>>) {
strokeWidth: selected ? 2.5 : 1.5,
}}
/>
{membership ? null : (
<EdgeLabelRenderer>
<div
className={cn(
@@ -88,6 +90,7 @@ function TopologyEdgeComponent(props: EdgeProps<Edge<TopologyEdgeData>>) {
) : null}
</div>
</EdgeLabelRenderer>
)}
</>
)
}
@@ -11,6 +11,7 @@ import {
lbModeLabel,
type GroupNodeData,
type NoteNodeData,
type ServiceNodeData,
type ShapeKind,
type ShapeNodeData,
type TopologyNodeType,
@@ -20,13 +21,18 @@ export type EditableElement =
| { kind: 'shape'; id: string; data: ShapeNodeData }
| { kind: 'note'; id: string; data: NoteNodeData }
| { kind: 'group'; id: string; data: GroupNodeData }
| { kind: 'service'; id: string; data: ServiceNodeData }
interface ElementEditSheetProps {
element: EditableElement | null
open: boolean
locked?: boolean
onOpenChange: (open: boolean) => void
onSave: (id: string, type: TopologyNodeType, data: ShapeNodeData | NoteNodeData | GroupNodeData) => void
onSave: (
id: string,
type: TopologyNodeType,
data: ShapeNodeData | NoteNodeData | GroupNodeData | ServiceNodeData,
) => void
}
export function ElementEditSheet({
@@ -52,6 +58,10 @@ export function ElementEditSheet({
setText(element.data.text ?? '')
setLabel('')
setNotes('')
} else if (element.kind === 'service') {
setLabel(element.data.label ?? '')
setNotes('')
setText('')
} else {
setLabel(element.data.label ?? '')
setNotes(element.data.notes ?? '')
@@ -64,7 +74,9 @@ export function ElementEditSheet({
? 'Параметры блока'
: element?.kind === 'note'
? 'Параметры заметки'
: 'Параметры группы'
: element?.kind === 'service'
? 'Параметры сервиса CFDM'
: 'Параметры группы'
return (
<FormSheet
@@ -74,7 +86,9 @@ export function ElementEditSheet({
description={
locked
? 'Схема заблокирована — только просмотр'
: 'Изменения сохранятся на схеме'
: element?.kind === 'service'
? 'Имя можно изменить. FQDN и тип резервирования — из CFDM.'
: 'Изменения сохранятся на схеме'
}
submitLabel="Сохранить"
submitDisabled={locked || !element}
@@ -84,12 +98,15 @@ export function ElementEditSheet({
onSave(element.id, 'shape', { kind, label: label.trim() || 'Блок' })
} else if (element.kind === 'note') {
onSave(element.id, 'note', { text: text.trim() || 'Заметка' })
} else if (element.kind === 'service') {
onSave(element.id, 'service', {
...element.data,
label: label.trim() || element.data.label || 'Сервис',
})
} else {
onSave(element.id, 'group', {
label: label.trim() || 'Группа',
notes: notes.trim() || undefined,
cfdmServiceId: element.data.cfdmServiceId,
lbMode: element.data.lbMode,
})
}
onOpenChange(false)
@@ -144,13 +161,6 @@ export function ElementEditSheet({
maxLength={80}
/>
</FormField>
{element.data.lbMode ? (
<FormField label="Резервирование">
<Badge variant={lbModeBadgeVariant(element.data.lbMode)} size="sm">
{lbModeLabel(element.data.lbMode)}
</Badge>
</FormField>
) : null}
<FormField label="Заметки" htmlFor="group-notes">
<Textarea
id="group-notes"
@@ -164,6 +174,39 @@ export function ElementEditSheet({
</FormField>
</>
) : null}
{element?.kind === 'service' ? (
<>
<FormField label="Название" htmlFor="service-label">
<Input
id="service-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
disabled={locked}
maxLength={80}
/>
</FormField>
{element.data.fqdn ? (
<FormField label="Основной FQDN">
<p className="font-mono text-sm text-foreground">{element.data.fqdn}</p>
</FormField>
) : null}
{element.data.extraFqdns && element.data.extraFqdns.length > 0 ? (
<FormField label="Другие FQDN">
<p className="font-mono text-sm text-muted-foreground">
{element.data.extraFqdns.join(', ')}
</p>
</FormField>
) : null}
{element.data.lbMode ? (
<FormField label="Резервирование">
<Badge variant={lbModeBadgeVariant(element.data.lbMode)} size="sm">
{lbModeLabel(element.data.lbMode)}
</Badge>
</FormField>
) : null}
</>
) : null}
</FormSheet>
)
}
@@ -3,7 +3,7 @@ import type { TopologyFlowNode, TopologyNodeType } from './types'
const GROUP_Z = -1
const CONTENT_Z = 1
const ATTACHABLE = new Set<TopologyNodeType>(['vps', 'shape', 'note'])
const ATTACHABLE = new Set<TopologyNodeType>(['vps', 'shape', 'note', 'service'])
export function isAttachableType(type: TopologyNodeType | undefined): boolean {
return type != null && ATTACHABLE.has(type)
@@ -3,10 +3,12 @@ import { VpsNode } from './nodes/vps-node'
import { ShapeNode } from './nodes/shape-node'
import { NoteNode } from './nodes/note-node'
import { GroupNode } from './nodes/group-node'
import { ServiceNode } from './nodes/service-node'
export const topologyNodeTypes = {
vps: VpsNode,
shape: ShapeNode,
note: NoteNode,
group: GroupNode,
service: ServiceNode,
} satisfies NodeTypes
@@ -0,0 +1,50 @@
import { memo } from 'react'
import { Handle, Position, 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'
function ServiceNodeComponent({ data, selected }: NodeProps & { data: ServiceNodeData }) {
return (
<div
className={cn(
'relative min-w-[200px] max-w-[280px] rounded-lg border bg-background px-3 py-2 shadow-sm',
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"
/>
<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" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{data.label || 'Сервис'}</span>
{data.lbMode ? (
<Badge variant={lbModeBadgeVariant(data.lbMode)} size="xs">
{lbModeLabel(data.lbMode)}
</Badge>
) : null}
</div>
{data.fqdn ? (
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
{data.fqdn}
</div>
) : null}
</div>
</div>
<Handle
type="source"
position={Position.Right}
className="!size-2.5 !border-background !bg-muted-foreground"
/>
</div>
)
}
export const ServiceNode = memo(ServiceNodeComponent)
@@ -1,13 +1,15 @@
import { memo } from 'react'
import { memo, useMemo } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { ServerIcon } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { cn } from '@cfdm/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
import { CountryFlag } from '@/components/country-flag'
import { StatusBadge } from '@/components/status-badge'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { resolveCountryCode, vpsStatusLabel } from '@/lib/format'
import type { Vps } from '@/types/entities'
import { aggregateCfdmServices, servicesForVps } from '../cfdm-services'
import { type VpsNodeData, vpsSpecsLine } from '../types'
function formatRate(vps: Vps): string | null {
@@ -31,6 +33,10 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData })
const rate = vps ? formatRate(vps) : null
const countryCode = resolveCountryCode(vps?.country)
const hasFlag = Boolean(countryCode)
const serviceChips = useMemo(
() => servicesForVps(aggregateCfdmServices(snapshot?.vpsDomains ?? []), data.vpsId),
[snapshot?.vpsDomains, data.vpsId],
)
return (
<div
@@ -92,6 +98,15 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData })
{[vps.country, vps.city, vps.datacenter].filter(Boolean).join(' · ')}
</div>
) : null}
{serviceChips.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{serviceChips.map((s) => (
<Badge key={s.serviceId} variant="secondary" size="xs">
{s.name}
</Badge>
))}
</div>
) : null}
</div>
</div>
<Handle
@@ -1,58 +1,152 @@
import { describe, expect, it } from 'vitest'
import { placeCfdmServiceGroup } from './place-cfdm-service'
import { isGroupNodeData, isVpsNodeData, type TopologyFlowNode } from './types'
import { placeCfdmService, placeCfdmServices, migrateCfdmGroupsToServices } from './place-cfdm-service'
import { isServiceNodeData, isVpsNodeData, type TopologyFlowNode } from './types'
import type { CfdmTopologyService } from './cfdm-services'
const service: CfdmTopologyService = {
const vpn: CfdmTopologyService = {
serviceId: 10,
name: 'VPN',
slug: 'vpn',
lbMode: 'failover',
fqdns: ['vpn.example.com'],
fqdns: ['vpn.example.com', 'vpn-alt.example.com'],
matchedVpsIds: ['vps-a', 'vps-b'],
unmatchedIps: [],
}
describe('placeCfdmServiceGroup', () => {
it('creates a group with two VPS children', () => {
const { nodes, alreadyOnCanvas } = placeCfdmServiceGroup([], service, { x: 0, y: 0 })
const dns: CfdmTopologyService = {
serviceId: 11,
name: 'DNS',
slug: 'dns',
lbMode: 'round_robin',
fqdns: ['ns.shnt.top'],
matchedVpsIds: ['vps-a', 'vps-b'],
unmatchedIps: [],
}
const bgp: CfdmTopologyService = {
serviceId: 12,
name: 'BGP',
slug: 'bgp',
lbMode: 'failover',
fqdns: ['bgp.example.com'],
matchedVpsIds: ['vps-a', 'vps-c'],
unmatchedIps: [],
}
function vpsIds(nodes: TopologyFlowNode[]): string[] {
return nodes
.filter((n): n is TopologyFlowNode & { data: { vpsId: string } } => isVpsNodeData(n.data))
.map((n) => n.data.vpsId)
.sort()
}
describe('placeCfdmService', () => {
it('creates a service node, VPS cards without parentId, and membership edges', () => {
const { nodes, edges, alreadyOnCanvas } = placeCfdmService([], [], vpn, { x: 0, y: 0 })
expect(alreadyOnCanvas).toBe(false)
const group = nodes.find((n) => n.type === 'group')
expect(group).toBeTruthy()
expect(isGroupNodeData(group!.data) && group!.data.lbMode).toBe('failover')
expect(isGroupNodeData(group!.data) && group!.data.cfdmServiceId).toBe(10)
const children = nodes.filter((n) => n.parentId === group!.id)
expect(children).toHaveLength(2)
const ids = children
.filter((n): n is TopologyFlowNode & { data: { vpsId: string } } => isVpsNodeData(n.data))
.map((n) => n.data.vpsId)
.sort()
expect(ids).toEqual(['vps-a', 'vps-b'])
const service = nodes.find((n) => n.type === 'service')
expect(service).toBeTruthy()
expect(isServiceNodeData(service!.data) && service!.data.lbMode).toBe('failover')
expect(isServiceNodeData(service!.data) && service!.data.cfdmServiceId).toBe(10)
expect(isServiceNodeData(service!.data) && service!.data.fqdn).toBe('vpn.example.com')
expect(isServiceNodeData(service!.data) && service!.data.extraFqdns).toEqual([
'vpn-alt.example.com',
])
expect(nodes.some((n) => n.parentId)).toBe(false)
expect(vpsIds(nodes)).toEqual(['vps-a', 'vps-b'])
expect(edges.every((e) => e.data?.relation === 'membership')).toBe(true)
expect(edges).toHaveLength(2)
expect(edges.every((e) => e.source === service!.id)).toBe(true)
})
it('does not duplicate a service already on the canvas', () => {
const first = placeCfdmServiceGroup([], service, { x: 0, y: 0 }).nodes
const second = placeCfdmServiceGroup(first, service, { x: 100, y: 100 })
const first = placeCfdmService([], [], vpn, { x: 0, y: 0 })
const second = placeCfdmService(first.nodes, first.edges, vpn, { x: 100, y: 100 })
expect(second.alreadyOnCanvas).toBe(true)
expect(second.nodes.filter((n) => n.type === 'group')).toHaveLength(1)
expect(second.nodes.filter((n) => n.type === 'service')).toHaveLength(1)
})
it('skips a VPS that already belongs to another CFDM group', () => {
const other: CfdmTopologyService = {
...service,
serviceId: 11,
name: 'DNS',
matchedVpsIds: ['vps-a'],
}
const withOther = placeCfdmServiceGroup([], other, { x: 0, y: 0 }).nodes
const result = placeCfdmServiceGroup(withOther, service, { x: 0, y: 200 })
expect(result.skippedVpsIds).toEqual(['vps-a'])
const vpn = result.nodes.find(
(n) => n.type === 'group' && isGroupNodeData(n.data) && n.data.cfdmServiceId === 10,
it('lets one VPS belong to DNS and BGP without cloning the card', () => {
const first = placeCfdmService([], [], dns, { x: 0, y: 0 })
const result = placeCfdmService(first.nodes, first.edges, bgp, { x: 0, y: 200 })
expect(result.alreadyOnCanvas).toBe(false)
expect(result.nodes.filter((n) => n.type === 'service')).toHaveLength(2)
expect(vpsIds(result.nodes)).toEqual(['vps-a', 'vps-b', 'vps-c'])
const aNode = result.nodes.find(
(n) => n.type === 'vps' && isVpsNodeData(n.data) && n.data.vpsId === 'vps-a',
)
expect(vpn).toBeTruthy()
const vpnChildren = result.nodes.filter((n) => n.parentId === vpn!.id)
expect(vpnChildren).toHaveLength(1)
expect(isVpsNodeData(vpnChildren[0]!.data) && vpnChildren[0]!.data.vpsId).toBe('vps-b')
expect(aNode).toBeTruthy()
const membershipToA = result.edges.filter(
(e) => e.data?.relation === 'membership' && e.target === aNode!.id,
)
expect(membershipToA).toHaveLength(2)
})
})
describe('placeCfdmServices', () => {
it('places several services and reports already-on-canvas ids', () => {
const first = placeCfdmServices([], [], [dns], { x: 0, y: 0 })
const second = placeCfdmServices(first.nodes, first.edges, [dns, bgp], { x: 0, y: 0 })
expect(second.alreadyIds).toEqual([11])
expect(second.nodes.filter((n) => n.type === 'service')).toHaveLength(2)
})
})
describe('migrateCfdmGroupsToServices', () => {
it('converts a CFDM group with parentId children into a service node and membership edges', () => {
const group: TopologyFlowNode = {
id: 'group-1',
type: 'group',
position: { x: 10, y: 20 },
style: { width: 400, height: 200 },
data: { label: 'DNS', cfdmServiceId: 11, lbMode: 'round_robin' },
}
const childA: TopologyFlowNode = {
id: 'vps-node-a',
type: 'vps',
parentId: 'group-1',
position: { x: 20, y: 40 },
data: { vpsId: 'vps-a' },
}
const childB: TopologyFlowNode = {
id: 'vps-node-b',
type: 'vps',
parentId: 'group-1',
position: { x: 240, y: 40 },
data: { vpsId: 'vps-b' },
}
const { nodes, edges, changed } = migrateCfdmGroupsToServices(
[group, childA, childB],
[],
[dns],
)
expect(changed).toBe(true)
const service = nodes.find((n) => n.id === 'group-1')
expect(service?.type).toBe('service')
expect(isServiceNodeData(service!.data) && service!.data.fqdn).toBe('ns.shnt.top')
expect(nodes.every((n) => !n.parentId)).toBe(true)
expect(edges).toHaveLength(2)
expect(edges.every((e) => e.data?.relation === 'membership' && e.source === 'group-1')).toBe(
true,
)
})
it('is a no-op when there are no CFDM groups', () => {
const placed = placeCfdmService([], [], dns, { x: 0, y: 0 })
const again = migrateCfdmGroupsToServices(placed.nodes, placed.edges, [dns])
expect(again.changed).toBe(false)
expect(again.nodes.filter((n) => n.type === 'group')).toHaveLength(0)
})
it('strips leftover group size from an already converted service node', () => {
const placed = placeCfdmService([], [], dns, { x: 0, y: 0 })
const wide = placed.nodes.map((n) =>
n.type === 'service' ? { ...n, width: 400, height: 200, style: { width: 400, height: 200 } } : n,
)
const { nodes, changed } = migrateCfdmGroupsToServices(wide, placed.edges, [dns])
expect(changed).toBe(true)
const service = nodes.find((n) => n.type === 'service')
expect(service?.width).toBeUndefined()
expect(service?.style).toBeUndefined()
})
})
@@ -1,32 +1,34 @@
import type { CfdmTopologyService } from './cfdm-services'
import { attachNodeToGroup, normalizeGroupLayers, sortParentsFirst } from './group-utils'
import { serviceFqdnMeta } from './cfdm-services'
import { createMembershipEdge } from './edge-utils'
import { detachNodeFromGroup, normalizeGroupLayers, sortParentsFirst } from './group-utils'
import {
isGroupNodeData,
isServiceNodeData,
isVpsNodeData,
newNodeId,
type TopologyFlowEdge,
type TopologyFlowNode,
type ServiceNodeData,
} from './types'
const PAD_X = 20
const PAD_Y = 44
const CELL_W = 240
const CELL_H = 110
const SERVICE_GAP_Y = 120
export type PlaceCfdmServiceResult = {
nodes: TopologyFlowNode[]
skippedVpsIds: string[]
edges: TopologyFlowEdge[]
alreadyOnCanvas: boolean
}
function cfdmGroupOf(
node: TopologyFlowNode,
function existingServiceNode(
nodes: TopologyFlowNode[],
serviceId: number,
): TopologyFlowNode | undefined {
if (!node.parentId) return undefined
const parent = nodes.find((n) => n.id === node.parentId)
if (!parent || parent.type !== 'group' || !isGroupNodeData(parent.data)) return undefined
if (parent.data.cfdmServiceId == null) return undefined
return parent
return nodes.find(
(n) => n.type === 'service' && isServiceNodeData(n.data) && n.data.cfdmServiceId === serviceId,
)
}
function existingVpsNode(
@@ -36,108 +38,209 @@ function existingVpsNode(
return nodes.find((n) => n.type === 'vps' && isVpsNodeData(n.data) && n.data.vpsId === vpsId)
}
/** Ставит сервис CFDM как существующую dashed-группу с VPS-детьми. */
export function placeCfdmServiceGroup(
function hasMembership(
edges: TopologyFlowEdge[],
serviceNodeId: string,
vpsNodeId: string,
): boolean {
return edges.some(
(e) =>
e.data?.relation === 'membership' &&
e.source === serviceNodeId &&
e.target === vpsNodeId,
)
}
function serviceNodeData(service: CfdmTopologyService): ServiceNodeData {
const { fqdn, extraFqdns } = serviceFqdnMeta(service)
return {
label: service.name,
cfdmServiceId: service.serviceId,
fqdn,
extraFqdns: extraFqdns.length > 0 ? extraFqdns : undefined,
lbMode: service.lbMode,
}
}
/** Ставит сервис CFDM как отдельный узел и пунктирные рёбра членства к VPS. */
export function placeCfdmService(
nodes: TopologyFlowNode[],
edges: TopologyFlowEdge[],
service: CfdmTopologyService,
origin: { x: number; y: number },
): PlaceCfdmServiceResult {
const already = nodes.some(
(n) =>
n.type === 'group' &&
isGroupNodeData(n.data) &&
n.data.cfdmServiceId === service.serviceId,
)
const already = existingServiceNode(nodes, service.serviceId)
if (already) {
return { nodes, skippedVpsIds: [], alreadyOnCanvas: true }
return { nodes, edges, alreadyOnCanvas: true }
}
const skippedVpsIds: string[] = []
const vpsIds: string[] = []
for (const vpsId of service.matchedVpsIds) {
const existing = existingVpsNode(nodes, vpsId)
if (existing) {
const other = cfdmGroupOf(existing, nodes)
if (other && isGroupNodeData(other.data) && other.data.cfdmServiceId !== service.serviceId) {
skippedVpsIds.push(vpsId)
continue
}
}
vpsIds.push(vpsId)
}
if (vpsIds.length === 0) {
return { nodes, skippedVpsIds, alreadyOnCanvas: false }
}
const width = PAD_X * 2 + vpsIds.length * CELL_W - (CELL_W - 220)
const height = PAD_Y + CELL_H + 16
const groupId = newNodeId('group')
const group: TopologyFlowNode = {
id: groupId,
type: 'group',
const serviceId = newNodeId('service')
const serviceNode: TopologyFlowNode = {
id: serviceId,
type: 'service',
position: origin,
style: { width, height },
width,
height,
data: {
label: service.name,
cfdmServiceId: service.serviceId,
lbMode: service.lbMode,
},
zIndex: -1,
data: serviceNodeData(service),
}
let next = [...nodes, group]
let nextNodes = [...nodes, serviceNode]
let nextEdges = [...edges]
vpsIds.forEach((vpsId, i) => {
const abs = {
x: origin.x + PAD_X + i * CELL_W,
y: origin.y + PAD_Y,
}
const existing = existingVpsNode(next, vpsId)
if (existing) {
const detached: TopologyFlowNode = {
...existing,
parentId: undefined,
position: abs,
service.matchedVpsIds.forEach((vpsId, i) => {
let vpsNode = existingVpsNode(nextNodes, vpsId)
if (!vpsNode) {
vpsNode = {
id: newNodeId('vps'),
type: 'vps',
position: {
x: origin.x + i * CELL_W,
y: origin.y + SERVICE_GAP_Y,
},
data: { vpsId },
}
const attached = attachNodeToGroup(detached, group, next)
next = next.map((n) => (n.id === existing.id ? attached : n))
return
nextNodes = [...nextNodes, vpsNode]
}
const draft: TopologyFlowNode = {
id: newNodeId('vps'),
type: 'vps',
position: abs,
data: { vpsId },
if (!hasMembership(nextEdges, serviceId, vpsNode.id)) {
nextEdges = [...nextEdges, createMembershipEdge(serviceId, vpsNode.id)]
}
next = [...next, attachNodeToGroup(draft, group, next)]
})
return {
nodes: normalizeGroupLayers(sortParentsFirst(next)),
skippedVpsIds,
nodes: normalizeGroupLayers(sortParentsFirst(nextNodes)),
edges: nextEdges,
alreadyOnCanvas: false,
}
}
export function placeCfdmServices(
nodes: TopologyFlowNode[],
edges: TopologyFlowEdge[],
services: CfdmTopologyService[],
origin: { x: number; y: number },
): { nodes: TopologyFlowNode[]; skippedVpsIds: string[]; alreadyIds: number[] } {
let next = nodes
const skippedVpsIds: string[] = []
): { nodes: TopologyFlowNode[]; edges: TopologyFlowEdge[]; alreadyIds: number[] } {
let nextNodes = nodes
let nextEdges = edges
const alreadyIds: number[] = []
services.forEach((service, i) => {
const result = placeCfdmServiceGroup(next, service, {
const result = placeCfdmService(nextNodes, nextEdges, service, {
x: origin.x,
y: origin.y + i * 220,
y: origin.y + i * (SERVICE_GAP_Y + CELL_H),
})
next = result.nodes
skippedVpsIds.push(...result.skippedVpsIds)
nextNodes = result.nodes
nextEdges = result.edges
if (result.alreadyOnCanvas) alreadyIds.push(service.serviceId)
})
return { nodes: next, skippedVpsIds, alreadyIds }
return { nodes: nextNodes, edges: nextEdges, alreadyIds }
}
function compactServiceNode(node: TopologyFlowNode): TopologyFlowNode {
if (node.type !== 'service') return node
if (node.style == null && node.width == null && node.height == null && node.measured == null) {
return node
}
const { style: _style, width: _width, height: _height, measured: _measured, ...rest } = node
return rest
}
function compactServiceFromGroup(
group: TopologyFlowNode,
service: CfdmTopologyService | undefined,
): TopologyFlowNode {
const data = isGroupNodeData(group.data) ? group.data : undefined
const { fqdn, extraFqdns } = service ? serviceFqdnMeta(service) : { fqdn: '', extraFqdns: [] }
const nextData: ServiceNodeData = {
label: data?.label ?? service?.name ?? 'Сервис',
cfdmServiceId: data?.cfdmServiceId ?? service?.serviceId ?? 0,
fqdn,
extraFqdns: extraFqdns.length > 0 ? extraFqdns : undefined,
lbMode: data?.lbMode ?? service?.lbMode,
}
const { style: _style, width: _w, height: _h, measured: _m, ...rest } = group
return {
...rest,
type: 'service',
data: nextData,
}
}
/**
* Одноразовая миграция: CFDM dashed-группа + parentId → узел service + membership.
* Идемпотентна: уже `type: 'service'` не трогает.
*/
export function migrateCfdmGroupsToServices(
nodes: TopologyFlowNode[],
edges: TopologyFlowEdge[],
services: CfdmTopologyService[],
): { nodes: TopologyFlowNode[]; edges: TopologyFlowEdge[]; changed: boolean } {
const byServiceId = new Map(services.map((s) => [s.serviceId, s]))
const groups = nodes.filter(
(n) => n.type === 'group' && isGroupNodeData(n.data) && n.data.cfdmServiceId != null,
)
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 }
}
let nextNodes = nodes
let nextEdges = edges
let changed = false
for (const group of groups) {
if (!isGroupNodeData(group.data) || group.data.cfdmServiceId == null) continue
const service = byServiceId.get(group.data.cfdmServiceId)
const children = nextNodes.filter((n) => n.parentId === group.id && n.type === 'vps')
for (const child of children) {
const detached = detachNodeFromGroup(child, nextNodes)
nextNodes = nextNodes.map((n) => (n.id === child.id ? detached : n))
if (!hasMembership(nextEdges, group.id, detached.id)) {
nextEdges = [...nextEdges, createMembershipEdge(group.id, detached.id)]
}
changed = true
}
const converted = compactServiceFromGroup(group, service)
nextNodes = nextNodes.map((n) => (n.id === group.id ? converted : n))
changed = true
}
return {
nodes: normalizeGroupLayers(sortParentsFirst(nextNodes)),
edges: nextEdges,
changed,
}
}
/** Подставляет FQDN/lbMode из агрегата, не меняя позиции. */
export function applyServiceFqdns(
nodes: TopologyFlowNode[],
services: CfdmTopologyService[],
): TopologyFlowNode[] {
if (services.length === 0) return nodes
const byId = new Map(services.map((s) => [s.serviceId, s]))
let changed = false
const next = nodes.map((n) => {
if (n.type !== 'service' || !isServiceNodeData(n.data)) return n
const service = byId.get(n.data.cfdmServiceId)
if (!service) return n
const { fqdn, extraFqdns } = serviceFqdnMeta(service)
const extra = extraFqdns.length > 0 ? extraFqdns : undefined
if (
n.data.fqdn === fqdn &&
n.data.lbMode === service.lbMode &&
JSON.stringify(n.data.extraFqdns) === JSON.stringify(extra)
) {
return n
}
changed = true
return {
...n,
data: {
...n.data,
fqdn,
extraFqdns: extra,
lbMode: service.lbMode,
},
}
})
return changed ? next : nodes
}
@@ -27,9 +27,11 @@ import {
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { toPng } from 'html-to-image'
import { useQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { toast } from 'sonner'
import { cn } from '@cfdm/ui/lib/utils'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { topologyNodeTypes } from './node-types'
import { topologyEdgeTypes } from './edge-types'
import { TopologyPalette, parsePaletteDrag, shapeLabel } from './palette'
@@ -38,9 +40,13 @@ import { AddVpsSheet } from './add-vps-sheet'
import { VpsDetailSheet } from './vps-detail-sheet'
import { ElementEditSheet, type EditableElement } from './element-edit-sheet'
import { EdgeEditSheet } from './edge-edit-sheet'
import { applyEdgeVisuals, createConnectedEdge } from './edge-utils'
import type { CfdmTopologyService } from './cfdm-services'
import { placeCfdmServices } from './place-cfdm-service'
import { applyEdgeVisuals, createConnectedEdge, isMembershipEdge } from './edge-utils'
import { aggregateCfdmServices, type CfdmTopologyService } from './cfdm-services'
import {
applyServiceFqdns,
migrateCfdmGroupsToServices,
placeCfdmServices,
} from './place-cfdm-service'
import {
normalizeGroupLayers,
placeWithOptionalParent,
@@ -51,12 +57,14 @@ import {
defaultEdgeData,
isGroupNodeData,
isNoteNodeData,
isServiceNodeData,
isShapeNodeData,
isVpsNodeData,
newNodeId,
type GroupNodeData,
type NoteNodeData,
type PaletteItem,
type ServiceNodeData,
type ShapeNodeData,
type TopologyEdgeData,
type TopologyFlowNode,
@@ -96,6 +104,13 @@ function TopologyEditorInner({
const wrapperRef = useRef<HTMLDivElement>(null)
const { screenToFlowPosition, fitView, zoomIn, zoomOut, getViewport, setViewport } =
useReactFlow()
const { data: snapshot } = useQuery(snapshotQueryOptions())
const cfdmServices = useMemo(
() => aggregateCfdmServices(snapshot?.vpsDomains ?? []),
[snapshot?.vpsDomains],
)
const cfdmServicesRef = useRef(cfdmServices)
cfdmServicesRef.current = cfdmServices
const [nodes, setNodes, onNodesChangeBase] = useNodesState<FlowNode>(
normalizeGroupLayers(sortParentsFirst(initialNodes)),
@@ -118,8 +133,14 @@ function TopologyEditorInner({
useEffect(() => {
skipSave.current = true
hydrated.current = false
setNodes(normalizeGroupLayers(sortParentsFirst(initialNodes)))
setEdges(normalizeEdges(initialEdges))
const migrated = migrateCfdmGroupsToServices(
initialNodes,
initialEdges as FlowEdge[],
cfdmServicesRef.current,
)
const withFqdn = applyServiceFqdns(migrated.nodes, cfdmServicesRef.current)
setNodes(() => normalizeGroupLayers(sortParentsFirst(withFqdn)))
setEdges(() => normalizeEdges(migrated.edges))
setEditElement(null)
setElementOpen(false)
setEditEdgeId(null)
@@ -136,6 +157,13 @@ function TopologyEditorInner({
return () => window.clearTimeout(t)
}, [diagramId]) // eslint-disable-line react-hooks/exhaustive-deps -- remount on diagram switch
useEffect(() => {
setNodes((ns) => {
const next = applyServiceFqdns(ns, cfdmServices)
return next === ns ? ns : next
})
}, [cfdmServices, setNodes])
const emitSave = useCallback(() => {
if (skipSave.current || !hydrated.current || locked) return
onDocumentChange({
@@ -224,6 +252,9 @@ function TopologyEditorInner({
const existingCfdmServiceIds = useMemo(() => {
const ids = new Set<number>()
for (const n of nodes) {
if (n.type === 'service' && isServiceNodeData(n.data)) {
ids.add(n.data.cfdmServiceId)
}
if (n.type === 'group' && isGroupNodeData(n.data) && n.data.cfdmServiceId != null) {
ids.add(n.data.cfdmServiceId)
}
@@ -311,16 +342,12 @@ function TopologyEditorInner({
x: (wrapperRef.current?.clientWidth ?? 400) / 2 + 80,
y: (wrapperRef.current?.clientHeight ?? 300) / 2,
})
const result = placeCfdmServices(nodes, services, origin)
const result = placeCfdmServices(nodes, edges, services, origin)
if (result.alreadyIds.length > 0) {
toast.message('Некоторые сервисы уже на схеме')
}
if (result.skippedVpsIds.length > 0) {
toast.warning(
`Не перенесены серверы из другой группы CFDM: ${result.skippedVpsIds.length}`,
)
}
setNodes(result.nodes)
setEdges(normalizeEdges(result.edges))
}
function placeItemAtCenter(item: PaletteItem) {
@@ -352,10 +379,16 @@ function TopologyEditorInner({
if (node.type === 'group' && isGroupNodeData(node.data)) {
setEditElement({ kind: 'group', id: node.id, data: node.data })
setElementOpen(true)
return
}
if (node.type === 'service' && isServiceNodeData(node.data)) {
setEditElement({ kind: 'service', id: node.id, data: node.data })
setElementOpen(true)
}
}
function onEdgeClick(_e: ReactMouseEvent, edge: FlowEdge) {
if (isMembershipEdge(edge)) return
const data = edge.data ?? defaultEdgeData()
setEditEdgeId(edge.id)
setEditEdgeData(data)
@@ -365,7 +398,7 @@ function TopologyEditorInner({
function handleSaveElement(
id: string,
type: TopologyNodeType,
data: ShapeNodeData | NoteNodeData | GroupNodeData,
data: ShapeNodeData | NoteNodeData | GroupNodeData | ServiceNodeData,
) {
setNodes((ns) =>
ns.map((n) => (n.id === id && n.type === type ? { ...n, data } : n)),
+30 -2
View File
@@ -5,7 +5,7 @@ import { uid } from '@/lib/format'
export type { TopologyLbMode }
export type TopologyNodeType = 'vps' | 'shape' | 'note' | 'group'
export type TopologyNodeType = 'vps' | 'shape' | 'note' | 'group' | 'service'
export type ShapeKind = 'rect' | 'ellipse' | 'diamond'
@@ -26,15 +26,25 @@ export type NoteNodeData = {
export type GroupNodeData = {
label: string
notes?: string
/** Только у немигрированных CFDM-коробок; после миграции поле на `service`. */
cfdmServiceId?: number
lbMode?: TopologyLbMode
}
export type ServiceNodeData = {
label: string
cfdmServiceId: number
fqdn: string
extraFqdns?: string[]
lbMode?: TopologyLbMode
}
export type TopologyNodeData =
| VpsNodeData
| ShapeNodeData
| NoteNodeData
| GroupNodeData
| ServiceNodeData
/** Тип связи на схеме инфраструктуры */
export type TopologyEdgeRelation =
@@ -44,6 +54,7 @@ export type TopologyEdgeRelation =
| 'vpn'
| 'sync'
| 'custom'
| 'membership'
export type TopologyEdgeDirection = 'forward' | 'bidirectional' | 'none'
@@ -102,8 +113,23 @@ export function isNoteNodeData(data: TopologyNodeData): data is NoteNodeData {
return 'text' in data
}
export function isServiceNodeData(data: TopologyNodeData): data is ServiceNodeData {
return (
typeof (data as ServiceNodeData).cfdmServiceId === 'number' &&
'fqdn' in data &&
'label' in data &&
!('vpsId' in data)
)
}
export function isGroupNodeData(data: TopologyNodeData): data is GroupNodeData {
return 'label' in data && !('kind' in data) && !('vpsId' in data) && !('text' in data)
return (
'label' in data &&
!('kind' in data) &&
!('vpsId' in data) &&
!('text' in data) &&
!isServiceNodeData(data)
)
}
export function defaultEdgeData(): TopologyEdgeData {
@@ -145,6 +171,8 @@ export function edgeRelationShortLabel(relation: TopologyEdgeRelation): string {
return 'Синк'
case 'custom':
return 'Связь'
case 'membership':
return 'Членство'
default:
return relation
}