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