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
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:
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user