import { useEffect, useMemo, useState } from 'react' import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react' import { ConfirmDialog } from '@/components/confirm-dialog' import { EmptyState } from '@/components/empty-state' import { TaggedInput, isValidIpv4 } from '@/components/tagged-input' import { ServiceBindingIpInput } from '@/components/service-binding-ip-input' import { HealthCheckConfigFields, type LbAndHealthConfig, type LbMode, type HealthCheckType, } from '@/components/health-check-config-fields' import type { CreateServiceWithConfigInput, DomainListItem, ServiceGroupView, ServiceView, UpdateServiceConfigInput, } from '@/lib/schemas' import { bindingToFqdn } from '@/lib/parse-fqdn' import { AppButton } from '@/components/app-button' import { AppInput } from '@/components/app-input' import { AppField, AppFieldGroup, AppFieldLabel, } from '@/components/app-field' import { AppItem, AppItemActions, AppItemContent, AppItemGroup, } from '@/components/app-item' import { AppSeparator } from '@/components/app-separator' import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from '@cfdm/ui/components/sheet' import { LoadingButton } from '@/components/loading-button' import { Tabs, TabsContent, TabsList, TabsTrigger, } from '@cfdm/ui/components/tabs' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@cfdm/ui/components/accordion' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@cfdm/ui/components/select' import { Separator } from '@cfdm/ui/components/separator' interface BindingHealthConfig { enabled: boolean type: HealthCheckType port: number | null path: string | null expected_status: number | null interval_sec: number timeout_ms: number } export interface ServiceBindingDraft { fqdn: string record_type: 'A' | 'CNAME' target_ips: string[] target_cname: string lb_mode: LbMode health: BindingHealthConfig target_ip_weights: Record target_ip_priorities: Record } const defaultHealth: BindingHealthConfig = { enabled: false, type: 'tcp', port: null, path: null, expected_status: null, interval_sec: 30, timeout_ms: 3000, } interface ServiceEditSheetProps { mode: 'create' | 'edit' service: ServiceView | null groups: ServiceGroupView[] open: boolean knownDomains: DomainListItem[] isSaving: boolean isDeleting?: boolean defaultGroupId?: number | null onOpenChange: (open: boolean) => void onCreate?: (body: CreateServiceWithConfigInput) => void onSave?: (id: number, body: UpdateServiceConfigInput) => void onDelete?: (id: number) => void } function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] { return (service.domains ?? []).map((binding) => ({ fqdn: bindingToFqdn(binding), record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'), target_ips: binding.target_ips ?? [], target_cname: binding.target_cname ?? '', lb_mode: binding.lb_mode, health: { enabled: binding.health_check_enabled, type: binding.health_check_type, port: binding.health_check_port, path: binding.health_check_path, expected_status: binding.health_check_expected_status, interval_sec: binding.health_check_interval_sec, timeout_ms: binding.health_check_timeout_ms, }, target_ip_weights: binding.target_ip_weights ?? {}, target_ip_priorities: binding.target_ip_priorities ?? {}, })) } function buildDomainsPayload(bindings: ServiceBindingDraft[]) { return bindings .filter((binding) => { if (!binding.fqdn.trim()) return false if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim()) return binding.target_ips.length > 0 }) .map((binding) => binding.record_type === 'CNAME' ? { fqdn: binding.fqdn.trim(), target_cname: binding.target_cname.trim(), lb_mode: binding.lb_mode, health_check_enabled: binding.health.enabled, health_check_type: binding.health.type, health_check_port: binding.health.port, health_check_path: binding.health.path, health_check_expected_status: binding.health.expected_status, health_check_interval_sec: binding.health.interval_sec, health_check_timeout_ms: binding.health.timeout_ms, } : { fqdn: binding.fqdn.trim(), target_ips: binding.target_ips, target_ip_weights: binding.target_ip_weights, target_ip_priorities: binding.target_ip_priorities, lb_mode: binding.lb_mode, health_check_enabled: binding.health.enabled, health_check_type: binding.health.type, health_check_port: binding.health.port, health_check_path: binding.health.path, health_check_expected_status: binding.health.expected_status, health_check_interval_sec: binding.health.interval_sec, health_check_timeout_ms: binding.health.timeout_ms, }, ) } export function ServiceEditSheet({ mode, service, groups, open, knownDomains, isSaving, isDeleting = false, defaultGroupId = null, onOpenChange, onCreate, onSave, onDelete, }: ServiceEditSheetProps) { const [name, setName] = useState('') const [slug, setSlug] = useState('') const [serviceGroupId, setServiceGroupId] = useState('none') const [ips, setIps] = useState([]) const [bindings, setBindings] = useState([]) const [lbWeight, setLbWeight] = useState(1) const [lbPriority, setLbPriority] = useState(1) const groupItems = useMemo( () => [ { label: 'Без группы', value: 'none' }, ...groups.map((group) => ({ label: group.name, value: String(group.id) })), ], [groups], ) const selectedGroup = useMemo(() => { if (serviceGroupId === 'none') return null return groups.find((g) => String(g.id) === serviceGroupId) ?? null }, [groups, serviceGroupId]) const groupHasDomain = Boolean(selectedGroup?.domain?.trim()) useEffect(() => { if (!open) return if (mode === 'edit' && service) { setName(service.name) setSlug(service.slug) setServiceGroupId( service.service_group_id != null ? String(service.service_group_id) : 'none', ) setIps(service.ips ?? []) setBindings(toBindingDrafts(service)) setLbWeight(service.lb_weight ?? 1) setLbPriority(service.lb_priority ?? 1) return } if (mode === 'create') { setName('') setSlug('') setServiceGroupId( defaultGroupId != null ? String(defaultGroupId) : 'none', ) setIps([]) setBindings([]) setLbWeight(1) setLbPriority(1) } }, [open, mode, service, defaultGroupId]) const zoneHints = useMemo( () => knownDomains.map((domain) => domain.zone_name), [knownDomains], ) function handleAddBinding() { setBindings((current) => [ ...current, { fqdn: '', record_type: 'A', target_ips: [], target_cname: '', lb_mode: 'round_robin', health: { ...defaultHealth }, target_ip_weights: {}, target_ip_priorities: {}, }, ]) } function handleRemoveBinding(index: number) { setBindings((current) => current.filter((_, i) => i !== index)) } function handleFqdnChange(index: number, tags: string[]) { const fqdn = tags[0] ?? '' setBindings((current) => current.map((item, i) => (i === index ? { ...item, fqdn } : item)), ) } function handleRecordTypeChange(index: number, recordType: 'A' | 'CNAME') { setBindings((current) => current.map((item, i) => i === index ? { ...item, record_type: recordType, target_ips: recordType === 'A' ? item.target_ips : [], target_cname: recordType === 'CNAME' ? item.target_cname : '', } : item, ), ) } function handleCnameChange(index: number, value: string) { setBindings((current) => current.map((item, i) => (i === index ? { ...item, target_cname: value } : item)), ) } function handleIpsChange(index: number, targetIps: string[]) { setBindings((current) => current.map((item, i) => i === index ? { ...item, target_ips: targetIps, target_ip_weights: Object.fromEntries( targetIps.map((ip) => [ip, item.target_ip_weights[ip] ?? 1]), ), target_ip_priorities: Object.fromEntries( targetIps.map((ip) => [ip, item.target_ip_priorities[ip] ?? 1]), ), } : item, ), ) } function handleBindingMetaChange( index: number, ip: string, meta: { weight?: number; priority?: number }, ) { setBindings((current) => current.map((item, i) => { if (i !== index) return item const weights = { ...item.target_ip_weights } const priorities = { ...item.target_ip_priorities } if (meta.weight !== undefined) weights[ip] = meta.weight if (meta.priority !== undefined) priorities[ip] = meta.priority return { ...item, target_ip_weights: weights, target_ip_priorities: priorities } }), ) } function handleBindingHealthChange(index: number, next: LbAndHealthConfig) { setBindings((current) => current.map((item, i) => i === index ? { ...item, lb_mode: next.lb_mode, health: { enabled: next.enabled, type: next.type, port: next.port, path: next.path, expected_status: next.expected_status, interval_sec: next.interval_sec, timeout_ms: next.timeout_ms, }, } : item, ), ) } function resolveServiceGroupId(): number | null { return serviceGroupId === 'none' ? null : Number(serviceGroupId) } function handleSubmit() { const domains = buildDomainsPayload(bindings) const groupId = resolveServiceGroupId() const lbFields = groupHasDomain ? { lb_weight: lbWeight, lb_priority: lbPriority } : {} const configPayload = { ips, ...lbFields, ...(domains.length > 0 ? { domains } : {}), } if (mode === 'create') { onCreate?.({ name: name.trim(), slug: slug.trim(), service_group_id: groupId, ips, ...lbFields, domains, }) return } if (!service) return onSave?.(service.id, { name, slug, service_group_id: groupId, ...configPayload, }) } function handleDelete() { if (!service) return onDelete?.(service.id) } const isCreate = mode === 'create' const canSubmit = isCreate ? name.trim().length > 0 && slug.trim().length > 0 : Boolean(service) return ( {isCreate ? 'Новый сервис' : 'Редактирование сервиса'} Настройте параметры сервиса и привязки FQDN → IP или CNAME. Зона определяется из FQDN автоматически.
Основное Привязки {bindings.length > 0 ? ( {bindings.length} ) : null} Название setName(e.target.value)} /> Slug setSlug(e.target.value)} /> Группа сервисов IP-адреса сервиса {groupHasDomain && ( <>

Балансировка внутри группы «{selectedGroup?.name}»: вес и приоритет сервиса для общего домена группы.

Вес setLbWeight(Math.max(1, Number(e.target.value) || 1)) } /> Приоритет setLbPriority(Math.max(1, Number(e.target.value) || 1)) } />
)}

