Сервис ставится существующей группой с Badge типа резервирования; sync сохраняет lbMode. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user