feat(web): enhance CountedLineTabs and HealthCheckConfigFields components
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m31s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m45s
Build, Test, and Push CFDM Docker Image / create-release (push) Skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 5s

- Added children prop to CountedLineTabs for improved flexibility in rendering additional content.
- Introduced CompactNumberField for better handling of numeric inputs in HealthCheckConfigFields, enhancing user experience.
- Refactored ServiceEditSheet and ServiceGroupEditSheet to utilize CountedLineTabs and streamline health check configurations.
- Updated SettingRow component to simplify its export and improve code organization.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-17 13:06:02 +07:00
co-authored by Cursor
parent a02f4f36a7
commit 62be7af0c3
6 changed files with 287 additions and 288 deletions
@@ -1,90 +1,3 @@
"use client"
import { type ReactNode } from "react"
import { cn } from "@cfdm/ui/lib/utils"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from "@cfdm/ui/components/field"
interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
compact?: boolean
stacked?: boolean
labelFor?: string
contentClassName?: string
titleAddon?: ReactNode
}
export function SettingRow({
title,
description,
children,
last,
compact,
stacked,
labelFor,
contentClassName,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field
orientation={stacked ? "vertical" : "responsive"}
className="gap-4 px-5 py-4"
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>
<span className="capitalize">{title}</span>
</FieldLabel>
) : (
<FieldTitle>
<span className="capitalize">{title}</span>
</FieldTitle>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription className="text-sm">
{description}
</FieldDescription>
) : null}
</div>
<FieldContent
className={cn(
"w-full min-w-0 @md/field-group:flex-1",
stacked
? "max-w-none"
: compact
? "@md/field-group:max-w-[17rem] @md/field-group:shrink-0"
: "@md/field-group:max-w-[34rem]",
contentClassName
)}
>
<div
className={cn(
"flex w-full justify-start",
stacked ? "justify-start" : "@md/field-group:justify-end"
)}
>
{children}
</div>
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}
export { SettingRow, type SettingRowProps } from "@/components/setting-row"
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import { cn } from '@cfdm/ui/lib/utils'
@@ -13,6 +14,7 @@ interface CountedLineTabsProps {
onValueChange: (value: string) => void
className?: string
listClassName?: string
children?: ReactNode
}
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
@@ -22,6 +24,7 @@ export function CountedLineTabs({
onValueChange,
className,
listClassName,
children,
}: CountedLineTabsProps) {
return (
<Tabs value={value} onValueChange={onValueChange} className={className}>
@@ -41,6 +44,7 @@ export function CountedLineTabs({
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
)
}
@@ -1,6 +1,14 @@
import { AppFieldGroup } from '@/components/app-field'
import { FormFieldSimple } from '@/components/form-field'
import { AppInput } from '@/components/app-input'
import { SettingRow } from '@/components/setting-row'
import { Badge } from '@/components/reui/badge'
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/reui/number-field'
import {
Select,
SelectContent,
@@ -9,7 +17,7 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch'
import { Label } from '@cfdm/ui/components/label'
import { FieldGroup } from '@cfdm/ui/components/field'
import { cn } from '@cfdm/ui/lib/utils'
export type LbMode = 'round_robin' | 'failover' | 'weighted'
@@ -50,6 +58,39 @@ interface HealthCheckConfigFieldsProps {
className?: string
}
function CompactNumberField({
id,
value,
onValueChange,
min,
max,
placeholder,
}: {
id: string
value: number | null
onValueChange: (next: number | null) => void
min?: number
max?: number
placeholder?: string
}) {
return (
<NumberField
id={id}
size="sm"
value={value ?? undefined}
min={min}
max={max}
onValueChange={(next) => onValueChange(next ?? null)}
>
<NumberFieldGroup className="w-full">
<NumberFieldDecrement />
<NumberFieldInput placeholder={placeholder} />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
)
}
export function HealthCheckConfigFields({
value,
onChange,
@@ -64,11 +105,18 @@ export function HealthCheckConfigFields({
}
const isHttp = value.type === 'http'
const rowClass = 'gap-3 px-0 py-3'
return (
<div className={cn('flex flex-col gap-4', className)}>
{showLbMode && (
<FormFieldSimple label={lbModeLabel} htmlFor={`${idPrefix}-lb-mode`}>
<FieldGroup className={cn('gap-0', className)}>
{showLbMode ? (
<SettingRow
title={lbModeLabel}
description="Как распределять трафик между IP"
labelFor={`${idPrefix}-lb-mode`}
compact
className={rowClass}
>
<Select
value={value.lb_mode}
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
@@ -84,29 +132,36 @@ export function HealthCheckConfigFields({
))}
</SelectContent>
</Select>
</FormFieldSimple>
)}
</SettingRow>
) : null}
<FormFieldSimple label="Health-check" htmlFor={`${idPrefix}-enabled`}>
<div className="flex items-center gap-2">
<Switch
id={`${idPrefix}-enabled`}
checked={value.enabled}
onCheckedChange={(checked) => patch({ enabled: checked })}
/>
<Label htmlFor={`${idPrefix}-enabled`} className="text-muted-foreground">
{value.enabled ? 'Включён' : 'Выключен'}
</Label>
</div>
</FormFieldSimple>
<SettingRow
title="Health-check"
description="TCP/HTTP проверка цели DNS"
labelFor={`${idPrefix}-enabled`}
compact
last={!value.enabled}
className={rowClass}
titleAddon={
<Badge
variant={value.enabled ? 'success-light' : 'outline'}
size="sm"
>
{value.enabled ? 'Вкл' : 'Выкл'}
</Badge>
}
>
<Switch
id={`${idPrefix}-enabled`}
checked={value.enabled}
onCheckedChange={(checked) => patch({ enabled: checked })}
/>
</SettingRow>
{value.enabled && (
<div className="rounded-md border border-border bg-muted/30 p-4">
<p className="mb-3 text-sm font-medium text-foreground">
Параметры проверки
</p>
<AppFieldGroup>
<FormFieldSimple label="Тип проверки" htmlFor={`${idPrefix}-type`}>
{value.enabled ? (
<div className="flex flex-col gap-3 pt-1 pb-1">
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
<Select
value={value.type}
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
@@ -125,23 +180,23 @@ export function HealthCheckConfigFields({
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
<AppInput
<CompactNumberField
id={`${idPrefix}-port`}
type="number"
inputMode="numeric"
value={value.port}
min={1}
max={65535}
placeholder="80"
value={value.port ?? ''}
onChange={(e) =>
patch({ port: e.target.value === '' ? null : Number(e.target.value) })
}
onValueChange={(port) => patch({ port })}
/>
</FormFieldSimple>
</div>
{isHttp && (
{isHttp ? (
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple
label="HTTP path"
htmlFor={`${idPrefix}-path`}
hint="Опционально. По умолчанию проверяется /"
hint="По умолчанию /"
>
<AppInput
id={`${idPrefix}-path`}
@@ -152,64 +207,48 @@ export function HealthCheckConfigFields({
}
/>
</FormFieldSimple>
)}
{isHttp && (
<FormFieldSimple
label="Ожидаемый HTTP-статус"
htmlFor={`${idPrefix}-status`}
>
<AppInput
<FormFieldSimple label="HTTP-статус" htmlFor={`${idPrefix}-status`}>
<CompactNumberField
id={`${idPrefix}-status`}
type="number"
inputMode="numeric"
value={value.expected_status}
min={100}
max={599}
placeholder="200"
value={value.expected_status ?? ''}
onChange={(e) =>
patch({
expected_status:
e.target.value === '' ? null : Number(e.target.value),
})
}
/>
</FormFieldSimple>
)}
<div className="grid grid-cols-2 gap-4">
<FormFieldSimple label="Интервал, сек" htmlFor={`${idPrefix}-interval`}>
<AppInput
id={`${idPrefix}-interval`}
type="number"
inputMode="numeric"
placeholder="30"
value={value.interval_sec}
onChange={(e) =>
patch({
interval_sec:
e.target.value === '' ? 30 : Number(e.target.value),
})
}
/>
</FormFieldSimple>
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
<AppInput
id={`${idPrefix}-timeout`}
type="number"
inputMode="numeric"
placeholder="3000"
value={value.timeout_ms}
onChange={(e) =>
patch({
timeout_ms:
e.target.value === '' ? 3000 : Number(e.target.value),
})
}
onValueChange={(expected_status) => patch({ expected_status })}
/>
</FormFieldSimple>
</div>
</AppFieldGroup>
) : null}
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple label="Интервал, сек" htmlFor={`${idPrefix}-interval`}>
<CompactNumberField
id={`${idPrefix}-interval`}
value={value.interval_sec}
min={5}
max={3600}
placeholder="30"
onValueChange={(next) =>
patch({ interval_sec: next ?? 30 })
}
/>
</FormFieldSimple>
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
<CompactNumberField
id={`${idPrefix}-timeout`}
value={value.timeout_ms}
min={100}
max={30000}
placeholder="3000"
onValueChange={(next) =>
patch({ timeout_ms: next ?? 3000 })
}
/>
</FormFieldSimple>
</div>
</div>
)}
</div>
) : null}
</FieldGroup>
)
}
+63 -92
View File
@@ -1,6 +1,7 @@
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'
@@ -27,7 +28,6 @@ import {
} from '@/components/app-field'
import {
AppItem,
AppItemActions,
AppItemContent,
AppItemGroup,
} from '@/components/app-item'
@@ -41,18 +41,7 @@ import {
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 { TabsContent } from '@cfdm/ui/components/tabs'
import {
Select,
SelectContent,
@@ -188,6 +177,7 @@ export function ServiceEditSheet({
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const [lbWeight, setLbWeight] = useState(1)
const [lbPriority, setLbPriority] = useState(1)
const [activeTab, setActiveTab] = useState('general')
const groupItems = useMemo(
() => [
@@ -206,6 +196,7 @@ export function ServiceEditSheet({
useEffect(() => {
if (!open) return
setActiveTab('general')
if (mode === 'edit' && service) {
setName(service.name)
setSlug(service.slug)
@@ -399,25 +390,20 @@ export function ServiceEditSheet({
</SheetHeader>
<div className="flex flex-1 flex-col gap-4 px-4 py-4">
<Tabs
defaultValue="general"
orientation="horizontal"
<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"
>
<TabsList variant="line" className="mb-3.5 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>
@@ -546,20 +532,32 @@ export function ServiceEditSheet({
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>
<AppItem key={`binding-${index}`} variant="outline" className="items-stretch">
<AppItemContent className="w-full flex flex-col gap-3">
<div className="flex items-end gap-2">
<AppField className="min-w-0 flex-1">
<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>
<AppButton
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</AppButton>
</div>
<AppField>
<AppFieldLabel htmlFor={`binding-type-${index}`}>Тип записи</AppFieldLabel>
<Select
@@ -611,61 +609,34 @@ export function ServiceEditSheet({
</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>
)}
{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,
}}
onChange={(next) => handleBindingHealthChange(index, next)}
lbModeLabel="Режим балансировки"
showLbMode={
binding.record_type === 'A' && binding.target_ips.length > 1
}
idPrefix={`binding-${index}-health`}
/>
) : null}
</AppItemContent>
<AppItemActions>
<AppButton
type="button"
variant="ghost"
size="icon-sm"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</AppButton>
</AppItemActions>
</AppItem>
)
})}
</AppItemGroup>
)}
</TabsContent>
</Tabs>
</CountedLineTabs>
</div>
<AppSeparator />
@@ -23,12 +23,6 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@cfdm/ui/components/accordion'
const groupTypes = [
{ value: 'vpn', label: 'VPN' },
@@ -199,21 +193,14 @@ export function ServiceGroupEditSheet({
</FormFieldSimple>
</AppFieldGroup>
{hasDomain && (
<Accordion defaultValue={['lb-health']}>
<AccordionItem value="lb-health">
<AccordionTrigger>Балансировка и Health-check</AccordionTrigger>
<AccordionContent>
<HealthCheckConfigFields
value={lbHealth}
onChange={setLbHealth}
lbModeLabel="Режим балансировки общего домена"
idPrefix="group-lb-health"
/>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
{hasDomain ? (
<HealthCheckConfigFields
value={lbHealth}
onChange={setLbHealth}
lbModeLabel="Режим балансировки общего домена"
idPrefix="group-lb-health"
/>
) : null}
</FormSheet>
)
}
+85
View File
@@ -0,0 +1,85 @@
import { type ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from '@cfdm/ui/components/field'
export interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
compact?: boolean
stacked?: boolean
labelFor?: string
contentClassName?: string
className?: string
titleAddon?: ReactNode
}
/** Compact settings row (REUI PRO profile-1 / settings-9 pattern). */
export function SettingRow({
title,
description,
children,
last,
compact,
stacked,
labelFor,
contentClassName,
className,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field
orientation={stacked ? 'vertical' : 'responsive'}
className={cn('gap-4 px-5 py-4', className)}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription className="text-sm">{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn(
'w-full min-w-0 @md/field-group:flex-1',
stacked
? 'max-w-none'
: compact
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
: '@md/field-group:max-w-[34rem]',
contentClassName,
)}
>
<div
className={cn(
'flex w-full justify-start',
stacked ? 'justify-start' : '@md/field-group:justify-end',
)}
>
{children}
</div>
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}