Files
cloudflare-domain-manager/apps/web/src/components/service-edit-sheet.tsx
T
DenozordecandCursor 6ceb7ff9c7
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
feat(web): enhance UI components and improve documentation
- 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>
2026-07-17 00:59:07 +07:00

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>
)
}