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 { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input' 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 { FormSheet } from '@/components/form-sheet'
import { snapshotQueryOptions } from '@/queries/snapshot' 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' import type { Vps } from '@/types/entities'
interface AddVpsSheetProps { interface AddVpsSheetProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
existingVpsIds: Set<string> existingVpsIds: Set<string>
existingCfdmServiceIds: Set<number>
onAdd: (vpsIds: string[]) => void onAdd: (vpsIds: string[]) => void
onAddServices: (services: CfdmTopologyService[]) => void
} }
export function AddVpsSheet({ export function AddVpsSheet({
open, open,
onOpenChange, onOpenChange,
existingVpsIds, existingVpsIds,
existingCfdmServiceIds,
onAdd, onAdd,
onAddServices,
}: AddVpsSheetProps) { }: AddVpsSheetProps) {
const { data: snapshot } = useQuery(snapshotQueryOptions()) const { data: snapshot } = useQuery(snapshotQueryOptions())
const [tab, setTab] = useState('vps')
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const [selectedServices, setSelectedServices] = useState<Set<number>>(new Set())
const list = useMemo(() => { const list = useMemo(() => {
const all = (snapshot?.vps ?? []) as Vps[] const all = (snapshot?.vps ?? []) as Vps[]
@@ -38,6 +58,20 @@ export function AddVpsSheet({
.slice(0, 80) .slice(0, 80)
}, [snapshot?.vps, q]) }, [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) { function toggle(id: string) {
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev) const next = new Set(prev)
@@ -47,32 +81,69 @@ 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 ( return (
<FormSheet <FormSheet
open={open} open={open}
onOpenChange={(v) => { onOpenChange={(v) => {
if (!v) { if (!v) reset()
setSelected(new Set())
setQ('')
}
onOpenChange(v) onOpenChange(v)
}} }}
title="Добавить VPS на схему" title={tab === 'cfdm' ? 'Добавить сервис CFDM' : 'Добавить VPS на схему'}
description="Сервер появится на канве. Позицию и связи можно настроить вручную." description={
submitLabel={`Добавить${selected.size ? ` (${selected.size})` : ''}`} tab === 'cfdm'
submitDisabled={selected.size === 0} ? 'Серверы сервиса появятся в группе. Тип резервирования — из CFDM.'
: 'Сервер появится на канве. Позицию и связи можно настроить вручную.'
}
submitLabel={`Добавить${submitCount ? ` (${submitCount})` : ''}`}
submitDisabled={submitDisabled}
onSubmit={() => { onSubmit={() => {
if (tab === 'cfdm') {
const picked = services.filter((s) => selectedServices.has(s.serviceId))
onAddServices(picked)
} else {
onAdd([...selected]) onAdd([...selected])
setSelected(new Set()) }
setQ('') reset()
onOpenChange(false) onOpenChange(false)
}} }}
>
<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 <Input
placeholder="Поиск по IP, DNS, проекту…" placeholder={
tab === 'cfdm' ? 'Поиск по сервису, FQDN…' : 'Поиск по IP, DNS, проекту…'
}
value={q} value={q}
onChange={(e) => setQ(e.target.value)} 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"> <div className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto">
{list.length === 0 ? ( {list.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих VPS</p> <p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих VPS</p>
@@ -90,9 +161,7 @@ export function AddVpsSheet({
data-selected={checked || undefined} data-selected={checked || undefined}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium"> <span className="truncate text-sm font-medium">{v.dns || v.ip}</span>
{v.dns || v.ip}
</span>
{already ? ( {already ? (
<span className="text-[10px] text-muted-foreground">уже на схеме</span> <span className="text-[10px] text-muted-foreground">уже на схеме</span>
) : ( ) : (
@@ -112,12 +181,92 @@ export function AddVpsSheet({
}) })
)} )}
</div> </div>
{selected.size > 0 ? ( </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 <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setSelected(new Set())} onClick={() => {
setSelected(new Set())
setSelectedServices(new Set())
}}
> >
Сбросить выбор Сбросить выбор
</Button> </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 { FormSheet } from '@/components/form-sheet'
import { FormField } from '@/components/form-field' import { FormField } from '@/components/form-field'
import { SelectField } from '@/components/select-field' import { SelectField } from '@/components/select-field'
import { Badge } from '@/components/reui/badge'
import { import {
SHAPE_KIND_OPTIONS, SHAPE_KIND_OPTIONS,
lbModeBadgeVariant,
lbModeLabel,
type GroupNodeData, type GroupNodeData,
type NoteNodeData, type NoteNodeData,
type ShapeKind, type ShapeKind,
@@ -85,6 +88,8 @@ export function ElementEditSheet({
onSave(element.id, 'group', { onSave(element.id, 'group', {
label: label.trim() || 'Группа', label: label.trim() || 'Группа',
notes: notes.trim() || undefined, notes: notes.trim() || undefined,
cfdmServiceId: element.data.cfdmServiceId,
lbMode: element.data.lbMode,
}) })
} }
onOpenChange(false) onOpenChange(false)
@@ -139,6 +144,13 @@ export function ElementEditSheet({
maxLength={80} maxLength={80}
/> />
</FormField> </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"> <FormField label="Заметки" htmlFor="group-notes">
<Textarea <Textarea
id="group-notes" id="group-notes"
@@ -1,7 +1,8 @@
import { memo } from 'react' import { memo } from 'react'
import { type NodeProps, NodeResizer } from '@xyflow/react' import { type NodeProps, NodeResizer } from '@xyflow/react'
import { cn } from '@cfdm/ui/lib/utils' 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 }) { function GroupNodeComponent({ data, selected }: NodeProps & { data: GroupNodeData }) {
return ( return (
@@ -13,9 +14,16 @@ function GroupNodeComponent({ data, selected }: NodeProps & { data: GroupNodeDat
> >
<NodeResizer minWidth={200} minHeight={120} isVisible={selected} /> <NodeResizer minWidth={200} minHeight={120} isVisible={selected} />
<div className="flex flex-col gap-0.5 px-3 py-2"> <div className="flex flex-col gap-0.5 px-3 py-2">
<div className="text-xs font-medium text-foreground"> <div className="flex items-center gap-1.5">
<div className="min-w-0 truncate text-xs font-medium text-foreground">
{data.label || 'Группа'} {data.label || 'Группа'}
</div> </div>
{data.lbMode ? (
<Badge variant={lbModeBadgeVariant(data.lbMode)} size="xs">
{lbModeLabel(data.lbMode)}
</Badge>
) : null}
</div>
{data.notes ? ( {data.notes ? (
<div className="text-[10px] text-muted-foreground whitespace-pre-wrap"> <div className="text-[10px] text-muted-foreground whitespace-pre-wrap">
{data.notes} {data.notes}
@@ -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 { ElementEditSheet, type EditableElement } from './element-edit-sheet'
import { EdgeEditSheet } from './edge-edit-sheet' import { EdgeEditSheet } from './edge-edit-sheet'
import { applyEdgeVisuals, createConnectedEdge } from './edge-utils' import { applyEdgeVisuals, createConnectedEdge } from './edge-utils'
import type { CfdmTopologyService } from './cfdm-services'
import { placeCfdmServices } from './place-cfdm-service'
import { import {
normalizeGroupLayers, normalizeGroupLayers,
placeWithOptionalParent, placeWithOptionalParent,
@@ -219,6 +221,16 @@ function TopologyEditorInner({
return ids return ids
}, [nodes]) }, [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 }) { function placeNode(item: PaletteItem, position: { x: number; y: number }) {
if (item.kind === 'vps-picker') { if (item.kind === 'vps-picker') {
setAddVpsOpen(true) 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) { function placeItemAtCenter(item: PaletteItem) {
if (locked) return if (locked) return
const rect = wrapperRef.current?.getBoundingClientRect() const rect = wrapperRef.current?.getBoundingClientRect()
@@ -448,7 +477,9 @@ function TopologyEditorInner({
open={addVpsOpen} open={addVpsOpen}
onOpenChange={setAddVpsOpen} onOpenChange={setAddVpsOpen}
existingVpsIds={existingVpsIds} existingVpsIds={existingVpsIds}
existingCfdmServiceIds={existingCfdmServiceIds}
onAdd={handleAddVps} onAdd={handleAddVps}
onAddServices={handleAddCfdmServices}
/> />
<VpsDetailSheet <VpsDetailSheet
vpsId={detailVpsId} vpsId={detailVpsId}
+33
View File
@@ -1,7 +1,10 @@
import type { Edge, Node } from '@xyflow/react' import type { Edge, Node } from '@xyflow/react'
import type { TopologyLbMode } from '@cfdm/shared/contracts/integration-cfdm'
import type { Vps } from '@/types/entities' import type { Vps } from '@/types/entities'
import { uid } from '@/lib/format' import { uid } from '@/lib/format'
export type { TopologyLbMode }
export type TopologyNodeType = 'vps' | 'shape' | 'note' | 'group' export type TopologyNodeType = 'vps' | 'shape' | 'note' | 'group'
export type ShapeKind = 'rect' | 'ellipse' | 'diamond' export type ShapeKind = 'rect' | 'ellipse' | 'diamond'
@@ -23,6 +26,8 @@ export type NoteNodeData = {
export type GroupNodeData = { export type GroupNodeData = {
label: string label: string
notes?: string notes?: string
cfdmServiceId?: number
lbMode?: TopologyLbMode
} }
export type TopologyNodeData = export type TopologyNodeData =
@@ -154,3 +159,31 @@ export function newNodeId(prefix: string): string {
// uid() falls back when crypto.randomUUID unavailable (HTTP non-localhost) // uid() falls back when crypto.randomUUID unavailable (HTTP non-localhost)
return `${prefix}-${uid()}` 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 source: string
matchStatus: 'matched' | 'unmatched' | 'orphaned' matchStatus: 'matched' | 'unmatched' | 'orphaned'
targetIps?: string | null targetIps?: string | null
lbMode?: 'round_robin' | 'failover' | 'weighted' | null
syncedAt: string syncedAt: string
} }
@@ -49,6 +49,39 @@ describe('vpsDomainsRepository', () => {
expect(domains).toHaveLength(1) expect(domains).toHaveLength(1)
expect(domains[0]?.fqdn).toBe('vpn.example.com') expect(domains[0]?.fqdn).toBe('vpn.example.com')
expect(domains[0]?.matchStatus).toBe('matched') 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', () => { it('помечает unmatched без совпадения IP', () => {
@@ -63,6 +63,7 @@ type BindingFields = {
cfdmBindingId: number cfdmBindingId: number
source: 'cfdm' source: 'cfdm'
targetIps: string targetIps: string
lbMode: string | null
syncedAt: string syncedAt: string
} }
@@ -239,6 +240,7 @@ export const vpsDomainsRepository = {
cfdmBindingId: seed.cfdmBindingId, cfdmBindingId: seed.cfdmBindingId,
source: 'cfdm', source: 'cfdm',
targetIps: seed.targetIps ?? JSON.stringify(storedIps), targetIps: seed.targetIps ?? JSON.stringify(storedIps),
lbMode: seed.lbMode ?? null,
syncedAt: now, syncedAt: now,
} }
const result = reconcileBindingToVpsIds(db, group, fields, wanted, emptyStatus) const result = reconcileBindingToVpsIds(db, group, fields, wanted, emptyStatus)
@@ -282,6 +284,7 @@ export const vpsDomainsRepository = {
cfdmBindingId: item.bindingId, cfdmBindingId: item.bindingId,
source: 'cfdm', source: 'cfdm',
targetIps: JSON.stringify(item.ips.filter(isIpLiteral)), targetIps: JSON.stringify(item.ips.filter(isIpLiteral)),
lbMode: item.lbMode ?? null,
syncedAt: now, syncedAt: now,
}) })
+9 -2
View File
@@ -262,6 +262,7 @@ const CORE_TABLE_MIGRATIONS: string[] = [
source TEXT NOT NULL DEFAULT 'cfdm', source TEXT NOT NULL DEFAULT 'cfdm',
matchStatus TEXT NOT NULL DEFAULT 'unmatched', matchStatus TEXT NOT NULL DEFAULT 'unmatched',
targetIps TEXT, targetIps TEXT,
lbMode TEXT,
syncedAt TEXT NOT NULL, syncedAt TEXT NOT NULL,
UNIQUE(cfdmBindingId, vpsId) UNIQUE(cfdmBindingId, vpsId)
)`, )`,
@@ -376,6 +377,7 @@ const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE settings ADD COLUMN notifyLowBalanceEnabled INTEGER`, `ALTER TABLE settings ADD COLUMN notifyLowBalanceEnabled INTEGER`,
`ALTER TABLE settings ADD COLUMN notifySyncDigestEnabled INTEGER`, `ALTER TABLE settings ADD COLUMN notifySyncDigestEnabled INTEGER`,
`ALTER TABLE vps_domains ADD COLUMN targetIps TEXT`, `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 providers ADD COLUMN spaceId TEXT`,
`ALTER TABLE provider_accounts ADD COLUMN spaceId TEXT`, `ALTER TABLE provider_accounts ADD COLUMN spaceId TEXT`,
`ALTER TABLE provider_accounts ADD COLUMN balance_api REAL`, `ALTER TABLE provider_accounts ADD COLUMN balance_api REAL`,
@@ -473,6 +475,10 @@ function rebuildVpsDomainsBindingUnique(sqlite: Database.Database): void {
return 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('PRAGMA foreign_keys = OFF')
sqlite.exec('BEGIN') sqlite.exec('BEGIN')
try { try {
@@ -490,15 +496,16 @@ function rebuildVpsDomainsBindingUnique(sqlite: Database.Database): void {
source TEXT NOT NULL DEFAULT 'cfdm', source TEXT NOT NULL DEFAULT 'cfdm',
matchStatus TEXT NOT NULL DEFAULT 'unmatched', matchStatus TEXT NOT NULL DEFAULT 'unmatched',
targetIps TEXT, targetIps TEXT,
lbMode TEXT,
syncedAt TEXT NOT NULL, syncedAt TEXT NOT NULL,
UNIQUE(cfdmBindingId, vpsId) UNIQUE(cfdmBindingId, vpsId)
)`) )`)
sqlite.exec(`INSERT INTO vps_domains_new ( sqlite.exec(`INSERT INTO vps_domains_new (
id, spaceId, vpsId, fqdn, zoneName, hostname, serviceName, serviceSlug, id, spaceId, vpsId, fqdn, zoneName, hostname, serviceName, serviceSlug,
cfdmServiceId, cfdmBindingId, source, matchStatus, targetIps, syncedAt cfdmServiceId, cfdmBindingId, source, matchStatus, targetIps, lbMode, syncedAt
) SELECT ) SELECT
id, COALESCE(spaceId, 'space-main'), vpsId, fqdn, zoneName, hostname, serviceName, serviceSlug, 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`) FROM vps_domains`)
sqlite.exec('DROP TABLE vps_domains') sqlite.exec('DROP TABLE vps_domains')
sqlite.exec('ALTER TABLE vps_domains_new RENAME TO 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'), source: text('source').notNull().default('cfdm'),
matchStatus: text('matchStatus').notNull().default('unmatched'), matchStatus: text('matchStatus').notNull().default('unmatched'),
targetIps: text('targetIps'), targetIps: text('targetIps'),
lbMode: text('lbMode'),
syncedAt: text('syncedAt').notNull(), syncedAt: text('syncedAt').notNull(),
}, },
(t) => ({ (t) => ({
@@ -1,5 +1,8 @@
import { z } from 'zod' 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({ export const cfdmBindingSyncItemSchema = z.object({
bindingId: z.number().int().positive(), bindingId: z.number().int().positive(),
serviceId: z.number().int().positive(), serviceId: z.number().int().positive(),
@@ -11,6 +14,8 @@ export const cfdmBindingSyncItemSchema = z.object({
ips: z.array(z.string()), ips: z.array(z.string()),
/** CNAME-цель (FQDN), если binding — CNAME; для матчинга по dns / цепочке. */ /** CNAME-цель (FQDN), если binding — CNAME; для матчинга по dns / цепочке. */
cnameTarget: z.string().optional(), cnameTarget: z.string().optional(),
/** HA-режим binding (fallback — service group). Optional для старых payload. */
lbMode: topologyLbModeSchema.optional(),
deleted: z.boolean().optional(), deleted: z.boolean().optional(),
}) })