feat(health-checks): enhance health check functionality and add new routes
quality / commitlint (push) Skipped
CD / update-wiki (push) Failing after 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 50s
quality / api (push) Successful in 41s
CD / quality (push) Successful in 1m40s
CD / publish (push) Successful in 1m33s

- Introduced origin health check routes and integrated them into the application.
- Updated health check configuration to include success recovery thresholds.
- Expanded error handling with new error codes for health check failures.
- Added new service routes for managing health checks, including creation and listing.
- Improved health check service logic to track consecutive successes and failures.

This commit enhances the health check capabilities, providing better monitoring and management of service health.
This commit is contained in:
Denozordec
2026-08-19 12:26:12 +07:00
parent 9c00b268dc
commit 3f6f402872
64 changed files with 6356 additions and 360 deletions
+6 -2
View File
@@ -25,15 +25,18 @@ import {
const infrastructureNav = [
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
] as const
const operationsNav = [
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
] as const
const secondaryNav = [
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
] as const
function isNavActive(
pathname: string,
to: string,
@@ -106,6 +109,7 @@ export function AppSidebar() {
<SidebarContent>
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
<NavSection label="Прочее" items={secondaryNav} pathname={pathname} />
</SidebarContent>
<SidebarFooter>
<NavUser />
@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { Button } from '@cfdm/ui/components/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { changeServiceDomain, domainsListQueryOptions } from '@/queries'
interface ChangeDomainSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
serviceId: number
fromDomainId?: number | null
}
interface FormValues {
from_domain_id: string
to_domain_id: string
}
export function ChangeDomainSheet({
open,
onOpenChange,
serviceId,
fromDomainId,
}: ChangeDomainSheetProps) {
const queryClient = useQueryClient()
const domainsQuery = useQuery(domainsListQueryOptions())
const form = useForm<FormValues>({
defaultValues: {
from_domain_id: fromDomainId ? String(fromDomainId) : '',
to_domain_id: '',
},
})
const [confirmOpen, setConfirmOpen] = useState(false)
const [preview, setPreview] = useState<string | null>(null)
useEffect(() => {
if (open) {
form.reset({
from_domain_id: fromDomainId ? String(fromDomainId) : '',
to_domain_id: '',
})
setPreview(null)
}
}, [open, fromDomainId, form])
const domains = domainsQuery.data ?? []
const fromId = form.watch('from_domain_id')
const toId = form.watch('to_domain_id')
const fromZone = domains.find((d) => String(d.id) === fromId)?.zone_name
const toZone = domains.find((d) => String(d.id) === toId)?.zone_name
const mutate = useMutation({
mutationFn: () =>
changeServiceDomain(serviceId, {
from_domain_id: Number(fromId),
to_domain_id: Number(toId),
dry_run: false,
}),
onSuccess: async (result: { message?: string }) => {
toast.success(result.message ?? 'Привязки перенесены')
await queryClient.invalidateQueries()
setConfirmOpen(false)
onOpenChange(false)
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось перенести домен'),
})
return (
<>
<FormSheet
open={open}
onOpenChange={onOpenChange}
title="Сменить домен"
description="Перенос привязок между зонами Cloudflare без orphan-записей."
form={form}
onSubmit={() => {
setPreview(
fromZone && toZone
? `${fromZone}${toZone}`
: 'Проверьте выбранные зоны',
)
setConfirmOpen(true)
}}
footer={
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button type="submit" disabled={!fromId || !toId || fromId === toId}>
Предпросмотр
</Button>
</>
}
>
<FormFieldSimple label="Исходная зона" htmlFor="from_domain_id">
<Select
value={fromId || null}
onValueChange={(value) => form.setValue('from_domain_id', value ?? '')}
>
<SelectTrigger id="from_domain_id">
<SelectValue placeholder="Откуда" />
</SelectTrigger>
<SelectContent>
{domains.map((domain) => (
<SelectItem key={domain.id} value={String(domain.id)}>
{domain.zone_name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormFieldSimple>
<FormFieldSimple label="Целевая зона" htmlFor="to_domain_id">
<Select
value={toId || null}
onValueChange={(value) => form.setValue('to_domain_id', value ?? '')}
>
<SelectTrigger id="to_domain_id">
<SelectValue placeholder="Куда" />
</SelectTrigger>
<SelectContent>
{domains.map((domain) => (
<SelectItem key={domain.id} value={String(domain.id)}>
{domain.zone_name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormFieldSimple>
{fromZone && toZone ? (
<Alert>
<AlertTitle>Предпросмотр FQDN</AlertTitle>
<AlertDescription>
Привязки будут перенесены из {fromZone} в {toZone}. Старые DNS-записи
исходной зоны будут удалены.
</AlertDescription>
</Alert>
) : null}
</FormSheet>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Подтвердить перенос"
description={preview ?? 'Перенести привязки в другую зону?'}
confirmLabel="Перенести"
onConfirm={() => mutate.mutate()}
/>
</>
)
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { changeBindingIp, serviceNodesQueryOptions } from '@/queries'
interface ChangeIpSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
bindingId: number | null
serviceId?: number | null
currentIp?: string | null
}
interface FormValues {
from_ip: string
to_ip: string
node_id: string
}
export function ChangeIpSheet({
open,
onOpenChange,
bindingId,
serviceId,
currentIp,
}: ChangeIpSheetProps) {
const queryClient = useQueryClient()
const form = useForm<FormValues>({
defaultValues: { from_ip: currentIp ?? '', to_ip: '', node_id: '' },
})
const [preview, setPreview] = useState<string | null>(null)
const nodesQuery = useQuery({
...serviceNodesQueryOptions(serviceId ?? 0),
enabled: open && serviceId != null,
})
useEffect(() => {
if (open) {
form.reset({ from_ip: currentIp ?? '', to_ip: '', node_id: '' })
setPreview(null)
}
}, [open, currentIp, form])
const fromIp = form.watch('from_ip')
const toIp = form.watch('to_ip')
const nodeId = form.watch('node_id')
const nodes = (nodesQuery.data ?? []) as Array<{ id: number; address: string }>
const previewText = useMemo(() => {
const next = nodeId
? nodes.find((n) => String(n.id) === nodeId)?.address
: toIp
if (!fromIp || !next) return null
return `${fromIp}${next}`
}, [fromIp, toIp, nodeId, nodes])
const mutate = useMutation({
mutationFn: async () => {
if (bindingId == null) throw new Error('нет привязки')
const selectedNode = nodeId ? Number(nodeId) : undefined
return changeBindingIp(bindingId, {
from_ip: fromIp || undefined,
to_ip: selectedNode ? undefined : toIp || undefined,
node_id: selectedNode,
dry_run: false,
})
},
onSuccess: async (result) => {
setPreview(result.message)
toast.success(result.message)
await queryClient.invalidateQueries()
onOpenChange(false)
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось сменить IP'),
})
return (
<FormSheet
open={open}
onOpenChange={onOpenChange}
title="Сменить IP"
description="Обновить A-запись в Cloudflare без перехода на страницу DNS."
form={form}
onSubmit={() => mutate.mutate()}
footer={
<>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton
type="submit"
isLoading={mutate.isPending}
loadingLabel="Updating…"
>
Сменить IP
</LoadingButton>
</>
}
>
<FormFieldSimple label="Текущий IP" htmlFor="from_ip">
<Input id="from_ip" {...form.register('from_ip')} />
</FormFieldSimple>
{nodes.length > 0 ? (
<FormFieldSimple label="Нода" htmlFor="node_id">
<Select
value={nodeId || null}
onValueChange={(value) => {
form.setValue('node_id', value ?? '')
const node = nodes.find((n) => String(n.id) === value)
if (node) form.setValue('to_ip', node.address)
}}
>
<SelectTrigger id="node_id">
<SelectValue placeholder="Выберите ноду" />
</SelectTrigger>
<SelectContent>
{nodes.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{node.address}
</SelectItem>
))}
</SelectContent>
</Select>
</FormFieldSimple>
) : null}
<FormFieldSimple label="Новый IP" htmlFor="to_ip" hint="IPv4">
<Input id="to_ip" {...form.register('to_ip')} placeholder="10.0.0.20" />
</FormFieldSimple>
{previewText ? (
<Alert>
<AlertTitle>Предпросмотр</AlertTitle>
<AlertDescription>{preview ?? previewText}</AlertDescription>
</Alert>
) : null}
</FormSheet>
)
}
@@ -0,0 +1,40 @@
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
export interface FailoverEvent {
id: string
title: string
detail: string
}
export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
if (events.length === 0) {
return (
<p className="text-muted-foreground text-sm">
Событий failover пока нет.
</p>
)
}
return (
<Timeline defaultValue={events.length} className="w-full">
{events.map((event, index) => (
<TimelineItem key={event.id} step={index + 1}>
<TimelineSeparator />
<TimelineIndicator />
<TimelineHeader>
<TimelineTitle>{event.title}</TimelineTitle>
</TimelineHeader>
<TimelineContent>{event.detail}</TimelineContent>
</TimelineItem>
))}
</Timeline>
)
}
+26 -4
View File
@@ -7,6 +7,20 @@ import { formatDate } from '@/lib/format'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
type HealthStatus =
| IpHealthStatus['status']
| 'healthy'
| 'unhealthy'
| 'checking'
| 'disabled'
function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] {
if (status === 'healthy') return 'up'
if (status === 'unhealthy' || status === 'disabled') return 'down'
if (status === 'checking') return 'unknown'
return status
}
const healthVariants: Record<IpHealthStatus['status'], BadgeVariant> = {
up: 'success-light',
degraded: 'warning-light',
@@ -21,6 +35,13 @@ const healthLabels: Record<IpHealthStatus['status'], string> = {
unknown: '—',
}
const extraLabels: Partial<Record<HealthStatus, string>> = {
healthy: 'Healthy',
unhealthy: 'Unhealthy',
checking: 'Checking',
disabled: 'Disabled',
}
const dotColor: Record<IpHealthStatus['status'], string> = {
up: 'bg-success',
degraded: 'bg-warning',
@@ -29,7 +50,7 @@ const dotColor: Record<IpHealthStatus['status'], string> = {
}
interface HealthCheckBadgeProps {
status: IpHealthStatus['status']
status: HealthStatus
latencyMs?: number | null
lastCheckedAt?: string | null
lastError?: string | null
@@ -49,8 +70,9 @@ export function HealthCheckBadge({
size = 'sm',
className,
}: HealthCheckBadgeProps) {
const variant = healthVariants[status]
const label = healthLabels[status]
const normalized = normalizeHealth(status)
const variant = healthVariants[normalized]
const label = extraLabels[status] ?? healthLabels[normalized]
const tooltipParts: string[] = []
if (title) tooltipParts.push(title)
@@ -74,7 +96,7 @@ export function HealthCheckBadge({
className={cn('gap-1.5', className)}
>
<span
className={cn('size-1.5 shrink-0 rounded-full', dotColor[status])}
className={cn('size-1.5 shrink-0 rounded-full', dotColor[normalized])}
aria-hidden
/>
{label}
@@ -32,6 +32,7 @@ export interface HealthCheckConfig {
interval_sec: number
timeout_ms: number
verify_tls: boolean
provider?: 'local' | 'cloudflare'
}
export interface LbAndHealthConfig extends HealthCheckConfig {
@@ -136,6 +137,29 @@ export function HealthCheckConfigFields({
</SettingRow>
) : null}
<SettingRow
title="Провайдер health-check"
description="Local TCP/HTTP или Cloudflare Health Checks API"
labelFor={`${idPrefix}-provider`}
compact
className={rowClass}
>
<Select
value={value.provider ?? 'local'}
onValueChange={(v) =>
patch({ provider: (v ?? 'local') as 'local' | 'cloudflare' })
}
>
<SelectTrigger id={`${idPrefix}-provider`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="cloudflare">Cloudflare</SelectItem>
</SelectContent>
</Select>
</SettingRow>
<SettingRow
title="Health-check"
description="TCP/HTTP проверка цели DNS"
@@ -1,4 +1,5 @@
import { useMemo } from 'react'
import { Link } from '@tanstack/react-router'
import type { ColumnDef } from '@tanstack/react-table'
import {
ChevronRightIcon,
@@ -264,6 +265,16 @@ export function createServicesGroupedColumns({
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(original.service.id) }}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditService(original.service)}>
Изменить
</DropdownMenuItem>