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

This commit is contained in:
Denozordec
2026-06-25 18:24:00 +07:00
parent e5dc483d43
commit 6a1498bf80
35 changed files with 5312 additions and 281 deletions
+108 -28
View File
@@ -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>
)
}
+325 -74
View File
@@ -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" />
+84 -3
View File
@@ -27,6 +27,14 @@ export const serviceGroupSchema = z.object({
icon: z.string().nullable(),
domain: z.string().nullable(),
enabled: z.boolean(),
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
health_check_enabled: z.boolean().default(false),
health_check_type: z.enum(['tcp', 'http']).catch('tcp'),
health_check_port: z.number().nullable(),
health_check_path: z.string().nullable(),
health_check_expected_status: z.number().nullable(),
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
created_at: z.string(),
updated_at: z.string(),
})
@@ -39,6 +47,8 @@ export const serviceSchema = z.object({
subdomain: z.string().optional(),
enabled: z.boolean().optional(),
computed_fqdn: z.string().nullable().optional(),
lb_weight: z.number().default(1),
lb_priority: z.number().default(1),
created_at: z.string(),
updated_at: z.string(),
})
@@ -53,7 +63,17 @@ export const serviceDomainBindingSchema = z
record_type: z.enum(['A', 'CNAME']).default('A'),
target_ips: z.array(z.string()).optional(),
target_ip: z.string().nullable().optional(),
target_ip_weights: z.record(z.string(), z.number()).optional(),
target_ip_priorities: z.record(z.string(), z.number()).optional(),
target_cname: z.string().nullable().optional(),
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
health_check_enabled: z.boolean().default(false),
health_check_type: z.enum(['tcp', 'http']).catch('tcp'),
health_check_port: z.number().nullable(),
health_check_path: z.string().nullable(),
health_check_expected_status: z.number().nullable(),
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
sync_status: z.string().nullable(),
})
.transform((binding) => ({
@@ -64,6 +84,8 @@ export const serviceDomainBindingSchema = z
: binding.target_ip
? [binding.target_ip]
: [],
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
target_cname: binding.target_cname?.trim() || null,
record_type: binding.target_cname?.trim()
? ('CNAME' as const)
@@ -117,6 +139,16 @@ export const serviceBindingSchema = z
service_slug: z.string(),
target_ip: z.string().nullable(),
target_ips: z.array(z.string()).optional(),
target_ip_weights: z.record(z.string(), z.number()).optional(),
target_ip_priorities: z.record(z.string(), z.number()).optional(),
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
health_check_enabled: z.boolean().default(false),
health_check_type: z.enum(['tcp', 'http']).catch('tcp'),
health_check_port: z.number().nullable(),
health_check_path: z.string().nullable(),
health_check_expected_status: z.number().nullable(),
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
sync_status: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
@@ -129,6 +161,8 @@ export const serviceBindingSchema = z
: binding.target_ip
? [binding.target_ip]
: [],
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
}))
export const dnsRecordSchema = z.object({
@@ -187,11 +221,28 @@ const ipv4Schema = z
'╨Э╨╡╨║╨╛╤А╤А╨╡╨║╤В╨╜╤Л╨╣ IPv4',
)
const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
const healthCheckTypeSchema = z.enum(['tcp', 'http'])
const healthCheckConfigFields = {
health_check_enabled: z.boolean().optional(),
health_check_type: healthCheckTypeSchema.optional(),
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
health_check_path: z.string().nullable().optional(),
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
}
const serviceDomainInputSchema = z
.object({
fqdn: z.string().min(1, 'Укажите FQDN'),
target_ips: z.array(ipv4Schema).optional(),
target_cname: z.string().min(1, 'Укажите CNAME-цель').optional(),
target_ip_weights: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(),
target_ip_priorities: z.record(ipv4Schema, z.number().int().min(1).max(100)).optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields,
})
.superRefine((data, ctx) => {
const hasIps = (data.target_ips?.length ?? 0) > 0
@@ -220,6 +271,8 @@ export const createServiceSchema = z.object({
export const createServiceWithConfigSchema = createServiceSchema.extend({
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).default([]),
lb_weight: z.number().int().min(1).max(100).optional(),
lb_priority: z.number().int().min(1).max(100).optional(),
domains: z.array(serviceDomainInputSchema).default([]),
})
@@ -258,10 +311,12 @@ export type CreateServiceInput = z.infer<typeof createServiceSchema>
export type CreateServiceWithConfigInput = z.infer<typeof createServiceWithConfigSchema>
export const updateServiceConfigSchema = z.object({
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡').optional(),
slug: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ slug').optional(),
name: z.string().min(1, 'name').optional(),
slug: z.string().min(1, 'slug').optional(),
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).optional(),
lb_weight: z.number().int().min(1).max(100).optional(),
lb_priority: z.number().int().min(1).max(100).optional(),
domains: z
.array(serviceDomainInputSchema)
.optional(),
@@ -269,13 +324,39 @@ export const updateServiceConfigSchema = z.object({
export type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>
export const updateServiceGroupSchema = z.object({
name: z.string().min(1, 'name').optional(),
type: serviceGroupTypeSchema.optional(),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields,
})
export type UpdateServiceGroupInput = z.infer<typeof updateServiceGroupSchema>
export const createServiceGroupSchema = z.object({
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
name: z.string().min(1, 'name'),
type: serviceGroupTypeSchema.default('custom'),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
lb_mode: lbModeSchema.optional(),
...healthCheckConfigFields,
})
export const ipHealthStatusSchema = z.object({
scope: z.enum(['binding', 'group']),
ref_id: z.number(),
ip: z.string(),
status: z.enum(['up', 'down', 'degraded', 'unknown']),
latency_ms: z.number().nullable(),
consecutive_failures: z.number(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable(),
})
export type IpHealthStatus = z.infer<typeof ipHealthStatusSchema>
export const toggleEnabledSchema = z.object({
enabled: z.boolean(),
})
+106
View File
@@ -0,0 +1,106 @@
import { useQuery } from '@tanstack/react-query'
import { healthStatusQueryOptions } from '@/queries'
import type { IpHealthStatus } from '@/lib/schemas'
export type HealthScope = 'binding' | 'group'
export type AggregatedHealthStatus =
| 'up'
| 'degraded'
| 'down'
| 'unknown'
const statusRank: Record<AggregatedHealthStatus, number> = {
up: 0,
unknown: 1,
degraded: 2,
down: 3,
}
export interface AggregatedHealth {
status: AggregatedHealthStatus
upCount: number
downCount: number
degradedCount: number
unknownCount: number
total: number
worstLatencyMs: number | null
lastCheckedAt: string | null
lastError: string | null
}
function emptyAggregated(): AggregatedHealth {
return {
status: 'unknown',
upCount: 0,
downCount: 0,
degradedCount: 0,
unknownCount: 0,
total: 0,
worstLatencyMs: null,
lastCheckedAt: null,
lastError: null,
}
}
export function aggregateHealth(rows: IpHealthStatus[]): AggregatedHealth {
if (rows.length === 0) return emptyAggregated()
const counts = { up: 0, degraded: 0, down: 0, unknown: 0 }
let worstLatencyMs: number | null = null
let lastCheckedAt: string | null = null
let lastError: string | null = null
let worstStatus: AggregatedHealthStatus = 'up'
for (const row of rows) {
const status = row.status as AggregatedHealthStatus
counts[status] = (counts[status] ?? 0) + 1
if (statusRank[status] > statusRank[worstStatus]) worstStatus = status
if (row.latency_ms != null) {
if (worstLatencyMs == null || row.latency_ms > worstLatencyMs) {
worstLatencyMs = row.latency_ms
}
}
if (row.last_checked_at) {
if (!lastCheckedAt || row.last_checked_at > lastCheckedAt) {
lastCheckedAt = row.last_checked_at
}
}
if (row.last_error && !lastError) lastError = row.last_error
}
return {
status: worstStatus,
upCount: counts.up,
degradedCount: counts.degraded,
downCount: counts.down,
unknownCount: counts.unknown,
total: rows.length,
worstLatencyMs,
lastCheckedAt,
lastError,
}
}
export function useAggregatedHealth(
scope: HealthScope,
refId: number | null | undefined,
enabled: boolean,
) {
return useQuery({
...healthStatusQueryOptions(
scope,
refId ?? 0,
),
enabled: enabled && refId != null,
select: aggregateHealth,
})
}
export function useHealthRows(
scope: HealthScope,
refId: number | null | undefined,
enabled: boolean,
) {
return useQuery({
...healthStatusQueryOptions(scope, refId ?? 0),
enabled: enabled && refId != null,
})
}
+28
View File
@@ -7,6 +7,7 @@ import {
domainSchema,
groupSchema,
groupWithStatsSchema,
ipHealthStatusSchema,
serviceBindingSchema,
serviceGroupsResponseSchema,
serviceViewSchema,
@@ -208,3 +209,30 @@ export function invalidateDomainPage(
void queryClient.invalidateQueries({ queryKey: domainKeys.detail(domainId) })
void queryClient.invalidateQueries({ queryKey: dnsKeys.list(domainId) })
}
export const healthStatusKeys = {
all: ['health-status'] as const,
list: (scope: 'binding' | 'group', refId: number) =>
[...healthStatusKeys.all, scope, refId] as const,
}
export function healthStatusQueryOptions(
scope: 'binding' | 'group',
refId: number,
) {
return queryOptions({
queryKey: healthStatusKeys.list(scope, refId),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/health-status?scope=${scope}&ref_id=${refId}`,
)
return z.array(ipHealthStatusSchema).parse(data)
},
refetchInterval: 10_000,
staleTime: 5_000,
})
}
export async function runHealthCheck() {
return api.post<{ checked: number }>('/api/v1/health-check/run', {})
}