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