diff --git a/apps/web/src/components/topology/add-vps-sheet.tsx b/apps/web/src/components/topology/add-vps-sheet.tsx index a559f8f..c826c5d 100644 --- a/apps/web/src/components/topology/add-vps-sheet.tsx +++ b/apps/web/src/components/topology/add-vps-sheet.tsx @@ -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 + existingCfdmServiceIds: Set 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>(new Set()) + const [selectedServices, setSelectedServices] = useState>(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 ( { - 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) }} > - setQ(e.target.value)} - /> -
- {list.length === 0 ? ( -

Нет подходящих VPS

- ) : ( - list.map((v) => { - const already = existingVpsIds.has(v.id) - const checked = selected.has(v.id) - return ( - - ) - }) - )} -
- {selected.size > 0 ? ( + + setQ(e.target.value)} + /> + + +
+ {list.length === 0 ? ( +

Нет подходящих VPS

+ ) : ( + list.map((v) => { + const already = existingVpsIds.has(v.id) + const checked = selected.has(v.id) + return ( + + ) + }) + )} +
+
+ + + {services.length === 0 ? ( + }> + Настройки интеграции + + } + /> + ) : filteredServices.length === 0 ? ( +

Нет подходящих сервисов

+ ) : ( + + {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 ( + toggleService(s.serviceId)} + /> + } + className="w-full text-left disabled:opacity-50" + data-selected={checked || undefined} + > + + + {s.name} + {s.lbMode ? ( + + {lbModeLabel(s.lbMode)} + + ) : null} + + + {s.fqdns.slice(0, 2).join(', ')} + {s.fqdns.length > 2 ? ` +${s.fqdns.length - 2}` : ''} + {' · '} + {s.matchedVpsIds.length} серв. + + + + {already ? ( + уже на схеме + ) : noServers ? ( + нет серверов + ) : ( + + )} + + + ) + })} + + )} +
+
+ + {(tab === 'vps' ? selected.size : selectedServices.size) > 0 ? ( diff --git a/apps/web/src/components/topology/cfdm-services.test.ts b/apps/web/src/components/topology/cfdm-services.test.ts new file mode 100644 index 0000000..f1843a3 --- /dev/null +++ b/apps/web/src/components/topology/cfdm-services.test.ts @@ -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 & Pick): 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']) + }) +}) diff --git a/apps/web/src/components/topology/cfdm-services.ts b/apps/web/src/components/topology/cfdm-services.ts new file mode 100644 index 0000000..6fd5a95 --- /dev/null +++ b/apps/web/src/components/topology/cfdm-services.ts @@ -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 + matchedVpsIds: Set + unmatchedIps: Set + } + >() + + 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')) +} diff --git a/apps/web/src/components/topology/element-edit-sheet.tsx b/apps/web/src/components/topology/element-edit-sheet.tsx index 1e72de6..f837296 100644 --- a/apps/web/src/components/topology/element-edit-sheet.tsx +++ b/apps/web/src/components/topology/element-edit-sheet.tsx @@ -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} /> + {element.data.lbMode ? ( + + + {lbModeLabel(element.data.lbMode)} + + + ) : null}