feat(health): деплоить probe-Worker из CFDM и опрашивать цели с edge
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 1m4s
quality / api (push) Successful in 54s
CD / quality (push) Successful in 2m17s
CD / publish (push) Successful in 2m21s

Worker сам ходит на origin по Cron Trigger; CFDM кладёт цели в KV и забирает результаты без POST /probe.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-19 17:22:07 +07:00
co-authored by Cursor
parent 2c92e78b24
commit 4c4908558b
38 changed files with 1941 additions and 555 deletions
@@ -211,12 +211,12 @@ export function HealthCheckConfigFields({
<AlertTitle>Cloudflare Worker</AlertTitle>
<AlertDescription>
Проба с edge Cloudflare, не продукт Health Checks API (на Free его нет).
Регионы WNAM/WEU недоступны в результате будет colo ближайшего POP
(например AMS). URL и токен Worker в{' '}
Worker создаётся автоматически и сам опрашивает IP (KV mailbox).
Статус деплоя в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
. Если Worker не задан, цель не пробируется как Local.
. Если Worker не создан, цель не пробируется как Local.
</AlertDescription>
</Alert>
) : (
+137 -65
View File
@@ -28,6 +28,8 @@ import {
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import { Button } from '@cfdm/ui/components/button'
const formSchema = z.object({
healthCheckCron: z.string().trim().min(1, 'Укажите cron').max(64),
@@ -35,8 +37,6 @@ 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,10 +48,16 @@ const formSchema = z.object({
})
type FormValues = z.infer<typeof formSchema>
type HealthWorkerStatus = 'missing' | 'ready' | 'error'
type SettingsResponse = FormValues & {
id: string
healthWorkerTokenSet?: boolean
healthWorkerUrl?: string
healthWorkerStatus?: HealthWorkerStatus
healthWorkerError?: string | null
healthWorkerDeployedAt?: string | null
healthWorkerLastIngestAt?: string | null
healthWorkerKvNamespaceId?: string
}
export const Route = createFileRoute('/_auth/settings/health')({
@@ -94,6 +100,28 @@ function CompactNumberInput({
)
}
function statusBadge(status: HealthWorkerStatus | undefined) {
if (status === 'ready') {
return (
<Badge variant="success-light" size="sm">
Готов
</Badge>
)
}
if (status === 'error') {
return (
<Badge variant="destructive-light" size="sm">
Ошибка
</Badge>
)
}
return (
<Badge variant="outline" size="sm">
Не создан
</Badge>
)
}
function HealthSettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({
@@ -109,8 +137,6 @@ function HealthSettingsPage() {
healthDownFailures: 2,
healthLatencyWarnMs: 1000,
healthSuccessRecoveries: 2,
healthWorkerUrl: '',
healthWorkerToken: '',
},
})
@@ -122,26 +148,18 @@ function HealthSettingsPage() {
healthDownFailures: data.healthDownFailures,
healthLatencyWarnMs: data.healthLatencyWarnMs,
healthSuccessRecoveries: data.healthSuccessRecoveries,
healthWorkerUrl: data.healthWorkerUrl ?? '',
healthWorkerToken: '',
})
}, [data, form])
const saveMut = useMutation({
mutationFn: (values: FormValues) => {
const payload: Record<string, unknown> = {
mutationFn: (values: FormValues) =>
api.patch<SettingsResponse>('/api/v1/settings', {
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 сохранены')
@@ -150,6 +168,17 @@ function HealthSettingsPage() {
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
const ensureMut = useMutation({
mutationFn: () =>
api.post<SettingsResponse>('/api/v1/settings/health/worker/ensure'),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
toast.success('Worker создан или обновлён')
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось создать Worker'),
})
return (
<form
className="flex w-full flex-col gap-4"
@@ -172,7 +201,7 @@ function HealthSettingsPage() {
<FieldGroup className="gap-0">
<SettingRow
title="Cron"
description="Расписание проб (6 полей: сек мин час день месяц день-недели). Env: HEALTH_CHECK_CRON."
description="Расписание проб CFDM (6 полей). Worker на edge получает 5-польное cron без секунд. Env: HEALTH_CHECK_CRON."
labelFor="health-cron"
stacked
>
@@ -267,6 +296,7 @@ function HealthSettingsPage() {
description="Подряд успешных проб, чтобы выйти из Checking в Healthy. Env: HEALTH_SUCCESS_RECOVERIES."
labelFor="health-recoveries"
compact
last
>
<Controller
control={form.control}
@@ -283,55 +313,7 @@ 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"
@@ -343,6 +325,96 @@ function HealthSettingsPage() {
</FrameFooter>
</FramePanel>
</Frame>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
Cloudflare Worker
{statusBadge(data?.healthWorkerStatus)}
</FrameTitle>
<FrameDescription>
Worker сам опрашивает IP/порты с edge. CFDM создаёт скрипт через API
и забирает результаты из KV. Preview:{' '}
<a
href="https://reui.io/preview/base/settings-16"
className="underline"
target="_blank"
rel="noreferrer"
>
settings-16
</a>
.
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3 p-4">
<Alert variant={data?.healthWorkerStatus === 'error' ? 'destructive' : 'info'}>
<AlertTitle>Не Health Checks API</AlertTitle>
<AlertDescription>
На Free-плане продукта Health Checks нет. Нужен Account-токен с
Workers Scripts Write и Workers KV Storage Write Zone DNS
недостаточно. Лимиты Free: 5 cron на аккаунт, KV 1000 writes/сутки
(интервал 2 мин), до 48 целей за тик.
</AlertDescription>
</Alert>
{data?.healthWorkerError ? (
<Alert variant="destructive">
<AlertTitle>Ошибка деплоя</AlertTitle>
<AlertDescription>{data.healthWorkerError}</AlertDescription>
</Alert>
) : null}
<FieldGroup className="gap-0">
<SettingRow title="Скрипт" compact>
<span className="font-mono text-sm">cfdm-health-probe</span>
</SettingRow>
<SettingRow
title="KV namespace"
description="id mailbox targets/results"
compact
>
<span className="font-mono text-sm break-all">
{data?.healthWorkerKvNamespaceId || '—'}
</span>
</SettingRow>
<SettingRow
title="URL"
description="workers.dev после автодеплоя"
compact
>
<span className="font-mono text-sm break-all">
{data?.healthWorkerUrl || '—'}
</span>
</SettingRow>
<SettingRow
title="Последний деплой"
compact
>
<span className="text-sm text-muted-foreground">
{data?.healthWorkerDeployedAt || '—'}
</span>
</SettingRow>
<SettingRow
title="Последний ingest"
description="colo пишется в журнал проб"
compact
last
>
<span className="text-sm text-muted-foreground">
{data?.healthWorkerLastIngestAt || '—'}
</span>
</SettingRow>
</FieldGroup>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
disabled={ensureMut.isPending}
onClick={() => ensureMut.mutate()}
>
{ensureMut.isPending ? 'Создаём…' : 'Создать / обновить Worker'}
</Button>
</div>
</FramePanel>
</Frame>
</form>
)
}