Build, Test, and Push CFDM Docker Image / test (push) Failing after 37s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
- Updated frontend UI patterns documentation to reflect new components and their usage. - Enhanced CertKpiCards for better KPI visualization and streamlined data handling. - Refactored OpsDashboard and ResourcePage for improved layout consistency and loading states. - Introduced CatalogBoardToggle for better navigation between views in the Groups and Services pages. - Improved Tabs component styles for better responsiveness and accessibility. Co-authored-by: Cursor <cursoragent@cursor.com>
708 lines
26 KiB
TypeScript
708 lines
26 KiB
TypeScript
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<string, number>
|
|
target_ip_priorities: Record<string, number>
|
|
}
|
|
|
|
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<string[]>([])
|
|
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
|
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 (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-xl">
|
|
<SheetHeader className="border-b pb-4">
|
|
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
|
<SheetDescription>
|
|
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Зона определяется из FQDN
|
|
автоматически.
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<div className="flex flex-1 flex-col gap-4 px-4 py-4">
|
|
<Tabs
|
|
defaultValue="general"
|
|
orientation="horizontal"
|
|
className="flex w-full flex-col gap-4"
|
|
>
|
|
<TabsList variant="line" className="w-full gap-5">
|
|
<TabsTrigger value="general" className="px-0 pb-2">
|
|
Основное
|
|
</TabsTrigger>
|
|
<TabsTrigger value="bindings" className="gap-2 px-0 pb-2">
|
|
Привязки
|
|
{bindings.length > 0 ? (
|
|
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
|
|
{bindings.length}
|
|
</span>
|
|
) : null}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="general" className="flex flex-col gap-4">
|
|
<AppFieldGroup className="flex flex-col gap-4">
|
|
<AppField>
|
|
<AppFieldLabel htmlFor="edit-service-name">Название</AppFieldLabel>
|
|
<AppInput
|
|
id="edit-service-name"
|
|
value={name}
|
|
placeholder={isCreate ? 'VPN Panel' : undefined}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
</AppField>
|
|
<AppField>
|
|
<AppFieldLabel htmlFor="edit-service-slug">Slug</AppFieldLabel>
|
|
<AppInput
|
|
id="edit-service-slug"
|
|
value={slug}
|
|
placeholder={isCreate ? 'vpn-panel' : undefined}
|
|
onChange={(e) => setSlug(e.target.value)}
|
|
/>
|
|
</AppField>
|
|
<AppField>
|
|
<AppFieldLabel htmlFor="edit-service-group">Группа сервисов</AppFieldLabel>
|
|
<Select
|
|
items={groupItems}
|
|
value={serviceGroupId}
|
|
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
|
|
>
|
|
<SelectTrigger id="edit-service-group" className="w-full">
|
|
<SelectValue placeholder="Без группы" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{groupItems.map((item) => (
|
|
<SelectItem key={item.value} value={item.value}>
|
|
{item.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</AppField>
|
|
<AppField>
|
|
<AppFieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</AppFieldLabel>
|
|
<TaggedInput
|
|
id="edit-service-ips"
|
|
value={ips}
|
|
onChange={setIps}
|
|
placeholder="192.168.1.1"
|
|
validate={isValidIpv4}
|
|
/>
|
|
</AppField>
|
|
</AppFieldGroup>
|
|
|
|
{groupHasDomain && (
|
|
<>
|
|
<Separator />
|
|
<div className="flex flex-col gap-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
Балансировка внутри группы «{selectedGroup?.name}»: вес и приоритет
|
|
сервиса для общего домена группы.
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<AppField>
|
|
<AppFieldLabel htmlFor="service-lb-weight">Вес</AppFieldLabel>
|
|
<AppInput
|
|
id="service-lb-weight"
|
|
type="number"
|
|
inputMode="numeric"
|
|
min={1}
|
|
max={100}
|
|
value={lbWeight}
|
|
onChange={(e) =>
|
|
setLbWeight(Math.max(1, Number(e.target.value) || 1))
|
|
}
|
|
/>
|
|
</AppField>
|
|
<AppField>
|
|
<AppFieldLabel htmlFor="service-lb-priority">Приоритет</AppFieldLabel>
|
|
<AppInput
|
|
id="service-lb-priority"
|
|
type="number"
|
|
inputMode="numeric"
|
|
min={1}
|
|
max={100}
|
|
value={lbPriority}
|
|
onChange={(e) =>
|
|
setLbPriority(Math.max(1, Number(e.target.value) || 1))
|
|
}
|
|
/>
|
|
</AppField>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="bindings" className="flex flex-col gap-4">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
FQDN → IP или CNAME для DNS-записей Cloudflare
|
|
</p>
|
|
<AppButton type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
|
<PlusIcon data-icon="inline-start" />
|
|
Добавить
|
|
</AppButton>
|
|
</div>
|
|
|
|
{bindings.length === 0 ? (
|
|
<EmptyState
|
|
icon={Link2Icon}
|
|
title="Нет привязок"
|
|
description="Необязательно. Пример: newdom.ivx.su — зона ivx.su определится автоматически."
|
|
action={
|
|
<AppButton type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
|
<PlusIcon data-icon="inline-start" />
|
|
Добавить привязку
|
|
</AppButton>
|
|
}
|
|
/>
|
|
) : (
|
|
<AppItemGroup className="gap-2">
|
|
{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 (
|
|
<AppItem key={`binding-${index}`} variant="outline">
|
|
<AppItemContent className="flex flex-col gap-3">
|
|
<AppField>
|
|
<AppFieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</AppFieldLabel>
|
|
<TaggedInput
|
|
id={`binding-fqdn-${index}`}
|
|
value={binding.fqdn ? [binding.fqdn] : []}
|
|
onChange={(tags) => handleFqdnChange(index, tags)}
|
|
placeholder={
|
|
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
|
|
}
|
|
maxItems={1}
|
|
/>
|
|
</AppField>
|
|
<AppField>
|
|
<AppFieldLabel htmlFor={`binding-type-${index}`}>Тип записи</AppFieldLabel>
|
|
<Select
|
|
items={[
|
|
{ label: 'A (IP)', value: 'A' },
|
|
{ label: 'CNAME', value: 'CNAME' },
|
|
]}
|
|
value={binding.record_type}
|
|
onValueChange={(value) =>
|
|
handleRecordTypeChange(index, (value ?? 'A') as 'A' | 'CNAME')
|
|
}
|
|
>
|
|
<SelectTrigger id={`binding-type-${index}`} className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="A">A (IP)</SelectItem>
|
|
<SelectItem value="CNAME">CNAME</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</AppField>
|
|
{binding.record_type === 'CNAME' ? (
|
|
<AppField>
|
|
<AppFieldLabel htmlFor={`binding-cname-${index}`}>
|
|
CNAME-цель
|
|
</AppFieldLabel>
|
|
<AppInput
|
|
id={`binding-cname-${index}`}
|
|
value={binding.target_cname}
|
|
placeholder="mmsk.rkns.top"
|
|
onChange={(event) => handleCnameChange(index, event.target.value)}
|
|
/>
|
|
</AppField>
|
|
) : (
|
|
<AppField>
|
|
<AppFieldLabel htmlFor={`binding-ip-${index}`}>IP</AppFieldLabel>
|
|
<ServiceBindingIpInput
|
|
id={`binding-ip-${index}`}
|
|
value={binding.target_ips}
|
|
pool={ips}
|
|
onChange={(targetIps) => handleIpsChange(index, targetIps)}
|
|
showMeta={showLbBlock && showMeta}
|
|
weights={binding.target_ip_weights}
|
|
priorities={binding.target_ip_priorities}
|
|
onMetaChange={(ip, meta) =>
|
|
handleBindingMetaChange(index, ip, meta)
|
|
}
|
|
/>
|
|
</AppField>
|
|
)}
|
|
|
|
{showLbBlock && (
|
|
<Accordion defaultValue={[`lb-${index}`]}>
|
|
<AccordionItem value={`lb-${index}`}>
|
|
<AccordionTrigger>
|
|
{binding.record_type === 'CNAME'
|
|
? 'Health-check'
|
|
: binding.target_ips.length > 1
|
|
? 'Балансировка и Health-check (multi-A)'
|
|
: 'Health-check'}
|
|
</AccordionTrigger>
|
|
<AccordionContent>
|
|
<HealthCheckConfigFields
|
|
value={{
|
|
lb_mode: binding.lb_mode,
|
|
enabled: binding.health.enabled,
|
|
type: binding.health.type,
|
|
port: binding.health.port,
|
|
path: binding.health.path,
|
|
expected_status: binding.health.expected_status,
|
|
interval_sec: binding.health.interval_sec,
|
|
timeout_ms: binding.health.timeout_ms,
|
|
}}
|
|
onChange={(next) =>
|
|
handleBindingHealthChange(index, next)
|
|
}
|
|
lbModeLabel="Режим балансировки"
|
|
showLbMode={
|
|
binding.record_type === 'A' &&
|
|
binding.target_ips.length > 1
|
|
}
|
|
idPrefix={`binding-${index}-health`}
|
|
/>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
)}
|
|
</AppItemContent>
|
|
<AppItemActions>
|
|
<AppButton
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Удалить привязку"
|
|
onClick={() => handleRemoveBinding(index)}
|
|
>
|
|
<Trash2Icon />
|
|
</AppButton>
|
|
</AppItemActions>
|
|
</AppItem>
|
|
)
|
|
})}
|
|
</AppItemGroup>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<AppSeparator />
|
|
|
|
<SheetFooter className="flex flex-row flex-wrap gap-2 border-t-0 pt-4">
|
|
{!isCreate ? (
|
|
<ConfirmDialog
|
|
trigger={
|
|
<LoadingButton
|
|
type="button"
|
|
variant="destructive"
|
|
disabled={!service || isDeleting || isSaving}
|
|
isLoading={isDeleting}
|
|
loadingLabel="Удаление…"
|
|
>
|
|
<Trash2Icon data-icon="inline-start" />
|
|
Удалить
|
|
</LoadingButton>
|
|
}
|
|
title="Удалить сервис?"
|
|
description="Сервис и связанные DNS-привязки будут удалены. Действие необратимо."
|
|
onConfirm={handleDelete}
|
|
/>
|
|
) : null}
|
|
<LoadingButton
|
|
type="button"
|
|
className="ml-auto"
|
|
disabled={!canSubmit || isDeleting}
|
|
isLoading={isSaving}
|
|
loadingLabel="Сохранение…"
|
|
onClick={handleSubmit}
|
|
>
|
|
{isCreate ? 'Создать' : 'Сохранить'}
|
|
</LoadingButton>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
}
|