feat(topology): добавить группы сервисов CFDM на схему
Docker / build (push) Failing after 20s

Сервис ставится существующей группой с Badge типа резервирования; sync сохраняет lbMode.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-01 11:30:40 +07:00
co-authored by Cursor
parent 29325179f4
commit 4dc5609f17
15 changed files with 711 additions and 63 deletions
@@ -1,28 +1,48 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { TabsContent } from '@cfdm/ui/components/tabs'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Badge } from '@/components/reui/badge'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state'
import { FormSheet } from '@/components/form-sheet'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { vpsSpecsLine } from './types'
import { aggregateCfdmServices, type CfdmTopologyService } from './cfdm-services'
import { lbModeBadgeVariant, lbModeLabel, vpsSpecsLine } from './types'
import type { Vps } from '@/types/entities'
interface AddVpsSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
existingVpsIds: Set<string>
existingCfdmServiceIds: Set<number>
onAdd: (vpsIds: string[]) => void
onAddServices: (services: CfdmTopologyService[]) => void
}
export function AddVpsSheet({
open,
onOpenChange,
existingVpsIds,
existingCfdmServiceIds,
onAdd,
onAddServices,
}: AddVpsSheetProps) {
const { data: snapshot } = useQuery(snapshotQueryOptions())
const [tab, setTab] = useState('vps')
const [q, setQ] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [selectedServices, setSelectedServices] = useState<Set<number>>(new Set())
const list = useMemo(() => {
const all = (snapshot?.vps ?? []) as Vps[]
@@ -38,6 +58,20 @@ export function AddVpsSheet({
.slice(0, 80)
}, [snapshot?.vps, q])
const services = useMemo(
() => aggregateCfdmServices(snapshot?.vpsDomains ?? []),
[snapshot?.vpsDomains],
)
const filteredServices = useMemo(() => {
const term = q.trim().toLowerCase()
if (!term) return services
return services.filter((s) => {
if (s.name.toLowerCase().includes(term) || s.slug.toLowerCase().includes(term)) return true
return s.fqdns.some((f) => f.toLowerCase().includes(term))
})
}, [services, q])
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev)
@@ -47,77 +81,192 @@ export function AddVpsSheet({
})
}
function toggleService(id: number) {
setSelectedServices((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function reset() {
setSelected(new Set())
setSelectedServices(new Set())
setQ('')
setTab('vps')
}
const submitCount = tab === 'cfdm' ? selectedServices.size : selected.size
const submitDisabled = submitCount === 0
return (
<FormSheet
open={open}
onOpenChange={(v) => {
if (!v) {
setSelected(new Set())
setQ('')
}
if (!v) reset()
onOpenChange(v)
}}
title="Добавить VPS на схему"
description="Сервер появится на канве. Позицию и связи можно настроить вручную."
submitLabel={`Добавить${selected.size ? ` (${selected.size})` : ''}`}
submitDisabled={selected.size === 0}
title={tab === 'cfdm' ? 'Добавить сервис CFDM' : 'Добавить VPS на схему'}
description={
tab === 'cfdm'
? 'Серверы сервиса появятся в группе. Тип резервирования — из CFDM.'
: 'Сервер появится на канве. Позицию и связи можно настроить вручную.'
}
submitLabel={`Добавить${submitCount ? ` (${submitCount})` : ''}`}
submitDisabled={submitDisabled}
onSubmit={() => {
onAdd([...selected])
setSelected(new Set())
setQ('')
if (tab === 'cfdm') {
const picked = services.filter((s) => selectedServices.has(s.serviceId))
onAddServices(picked)
} else {
onAdd([...selected])
}
reset()
onOpenChange(false)
}}
>
<Input
placeholder="Поиск по IP, DNS, проекту…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto">
{list.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих VPS</p>
) : (
list.map((v) => {
const already = existingVpsIds.has(v.id)
const checked = selected.has(v.id)
return (
<button
key={v.id}
type="button"
disabled={already}
onClick={() => toggle(v.id)}
className="flex flex-col gap-0.5 rounded-md border border-transparent px-2 py-2 text-left hover:bg-muted disabled:opacity-50"
data-selected={checked || undefined}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">
{v.dns || v.ip}
</span>
{already ? (
<span className="text-[10px] text-muted-foreground">уже на схеме</span>
) : (
<span
className={
checked
? 'size-2 rounded-full bg-primary'
: 'size-2 rounded-full border border-border'
}
/>
)}
</div>
<span className="font-mono text-[11px] text-muted-foreground">{v.ip}</span>
<span className="text-[11px] text-muted-foreground">{vpsSpecsLine(v)}</span>
</button>
)
})
)}
</div>
{selected.size > 0 ? (
<CountedLineTabs
tabs={[
{ id: 'vps', label: 'VPS', count: list.length },
{ id: 'cfdm', label: 'Сервисы CFDM', count: services.length },
]}
value={tab}
onValueChange={setTab}
className="flex flex-col gap-3"
>
<Input
placeholder={
tab === 'cfdm' ? 'Поиск по сервису, FQDN…' : 'Поиск по IP, DNS, проекту…'
}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<TabsContent value="vps" className="mt-0 flex flex-col gap-1">
<div className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto">
{list.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих VPS</p>
) : (
list.map((v) => {
const already = existingVpsIds.has(v.id)
const checked = selected.has(v.id)
return (
<button
key={v.id}
type="button"
disabled={already}
onClick={() => toggle(v.id)}
className="flex flex-col gap-0.5 rounded-md border border-transparent px-2 py-2 text-left hover:bg-muted disabled:opacity-50"
data-selected={checked || undefined}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{v.dns || v.ip}</span>
{already ? (
<span className="text-[10px] text-muted-foreground">уже на схеме</span>
) : (
<span
className={
checked
? 'size-2 rounded-full bg-primary'
: 'size-2 rounded-full border border-border'
}
/>
)}
</div>
<span className="font-mono text-[11px] text-muted-foreground">{v.ip}</span>
<span className="text-[11px] text-muted-foreground">{vpsSpecsLine(v)}</span>
</button>
)
})
)}
</div>
</TabsContent>
<TabsContent value="cfdm" className="mt-0 flex flex-col gap-1">
{services.length === 0 ? (
<EmptyState
title="Нет сервисов CFDM"
description="Включите интеграцию и синхронизируйте bindings — сервисы появятся здесь."
centered={false}
action={
<Button variant="outline" size="sm" render={<Link to="/settings/integrations" />}>
Настройки интеграции
</Button>
}
/>
) : filteredServices.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих сервисов</p>
) : (
<ItemGroup className="max-h-[50vh] gap-1 overflow-y-auto">
{filteredServices.map((s) => {
const already = existingCfdmServiceIds.has(s.serviceId)
const noServers = s.matchedVpsIds.length === 0
const disabled = already || noServers
const checked = selectedServices.has(s.serviceId)
return (
<Item
key={s.serviceId}
size="sm"
variant={checked ? 'muted' : 'default'}
render={
<button
type="button"
disabled={disabled}
onClick={() => toggleService(s.serviceId)}
/>
}
className="w-full text-left disabled:opacity-50"
data-selected={checked || undefined}
>
<ItemContent className="gap-0.5">
<ItemTitle className="flex flex-wrap items-center gap-1.5">
<span className="truncate">{s.name}</span>
{s.lbMode ? (
<Badge variant={lbModeBadgeVariant(s.lbMode)} size="xs">
{lbModeLabel(s.lbMode)}
</Badge>
) : null}
</ItemTitle>
<ItemDescription>
{s.fqdns.slice(0, 2).join(', ')}
{s.fqdns.length > 2 ? ` +${s.fqdns.length - 2}` : ''}
{' · '}
{s.matchedVpsIds.length} серв.
</ItemDescription>
</ItemContent>
<ItemActions>
{already ? (
<span className="text-[10px] text-muted-foreground">уже на схеме</span>
) : noServers ? (
<span className="text-[10px] text-muted-foreground">нет серверов</span>
) : (
<span
className={
checked
? 'size-2 rounded-full bg-primary'
: 'size-2 rounded-full border border-border'
}
/>
)}
</ItemActions>
</Item>
)
})}
</ItemGroup>
)}
</TabsContent>
</CountedLineTabs>
{(tab === 'vps' ? selected.size : selectedServices.size) > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSelected(new Set())}
onClick={() => {
setSelected(new Set())
setSelectedServices(new Set())
}}
>
Сбросить выбор
</Button>
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import type { VpsDomain } from '@/types/entities'
import { aggregateCfdmServices } from './cfdm-services'
function domain(partial: Partial<VpsDomain> & Pick<VpsDomain, 'id' | 'cfdmServiceId'>): VpsDomain {
return {
vpsId: null,
fqdn: 'vpn.example.com',
zoneName: 'example.com',
hostname: 'vpn',
serviceName: 'VPN',
serviceSlug: 'vpn',
cfdmBindingId: 1,
source: 'cfdm',
matchStatus: 'unmatched',
syncedAt: '2026-01-01T00:00:00.000Z',
...partial,
}
}
describe('aggregateCfdmServices', () => {
it('groups bindings by service and collects matched VPS', () => {
const rows: VpsDomain[] = [
domain({
id: 'a',
cfdmServiceId: 10,
cfdmBindingId: 1,
fqdn: 'vpn-a.example.com',
vpsId: 'vps-1',
matchStatus: 'matched',
lbMode: 'failover',
}),
domain({
id: 'b',
cfdmServiceId: 10,
cfdmBindingId: 2,
fqdn: 'vpn-b.example.com',
vpsId: 'vps-2',
matchStatus: 'matched',
lbMode: 'failover',
}),
domain({
id: 'c',
cfdmServiceId: 11,
cfdmBindingId: 3,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: 'ns.example.com',
matchStatus: 'unmatched',
targetIps: JSON.stringify(['198.51.100.1']),
lbMode: 'round_robin',
}),
]
const services = aggregateCfdmServices(rows)
expect(services).toHaveLength(2)
const vpn = services.find((s) => s.serviceId === 10)
expect(vpn?.name).toBe('VPN')
expect(vpn?.lbMode).toBe('failover')
expect(vpn?.matchedVpsIds.sort()).toEqual(['vps-1', 'vps-2'])
expect(vpn?.fqdns).toEqual(['vpn-a.example.com', 'vpn-b.example.com'])
expect(vpn?.unmatchedIps).toEqual([])
const dns = services.find((s) => s.serviceId === 11)
expect(dns?.matchedVpsIds).toEqual([])
expect(dns?.unmatchedIps).toEqual(['198.51.100.1'])
expect(dns?.lbMode).toBe('round_robin')
})
it('deduplicates the same vpsId across bindings of one service', () => {
const rows: VpsDomain[] = [
domain({
id: 'a',
cfdmServiceId: 10,
cfdmBindingId: 1,
vpsId: 'vps-1',
matchStatus: 'matched',
}),
domain({
id: 'b',
cfdmServiceId: 10,
cfdmBindingId: 2,
fqdn: 'vpn-b.example.com',
vpsId: 'vps-1',
matchStatus: 'matched',
}),
]
const [svc] = aggregateCfdmServices(rows)
expect(svc?.matchedVpsIds).toEqual(['vps-1'])
})
})
@@ -0,0 +1,72 @@
import type { TopologyLbMode } from '@cfdm/shared/contracts/integration-cfdm'
import type { VpsDomain } from '@/types/entities'
import { isTopologyLbMode } from './types'
export type CfdmTopologyService = {
serviceId: number
name: string
slug: string
lbMode?: TopologyLbMode
fqdns: string[]
matchedVpsIds: string[]
unmatchedIps: string[]
}
function parseTargetIps(raw: string | null | undefined): string[] {
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
} catch {
return []
}
}
/** Группирует vps_domains по сервису CFDM для пикера на схеме. */
export function aggregateCfdmServices(domains: VpsDomain[]): CfdmTopologyService[] {
const byService = new Map<
number,
{
name: string
slug: string
lbMode?: TopologyLbMode
fqdns: Set<string>
matchedVpsIds: Set<string>
unmatchedIps: Set<string>
}
>()
for (const row of domains) {
if (row.source && row.source !== 'cfdm') continue
let bucket = byService.get(row.cfdmServiceId)
if (!bucket) {
bucket = {
name: row.serviceName,
slug: row.serviceSlug,
fqdns: new Set(),
matchedVpsIds: new Set(),
unmatchedIps: new Set(),
}
byService.set(row.cfdmServiceId, bucket)
}
if (row.fqdn) bucket.fqdns.add(row.fqdn)
if (isTopologyLbMode(row.lbMode)) bucket.lbMode = row.lbMode
if (row.vpsId && row.matchStatus === 'matched') {
bucket.matchedVpsIds.add(row.vpsId)
} else {
for (const ip of parseTargetIps(row.targetIps)) bucket.unmatchedIps.add(ip)
}
}
return [...byService.entries()]
.map(([serviceId, b]) => ({
serviceId,
name: b.name,
slug: b.slug,
lbMode: b.lbMode,
fqdns: [...b.fqdns].sort(),
matchedVpsIds: [...b.matchedVpsIds],
unmatchedIps: [...b.unmatchedIps],
}))
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
}
@@ -4,8 +4,11 @@ import { Textarea } from '@cfdm/ui/components/textarea'
import { FormSheet } from '@/components/form-sheet'
import { FormField } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { Badge } from '@/components/reui/badge'
import {
SHAPE_KIND_OPTIONS,
lbModeBadgeVariant,
lbModeLabel,
type GroupNodeData,
type NoteNodeData,
type ShapeKind,
@@ -85,6 +88,8 @@ export function ElementEditSheet({
onSave(element.id, 'group', {
label: label.trim() || 'Группа',
notes: notes.trim() || undefined,
cfdmServiceId: element.data.cfdmServiceId,
lbMode: element.data.lbMode,
})
}
onOpenChange(false)
@@ -139,6 +144,13 @@ 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"
@@ -1,7 +1,8 @@
import { memo } from 'react'
import { type NodeProps, NodeResizer } from '@xyflow/react'
import { cn } from '@cfdm/ui/lib/utils'
import type { GroupNodeData } from '../types'
import { Badge } from '@/components/reui/badge'
import { lbModeBadgeVariant, lbModeLabel, type GroupNodeData } from '../types'
function GroupNodeComponent({ data, selected }: NodeProps & { data: GroupNodeData }) {
return (
@@ -13,8 +14,15 @@ function GroupNodeComponent({ data, selected }: NodeProps & { data: GroupNodeDat
>
<NodeResizer minWidth={200} minHeight={120} isVisible={selected} />
<div className="flex flex-col gap-0.5 px-3 py-2">
<div className="text-xs font-medium text-foreground">
{data.label || 'Группа'}
<div className="flex items-center gap-1.5">
<div className="min-w-0 truncate text-xs font-medium text-foreground">
{data.label || 'Группа'}
</div>
{data.lbMode ? (
<Badge variant={lbModeBadgeVariant(data.lbMode)} size="xs">
{lbModeLabel(data.lbMode)}
</Badge>
) : null}
</div>
{data.notes ? (
<div className="text-[10px] text-muted-foreground whitespace-pre-wrap">
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { placeCfdmServiceGroup } from './place-cfdm-service'
import { isGroupNodeData, isVpsNodeData, type TopologyFlowNode } from './types'
import type { CfdmTopologyService } from './cfdm-services'
const service: CfdmTopologyService = {
serviceId: 10,
name: 'VPN',
slug: 'vpn',
lbMode: 'failover',
fqdns: ['vpn.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 })
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'])
})
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 })
expect(second.alreadyOnCanvas).toBe(true)
expect(second.nodes.filter((n) => n.type === 'group')).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,
)
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')
})
})
@@ -0,0 +1,143 @@
import type { CfdmTopologyService } from './cfdm-services'
import { attachNodeToGroup, normalizeGroupLayers, sortParentsFirst } from './group-utils'
import {
isGroupNodeData,
isVpsNodeData,
newNodeId,
type TopologyFlowNode,
} from './types'
const PAD_X = 20
const PAD_Y = 44
const CELL_W = 240
const CELL_H = 110
export type PlaceCfdmServiceResult = {
nodes: TopologyFlowNode[]
skippedVpsIds: string[]
alreadyOnCanvas: boolean
}
function cfdmGroupOf(
node: TopologyFlowNode,
nodes: TopologyFlowNode[],
): 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
}
function existingVpsNode(
nodes: TopologyFlowNode[],
vpsId: string,
): TopologyFlowNode | undefined {
return nodes.find((n) => n.type === 'vps' && isVpsNodeData(n.data) && n.data.vpsId === vpsId)
}
/** Ставит сервис CFDM как существующую dashed-группу с VPS-детьми. */
export function placeCfdmServiceGroup(
nodes: TopologyFlowNode[],
service: CfdmTopologyService,
origin: { x: number; y: number },
): PlaceCfdmServiceResult {
const already = nodes.some(
(n) =>
n.type === 'group' &&
isGroupNodeData(n.data) &&
n.data.cfdmServiceId === service.serviceId,
)
if (already) {
return { nodes, skippedVpsIds: [], 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',
position: origin,
style: { width, height },
width,
height,
data: {
label: service.name,
cfdmServiceId: service.serviceId,
lbMode: service.lbMode,
},
zIndex: -1,
}
let next = [...nodes, group]
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,
}
const attached = attachNodeToGroup(detached, group, next)
next = next.map((n) => (n.id === existing.id ? attached : n))
return
}
const draft: TopologyFlowNode = {
id: newNodeId('vps'),
type: 'vps',
position: abs,
data: { vpsId },
}
next = [...next, attachNodeToGroup(draft, group, next)]
})
return {
nodes: normalizeGroupLayers(sortParentsFirst(next)),
skippedVpsIds,
alreadyOnCanvas: false,
}
}
export function placeCfdmServices(
nodes: TopologyFlowNode[],
services: CfdmTopologyService[],
origin: { x: number; y: number },
): { nodes: TopologyFlowNode[]; skippedVpsIds: string[]; alreadyIds: number[] } {
let next = nodes
const skippedVpsIds: string[] = []
const alreadyIds: number[] = []
services.forEach((service, i) => {
const result = placeCfdmServiceGroup(next, service, {
x: origin.x,
y: origin.y + i * 220,
})
next = result.nodes
skippedVpsIds.push(...result.skippedVpsIds)
if (result.alreadyOnCanvas) alreadyIds.push(service.serviceId)
})
return { nodes: next, skippedVpsIds, alreadyIds }
}
@@ -39,6 +39,8 @@ 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 {
normalizeGroupLayers,
placeWithOptionalParent,
@@ -219,6 +221,16 @@ function TopologyEditorInner({
return ids
}, [nodes])
const existingCfdmServiceIds = useMemo(() => {
const ids = new Set<number>()
for (const n of nodes) {
if (n.type === 'group' && isGroupNodeData(n.data) && n.data.cfdmServiceId != null) {
ids.add(n.data.cfdmServiceId)
}
}
return ids
}, [nodes])
function placeNode(item: PaletteItem, position: { x: number; y: number }) {
if (item.kind === 'vps-picker') {
setAddVpsOpen(true)
@@ -294,6 +306,23 @@ function TopologyEditorInner({
})
}
function handleAddCfdmServices(services: CfdmTopologyService[]) {
const origin = screenToFlowPosition({
x: (wrapperRef.current?.clientWidth ?? 400) / 2 + 80,
y: (wrapperRef.current?.clientHeight ?? 300) / 2,
})
const result = placeCfdmServices(nodes, services, origin)
if (result.alreadyIds.length > 0) {
toast.message('Некоторые сервисы уже на схеме')
}
if (result.skippedVpsIds.length > 0) {
toast.warning(
`Не перенесены серверы из другой группы CFDM: ${result.skippedVpsIds.length}`,
)
}
setNodes(result.nodes)
}
function placeItemAtCenter(item: PaletteItem) {
if (locked) return
const rect = wrapperRef.current?.getBoundingClientRect()
@@ -448,7 +477,9 @@ function TopologyEditorInner({
open={addVpsOpen}
onOpenChange={setAddVpsOpen}
existingVpsIds={existingVpsIds}
existingCfdmServiceIds={existingCfdmServiceIds}
onAdd={handleAddVps}
onAddServices={handleAddCfdmServices}
/>
<VpsDetailSheet
vpsId={detailVpsId}
+33
View File
@@ -1,7 +1,10 @@
import type { Edge, Node } from '@xyflow/react'
import type { TopologyLbMode } from '@cfdm/shared/contracts/integration-cfdm'
import type { Vps } from '@/types/entities'
import { uid } from '@/lib/format'
export type { TopologyLbMode }
export type TopologyNodeType = 'vps' | 'shape' | 'note' | 'group'
export type ShapeKind = 'rect' | 'ellipse' | 'diamond'
@@ -23,6 +26,8 @@ export type NoteNodeData = {
export type GroupNodeData = {
label: string
notes?: string
cfdmServiceId?: number
lbMode?: TopologyLbMode
}
export type TopologyNodeData =
@@ -154,3 +159,31 @@ export function newNodeId(prefix: string): string {
// uid() falls back when crypto.randomUUID unavailable (HTTP non-localhost)
return `${prefix}-${uid()}`
}
export function isTopologyLbMode(value: unknown): value is TopologyLbMode {
return value === 'round_robin' || value === 'failover' || value === 'weighted'
}
export function lbModeLabel(mode: TopologyLbMode): string {
switch (mode) {
case 'failover':
return 'Failover'
case 'round_robin':
return 'Round robin'
case 'weighted':
return 'Weighted'
}
}
export function lbModeBadgeVariant(
mode: TopologyLbMode,
): 'warning' | 'success' | 'info' {
switch (mode) {
case 'failover':
return 'warning'
case 'round_robin':
return 'success'
case 'weighted':
return 'info'
}
}
+1
View File
@@ -154,6 +154,7 @@ export interface VpsDomain {
source: string
matchStatus: 'matched' | 'unmatched' | 'orphaned'
targetIps?: string | null
lbMode?: 'round_robin' | 'failover' | 'weighted' | null
syncedAt: string
}
@@ -49,6 +49,39 @@ describe('vpsDomainsRepository', () => {
expect(domains).toHaveLength(1)
expect(domains[0]?.fqdn).toBe('vpn.example.com')
expect(domains[0]?.matchStatus).toBe('matched')
expect(domains[0]?.lbMode).toBeNull()
})
it('сохраняет lbMode из binding', () => {
const vps = vpsRepository.create({
ip: '203.0.113.10',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const created = Array.isArray(vps) ? vps[0]! : vps
vpsDomainsRepository.syncBindings([
{
bindingId: 1,
serviceId: 10,
serviceName: 'VPN Node',
serviceSlug: 'vpn-node',
fqdn: 'vpn.example.com',
zoneName: 'example.com',
hostname: 'vpn',
ips: ['203.0.113.10'],
lbMode: 'failover',
},
])
const domains = vpsDomainsRepository.listByVpsId(created.id)
expect(domains[0]?.lbMode).toBe('failover')
})
it('помечает unmatched без совпадения IP', () => {
@@ -63,6 +63,7 @@ type BindingFields = {
cfdmBindingId: number
source: 'cfdm'
targetIps: string
lbMode: string | null
syncedAt: string
}
@@ -239,6 +240,7 @@ export const vpsDomainsRepository = {
cfdmBindingId: seed.cfdmBindingId,
source: 'cfdm',
targetIps: seed.targetIps ?? JSON.stringify(storedIps),
lbMode: seed.lbMode ?? null,
syncedAt: now,
}
const result = reconcileBindingToVpsIds(db, group, fields, wanted, emptyStatus)
@@ -282,6 +284,7 @@ export const vpsDomainsRepository = {
cfdmBindingId: item.bindingId,
source: 'cfdm',
targetIps: JSON.stringify(item.ips.filter(isIpLiteral)),
lbMode: item.lbMode ?? null,
syncedAt: now,
})
+9 -2
View File
@@ -262,6 +262,7 @@ const CORE_TABLE_MIGRATIONS: string[] = [
source TEXT NOT NULL DEFAULT 'cfdm',
matchStatus TEXT NOT NULL DEFAULT 'unmatched',
targetIps TEXT,
lbMode TEXT,
syncedAt TEXT NOT NULL,
UNIQUE(cfdmBindingId, vpsId)
)`,
@@ -376,6 +377,7 @@ const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE settings ADD COLUMN notifyLowBalanceEnabled INTEGER`,
`ALTER TABLE settings ADD COLUMN notifySyncDigestEnabled INTEGER`,
`ALTER TABLE vps_domains ADD COLUMN targetIps TEXT`,
`ALTER TABLE vps_domains ADD COLUMN lbMode TEXT`,
`ALTER TABLE providers ADD COLUMN spaceId TEXT`,
`ALTER TABLE provider_accounts ADD COLUMN spaceId TEXT`,
`ALTER TABLE provider_accounts ADD COLUMN balance_api REAL`,
@@ -473,6 +475,10 @@ function rebuildVpsDomainsBindingUnique(sqlite: Database.Database): void {
return
}
const colRows = sqlite.prepare(`PRAGMA table_info(vps_domains)`).all() as { name: string }[]
const hasLbMode = colRows.some((c) => c.name === 'lbMode')
const lbSelect = hasLbMode ? 'lbMode' : 'NULL'
sqlite.exec('PRAGMA foreign_keys = OFF')
sqlite.exec('BEGIN')
try {
@@ -490,15 +496,16 @@ function rebuildVpsDomainsBindingUnique(sqlite: Database.Database): void {
source TEXT NOT NULL DEFAULT 'cfdm',
matchStatus TEXT NOT NULL DEFAULT 'unmatched',
targetIps TEXT,
lbMode TEXT,
syncedAt TEXT NOT NULL,
UNIQUE(cfdmBindingId, vpsId)
)`)
sqlite.exec(`INSERT INTO vps_domains_new (
id, spaceId, vpsId, fqdn, zoneName, hostname, serviceName, serviceSlug,
cfdmServiceId, cfdmBindingId, source, matchStatus, targetIps, syncedAt
cfdmServiceId, cfdmBindingId, source, matchStatus, targetIps, lbMode, syncedAt
) SELECT
id, COALESCE(spaceId, 'space-main'), vpsId, fqdn, zoneName, hostname, serviceName, serviceSlug,
cfdmServiceId, cfdmBindingId, source, matchStatus, targetIps, syncedAt
cfdmServiceId, cfdmBindingId, source, matchStatus, targetIps, ${lbSelect}, syncedAt
FROM vps_domains`)
sqlite.exec('DROP TABLE vps_domains')
sqlite.exec('ALTER TABLE vps_domains_new RENAME TO vps_domains')
+1
View File
@@ -232,6 +232,7 @@ export const vpsDomains = sqliteTable(
source: text('source').notNull().default('cfdm'),
matchStatus: text('matchStatus').notNull().default('unmatched'),
targetIps: text('targetIps'),
lbMode: text('lbMode'),
syncedAt: text('syncedAt').notNull(),
},
(t) => ({
@@ -1,5 +1,8 @@
import { z } from 'zod'
export const topologyLbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
export type TopologyLbMode = z.infer<typeof topologyLbModeSchema>
export const cfdmBindingSyncItemSchema = z.object({
bindingId: z.number().int().positive(),
serviceId: z.number().int().positive(),
@@ -11,6 +14,8 @@ export const cfdmBindingSyncItemSchema = z.object({
ips: z.array(z.string()),
/** CNAME-цель (FQDN), если binding — CNAME; для матчинга по dns / цепочке. */
cnameTarget: z.string().optional(),
/** HA-режим binding (fallback — service group). Optional для старых payload. */
lbMode: topologyLbModeSchema.optional(),
deleted: z.boolean().optional(),
})