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" "use client"
import { type ReactNode } from "react" export { SettingRow, type SettingRowProps } from "@/components/setting-row"
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}
</>
)
}
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs' import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
@@ -13,6 +14,7 @@ interface CountedLineTabsProps {
onValueChange: (value: string) => void onValueChange: (value: string) => void
className?: string className?: string
listClassName?: string listClassName?: string
children?: ReactNode
} }
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */ /** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
@@ -22,6 +24,7 @@ export function CountedLineTabs({
onValueChange, onValueChange,
className, className,
listClassName, listClassName,
children,
}: CountedLineTabsProps) { }: CountedLineTabsProps) {
return ( return (
<Tabs value={value} onValueChange={onValueChange} className={className}> <Tabs value={value} onValueChange={onValueChange} className={className}>
@@ -41,6 +44,7 @@ export function CountedLineTabs({
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
{children}
</Tabs> </Tabs>
) )
} }
@@ -1,6 +1,14 @@
import { AppFieldGroup } from '@/components/app-field'
import { FormFieldSimple } from '@/components/form-field' import { FormFieldSimple } from '@/components/form-field'
import { AppInput } from '@/components/app-input' 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 { import {
Select, Select,
SelectContent, SelectContent,
@@ -9,7 +17,7 @@ import {
SelectValue, SelectValue,
} from '@cfdm/ui/components/select' } from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch' 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' import { cn } from '@cfdm/ui/lib/utils'
export type LbMode = 'round_robin' | 'failover' | 'weighted' export type LbMode = 'round_robin' | 'failover' | 'weighted'
@@ -50,6 +58,39 @@ interface HealthCheckConfigFieldsProps {
className?: string 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({ export function HealthCheckConfigFields({
value, value,
onChange, onChange,
@@ -64,11 +105,18 @@ export function HealthCheckConfigFields({
} }
const isHttp = value.type === 'http' const isHttp = value.type === 'http'
const rowClass = 'gap-3 px-0 py-3'
return ( return (
<div className={cn('flex flex-col gap-4', className)}> <FieldGroup className={cn('gap-0', className)}>
{showLbMode && ( {showLbMode ? (
<FormFieldSimple label={lbModeLabel} htmlFor={`${idPrefix}-lb-mode`}> <SettingRow
title={lbModeLabel}
description="Как распределять трафик между IP"
labelFor={`${idPrefix}-lb-mode`}
compact
className={rowClass}
>
<Select <Select
value={value.lb_mode} value={value.lb_mode}
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })} onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
@@ -84,29 +132,36 @@ export function HealthCheckConfigFields({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</FormFieldSimple> </SettingRow>
)} ) : null}
<FormFieldSimple label="Health-check" htmlFor={`${idPrefix}-enabled`}> <SettingRow
<div className="flex items-center gap-2"> title="Health-check"
<Switch description="TCP/HTTP проверка цели DNS"
id={`${idPrefix}-enabled`} labelFor={`${idPrefix}-enabled`}
checked={value.enabled} compact
onCheckedChange={(checked) => patch({ enabled: checked })} last={!value.enabled}
/> className={rowClass}
<Label htmlFor={`${idPrefix}-enabled`} className="text-muted-foreground"> titleAddon={
{value.enabled ? 'Включён' : 'Выключен'} <Badge
</Label> variant={value.enabled ? 'success-light' : 'outline'}
</div> size="sm"
</FormFieldSimple> >
{value.enabled ? 'Вкл' : 'Выкл'}
</Badge>
}
>
<Switch
id={`${idPrefix}-enabled`}
checked={value.enabled}
onCheckedChange={(checked) => patch({ enabled: checked })}
/>
</SettingRow>
{value.enabled && ( {value.enabled ? (
<div className="rounded-md border border-border bg-muted/30 p-4"> <div className="flex flex-col gap-3 pt-1 pb-1">
<p className="mb-3 text-sm font-medium text-foreground"> <div className="grid grid-cols-2 gap-3">
Параметры проверки <FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
</p>
<AppFieldGroup>
<FormFieldSimple label="Тип проверки" htmlFor={`${idPrefix}-type`}>
<Select <Select
value={value.type} value={value.type}
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })} onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
@@ -125,23 +180,23 @@ export function HealthCheckConfigFields({
</FormFieldSimple> </FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}> <FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
<AppInput <CompactNumberField
id={`${idPrefix}-port`} id={`${idPrefix}-port`}
type="number" value={value.port}
inputMode="numeric" min={1}
max={65535}
placeholder="80" placeholder="80"
value={value.port ?? ''} onValueChange={(port) => patch({ port })}
onChange={(e) =>
patch({ port: e.target.value === '' ? null : Number(e.target.value) })
}
/> />
</FormFieldSimple> </FormFieldSimple>
</div>
{isHttp && ( {isHttp ? (
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple <FormFieldSimple
label="HTTP path" label="HTTP path"
htmlFor={`${idPrefix}-path`} htmlFor={`${idPrefix}-path`}
hint="Опционально. По умолчанию проверяется /" hint="По умолчанию /"
> >
<AppInput <AppInput
id={`${idPrefix}-path`} id={`${idPrefix}-path`}
@@ -152,64 +207,48 @@ export function HealthCheckConfigFields({
} }
/> />
</FormFieldSimple> </FormFieldSimple>
)}
{isHttp && ( <FormFieldSimple label="HTTP-статус" htmlFor={`${idPrefix}-status`}>
<FormFieldSimple <CompactNumberField
label="Ожидаемый HTTP-статус"
htmlFor={`${idPrefix}-status`}
>
<AppInput
id={`${idPrefix}-status`} id={`${idPrefix}-status`}
type="number" value={value.expected_status}
inputMode="numeric" min={100}
max={599}
placeholder="200" placeholder="200"
value={value.expected_status ?? ''} onValueChange={(expected_status) => patch({ 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),
})
}
/> />
</FormFieldSimple> </FormFieldSimple>
</div> </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}
</div> </FieldGroup>
) )
} }
+63 -92
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react' import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state' import { EmptyState } from '@/components/empty-state'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input' import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input' import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
@@ -27,7 +28,6 @@ import {
} from '@/components/app-field' } from '@/components/app-field'
import { import {
AppItem, AppItem,
AppItemActions,
AppItemContent, AppItemContent,
AppItemGroup, AppItemGroup,
} from '@/components/app-item' } from '@/components/app-item'
@@ -41,18 +41,7 @@ import {
SheetTitle, SheetTitle,
} from '@cfdm/ui/components/sheet' } from '@cfdm/ui/components/sheet'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { import { TabsContent } from '@cfdm/ui/components/tabs'
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@cfdm/ui/components/accordion'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -188,6 +177,7 @@ export function ServiceEditSheet({
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([]) const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const [lbWeight, setLbWeight] = useState(1) const [lbWeight, setLbWeight] = useState(1)
const [lbPriority, setLbPriority] = useState(1) const [lbPriority, setLbPriority] = useState(1)
const [activeTab, setActiveTab] = useState('general')
const groupItems = useMemo( const groupItems = useMemo(
() => [ () => [
@@ -206,6 +196,7 @@ export function ServiceEditSheet({
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setActiveTab('general')
if (mode === 'edit' && service) { if (mode === 'edit' && service) {
setName(service.name) setName(service.name)
setSlug(service.slug) setSlug(service.slug)
@@ -399,25 +390,20 @@ export function ServiceEditSheet({
</SheetHeader> </SheetHeader>
<div className="flex flex-1 flex-col gap-4 px-4 py-4"> <div className="flex flex-1 flex-col gap-4 px-4 py-4">
<Tabs <CountedLineTabs
defaultValue="general" tabs={[
orientation="horizontal" { 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" 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"> <TabsContent value="general" className="flex flex-col gap-4">
<AppFieldGroup className="flex flex-col gap-4"> <AppFieldGroup className="flex flex-col gap-4">
<AppField> <AppField>
@@ -546,20 +532,32 @@ export function ServiceEditSheet({
binding.target_ips.length > 1 && binding.target_ips.length > 1 &&
binding.lb_mode !== 'round_robin' binding.lb_mode !== 'round_robin'
return ( return (
<AppItem key={`binding-${index}`} variant="outline"> <AppItem key={`binding-${index}`} variant="outline" className="items-stretch">
<AppItemContent className="flex flex-col gap-3"> <AppItemContent className="w-full flex flex-col gap-3">
<AppField> <div className="flex items-end gap-2">
<AppFieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</AppFieldLabel> <AppField className="min-w-0 flex-1">
<TaggedInput <AppFieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</AppFieldLabel>
id={`binding-fqdn-${index}`} <TaggedInput
value={binding.fqdn ? [binding.fqdn] : []} id={`binding-fqdn-${index}`}
onChange={(tags) => handleFqdnChange(index, tags)} value={binding.fqdn ? [binding.fqdn] : []}
placeholder={ onChange={(tags) => handleFqdnChange(index, tags)}
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su' placeholder={
} zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
maxItems={1} }
/> maxItems={1}
</AppField> />
</AppField>
<AppButton
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</AppButton>
</div>
<AppField> <AppField>
<AppFieldLabel htmlFor={`binding-type-${index}`}>Тип записи</AppFieldLabel> <AppFieldLabel htmlFor={`binding-type-${index}`}>Тип записи</AppFieldLabel>
<Select <Select
@@ -611,61 +609,34 @@ export function ServiceEditSheet({
</AppField> </AppField>
)} )}
{showLbBlock && ( {showLbBlock ? (
<Accordion defaultValue={[`lb-${index}`]}> <HealthCheckConfigFields
<AccordionItem value={`lb-${index}`}> value={{
<AccordionTrigger> lb_mode: binding.lb_mode,
{binding.record_type === 'CNAME' enabled: binding.health.enabled,
? 'Health-check' type: binding.health.type,
: binding.target_ips.length > 1 port: binding.health.port,
? 'Балансировка и Health-check (multi-A)' path: binding.health.path,
: 'Health-check'} expected_status: binding.health.expected_status,
</AccordionTrigger> interval_sec: binding.health.interval_sec,
<AccordionContent> timeout_ms: binding.health.timeout_ms,
<HealthCheckConfigFields }}
value={{ onChange={(next) => handleBindingHealthChange(index, next)}
lb_mode: binding.lb_mode, lbModeLabel="Режим балансировки"
enabled: binding.health.enabled, showLbMode={
type: binding.health.type, binding.record_type === 'A' && binding.target_ips.length > 1
port: binding.health.port, }
path: binding.health.path, idPrefix={`binding-${index}-health`}
expected_status: binding.health.expected_status, />
interval_sec: binding.health.interval_sec, ) : null}
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> </AppItemContent>
<AppItemActions>
<AppButton
type="button"
variant="ghost"
size="icon-sm"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</AppButton>
</AppItemActions>
</AppItem> </AppItem>
) )
})} })}
</AppItemGroup> </AppItemGroup>
)} )}
</TabsContent> </TabsContent>
</Tabs> </CountedLineTabs>
</div> </div>
<AppSeparator /> <AppSeparator />
@@ -23,12 +23,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@cfdm/ui/components/select' } from '@cfdm/ui/components/select'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@cfdm/ui/components/accordion'
const groupTypes = [ const groupTypes = [
{ value: 'vpn', label: 'VPN' }, { value: 'vpn', label: 'VPN' },
@@ -199,21 +193,14 @@ export function ServiceGroupEditSheet({
</FormFieldSimple> </FormFieldSimple>
</AppFieldGroup> </AppFieldGroup>
{hasDomain && ( {hasDomain ? (
<Accordion defaultValue={['lb-health']}> <HealthCheckConfigFields
<AccordionItem value="lb-health"> value={lbHealth}
<AccordionTrigger>Балансировка и Health-check</AccordionTrigger> onChange={setLbHealth}
<AccordionContent> lbModeLabel="Режим балансировки общего домена"
<HealthCheckConfigFields idPrefix="group-lb-health"
value={lbHealth} />
onChange={setLbHealth} ) : null}
lbModeLabel="Режим балансировки общего домена"
idPrefix="group-lb-health"
/>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</FormSheet> </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}
</>
)
}