FQDN → IP или CNAME для DNS-записей Cloudflare

Добавить
{bindings.length === 0 ? ( Добавить привязку } /> ) : ( {bindings.map((binding, index) => { const showLbBlock = (binding.record_type === 'A' && binding.target_ips.length > 0) || (binding.record_type === 'CNAME' && binding.target_cname.trim().length > 0) const showMeta = binding.record_type === 'A' && binding.target_ips.length > 1 && binding.lb_mode !== 'round_robin' return ( FQDN handleFqdnChange(index, tags)} placeholder={ zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su' } maxItems={1} /> Тип записи {binding.record_type === 'CNAME' ? ( CNAME-цель handleCnameChange(index, event.target.value)} /> ) : ( IP handleIpsChange(index, targetIps)} showMeta={showLbBlock && showMeta} weights={binding.target_ip_weights} priorities={binding.target_ip_priorities} onMetaChange={(ip, meta) => handleBindingMetaChange(index, ip, meta) } /> )} {showLbBlock && ( {binding.record_type === 'CNAME' ? 'Health-check' : binding.target_ips.length > 1 ? 'Балансировка и Health-check (multi-A)' : 'Health-check'} handleBindingHealthChange(index, next) } lbModeLabel="Режим балансировки" showLbMode={ binding.record_type === 'A' && binding.target_ips.length > 1 } idPrefix={`binding-${index}-health`} /> )} handleRemoveBinding(index)} > ) })} )}
{!isCreate ? ( Удалить } title="Удалить сервис?" description="Сервис и связанные DNS-привязки будут удалены. Действие необратимо." onConfirm={handleDelete} /> ) : null} {isCreate ? 'Создать' : 'Сохранить'}
) }