feat(topology): вынести сервис CFDM в отдельный узел со связями членства
Docker / build (push) Failing after 23s
Docker / build (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user