feat(health): добавить Globalping и мультивыбор источников проб
CD / update-wiki (push) Successful in 8s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 46s
CD / quality (push) Successful in 1m49s
CD / publish (push) Successful in 1m40s

Несколько источников проб сразу и правило агрегации на сервисе вместо XOR Local/Cloudflare.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-19 18:32:18 +07:00
co-authored by Cursor
parent 4c4908558b
commit b9bea44dce
31 changed files with 2671 additions and 271 deletions
@@ -17,16 +17,22 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch'
import { Button } from '@cfdm/ui/components/button'
import { ButtonGroup } from '@cfdm/ui/components/button-group'
import { FieldGroup } from '@cfdm/ui/components/field'
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
import { Link } from '@tanstack/react-router'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { cn } from '@cfdm/ui/lib/utils'
import {
HealthAggregateTiles,
HealthSourceTiles,
type HealthAggregate,
type HealthProvider,
} from '@/components/reui-kit/health-source-tiles'
import { uniqueHealthProviders } from '@cfdm/shared'
export type LbMode = 'round_robin' | 'failover' | 'weighted'
export type HealthCheckType = 'tcp' | 'http'
export type HealthProvider = 'local' | 'cloudflare'
export type { HealthProvider, HealthAggregate }
export interface HealthCheckConfig {
enabled: boolean
@@ -38,6 +44,8 @@ export interface HealthCheckConfig {
timeout_ms: number
verify_tls: boolean
provider: HealthProvider
providers: HealthProvider[]
aggregate: HealthAggregate
method?: string | null
retries?: number
consecutive_fails?: number
@@ -54,62 +62,6 @@ const defaultLbModeOptions = [
{ value: 'weighted', label: 'Weighted (веса)' },
]
const healthCheckTypes = [
{ value: 'tcp', label: 'TCP connect' },
{ value: 'http', label: 'HTTP' },
] as const
const cloudflareTypes = [
{ value: 'tcp', label: 'TCP' },
{ value: 'http', label: 'HTTP' },
] as const
export function HealthProviderToggle({
value,
onChange,
id,
}: {
value: HealthProvider
onChange: (next: HealthProvider) => void
id?: string
}) {
const provider = value || 'local'
return (
<ButtonGroup className="w-full" id={id}>
<Button
type="button"
size="sm"
className="flex-1"
variant={provider === 'local' ? 'secondary' : 'outline'}
aria-pressed={provider === 'local'}
onClick={() => onChange('local')}
>
Local
</Button>
<Button
type="button"
size="sm"
className="flex-1"
variant={provider === 'cloudflare' ? 'secondary' : 'outline'}
aria-pressed={provider === 'cloudflare'}
onClick={() => onChange('cloudflare')}
>
Cloudflare
</Button>
</ButtonGroup>
)
}
interface HealthCheckConfigFieldsProps {
value: LbAndHealthConfig
onChange: (next: LbAndHealthConfig) => void
lbModeLabel?: string
lbModeOptions?: { value: string; label: string }[]
idPrefix?: string
showLbMode?: boolean
className?: string
}
function CompactNumberField({
id,
value,
@@ -151,13 +103,29 @@ export function HealthCheckConfigFields({
idPrefix = 'health',
showLbMode = true,
className,
}: HealthCheckConfigFieldsProps) {
}: {
value: LbAndHealthConfig
onChange: (next: LbAndHealthConfig) => void
lbModeLabel?: string
lbModeOptions?: { value: string; label: string }[]
idPrefix?: string
showLbMode?: boolean
className?: string
}) {
function patch(next: Partial<LbAndHealthConfig>) {
onChange({ ...value, ...next })
}
const providers =
value.providers?.length > 0
? uniqueHealthProviders(value.providers)
: uniqueHealthProviders([value.provider ?? 'local'])
const aggregate = value.aggregate ?? 'majority'
const isHttp = value.type === 'http'
const rowClass = 'gap-3 px-0 py-3'
const hasCloudflare = providers.includes('cloudflare')
const hasGlobalping = providers.includes('globalping')
const hasLocal = providers.includes('local')
return (
<FieldGroup className={cn('gap-0', className)}>
@@ -190,23 +158,25 @@ export function HealthCheckConfigFields({
<SettingRow
title="Провайдер health-check"
description="Откуда идёт проба: API CFDM или Cloudflare Worker (edge)"
description="Кто пробирует цель. Можно выбрать несколько источников."
labelFor={`${idPrefix}-provider`}
compact
stacked
className={rowClass}
>
<HealthProviderToggle
id={`${idPrefix}-provider`}
value={value.provider ?? 'local'}
onChange={(provider) =>
<HealthSourceTiles
value={providers}
onChange={(next) =>
patch({
provider,
enabled: provider === 'cloudflare' ? true : value.enabled,
providers: next,
provider: next[0] ?? 'local',
enabled: next.includes('cloudflare') ? true : value.enabled,
})
}
/>
</SettingRow>
{value.provider === 'cloudflare' ? (
{hasCloudflare ? (
<Alert>
<AlertTitle>Cloudflare Worker</AlertTitle>
<AlertDescription>
@@ -216,10 +186,24 @@ export function HealthCheckConfigFields({
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
. Если Worker не создан, цель не пробируется как Local.
. Если Worker не создан, этот источник не пробируется как Local.
</AlertDescription>
</Alert>
) : (
) : null}
{hasGlobalping ? (
<Alert>
<AlertTitle>Globalping</AlertTitle>
<AlertDescription>
Пробы из сети globalping.io (TCP ping / HTTP). Токен, локации и лимит
в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
. Без токена или при 429 этот источник = fail, без fallback на Local.
</AlertDescription>
</Alert>
) : null}
{hasLocal ? (
<Alert>
<AlertTitle>Local health-check</AlertTitle>
<AlertDescription>
@@ -230,7 +214,22 @@ export function HealthCheckConfigFields({
. Интервал в карточке не используется.
</AlertDescription>
</Alert>
)}
) : null}
{providers.length > 1 ? (
<SettingRow
title="Агрегация"
description="Как свести результаты источников в один статус IP для failover"
compact
stacked
className={rowClass}
>
<HealthAggregateTiles
value={aggregate}
onChange={(next) => patch({ aggregate: next })}
/>
</SettingRow>
) : null}
<SettingRow
title="Health-check"
@@ -259,25 +258,25 @@ export function HealthCheckConfigFields({
<div className="flex flex-col gap-3 pt-1 pb-1">
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
<Select
modal={false}
value={value.type}
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
<ToggleGroup
id={`${idPrefix}-type`}
variant="outline"
className="w-full"
value={[value.type]}
onValueChange={(next) => {
const picked = next[0]
if (picked === 'tcp' || picked === 'http') {
patch({ type: picked })
}
}}
>
<SelectTrigger id={`${idPrefix}-type`} className="w-full">
<SelectValue placeholder="Тип" />
</SelectTrigger>
<SelectContent>
{(value.provider === 'cloudflare'
? cloudflareTypes
: healthCheckTypes
).map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<ToggleGroupItem value="tcp" className="flex-1">
TCP
</ToggleGroupItem>
<ToggleGroupItem value="http" className="flex-1">
HTTP
</ToggleGroupItem>
</ToggleGroup>
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
@@ -0,0 +1,202 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { CheckIcon, GlobeIcon, ServerIcon, CloudIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@cfdm/ui/lib/utils'
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
export type HealthProvider = HealthCheckProvider
export type HealthAggregate = HealthCheckAggregate
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
const PROVIDER_ITEMS: Array<{
id: HealthProvider
title: string
description: string
icon: ReactNode
iconClassName: string
}> = [
{
id: 'local',
title: 'Local',
description: 'TCP/HTTP с сервера API',
icon: <ServerIcon />,
iconClassName: 'text-info [&_svg]:text-current',
},
{
id: 'cloudflare',
title: 'Cloudflare',
description: 'Worker на edge, KV mailbox',
icon: <CloudIcon />,
iconClassName: 'text-warning [&_svg]:text-current',
},
{
id: 'globalping',
title: 'Globalping',
description: 'Пробы из сети globalping.io',
icon: <GlobeIcon />,
iconClassName: 'text-success [&_svg]:text-current',
},
]
const AGGREGATE_ITEMS: Array<{
id: HealthAggregate
title: string
description: string
icon: ReactNode
}> = [
{
id: 'any',
title: 'Any',
description: 'Down, если хотя бы один источник Down',
icon: <ShieldAlertIcon />,
},
{
id: 'all',
title: 'All',
description: 'Down, только если все выбранные Down',
icon: <LayersIcon />,
},
{
id: 'majority',
title: 'Majority',
description: 'Down по большинству (2 → оба, 3 → ≥2)',
icon: <ScaleIcon />,
},
]
function handleTileKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
}
}
function TilePanel({
selected,
title,
description,
icon,
iconClassName,
role,
onActivate,
}: {
selected: boolean
title: string
description: string
icon: ReactNode
iconClassName?: string
role: 'checkbox' | 'radio'
onActivate: () => void
}) {
return (
<FramePanel
role={role}
aria-checked={selected}
aria-pressed={selected}
tabIndex={0}
className={cn(
'relative isolate flex h-full cursor-pointer flex-col p-3 transition-colors',
'hover:bg-muted/40 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
selected && 'ring-ring ring-1',
)}
onClick={onActivate}
onKeyDown={(event) => handleTileKeyDown(onActivate, event)}
>
<div className="relative z-10 flex h-full items-start gap-3">
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)}
>
{icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{title}</span>
{selected ? (
<CheckIcon className="text-foreground size-4 shrink-0" aria-hidden />
) : null}
</div>
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
</div>
</div>
</FramePanel>
)
}
/**
* Мультивыбор источников проб (Local / Cloudflare / Globalping).
* Preview: https://reui.io/preview/base/card-12
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
*/
export function HealthSourceTiles({
value,
onChange,
}: {
value: HealthProvider[]
onChange: (next: HealthProvider[]) => void
}) {
const selected = value.length > 0 ? value : (['local'] as HealthProvider[])
function toggle(id: HealthProvider) {
if (selected.includes(id)) {
if (selected.length === 1) return
onChange(selected.filter((item) => item !== id))
return
}
onChange([...selected, id])
}
return (
<Frame dense spacing="sm" className="@container w-full">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{PROVIDER_ITEMS.map((item) => (
<TilePanel
key={item.id}
selected={selected.includes(item.id)}
title={item.title}
description={item.description}
icon={item.icon}
iconClassName={item.iconClassName}
role="checkbox"
onActivate={() => toggle(item.id)}
/>
))}
</div>
</Frame>
)
}
/**
* Правило агрегации (ровно одно): any / all / majority.
* Preview: https://reui.io/preview/base/card-12 · https://reui.io/preview/base/settings-5
*/
export function HealthAggregateTiles({
value,
onChange,
}: {
value: HealthAggregate
onChange: (next: HealthAggregate) => void
}) {
const selected = value || 'majority'
return (
<Frame dense spacing="sm" className="@container w-full">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{AGGREGATE_ITEMS.map((item) => (
<TilePanel
key={item.id}
selected={selected === item.id}
title={item.title}
description={item.description}
icon={item.icon}
role="radio"
onActivate={() => onChange(item.id)}
/>
))}
</div>
</Frame>
)
}
@@ -18,3 +18,9 @@ export { OpsDashboard } from './ops-dashboard'
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export {
HealthSourceTiles,
HealthAggregateTiles,
type HealthProvider,
type HealthAggregate,
} from './health-source-tiles'
+19 -2
View File
@@ -8,6 +8,8 @@ import {
type LbAndHealthConfig,
type LbMode,
type HealthCheckType,
type HealthProvider,
type HealthAggregate,
} from '@/components/health-check-config-fields'
import type {
CreateServiceWithConfigInput,
@@ -53,7 +55,9 @@ interface BindingHealthConfig {
interval_sec: number
timeout_ms: number
verify_tls: boolean
provider: 'local' | 'cloudflare'
provider: HealthProvider
providers: HealthProvider[]
aggregate: HealthAggregate
}
export interface ServiceBindingDraft {
@@ -77,6 +81,8 @@ const defaultHealth: BindingHealthConfig = {
timeout_ms: 3000,
verify_tls: false,
provider: 'local',
providers: ['local'],
aggregate: 'majority',
}
interface ServiceEditSheetProps {
@@ -110,7 +116,12 @@ 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: binding.health_check_provider === 'cloudflare' ? 'cloudflare' : 'local',
provider: binding.health_check_provider ?? 'local',
providers:
binding.health_check_providers?.length > 0
? binding.health_check_providers
: [binding.health_check_provider ?? 'local'],
aggregate: binding.health_check_aggregate ?? 'majority',
},
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
@@ -139,6 +150,8 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
health_check_timeout_ms: binding.health.timeout_ms,
health_check_verify_tls: binding.health.verify_tls,
health_check_provider: binding.health.provider,
health_check_providers: binding.health.providers,
health_check_aggregate: binding.health.aggregate,
}
: {
fqdn: binding.fqdn.trim(),
@@ -155,6 +168,8 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
health_check_timeout_ms: binding.health.timeout_ms,
health_check_verify_tls: binding.health.verify_tls,
health_check_provider: binding.health.provider,
health_check_providers: binding.health.providers,
health_check_aggregate: binding.health.aggregate,
},
)
}
@@ -334,6 +349,8 @@ export function ServiceEditSheet({
timeout_ms: next.timeout_ms,
verify_tls: next.verify_tls,
provider: next.provider,
providers: next.providers,
aggregate: next.aggregate,
}
}
+14 -6
View File
@@ -36,7 +36,9 @@ 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'),
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
created_at: z.string(),
updated_at: z.string(),
})
@@ -77,7 +79,9 @@ 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'),
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
sync_status: z.string().nullable().default(null),
})
.transform((binding) => ({
@@ -102,7 +106,7 @@ export const serviceIpHealthSchema = z.object({
latency_ms: z.number().nullable(),
last_checked_at: z.string().nullable().optional(),
last_error: z.string().nullable().optional(),
provider: z.enum(['local', 'cloudflare']).optional(),
provider: z.enum(['local', 'cloudflare', 'globalping', 'aggregate']).optional(),
colo: z.string().nullable().optional(),
})
@@ -111,7 +115,7 @@ export const healthProbeLogSchema = z.object({
scope: z.string(),
ref_id: z.number(),
ip: z.string(),
provider: z.enum(['local', 'cloudflare']),
provider: z.enum(['local', 'cloudflare', 'globalping']),
status: z.enum(['up', 'down', 'degraded', 'unknown']),
ok: z.coerce.boolean(),
latency_ms: z.number().nullable(),
@@ -188,7 +192,9 @@ 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'),
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
sync_status: z.string().nullable().default(null),
created_at: z.string(),
updated_at: z.string(),
@@ -273,7 +279,9 @@ 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(),
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).optional(),
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).optional(),
health_check_aggregate: z.enum(['any', 'all', 'majority']).optional(),
}
const serviceDomainInputSchema = z
+172 -4
View File
@@ -5,7 +5,7 @@ import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { HeartPulseIcon } from 'lucide-react'
import { HeartPulseIcon, GlobeIcon } from 'lucide-react'
import { api } from '@/lib/api-client'
import { SettingRow } from '@/components/setting-row'
@@ -47,7 +47,14 @@ const formSchema = z.object({
}
})
const globalpingSchema = z.object({
globalpingToken: z.string().optional(),
globalpingLocations: z.string().trim().min(1).max(200),
globalpingLimit: z.number().int().min(1).max(10),
})
type FormValues = z.infer<typeof formSchema>
type GlobalpingValues = z.infer<typeof globalpingSchema>
type HealthWorkerStatus = 'missing' | 'ready' | 'error'
type SettingsResponse = FormValues & {
@@ -58,6 +65,9 @@ type SettingsResponse = FormValues & {
healthWorkerDeployedAt?: string | null
healthWorkerLastIngestAt?: string | null
healthWorkerKvNamespaceId?: string
globalpingTokenSet?: boolean
globalpingLocations?: string
globalpingLimit?: number
}
export const Route = createFileRoute('/_auth/settings/health')({
@@ -140,6 +150,15 @@ function HealthSettingsPage() {
},
})
const gpForm = useForm<GlobalpingValues>({
resolver: zodResolver(globalpingSchema),
defaultValues: {
globalpingToken: '',
globalpingLocations: 'World',
globalpingLimit: 3,
},
})
useEffect(() => {
if (!data) return
form.reset({
@@ -149,7 +168,12 @@ function HealthSettingsPage() {
healthLatencyWarnMs: data.healthLatencyWarnMs,
healthSuccessRecoveries: data.healthSuccessRecoveries,
})
}, [data, form])
gpForm.reset({
globalpingToken: '',
globalpingLocations: data.globalpingLocations || 'World',
globalpingLimit: data.globalpingLimit ?? 3,
})
}, [data, form, gpForm])
const saveMut = useMutation({
mutationFn: (values: FormValues) =>
@@ -168,6 +192,27 @@ function HealthSettingsPage() {
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
const saveGpMut = useMutation({
mutationFn: (values: GlobalpingValues) =>
api.patch<SettingsResponse>('/api/v1/settings', {
globalpingLocations: values.globalpingLocations,
globalpingLimit: values.globalpingLimit,
...(values.globalpingToken?.trim()
? { globalpingToken: values.globalpingToken.trim() }
: {}),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
toast.success('Настройки Globalping сохранены')
gpForm.reset({
...gpForm.getValues(),
globalpingToken: '',
})
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
const ensureMut = useMutation({
mutationFn: () =>
api.post<SettingsResponse>('/api/v1/settings/health/worker/ensure'),
@@ -180,6 +225,7 @@ function HealthSettingsPage() {
})
return (
<div className="flex w-full flex-col gap-4">
<form
className="flex w-full flex-col gap-4"
onSubmit={(event) =>
@@ -193,8 +239,8 @@ function HealthSettingsPage() {
Local health-check
</FrameTitle>
<FrameDescription>
Расписание и пороги движка общие для Local и Cloudflare Worker.
Тип/порт/path задаются в карточке сервиса.
Расписание и пороги движка общие для Local, Cloudflare Worker и Globalping.
Тип/порт/path и правило агрегации задаются в карточке сервиса.
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
@@ -416,5 +462,127 @@ function HealthSettingsPage() {
</FramePanel>
</Frame>
</form>
<form
className="flex w-full flex-col gap-4"
onSubmit={(event) =>
void gpForm.handleSubmit((values) => saveGpMut.mutate(values))(event)
}
>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
<GlobeIcon className="size-4" aria-hidden />
Globalping
<Badge
variant={data?.globalpingTokenSet ? 'success-light' : 'outline'}
size="sm"
>
{data?.globalpingTokenSet ? 'Токен задан' : 'Нет токена'}
</Badge>
</FrameTitle>
<FrameDescription>
Пробы из сети globalping.io. Poll 500 мс.{' '}
<a
href="https://reui.io/preview/base/settings-16"
className="underline"
target="_blank"
rel="noreferrer"
>
settings-16
</a>
.
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<div className="flex flex-col gap-3 p-4">
<Alert>
<AlertTitle>Лимиты и credits</AlertTitle>
<AlertDescription>
Без токена 250 tests/hour, с токеном 500 +{' '}
<a
href="https://globalping.io/credits"
className="underline"
target="_blank"
rel="noreferrer"
>
credits
</a>
. Токен: dash.globalping.io/tokens. Один measurement на
уникальный IP/порт за тик cron. При десятках IP следите за
hourly credits.
</AlertDescription>
</Alert>
</div>
<FieldGroup className="gap-0">
<SettingRow
title="Токен"
description={
data?.globalpingTokenSet
? 'Оставьте пустым, чтобы не менять сохранённый токен.'
: 'Authorization: Bearer. Без токена источник Globalping = fail.'
}
labelFor="gp-token"
stacked
>
<Input
id="gp-token"
type="password"
autoComplete="off"
placeholder={data?.globalpingTokenSet ? '••••••••' : 'gp_…'}
disabled={isLoading || saveGpMut.isPending}
{...gpForm.register('globalpingToken')}
/>
</SettingRow>
<SettingRow
title="Локации"
description="Magic CSV, например World или EU,US. Default World."
labelFor="gp-locations"
stacked
>
<Input
id="gp-locations"
spellCheck={false}
autoComplete="off"
disabled={isLoading || saveGpMut.isPending}
{...gpForm.register('globalpingLocations')}
/>
</SettingRow>
<SettingRow
title="Проб в measurement"
description="limit 110. Default 3."
labelFor="gp-limit"
compact
last
>
<Controller
control={gpForm.control}
name="globalpingLimit"
render={({ field }) => (
<CompactNumberInput
id="gp-limit"
value={field.value}
min={1}
max={10}
disabled={isLoading || saveGpMut.isPending}
onValueChange={field.onChange}
/>
)}
/>
</SettingRow>
</FieldGroup>
<FrameFooter className="flex flex-row justify-end">
<LoadingButton
type="submit"
isLoading={saveGpMut.isPending}
disabled={isLoading || !gpForm.formState.isDirty}
>
Сохранить
</LoadingButton>
</FrameFooter>
</FramePanel>
</Frame>
</form>
</div>
)
}