feat: Implement load balancing and health check features for service groups, including DNS-based load balancing modes and health check configurations, enhancing service reliability and performance monitoring
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m0s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m13s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Failing after 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m0s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m13s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Failing after 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
This commit is contained in:
@@ -2,9 +2,15 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { TableCard } from '@/components/table-card'
|
||||
import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping'
|
||||
import type { DnsRecord } from '@/lib/schemas'
|
||||
import type { DnsRecord, IpHealthStatus } from '@/lib/schemas'
|
||||
import { AppBadge } from '@/components/app-badge'
|
||||
import { AppButton } from '@/components/app-button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,20 +25,68 @@ interface DnsRecordsTableProps {
|
||||
records: DnsRecord[]
|
||||
onDelete: (recordId: number) => void
|
||||
isDeleting?: boolean
|
||||
healthByIp?: Record<string, IpHealthStatus>
|
||||
}
|
||||
|
||||
const healthDotClass: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'bg-emerald-500',
|
||||
degraded: 'bg-amber-500',
|
||||
down: 'bg-rose-500',
|
||||
unknown: 'bg-muted-foreground/40',
|
||||
}
|
||||
|
||||
const healthLabel: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'OK',
|
||||
degraded: 'Деград.',
|
||||
down: 'Down',
|
||||
unknown: '—',
|
||||
}
|
||||
|
||||
function IpHealthDot({ health }: { health: IpHealthStatus }) {
|
||||
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
|
||||
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
|
||||
if (health.last_checked_at) tooltipParts.push(`Проверка: ${health.last_checked_at}`)
|
||||
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-2 shrink-0 cursor-default rounded-full',
|
||||
healthDotClass[health.status],
|
||||
)}
|
||||
aria-label={`Health: ${healthLabel[health.status]}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{tooltipParts.join('\n')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DnsRecordCells({
|
||||
record,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
healthByIp,
|
||||
}: {
|
||||
record: DnsRecord
|
||||
onDelete: (recordId: number) => void
|
||||
isDeleting?: boolean
|
||||
healthByIp?: Record<string, IpHealthStatus>
|
||||
}) {
|
||||
const health = healthByIp?.[record.content]
|
||||
return (
|
||||
<>
|
||||
<TableCell className="max-w-xs truncate font-mono text-sm">{record.content}</TableCell>
|
||||
<TableCell className="max-w-xs truncate font-mono text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
{health ? <IpHealthDot health={health} /> : null}
|
||||
<span className="truncate">{record.content}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{record.ttl}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={record.sync_status} />
|
||||
@@ -58,17 +112,24 @@ function SingleRecordRow({
|
||||
onDelete,
|
||||
isDeleting,
|
||||
isFirstGroup,
|
||||
healthByIp,
|
||||
}: {
|
||||
record: DnsRecord
|
||||
onDelete: (recordId: number) => void
|
||||
isDeleting?: boolean
|
||||
isFirstGroup: boolean
|
||||
healthByIp?: Record<string, IpHealthStatus>
|
||||
}) {
|
||||
return (
|
||||
<TableRow className={cn(!isFirstGroup && 'border-t-4 border-muted')}>
|
||||
<TableCell>{record.record_type}</TableCell>
|
||||
<TableCell className="font-medium">{record.name}</TableCell>
|
||||
<DnsRecordCells record={record} onDelete={onDelete} isDeleting={isDeleting} />
|
||||
<DnsRecordCells
|
||||
record={record}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
healthByIp={healthByIp}
|
||||
/>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -78,11 +139,13 @@ function MultiValueGroupRows({
|
||||
onDelete,
|
||||
isDeleting,
|
||||
isFirstGroup,
|
||||
healthByIp,
|
||||
}: {
|
||||
group: DnsRecordGroup
|
||||
onDelete: (recordId: number) => void
|
||||
isDeleting?: boolean
|
||||
isFirstGroup: boolean
|
||||
healthByIp?: Record<string, IpHealthStatus>
|
||||
}) {
|
||||
const [first, ...rest] = group.records
|
||||
|
||||
@@ -105,18 +168,33 @@ function MultiValueGroupRows({
|
||||
</AppBadge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<DnsRecordCells record={first} onDelete={onDelete} isDeleting={isDeleting} />
|
||||
<DnsRecordCells
|
||||
record={first}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
healthByIp={healthByIp}
|
||||
/>
|
||||
</TableRow>
|
||||
{rest.map((record) => (
|
||||
<TableRow key={record.id} className="bg-muted/25">
|
||||
<DnsRecordCells record={record} onDelete={onDelete} isDeleting={isDeleting} />
|
||||
<DnsRecordCells
|
||||
record={record}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
healthByIp={healthByIp}
|
||||
/>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTableProps) {
|
||||
export function DnsRecordsTable({
|
||||
records,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
healthByIp,
|
||||
}: DnsRecordsTableProps) {
|
||||
const groups = groupDnsRecords(records)
|
||||
|
||||
return (
|
||||
@@ -133,28 +211,30 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab
|
||||
<TableHead className="w-28" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group, index) =>
|
||||
group.isMultiValue ? (
|
||||
<MultiValueGroupRows
|
||||
key={group.key}
|
||||
group={group}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
isFirstGroup={index === 0}
|
||||
/>
|
||||
) : (
|
||||
<SingleRecordRow
|
||||
key={group.records[0].id}
|
||||
record={group.records[0]}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
isFirstGroup={index === 0}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TableBody>
|
||||
{groups.map((group, index) =>
|
||||
group.isMultiValue ? (
|
||||
<MultiValueGroupRows
|
||||
key={group.key}
|
||||
group={group}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
isFirstGroup={index === 0}
|
||||
healthByIp={healthByIp}
|
||||
/>
|
||||
) : (
|
||||
<SingleRecordRow
|
||||
key={group.records[0].id}
|
||||
record={group.records[0]}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
isFirstGroup={index === 0}
|
||||
healthByIp={healthByIp}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCard>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Link2Icon } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { groupBindingsByHostname } from '@/lib/domain-ips'
|
||||
import type { ServiceBinding } from '@/lib/schemas'
|
||||
import { useHealthRows } from '@/lib/use-aggregated-health'
|
||||
import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas'
|
||||
import { AppBadge } from '@/components/app-badge'
|
||||
import { AppButton } from '@/components/app-button'
|
||||
import {
|
||||
@@ -25,13 +26,84 @@ import {
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface DomainBindingsCardProps {
|
||||
bindings: ServiceBinding[]
|
||||
}
|
||||
|
||||
const healthDotClass: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'bg-emerald-500',
|
||||
degraded: 'bg-amber-500',
|
||||
down: 'bg-rose-500',
|
||||
unknown: 'bg-muted-foreground/40',
|
||||
}
|
||||
|
||||
const healthLabel: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'OK',
|
||||
degraded: 'Деград.',
|
||||
down: 'Down',
|
||||
unknown: '—',
|
||||
}
|
||||
|
||||
function IpHealthDot({ health }: { health: IpHealthStatus }) {
|
||||
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
|
||||
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
|
||||
if (health.last_checked_at) tooltipParts.push(`Проверка: ${health.last_checked_at}`)
|
||||
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-2 shrink-0 cursor-default rounded-full',
|
||||
healthDotClass[health.status],
|
||||
)}
|
||||
aria-label={`Health: ${healthLabel[health.status]}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{tooltipParts.join('\n')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function HostnameIpsHealth({
|
||||
bindings,
|
||||
ips,
|
||||
}: {
|
||||
bindings: ServiceBinding[]
|
||||
ips: string[]
|
||||
}) {
|
||||
const binding = bindings.find((b) => b.health_check_enabled)
|
||||
const { data: healthRows } = useHealthRows(
|
||||
'binding',
|
||||
binding?.id,
|
||||
Boolean(binding),
|
||||
)
|
||||
if (!binding || !healthRows) return <span>{ips.join(', ')}</span>
|
||||
const byIp = new Map(healthRows.map((r) => [r.ip, r] as const))
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{ips.map((ip) => {
|
||||
const row = byIp.get(ip)
|
||||
return (
|
||||
<span key={ip} className="inline-flex items-center gap-1 tabular-nums">
|
||||
{row ? <IpHealthDot health={row} /> : null}
|
||||
{ip}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function uniqueServices(bindings: ServiceBinding[]): string[] {
|
||||
return [...new Set(bindings.map((b) => b.service_name))]
|
||||
}
|
||||
@@ -85,8 +157,11 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
||||
<AppBadge key={name}>{name}</AppBadge>
|
||||
))}
|
||||
{uniqueIps.length > 0 && (
|
||||
<span className="tabular-nums text-xs text-muted-foreground">
|
||||
{uniqueIps.join(', ')}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<HostnameIpsHealth
|
||||
bindings={hostnameBindings}
|
||||
ips={uniqueIps}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{[
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Badge, badgeVariants } from '@cfdm/ui/components/badge'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@cfdm/ui/components/tooltip'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { IpHealthStatus } from '@/lib/schemas'
|
||||
|
||||
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
|
||||
|
||||
const healthVariants: Record<IpHealthStatus['status'], BadgeVariant> = {
|
||||
up: 'success',
|
||||
degraded: 'secondary',
|
||||
down: 'destructive',
|
||||
unknown: 'outline',
|
||||
}
|
||||
|
||||
const healthLabels: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'OK',
|
||||
degraded: 'Деград.',
|
||||
down: 'Down',
|
||||
unknown: '—',
|
||||
}
|
||||
|
||||
interface HealthCheckBadgeProps {
|
||||
status: IpHealthStatus['status']
|
||||
latencyMs?: number | null
|
||||
lastCheckedAt?: string | null
|
||||
lastError?: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function HealthCheckBadge({
|
||||
status,
|
||||
latencyMs,
|
||||
lastCheckedAt,
|
||||
lastError,
|
||||
className,
|
||||
}: HealthCheckBadgeProps) {
|
||||
const variant = healthVariants[status]
|
||||
const label = healthLabels[status]
|
||||
|
||||
const tooltipParts: string[] = [`Статус: ${label}`]
|
||||
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
|
||||
if (lastCheckedAt) tooltipParts.push(`Проверка: ${lastCheckedAt}`)
|
||||
if (lastError) tooltipParts.push(`Ошибка: ${lastError}`)
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span tabIndex={0} className="inline-flex cursor-default" />
|
||||
}
|
||||
>
|
||||
<Badge variant={variant} className={cn('gap-1.5', className)}>
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-current opacity-70"
|
||||
aria-hidden
|
||||
/>
|
||||
{label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipParts.join('\n')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { AppFieldGroup } from '@/components/app-field'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { AppInput } from '@/components/app-input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { Label } from '@cfdm/ui/components/label'
|
||||
|
||||
export type LbMode = 'round_robin' | 'failover' | 'weighted'
|
||||
export type HealthCheckType = 'tcp' | 'http'
|
||||
|
||||
export interface HealthCheckConfig {
|
||||
enabled: boolean
|
||||
type: HealthCheckType
|
||||
port: number | null
|
||||
path: string | null
|
||||
expected_status: number | null
|
||||
interval_sec: number
|
||||
timeout_ms: number
|
||||
}
|
||||
|
||||
export interface LbAndHealthConfig extends HealthCheckConfig {
|
||||
lb_mode: LbMode
|
||||
}
|
||||
|
||||
const defaultLbModeOptions = [
|
||||
{ value: 'round_robin', label: 'Round Robin' },
|
||||
{ value: 'failover', label: 'Failover (приоритет)' },
|
||||
{ value: 'weighted', label: 'Weighted (веса)' },
|
||||
]
|
||||
|
||||
const healthCheckTypes = [
|
||||
{ value: 'tcp', label: 'TCP connect' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
] as const
|
||||
|
||||
interface HealthCheckConfigFieldsProps {
|
||||
value: LbAndHealthConfig
|
||||
onChange: (next: LbAndHealthConfig) => void
|
||||
lbModeLabel?: string
|
||||
lbModeOptions?: { value: string; label: string }[]
|
||||
idPrefix?: string
|
||||
showLbMode?: boolean
|
||||
}
|
||||
|
||||
export function HealthCheckConfigFields({
|
||||
value,
|
||||
onChange,
|
||||
lbModeLabel = 'Режим балансировки',
|
||||
lbModeOptions = defaultLbModeOptions,
|
||||
idPrefix = 'health',
|
||||
showLbMode = true,
|
||||
}: HealthCheckConfigFieldsProps) {
|
||||
function patch(next: Partial<LbAndHealthConfig>) {
|
||||
onChange({ ...value, ...next })
|
||||
}
|
||||
|
||||
return (
|
||||
<AppFieldGroup>
|
||||
{showLbMode && (
|
||||
<FormFieldSimple label={lbModeLabel} htmlFor={`${idPrefix}-lb-mode`}>
|
||||
<Select
|
||||
value={value.lb_mode}
|
||||
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-lb-mode`} className="w-full">
|
||||
<SelectValue placeholder="Выберите режим" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lbModeOptions.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormFieldSimple>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<FormFieldSimple label="Тип проверки" htmlFor={`${idPrefix}-type`}>
|
||||
<Select
|
||||
value={value.type}
|
||||
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-type`} className="w-full">
|
||||
<SelectValue placeholder="Тип" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{healthCheckTypes.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormFieldSimple>
|
||||
|
||||
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
||||
<AppInput
|
||||
id={`${idPrefix}-port`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="80"
|
||||
value={value.port ?? ''}
|
||||
onChange={(e) =>
|
||||
patch({ port: e.target.value === '' ? null : Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
|
||||
<FormFieldSimple label="HTTP path (для типа HTTP)" htmlFor={`${idPrefix}-path`}>
|
||||
<AppInput
|
||||
id={`${idPrefix}-path`}
|
||||
placeholder="/health"
|
||||
value={value.path ?? ''}
|
||||
onChange={(e) =>
|
||||
patch({ path: e.target.value === '' ? null : e.target.value })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
|
||||
<FormFieldSimple
|
||||
label="Ожидаемый HTTP-статус"
|
||||
htmlFor={`${idPrefix}-status`}
|
||||
>
|
||||
<AppInput
|
||||
id={`${idPrefix}-status`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
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),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
</AppFieldGroup>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
||||
import { AppButton } from '@/components/app-button'
|
||||
import { AppInput } from '@/components/app-input'
|
||||
import { Label } from '@cfdm/ui/components/label'
|
||||
|
||||
interface ServiceBindingIpInputProps {
|
||||
id?: string
|
||||
@@ -7,6 +9,13 @@ interface ServiceBindingIpInputProps {
|
||||
pool: string[]
|
||||
onChange: (value: string[]) => void
|
||||
disabled?: boolean
|
||||
showMeta?: boolean
|
||||
weights?: Record<string, number>
|
||||
priorities?: Record<string, number>
|
||||
onMetaChange?: (
|
||||
ip: string,
|
||||
meta: { weight?: number; priority?: number },
|
||||
) => void
|
||||
}
|
||||
|
||||
export function ServiceBindingIpInput({
|
||||
@@ -15,6 +24,10 @@ export function ServiceBindingIpInput({
|
||||
pool,
|
||||
onChange,
|
||||
disabled,
|
||||
showMeta = false,
|
||||
weights,
|
||||
priorities,
|
||||
onMetaChange,
|
||||
}: ServiceBindingIpInputProps) {
|
||||
const available = pool.filter((ip) => !value.includes(ip))
|
||||
const isPoolEmpty = pool.length === 0
|
||||
@@ -47,6 +60,62 @@ export function ServiceBindingIpInput({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{showMeta && value.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{value.map((ip) => (
|
||||
<div
|
||||
key={ip}
|
||||
className="grid grid-cols-[1fr_80px_80px] items-center gap-2"
|
||||
>
|
||||
<span className="truncate text-sm font-medium">{ip}</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label
|
||||
htmlFor={`${id}-w-${ip}`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Вес
|
||||
</Label>
|
||||
<AppInput
|
||||
id={`${id}-w-${ip}`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={100}
|
||||
className="h-8"
|
||||
value={weights?.[ip] ?? 1}
|
||||
onChange={(e) =>
|
||||
onMetaChange?.(ip, {
|
||||
weight: Math.max(1, Number(e.target.value) || 1),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label
|
||||
htmlFor={`${id}-p-${ip}`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Приор.
|
||||
</Label>
|
||||
<AppInput
|
||||
id={`${id}-p-${ip}`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={100}
|
||||
className="h-8"
|
||||
value={priorities?.[ip] ?? 1}
|
||||
onChange={(e) =>
|
||||
onMetaChange?.(ip, {
|
||||
priority: Math.max(1, Number(e.target.value) || 1),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ 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,
|
||||
@@ -41,6 +47,12 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@cfdm/ui/components/tabs'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@cfdm/ui/components/accordion'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -48,12 +60,37 @@ import {
|
||||
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 {
|
||||
@@ -77,6 +114,18 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
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 ?? {},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -96,6 +145,16 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
: {
|
||||
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,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -119,6 +178,8 @@ export function ServiceEditSheet({
|
||||
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(
|
||||
() => [
|
||||
@@ -128,6 +189,13 @@ export function ServiceEditSheet({
|
||||
[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) {
|
||||
@@ -138,6 +206,8 @@ export function ServiceEditSheet({
|
||||
)
|
||||
setIps(service.ips ?? [])
|
||||
setBindings(toBindingDrafts(service))
|
||||
setLbWeight(service.lb_weight ?? 1)
|
||||
setLbPriority(service.lb_priority ?? 1)
|
||||
return
|
||||
}
|
||||
if (mode === 'create') {
|
||||
@@ -148,6 +218,8 @@ export function ServiceEditSheet({
|
||||
)
|
||||
setIps([])
|
||||
setBindings([])
|
||||
setLbWeight(1)
|
||||
setLbPriority(1)
|
||||
}
|
||||
}, [open, mode, service, defaultGroupId])
|
||||
|
||||
@@ -159,7 +231,16 @@ export function ServiceEditSheet({
|
||||
function handleAddBinding() {
|
||||
setBindings((current) => [
|
||||
...current,
|
||||
{ fqdn: '', record_type: 'A', target_ips: [], target_cname: '' },
|
||||
{
|
||||
fqdn: '',
|
||||
record_type: 'A',
|
||||
target_ips: [],
|
||||
target_cname: '',
|
||||
lb_mode: 'round_robin',
|
||||
health: { ...defaultHealth },
|
||||
target_ip_weights: {},
|
||||
target_ip_priorities: {},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
@@ -197,7 +278,65 @@ export function ServiceEditSheet({
|
||||
|
||||
function handleIpsChange(index: number, targetIps: string[]) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) => (i === index ? { ...item, target_ips: targetIps } : item)),
|
||||
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 handleBindingLbModeChange(index: number, lbMode: LbMode) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) => (i === index ? { ...item, lb_mode: lbMode } : 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -208,8 +347,12 @@ export function ServiceEditSheet({
|
||||
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') {
|
||||
@@ -218,6 +361,7 @@ export function ServiceEditSheet({
|
||||
slug: slug.trim(),
|
||||
service_group_id: groupId,
|
||||
ips,
|
||||
...lbFields,
|
||||
domains,
|
||||
})
|
||||
return
|
||||
@@ -320,6 +464,48 @@ export function ServiceEditSheet({
|
||||
/>
|
||||
</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">
|
||||
@@ -347,79 +533,144 @@ export function ServiceEditSheet({
|
||||
/>
|
||||
) : (
|
||||
<AppItemGroup className="gap-2">
|
||||
{bindings.map((binding, index) => (
|
||||
<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')
|
||||
}
|
||||
{bindings.map((binding, index) => {
|
||||
const showLbBlock =
|
||||
binding.record_type === 'A' && binding.target_ips.length > 1
|
||||
const showMeta = 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>
|
||||
<AccordionItem value={`lb-${index}`}>
|
||||
<AccordionTrigger>
|
||||
Балансировка и Health-check (multi-A)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<AppFieldGroup>
|
||||
<AppField>
|
||||
<AppFieldLabel htmlFor={`binding-lb-mode-${index}`}>
|
||||
Режим балансировки
|
||||
</AppFieldLabel>
|
||||
<Select
|
||||
value={binding.lb_mode}
|
||||
onValueChange={(value) =>
|
||||
handleBindingLbModeChange(
|
||||
index,
|
||||
(value ?? 'round_robin') as LbMode,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`binding-lb-mode-${index}`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="round_robin">Round Robin</SelectItem>
|
||||
<SelectItem value="failover">Failover (приоритет)</SelectItem>
|
||||
<SelectItem value="weighted">Weighted (веса)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AppField>
|
||||
<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)
|
||||
}
|
||||
showLbMode={false}
|
||||
idPrefix={`binding-${index}-health`}
|
||||
/>
|
||||
</AppFieldGroup>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</AppItemContent>
|
||||
<AppItemActions>
|
||||
<AppButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Удалить привязку"
|
||||
onClick={() => handleRemoveBinding(index)}
|
||||
>
|
||||
<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)}
|
||||
/>
|
||||
</AppField>
|
||||
)}
|
||||
</AppItemContent>
|
||||
<AppItemActions>
|
||||
<AppButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Удалить привязку"
|
||||
onClick={() => handleRemoveBinding(index)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</AppButton>
|
||||
</AppItemActions>
|
||||
</AppItem>
|
||||
))}
|
||||
<Trash2Icon />
|
||||
</AppButton>
|
||||
</AppItemActions>
|
||||
</AppItem>
|
||||
)
|
||||
})}
|
||||
</AppItemGroup>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
@@ -12,6 +12,10 @@ import { FormFieldSimple } from '@/components/form-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { AppFieldGroup } from '@/components/app-field'
|
||||
import { AppInput } from '@/components/app-input'
|
||||
import {
|
||||
HealthCheckConfigFields,
|
||||
type LbAndHealthConfig,
|
||||
} from '@/components/health-check-config-fields'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,6 +23,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@cfdm/ui/components/accordion'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
|
||||
const groupTypes = [
|
||||
{ value: 'vpn', label: 'VPN' },
|
||||
@@ -40,6 +51,17 @@ interface ServiceGroupEditSheetProps {
|
||||
onSave?: (id: number, body: CreateServiceGroupInput) => void
|
||||
}
|
||||
|
||||
const defaultLbHealth: LbAndHealthConfig = {
|
||||
lb_mode: 'round_robin',
|
||||
enabled: false,
|
||||
type: 'tcp',
|
||||
port: null,
|
||||
path: null,
|
||||
expected_status: null,
|
||||
interval_sec: 30,
|
||||
timeout_ms: 3000,
|
||||
}
|
||||
|
||||
export function ServiceGroupEditSheet({
|
||||
mode,
|
||||
group,
|
||||
@@ -51,8 +73,13 @@ export function ServiceGroupEditSheet({
|
||||
}: ServiceGroupEditSheetProps) {
|
||||
const form = useForm<ServiceGroupFormValues>({
|
||||
resolver: zodResolver(createServiceGroupSchema),
|
||||
defaultValues: { name: '', type: 'custom', domain: null },
|
||||
defaultValues: {
|
||||
name: '',
|
||||
type: 'custom',
|
||||
domain: null,
|
||||
},
|
||||
})
|
||||
const [lbHealth, setLbHealth] = useState<LbAndHealthConfig>(defaultLbHealth)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -62,17 +89,39 @@ export function ServiceGroupEditSheet({
|
||||
type: group.type,
|
||||
domain: group.domain ?? null,
|
||||
})
|
||||
setLbHealth({
|
||||
lb_mode: group.lb_mode,
|
||||
enabled: group.health_check_enabled,
|
||||
type: group.health_check_type,
|
||||
port: group.health_check_port,
|
||||
path: group.health_check_path,
|
||||
expected_status: group.health_check_expected_status,
|
||||
interval_sec: group.health_check_interval_sec,
|
||||
timeout_ms: group.health_check_timeout_ms,
|
||||
})
|
||||
} else {
|
||||
form.reset({ name: '', type: 'custom', domain: null })
|
||||
setLbHealth(defaultLbHealth)
|
||||
}
|
||||
}, [open, mode, group, form])
|
||||
|
||||
const domainValue = form.watch('domain')
|
||||
const hasDomain = Boolean(domainValue?.trim())
|
||||
|
||||
function handleSubmit(values: ServiceGroupFormValues) {
|
||||
const body: CreateServiceGroupInput = {
|
||||
name: values.name,
|
||||
type: values.type ?? 'custom',
|
||||
icon: values.icon,
|
||||
domain: values.domain?.trim() || null,
|
||||
lb_mode: lbHealth.lb_mode,
|
||||
health_check_enabled: lbHealth.enabled,
|
||||
health_check_type: lbHealth.type,
|
||||
health_check_port: lbHealth.port,
|
||||
health_check_path: lbHealth.path,
|
||||
health_check_expected_status: lbHealth.expected_status,
|
||||
health_check_interval_sec: lbHealth.interval_sec,
|
||||
health_check_timeout_ms: lbHealth.timeout_ms,
|
||||
}
|
||||
if (mode === 'create') {
|
||||
onCreate?.(body)
|
||||
@@ -150,6 +199,25 @@ export function ServiceGroupEditSheet({
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</AppFieldGroup>
|
||||
|
||||
{hasDomain && (
|
||||
<>
|
||||
<Separator />
|
||||
<Accordion>
|
||||
<AccordionItem value="lb-health">
|
||||
<AccordionTrigger>Балансировка и Health-check</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<HealthCheckConfigFields
|
||||
value={lbHealth}
|
||||
onChange={setLbHealth}
|
||||
lbModeLabel="Режим балансировки общего домена"
|
||||
idPrefix="group-lb-health"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</>
|
||||
)}
|
||||
</FormSheet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
import { ServiceGroupIcon } from '@/components/service-group-icon'
|
||||
import type { BoardColumn } from '@/components/services-board/types'
|
||||
import type { ServiceGroupView } from '@/lib/schemas'
|
||||
import { useAggregatedHealth } from '@/lib/use-aggregated-health'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { AppAccordionTrigger } from '@/components/app-accordion'
|
||||
import { AppBadge } from '@/components/app-badge'
|
||||
import { AppButton } from '@/components/app-button'
|
||||
@@ -50,6 +52,16 @@ export function ServiceGroupHeader({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
}: ServiceGroupHeaderProps) {
|
||||
const groupId = column.groupId
|
||||
const groupDomain = column.domain ?? null
|
||||
const groupHealthEnabled = Boolean(
|
||||
groupId !== null && groupDomain && column.group?.health_check_enabled,
|
||||
)
|
||||
const { data: groupHealth } = useAggregatedHealth(
|
||||
'group',
|
||||
groupId,
|
||||
groupHealthEnabled,
|
||||
)
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-1">
|
||||
{showCheckbox && !dragDisabled && column.items.length > 0 ? (
|
||||
@@ -83,6 +95,14 @@ export function ServiceGroupHeader({
|
||||
{column.domain ? (
|
||||
<AppBadge variant="outline">{column.domain}</AppBadge>
|
||||
) : null}
|
||||
{groupHealthEnabled && groupHealth ? (
|
||||
<HealthCheckBadge
|
||||
status={groupHealth.status}
|
||||
latencyMs={groupHealth.worstLatencyMs}
|
||||
lastCheckedAt={groupHealth.lastCheckedAt}
|
||||
lastError={groupHealth.lastError}
|
||||
/>
|
||||
) : null}
|
||||
{!isOpen && isDragging && !dragDisabled ? (
|
||||
<AppBadge variant="default" className="font-normal">
|
||||
Отпустите для переноса
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVerticalIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { useAggregatedHealth } from '@/lib/use-aggregated-health'
|
||||
import { bindingToFqdn } from '@/lib/parse-fqdn'
|
||||
import {
|
||||
aggregateServiceSyncStatus,
|
||||
@@ -70,6 +72,14 @@ export const ServiceRow = memo(function ServiceRow({
|
||||
const syncStatus = aggregateServiceSyncStatus(service)
|
||||
const fqdn = serviceDisplayFqdn(service)
|
||||
const allFqdns = (service.domains ?? []).map((d) => bindingToFqdn(d))
|
||||
const healthBinding = (service.domains ?? []).find(
|
||||
(d) => d.record_type === 'A' && (d.target_ips?.length ?? 0) > 1 && d.health_check_enabled,
|
||||
)
|
||||
const { data: bindingHealth } = useAggregatedHealth(
|
||||
'binding',
|
||||
healthBinding?.binding_id,
|
||||
Boolean(healthBinding),
|
||||
)
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
@@ -153,6 +163,14 @@ export const ServiceRow = memo(function ServiceRow({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{healthBinding && bindingHealth ? (
|
||||
<HealthCheckBadge
|
||||
status={bindingHealth.status}
|
||||
latencyMs={bindingHealth.worstLatencyMs}
|
||||
lastCheckedAt={bindingHealth.lastCheckedAt}
|
||||
lastError={bindingHealth.lastError}
|
||||
/>
|
||||
) : null}
|
||||
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
|
||||
{isToggling ? (
|
||||
<AppSpinner className="size-4" />
|
||||
|
||||
Reference in New Issue
Block a user