Сервис ставится существующей группой с Badge типа резервирования; sync сохраняет lbMode. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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,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 (
|
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={() => {
|
||||||
onAdd([...selected])
|
if (tab === 'cfdm') {
|
||||||
setSelected(new Set())
|
const picked = services.filter((s) => selectedServices.has(s.serviceId))
|
||||||
setQ('')
|
onAddServices(picked)
|
||||||
|
} else {
|
||||||
|
onAdd([...selected])
|
||||||
|
}
|
||||||
|
reset()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<CountedLineTabs
|
||||||
placeholder="Поиск по IP, DNS, проекту…"
|
tabs={[
|
||||||
value={q}
|
{ id: 'vps', label: 'VPS', count: list.length },
|
||||||
onChange={(e) => setQ(e.target.value)}
|
{ id: 'cfdm', label: 'Сервисы CFDM', count: services.length },
|
||||||
/>
|
]}
|
||||||
<div className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto">
|
value={tab}
|
||||||
{list.length === 0 ? (
|
onValueChange={setTab}
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих VPS</p>
|
className="flex flex-col gap-3"
|
||||||
) : (
|
>
|
||||||
list.map((v) => {
|
<Input
|
||||||
const already = existingVpsIds.has(v.id)
|
placeholder={
|
||||||
const checked = selected.has(v.id)
|
tab === 'cfdm' ? 'Поиск по сервису, FQDN…' : 'Поиск по IP, DNS, проекту…'
|
||||||
return (
|
}
|
||||||
<button
|
value={q}
|
||||||
key={v.id}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
type="button"
|
/>
|
||||||
disabled={already}
|
|
||||||
onClick={() => toggle(v.id)}
|
<TabsContent value="vps" className="mt-0 flex flex-col gap-1">
|
||||||
className="flex flex-col gap-0.5 rounded-md border border-transparent px-2 py-2 text-left hover:bg-muted disabled:opacity-50"
|
<div className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto">
|
||||||
data-selected={checked || undefined}
|
{list.length === 0 ? (
|
||||||
>
|
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих VPS</p>
|
||||||
<div className="flex items-center justify-between gap-2">
|
) : (
|
||||||
<span className="truncate text-sm font-medium">
|
list.map((v) => {
|
||||||
{v.dns || v.ip}
|
const already = existingVpsIds.has(v.id)
|
||||||
</span>
|
const checked = selected.has(v.id)
|
||||||
{already ? (
|
return (
|
||||||
<span className="text-[10px] text-muted-foreground">уже на схеме</span>
|
<button
|
||||||
) : (
|
key={v.id}
|
||||||
<span
|
type="button"
|
||||||
className={
|
disabled={already}
|
||||||
checked
|
onClick={() => toggle(v.id)}
|
||||||
? 'size-2 rounded-full bg-primary'
|
className="flex flex-col gap-0.5 rounded-md border border-transparent px-2 py-2 text-left hover:bg-muted disabled:opacity-50"
|
||||||
: 'size-2 rounded-full border border-border'
|
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>
|
||||||
</div>
|
{already ? (
|
||||||
<span className="font-mono text-[11px] text-muted-foreground">{v.ip}</span>
|
<span className="text-[10px] text-muted-foreground">уже на схеме</span>
|
||||||
<span className="text-[11px] text-muted-foreground">{vpsSpecsLine(v)}</span>
|
) : (
|
||||||
</button>
|
<span
|
||||||
)
|
className={
|
||||||
})
|
checked
|
||||||
)}
|
? 'size-2 rounded-full bg-primary'
|
||||||
</div>
|
: 'size-2 rounded-full border border-border'
|
||||||
{selected.size > 0 ? (
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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
|
<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,8 +14,15 @@ 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">
|
||||||
{data.label || 'Группа'}
|
<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>
|
</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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user