Introduced a new `verify_tls` boolean option in health check configurations across various services, allowing users to specify whether to validate TLS certificates during health checks. Updated related components and services to accommodate this new option, ensuring proper handling in both the backend and frontend. Enhanced tests to validate the new functionality and ensure correct behavior with different configurations.
682 lines
25 KiB
TypeScript
682 lines
25 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react'
|
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
|
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 {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@cfdm/ui/components/sheet'
|
|
import { Button } from '@cfdm/ui/components/button'
|
|
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
|
import { Input } from '@cfdm/ui/components/input'
|
|
import {
|
|
Item,
|
|
ItemContent,
|
|
ItemGroup,
|
|
} from '@cfdm/ui/components/item'
|
|
import { LoadingButton } from '@/components/loading-button'
|
|
import { TabsContent } from '@cfdm/ui/components/tabs'
|
|
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
|
|
verify_tls: boolean
|
|
}
|
|
|
|
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,
|
|
verify_tls: false,
|
|
}
|
|
|
|
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 === 'http' ? 'http' : 'tcp',
|
|
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,
|
|
verify_tls: binding.health_check_verify_tls ?? false,
|
|
},
|
|
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,
|
|
health_check_verify_tls: binding.health.verify_tls,
|
|
}
|
|
: {
|
|
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,
|
|
health_check_verify_tls: binding.health.verify_tls,
|
|
},
|
|
)
|
|
}
|
|
|
|
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 [activeTab, setActiveTab] = useState('general')
|
|
|
|
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
|
|
setActiveTab('general')
|
|
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,
|
|
verify_tls: next.verify_tls,
|
|
},
|
|
}
|
|
: 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">
|
|
<CountedLineTabs
|
|
tabs={[
|
|
{ id: 'general', label: 'Основное' },
|
|
{
|
|
id: 'bindings',
|
|
label: 'Привязки',
|
|
count: bindings.length > 0 ? bindings.length : undefined,
|
|
},
|
|
]}
|
|
value={activeTab}
|
|
onValueChange={setActiveTab}
|
|
className="flex w-full flex-col gap-4"
|
|
listClassName="mb-0 w-full"
|
|
>
|
|
<TabsContent value="general" className="flex flex-col gap-4">
|
|
<FieldGroup className="flex flex-col gap-4">
|
|
<Field>
|
|
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
|
<Input
|
|
id="edit-service-name"
|
|
value={name}
|
|
placeholder={isCreate ? 'VPN Panel' : undefined}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="edit-service-slug">Slug</FieldLabel>
|
|
<Input
|
|
id="edit-service-slug"
|
|
value={slug}
|
|
placeholder={isCreate ? 'vpn-panel' : undefined}
|
|
onChange={(e) => setSlug(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
|
<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>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
|
<TaggedInput
|
|
id="edit-service-ips"
|
|
value={ips}
|
|
onChange={setIps}
|
|
placeholder="192.168.1.1"
|
|
validate={isValidIpv4}
|
|
/>
|
|
</Field>
|
|
</FieldGroup>
|
|
|
|
{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">
|
|
<Field>
|
|
<FieldLabel htmlFor="service-lb-weight">Вес</FieldLabel>
|
|
<Input
|
|
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))
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="service-lb-priority">Приоритет</FieldLabel>
|
|
<Input
|
|
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))
|
|
}
|
|
/>
|
|
</Field>
|
|
</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>
|
|
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
|
<PlusIcon data-icon="inline-start" />
|
|
Добавить
|
|
</Button>
|
|
</div>
|
|
|
|
{bindings.length === 0 ? (
|
|
<EmptyState
|
|
icon={Link2Icon}
|
|
title="Нет привязок"
|
|
description="Необязательно. Пример: newdom.ivx.su — зона ivx.su определится автоматически."
|
|
centered={false}
|
|
action={
|
|
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
|
<PlusIcon data-icon="inline-start" />
|
|
Добавить привязку
|
|
</Button>
|
|
}
|
|
/>
|
|
) : (
|
|
<ItemGroup 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 (
|
|
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
|
|
<ItemContent className="w-full flex flex-col gap-3">
|
|
<div className="flex items-end gap-2">
|
|
<Field className="min-w-0 flex-1">
|
|
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
|
|
<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}
|
|
/>
|
|
</Field>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="shrink-0"
|
|
aria-label="Удалить привязку"
|
|
onClick={() => handleRemoveBinding(index)}
|
|
>
|
|
<Trash2Icon />
|
|
</Button>
|
|
</div>
|
|
<Field>
|
|
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
|
|
<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>
|
|
</Field>
|
|
{binding.record_type === 'CNAME' ? (
|
|
<Field>
|
|
<FieldLabel htmlFor={`binding-cname-${index}`}>
|
|
CNAME-цель
|
|
</FieldLabel>
|
|
<Input
|
|
id={`binding-cname-${index}`}
|
|
value={binding.target_cname}
|
|
placeholder="mmsk.rkns.top"
|
|
onChange={(event) => handleCnameChange(index, event.target.value)}
|
|
/>
|
|
</Field>
|
|
) : (
|
|
<Field>
|
|
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
|
|
<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)
|
|
}
|
|
/>
|
|
</Field>
|
|
)}
|
|
|
|
{showLbBlock ? (
|
|
<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,
|
|
verify_tls: binding.health.verify_tls,
|
|
}}
|
|
onChange={(next) => handleBindingHealthChange(index, next)}
|
|
lbModeLabel="Режим балансировки"
|
|
showLbMode={
|
|
binding.record_type === 'A' && binding.target_ips.length > 1
|
|
}
|
|
idPrefix={`binding-${index}-health`}
|
|
/>
|
|
) : null}
|
|
</ItemContent>
|
|
</Item>
|
|
)
|
|
})}
|
|
</ItemGroup>
|
|
)}
|
|
</TabsContent>
|
|
</CountedLineTabs>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<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>
|
|
)
|
|
}
|