feat(health-checks): enhance health check configuration and logging
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 1m44s
CD / publish (push) Successful in 1m35s
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 1m44s
CD / publish (push) Successful in 1m35s
- Added new health worker URL and token fields to the AppConfig interface, allowing for Cloudflare Worker integration. - Updated health check routes to utilize the new worker configuration, enabling dynamic health checks via Cloudflare Workers. - Introduced a health log endpoint for services, providing detailed logs of health probe results. - Enhanced health check service logic to support both local and Cloudflare Worker providers, improving flexibility in health monitoring. - Updated UI components to reflect changes in health check provider settings and display relevant health information. This commit significantly improves the health check management capabilities, allowing for better integration with Cloudflare Workers and enhanced logging features.
This commit is contained in:
@@ -54,6 +54,8 @@ interface HealthCheckBadgeProps {
|
||||
latencyMs?: number | null
|
||||
lastCheckedAt?: string | null
|
||||
lastError?: string | null
|
||||
colo?: string | null
|
||||
provider?: 'local' | 'cloudflare' | string | null
|
||||
title?: string
|
||||
showLatency?: boolean
|
||||
size?: 'xs' | 'sm'
|
||||
@@ -65,6 +67,8 @@ export function HealthCheckBadge({
|
||||
latencyMs,
|
||||
lastCheckedAt,
|
||||
lastError,
|
||||
colo,
|
||||
provider,
|
||||
title,
|
||||
showLatency = false,
|
||||
size = 'sm',
|
||||
@@ -79,6 +83,9 @@ export function HealthCheckBadge({
|
||||
tooltipParts.push(`Статус: ${label}`)
|
||||
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
|
||||
if (lastCheckedAt) tooltipParts.push(`Проверка: ${formatDate(lastCheckedAt)}`)
|
||||
if (colo) tooltipParts.push(`Colo: ${colo}`)
|
||||
if (provider === 'cloudflare') tooltipParts.push('Провайдер: Cloudflare Worker')
|
||||
if (provider === 'local') tooltipParts.push('Провайдер: Local')
|
||||
if (lastError) tooltipParts.push(`Ошибка: ${lastError}`)
|
||||
|
||||
return (
|
||||
|
||||
@@ -190,7 +190,7 @@ export function HealthCheckConfigFields({
|
||||
|
||||
<SettingRow
|
||||
title="Провайдер health-check"
|
||||
description="Local TCP/HTTP или Cloudflare Health Checks API"
|
||||
description="Откуда идёт проба: API CFDM или Cloudflare Worker (edge)"
|
||||
labelFor={`${idPrefix}-provider`}
|
||||
compact
|
||||
className={rowClass}
|
||||
@@ -208,21 +208,26 @@ export function HealthCheckConfigFields({
|
||||
</SettingRow>
|
||||
{value.provider === 'cloudflare' ? (
|
||||
<Alert>
|
||||
<AlertTitle>Cloudflare Health Checks</AlertTitle>
|
||||
<AlertTitle>Cloudflare Worker</AlertTitle>
|
||||
<AlertDescription>
|
||||
Поля соответствуют официальному API зоны. Если план не позволяет Health
|
||||
Checks, API вернёт ошибку — останется Local. Workers не используются.
|
||||
Проба с edge Cloudflare, не продукт Health Checks API (на Free его нет).
|
||||
Регионы WNAM/WEU недоступны — в результате будет colo ближайшего POP
|
||||
(например AMS). URL и токен Worker — в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
. Если Worker не задан, цель не пробируется как Local.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertTitle>Local health-check</AlertTitle>
|
||||
<AlertDescription>
|
||||
Интервал и таймаут пробы — ниже. Cron и пороги Slow/Down задаются в{' '}
|
||||
Проба TCP/HTTP с сервера API. Cron и пороги Slow/Down — в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
, как параметры Cloudflare Health Checks в этой форме.
|
||||
. Интервал в карточке не используется.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -334,83 +339,18 @@ export function HealthCheckConfigFields({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormFieldSimple label="Интервал, сек" htmlFor={`${idPrefix}-interval`}>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-interval`}
|
||||
value={value.interval_sec}
|
||||
min={5}
|
||||
max={3600}
|
||||
placeholder="30"
|
||||
onValueChange={(next) =>
|
||||
patch({ interval_sec: next ?? 30 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-timeout`}
|
||||
value={value.timeout_ms}
|
||||
min={100}
|
||||
max={30000}
|
||||
placeholder="3000"
|
||||
onValueChange={(next) =>
|
||||
patch({ timeout_ms: next ?? 3000 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
{value.provider === 'cloudflare' ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormFieldSimple
|
||||
label="Retries"
|
||||
htmlFor={`${idPrefix}-retries`}
|
||||
hint="Cloudflare retries"
|
||||
>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-retries`}
|
||||
value={value.retries ?? 2}
|
||||
min={0}
|
||||
max={10}
|
||||
placeholder="2"
|
||||
onValueChange={(retries) => patch({ retries: retries ?? 2 })}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Successes"
|
||||
htmlFor={`${idPrefix}-successes`}
|
||||
hint="consecutive_successes"
|
||||
>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-successes`}
|
||||
value={value.consecutive_successes ?? 2}
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="2"
|
||||
onValueChange={(consecutive_successes) =>
|
||||
patch({ consecutive_successes: consecutive_successes ?? 2 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
) : null}
|
||||
{value.provider === 'cloudflare' && isHttp ? (
|
||||
<FormFieldSimple label="HTTP method" htmlFor={`${idPrefix}-method`}>
|
||||
<Select
|
||||
modal={false}
|
||||
value={value.method ?? 'GET'}
|
||||
onValueChange={(v) => patch({ method: v ?? 'GET' })}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-method`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="HEAD">HEAD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormFieldSimple>
|
||||
) : null}
|
||||
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-timeout`}
|
||||
value={value.timeout_ms}
|
||||
min={100}
|
||||
max={30000}
|
||||
placeholder="3000"
|
||||
onValueChange={(next) =>
|
||||
patch({ timeout_ms: next ?? 3000 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface HealthTimelineEvent {
|
||||
latency_ms?: number | null
|
||||
error?: string | null
|
||||
checked_at: string
|
||||
colo?: string | null
|
||||
provider?: string | null
|
||||
}
|
||||
|
||||
interface HealthTimelineProps {
|
||||
@@ -63,6 +65,8 @@ export function HealthTimeline({ events }: HealthTimelineProps) {
|
||||
<HealthCheckBadge
|
||||
status={event.status}
|
||||
latencyMs={event.latency_ms}
|
||||
colo={event.colo}
|
||||
provider={event.provider}
|
||||
size="xs"
|
||||
showLatency
|
||||
/>
|
||||
|
||||
@@ -110,7 +110,7 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
interval_sec: binding.health_check_interval_sec,
|
||||
timeout_ms: binding.health_check_timeout_ms,
|
||||
verify_tls: binding.health_check_verify_tls ?? false,
|
||||
provider: 'local',
|
||||
provider: binding.health_check_provider === 'cloudflare' ? 'cloudflare' : 'local',
|
||||
},
|
||||
target_ip_weights: binding.target_ip_weights ?? {},
|
||||
target_ip_priorities: binding.target_ip_priorities ?? {},
|
||||
@@ -138,6 +138,7 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
health_check_interval_sec: binding.health.interval_sec,
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
}
|
||||
: {
|
||||
fqdn: binding.fqdn.trim(),
|
||||
@@ -153,6 +154,7 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
health_check_interval_sec: binding.health.interval_sec,
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,6 +166,10 @@ export function ServiceIpList({
|
||||
<HealthCheckBadge
|
||||
status={health?.status ?? 'unknown'}
|
||||
latencyMs={health?.latency_ms}
|
||||
lastCheckedAt={health?.last_checked_at}
|
||||
lastError={health?.last_error}
|
||||
colo={health?.colo}
|
||||
provider={health?.provider}
|
||||
size="xs"
|
||||
/>
|
||||
<TruncatedText
|
||||
|
||||
@@ -36,6 +36,7 @@ export const serviceGroupSchema = z.object({
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: z.enum(['local', 'cloudflare']).catch('local'),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
@@ -76,6 +77,7 @@ export const serviceDomainBindingSchema = z
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: z.enum(['local', 'cloudflare']).catch('local'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
})
|
||||
.transform((binding) => ({
|
||||
@@ -98,6 +100,24 @@ export const serviceIpHealthSchema = z.object({
|
||||
ip: z.string(),
|
||||
status: z.enum(['up', 'down', 'degraded', 'unknown']),
|
||||
latency_ms: z.number().nullable(),
|
||||
last_checked_at: z.string().nullable().optional(),
|
||||
last_error: z.string().nullable().optional(),
|
||||
provider: z.enum(['local', 'cloudflare']).optional(),
|
||||
colo: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export const healthProbeLogSchema = z.object({
|
||||
id: z.number(),
|
||||
scope: z.string(),
|
||||
ref_id: z.number(),
|
||||
ip: z.string(),
|
||||
provider: z.enum(['local', 'cloudflare']),
|
||||
status: z.enum(['up', 'down', 'degraded', 'unknown']),
|
||||
ok: z.coerce.boolean(),
|
||||
latency_ms: z.number().nullable(),
|
||||
colo: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
checked_at: z.string(),
|
||||
})
|
||||
|
||||
export const serviceViewSchema = serviceSchema.extend({
|
||||
@@ -168,6 +188,7 @@ export const serviceBindingSchema = z
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: z.enum(['local', 'cloudflare']).catch('local'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
@@ -252,6 +273,7 @@ const healthCheckConfigFields = {
|
||||
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
|
||||
health_check_verify_tls: z.boolean().optional(),
|
||||
health_check_provider: z.enum(['local', 'cloudflare']).optional(),
|
||||
}
|
||||
|
||||
const serviceDomainInputSchema = z
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import {
|
||||
healthProbeLogSchema,
|
||||
serviceBindingSchema,
|
||||
serviceGroupsResponseSchema,
|
||||
serviceViewSchema,
|
||||
@@ -87,8 +88,28 @@ export async function deleteServiceBinding(id: number) {
|
||||
export const serviceDetailKeys = {
|
||||
overview: (id: number) => [...serviceKeys.all, id, 'overview'] as const,
|
||||
nodes: (id: number) => [...serviceKeys.all, id, 'nodes'] as const,
|
||||
healthLog: (id: number) => [...serviceKeys.all, id, 'health-log'] as const,
|
||||
view: (id: number) => [...serviceKeys.all, id, 'view'] as const,
|
||||
}
|
||||
|
||||
export const serviceViewQueryOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: serviceDetailKeys.view(id),
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown>(`/api/v1/services/${id}`)
|
||||
return serviceViewSchema.parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const serviceHealthLogQueryOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: serviceDetailKeys.healthLog(id),
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown>(`/api/v1/services/${id}/health-log`)
|
||||
return z.object({ items: z.array(healthProbeLogSchema) }).parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const serviceOverviewQueryOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: serviceDetailKeys.overview(id),
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
||||
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
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 { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { HealthProviderToggle } from '@/components/health-check-config-fields'
|
||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import {
|
||||
createOriginHealthCheck,
|
||||
listOriginHealthChecks,
|
||||
serviceOverviewQueryOptions,
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
import { formatDate } from '@/lib/format'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
||||
component: ServiceHealthPage,
|
||||
@@ -25,119 +19,86 @@ export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
||||
export function ServiceHealthPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const queryClient = useQueryClient()
|
||||
const overview = useQuery(serviceOverviewQueryOptions(id))
|
||||
const checksQuery = useQuery({
|
||||
queryKey: ['health-checks'],
|
||||
queryFn: listOriginHealthChecks,
|
||||
})
|
||||
const [open, setOpen] = useState(false)
|
||||
const form = useForm<{
|
||||
name: string
|
||||
provider: 'local' | 'cloudflare'
|
||||
protocol: string
|
||||
}>({
|
||||
defaultValues: { name: '', provider: 'local', protocol: 'tcp' },
|
||||
})
|
||||
const provider = form.watch('provider')
|
||||
const checks = (checksQuery.data ?? []) as Array<{
|
||||
id: number
|
||||
name: string
|
||||
provider: string
|
||||
protocol: string
|
||||
}>
|
||||
const serviceQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||
const service = serviceQuery.data
|
||||
const items = logQuery.data?.items ?? []
|
||||
const ipHealth = service?.ip_health ?? []
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: { name: string; provider: 'local' | 'cloudflare'; protocol: string }) =>
|
||||
createOriginHealthCheck({
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
protocol: values.protocol,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('Health check сохранён')
|
||||
await queryClient.invalidateQueries({ queryKey: ['health-checks'] })
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: 'Cloudflare Health Checks недоступны для этой зоны',
|
||||
const kpiCards = ipHealth.map((row) => {
|
||||
const variant =
|
||||
row.status === 'down'
|
||||
? ('destructive' as const)
|
||||
: row.status === 'degraded'
|
||||
? ('warning' as const)
|
||||
: ('default' as const)
|
||||
return {
|
||||
id: row.ip,
|
||||
label: row.ip,
|
||||
value: row.latency_ms != null ? `${row.latency_ms} мс` : '—',
|
||||
hint: row.colo ? `colo ${row.colo}` : row.provider === 'cloudflare' ? 'Worker' : 'Local',
|
||||
icon: row.provider === 'cloudflare' ? <GlobeIcon /> : <ServerIcon />,
|
||||
variant,
|
||||
footer: (
|
||||
<HealthCheckBadge
|
||||
status={row.status}
|
||||
latencyMs={row.latency_ms}
|
||||
lastCheckedAt={row.last_checked_at}
|
||||
lastError={row.last_error}
|
||||
colo={row.colo}
|
||||
provider={row.provider}
|
||||
size="xs"
|
||||
/>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
void overview
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Health checks"
|
||||
description="Local TCP/HTTP или официальный Cloudflare Health Checks API."
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
Добавить проверку
|
||||
</Button>
|
||||
title="Health"
|
||||
description="Снимок проб этого сервиса. Cloudflare = Worker с edge, не Health Checks API."
|
||||
/>
|
||||
<Alert>
|
||||
<AlertTitle>XOR провайдеров</AlertTitle>
|
||||
<AlertDescription>
|
||||
Local ходит с API CFDM; Cloudflare — через Worker. Cron и пороги Slow/Down общие, в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
. Если Worker не задан, цель не пробируется как Local.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{kpiCards.length > 0 ? (
|
||||
<KpiStatGrid cards={kpiCards} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Нет проб"
|
||||
description="Включите health-check на привязке — статус IP появится после cron."
|
||||
/>
|
||||
)}
|
||||
<DetailPanel.Header
|
||||
title="Журнал проб"
|
||||
description={
|
||||
items[0]?.checked_at
|
||||
? `Последняя: ${formatDate(items[0].checked_at)}`
|
||||
: 'Последние пробы по IP этого сервиса'
|
||||
}
|
||||
/>
|
||||
{checks.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет проверок"
|
||||
description="Локальные пробы уже работают на привязках. Cloudflare Health Checks — опционально."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{checks.map((check) => (
|
||||
<div
|
||||
key={check.id}
|
||||
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">{check.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{check.provider} · {check.protocol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Health check"
|
||||
description="Поля Cloudflare соответствуют официальному API (address, type, interval, timeout, retries)."
|
||||
form={form}
|
||||
onSubmit={(values) => createMut.mutate(values)}
|
||||
footer={
|
||||
<LoadingButton type="submit" isLoading={createMut.isPending}>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FormFieldSimple label="Имя" htmlFor="hc-name">
|
||||
<Input id="hc-name" {...form.register('name')} />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Провайдер" htmlFor="hc-provider">
|
||||
<HealthProviderToggle
|
||||
id="hc-provider"
|
||||
value={provider}
|
||||
onChange={(next) => form.setValue('provider', next)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
{provider === 'cloudflare' ? (
|
||||
<Alert>
|
||||
<AlertTitle>Cloudflare Health Checks</AlertTitle>
|
||||
<AlertDescription>
|
||||
Если зона не поддерживает Health Checks, вернётся ошибка плана — останется
|
||||
Local. Workers не используются.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<FormFieldSimple label="Протокол" htmlFor="hc-protocol">
|
||||
<Input id="hc-protocol" {...form.register('protocol')} placeholder="tcp" />
|
||||
</FormFieldSimple>
|
||||
</FormSheet>
|
||||
<HealthTimeline
|
||||
events={items.map((row) => ({
|
||||
id: row.id,
|
||||
hostname: row.ip,
|
||||
type: row.provider,
|
||||
status: row.status,
|
||||
latency_ms: row.latency_ms,
|
||||
error: row.error,
|
||||
checked_at: row.checked_at,
|
||||
colo: row.colo,
|
||||
provider: row.provider,
|
||||
}))}
|
||||
/>
|
||||
</DetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '@/components/reui/number-field'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
|
||||
const formSchema = z.object({
|
||||
healthCheckCron: z.string().trim().min(1, 'Укажите cron').max(64),
|
||||
@@ -34,6 +35,8 @@ const formSchema = z.object({
|
||||
healthDownFailures: z.number().int().min(1).max(50),
|
||||
healthLatencyWarnMs: z.number().int().min(50).max(60_000),
|
||||
healthSuccessRecoveries: z.number().int().min(1).max(20),
|
||||
healthWorkerUrl: z.string().trim().url('Некорректный URL').or(z.literal('')),
|
||||
healthWorkerToken: z.string().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.healthDownFailures < data.healthDegradedFailures) {
|
||||
ctx.addIssue({
|
||||
@@ -48,6 +51,7 @@ type FormValues = z.infer<typeof formSchema>
|
||||
|
||||
type SettingsResponse = FormValues & {
|
||||
id: string
|
||||
healthWorkerTokenSet?: boolean
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings/health')({
|
||||
@@ -105,6 +109,8 @@ function HealthSettingsPage() {
|
||||
healthDownFailures: 2,
|
||||
healthLatencyWarnMs: 1000,
|
||||
healthSuccessRecoveries: 2,
|
||||
healthWorkerUrl: '',
|
||||
healthWorkerToken: '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -116,12 +122,26 @@ function HealthSettingsPage() {
|
||||
healthDownFailures: data.healthDownFailures,
|
||||
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
||||
healthWorkerUrl: data.healthWorkerUrl ?? '',
|
||||
healthWorkerToken: '',
|
||||
})
|
||||
}, [data, form])
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (values: FormValues) =>
|
||||
api.patch<SettingsResponse>('/api/v1/settings', values),
|
||||
mutationFn: (values: FormValues) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
healthCheckCron: values.healthCheckCron,
|
||||
healthDegradedFailures: values.healthDegradedFailures,
|
||||
healthDownFailures: values.healthDownFailures,
|
||||
healthLatencyWarnMs: values.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: values.healthSuccessRecoveries,
|
||||
healthWorkerUrl: values.healthWorkerUrl,
|
||||
}
|
||||
if (values.healthWorkerToken?.trim()) {
|
||||
payload.healthWorkerToken = values.healthWorkerToken.trim()
|
||||
}
|
||||
return api.patch<SettingsResponse>('/api/v1/settings', payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||
toast.success('Настройки health-check сохранены')
|
||||
@@ -144,8 +164,8 @@ function HealthSettingsPage() {
|
||||
Local health-check
|
||||
</FrameTitle>
|
||||
<FrameDescription>
|
||||
Расписание и пороги движка. Параметры Cloudflare Health Checks
|
||||
задаются в карточке сервиса.
|
||||
Расписание и пороги движка — общие для Local и Cloudflare Worker.
|
||||
Тип/порт/path задаются в карточке сервиса.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
@@ -247,7 +267,6 @@ function HealthSettingsPage() {
|
||||
description="Подряд успешных проб, чтобы выйти из Checking в Healthy. Env: HEALTH_SUCCESS_RECOVERIES."
|
||||
labelFor="health-recoveries"
|
||||
compact
|
||||
last
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
@@ -264,7 +283,55 @@ function HealthSettingsPage() {
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="URL Worker"
|
||||
description="https://cfdm-health-probe.<account>.workers.dev. Env: HEALTH_WORKER_URL."
|
||||
labelFor="health-worker-url"
|
||||
compact
|
||||
>
|
||||
<Input
|
||||
id="health-worker-url"
|
||||
type="url"
|
||||
placeholder="https://cfdm-health-probe.workers.dev"
|
||||
disabled={isLoading || saveMut.isPending}
|
||||
{...form.register('healthWorkerUrl')}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.formState.errors.healthWorkerUrl ? (
|
||||
<p className="text-destructive px-5 pb-2 text-sm">
|
||||
{form.formState.errors.healthWorkerUrl.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingRow
|
||||
title="Токен Worker"
|
||||
description={
|
||||
data?.healthWorkerTokenSet
|
||||
? 'Токен задан. Оставьте пустым, чтобы не менять.'
|
||||
: 'Authorization Bearer. Env: HEALTH_WORKER_TOKEN.'
|
||||
}
|
||||
labelFor="health-worker-token"
|
||||
compact
|
||||
last
|
||||
>
|
||||
<Input
|
||||
id="health-worker-token"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={data?.healthWorkerTokenSet ? '••••••••' : 'секрет'}
|
||||
disabled={isLoading || saveMut.isPending}
|
||||
{...form.register('healthWorkerToken')}
|
||||
/>
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
<Alert>
|
||||
<AlertTitle>Cloudflare Worker, не Health Checks API</AlertTitle>
|
||||
<AlertDescription>
|
||||
На Free-плане продукта Health Checks нет. CFDM вызывает Worker с edge;
|
||||
cron остаётся здесь. Лимит Free Workers ≈ 100k запросов/сутки (cron × число IP).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FrameFooter className="flex flex-row justify-end">
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
|
||||
Reference in New Issue
Block a user