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

- 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:
Denozordec
2026-08-19 12:26:12 +07:00
parent 9c00b268dc
commit 3f6f402872
64 changed files with 6356 additions and 360 deletions
+6 -2
View File
@@ -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()}
/>
</>
)
}
+152
View File
@@ -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>
)
}
+26 -4
View File
@@ -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>
+89
View File
@@ -83,3 +83,92 @@ export async function createServiceBinding(body: CreateServiceBindingBody) {
export async function deleteServiceBinding(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`)
}
export const serviceDetailKeys = {
overview: (id: number) => [...serviceKeys.all, id, 'overview'] as const,
nodes: (id: number) => [...serviceKeys.all, id, 'nodes'] as const,
}
export const serviceOverviewQueryOptions = (id: number) =>
queryOptions({
queryKey: serviceDetailKeys.overview(id),
queryFn: () => api.get(`/api/v1/services/${id}/overview`),
})
export const serviceNodesQueryOptions = (id: number) =>
queryOptions({
queryKey: serviceDetailKeys.nodes(id),
queryFn: () => api.get(`/api/v1/services/${id}/nodes`),
})
export const opsSummaryQueryOptions = () =>
queryOptions({
queryKey: ['ops-summary'] as const,
queryFn: () =>
api.get<{
domains: number
services: number
nodes: number
healthy: number
unhealthy: number
active_failovers: number
}>('/api/v1/ops-summary'),
})
export async function createServiceNode(
serviceId: number,
body: {
address: string
protocol?: string
port?: number | null
priority?: number
weight?: number
},
) {
return api.post(`/api/v1/services/${serviceId}/nodes`, body)
}
export async function deleteServiceNode(serviceId: number, nodeId: number) {
return api.delete(`/api/v1/services/${serviceId}/nodes/${nodeId}`)
}
export async function changeBindingIp(
bindingId: number,
body: {
from_ip?: string
to_ip?: string
node_id?: number
dry_run?: boolean
},
) {
return api.post<{
from_ip: string
to_ip: string
applied: boolean
dry_run: boolean
message: string
}>(`/api/v1/service-bindings/${bindingId}/change-ip`, body)
}
export async function changeServiceDomain(
serviceId: number,
body: {
from_domain_id: number
to_domain_id: number
hostnames?: string[]
dry_run?: boolean
},
) {
return api.post<{ message?: string }>(
`/api/v1/services/${serviceId}/change-domain`,
body,
)
}
export async function createOriginHealthCheck(body: Record<string, unknown>) {
return api.post('/api/v1/health-checks', body)
}
export async function listOriginHealthChecks() {
return api.get('/api/v1/health-checks')
}
+230 -88
View File
@@ -9,49 +9,55 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates'
import { Route as AuthGroupsRouteImport } from './routes/_auth/groups'
import { Route as AuthServicesRouteImport } from './routes/_auth/services'
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
import { Route as AuthGroupsRouteImport } from './routes/_auth/groups'
import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates'
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
import { Route as AuthServicesIndexRouteImport } from './routes/_auth/services/index'
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
import { Route as AuthServicesServiceIdRouteRouteImport } from './routes/_auth/services/$serviceId/route'
import { Route as AuthServicesServiceIdIndexRouteImport } from './routes/_auth/services/$serviceId/index'
import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index'
import { Route as AuthServicesServiceIdSubdomainsRouteImport } from './routes/_auth/services/$serviceId/subdomains'
import { Route as AuthServicesServiceIdRoutingRouteImport } from './routes/_auth/services/$serviceId/routing'
import { Route as AuthServicesServiceIdNodesRouteImport } from './routes/_auth/services/$serviceId/nodes'
import { Route as AuthServicesServiceIdHealthRouteImport } from './routes/_auth/services/$serviceId/health'
import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns'
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const AuthIndexRoute = AuthIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthRoute,
} as any)
const AuthCertificatesRoute = AuthCertificatesRouteImport.update({
id: '/certificates',
path: '/certificates',
getParentRoute: () => AuthRoute,
const AuthCallbackRoute = AuthCallbackRouteImport.update({
id: '/auth/callback',
path: '/auth/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthGroupsRoute = AuthGroupsRouteImport.update({
id: '/groups',
path: '/groups',
getParentRoute: () => AuthRoute,
} as any)
const AuthServicesRoute = AuthServicesRouteImport.update({
id: '/services',
path: '/services',
const AuthCertificatesRoute = AuthCertificatesRouteImport.update({
id: '/certificates',
path: '/certificates',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({
@@ -59,30 +65,20 @@ const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({
path: '/settings',
getParentRoute: () => AuthRoute,
} as any)
const AuthCallbackRoute = AuthCallbackRouteImport.update({
id: '/auth/callback',
path: '/auth/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({
id: '/domains/',
path: '/domains/',
getParentRoute: () => AuthRoute,
} as any)
const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({
id: '/$groupId',
path: '/$groupId',
getParentRoute: () => AuthGroupsRoute,
} as any)
const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
id: '/appearance',
path: '/appearance',
getParentRoute: () => AuthSettingsRouteRoute,
const AuthServicesIndexRoute = AuthServicesIndexRouteImport.update({
id: '/services/',
path: '/services/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({
id: '/domains/',
path: '/domains/',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsIntegrationsRoute =
AuthSettingsIntegrationsRouteImport.update({
@@ -90,12 +86,58 @@ const AuthSettingsIntegrationsRoute =
path: '/integrations',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
id: '/appearance',
path: '/appearance',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({
id: '/$groupId',
path: '/$groupId',
getParentRoute: () => AuthGroupsRoute,
} as any)
const AuthServicesServiceIdRouteRoute =
AuthServicesServiceIdRouteRouteImport.update({
id: '/services/$serviceId',
path: '/services/$serviceId',
getParentRoute: () => AuthRoute,
} as any)
const AuthServicesServiceIdIndexRoute =
AuthServicesServiceIdIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthServicesServiceIdRouteRoute,
} as any)
const AuthDomainsDomainIdIndexRoute =
AuthDomainsDomainIdIndexRouteImport.update({
id: '/domains/$domainId/',
path: '/domains/$domainId/',
getParentRoute: () => AuthRoute,
} as any)
const AuthServicesServiceIdSubdomainsRoute =
AuthServicesServiceIdSubdomainsRouteImport.update({
id: '/subdomains',
path: '/subdomains',
getParentRoute: () => AuthServicesServiceIdRouteRoute,
} as any)
const AuthServicesServiceIdRoutingRoute =
AuthServicesServiceIdRoutingRouteImport.update({
id: '/routing',
path: '/routing',
getParentRoute: () => AuthServicesServiceIdRouteRoute,
} as any)
const AuthServicesServiceIdNodesRoute =
AuthServicesServiceIdNodesRouteImport.update({
id: '/nodes',
path: '/nodes',
getParentRoute: () => AuthServicesServiceIdRouteRoute,
} as any)
const AuthServicesServiceIdHealthRoute =
AuthServicesServiceIdHealthRouteImport.update({
id: '/health',
path: '/health',
getParentRoute: () => AuthServicesServiceIdRouteRoute,
} as any)
const AuthDomainsDomainIdDnsRoute = AuthDomainsDomainIdDnsRouteImport.update({
id: '/domains/$domainId/dns',
path: '/domains/$domainId/dns',
@@ -108,30 +150,41 @@ export interface FileRoutesByFullPath {
'/settings': typeof AuthSettingsRouteRouteWithChildren
'/certificates': typeof AuthCertificatesRoute
'/groups': typeof AuthGroupsRouteWithChildren
'/services': typeof AuthServicesRoute
'/auth/callback': typeof AuthCallbackRoute
'/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/domains/': typeof AuthDomainsIndexRoute
'/services/': typeof AuthServicesIndexRoute
'/settings/': typeof AuthSettingsIndexRoute
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
'/services/$serviceId/health': typeof AuthServicesServiceIdHealthRoute
'/services/$serviceId/nodes': typeof AuthServicesServiceIdNodesRoute
'/services/$serviceId/routing': typeof AuthServicesServiceIdRoutingRoute
'/services/$serviceId/subdomains': typeof AuthServicesServiceIdSubdomainsRoute
'/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
'/services/$serviceId/': typeof AuthServicesServiceIdIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/certificates': typeof AuthCertificatesRoute
'/groups': typeof AuthGroupsRouteWithChildren
'/services': typeof AuthServicesRoute
'/auth/callback': typeof AuthCallbackRoute
'/': typeof AuthIndexRoute
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/domains': typeof AuthDomainsIndexRoute
'/services': typeof AuthServicesIndexRoute
'/settings': typeof AuthSettingsIndexRoute
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
'/services/$serviceId/health': typeof AuthServicesServiceIdHealthRoute
'/services/$serviceId/nodes': typeof AuthServicesServiceIdNodesRoute
'/services/$serviceId/routing': typeof AuthServicesServiceIdRoutingRoute
'/services/$serviceId/subdomains': typeof AuthServicesServiceIdSubdomainsRoute
'/domains/$domainId': typeof AuthDomainsDomainIdIndexRoute
'/services/$serviceId': typeof AuthServicesServiceIdIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -140,16 +193,22 @@ export interface FileRoutesById {
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
'/_auth/certificates': typeof AuthCertificatesRoute
'/_auth/groups': typeof AuthGroupsRouteWithChildren
'/_auth/services': typeof AuthServicesRoute
'/auth/callback': typeof AuthCallbackRoute
'/_auth/': typeof AuthIndexRoute
'/_auth/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/_auth/domains/': typeof AuthDomainsIndexRoute
'/_auth/services/': typeof AuthServicesIndexRoute
'/_auth/settings/': typeof AuthSettingsIndexRoute
'/_auth/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
'/_auth/services/$serviceId/health': typeof AuthServicesServiceIdHealthRoute
'/_auth/services/$serviceId/nodes': typeof AuthServicesServiceIdNodesRoute
'/_auth/services/$serviceId/routing': typeof AuthServicesServiceIdRoutingRoute
'/_auth/services/$serviceId/subdomains': typeof AuthServicesServiceIdSubdomainsRoute
'/_auth/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
'/_auth/services/$serviceId/': typeof AuthServicesServiceIdIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -159,30 +218,41 @@ export interface FileRouteTypes {
| '/settings'
| '/certificates'
| '/groups'
| '/services'
| '/auth/callback'
| '/services/$serviceId'
| '/groups/$groupId'
| '/settings/appearance'
| '/settings/integrations'
| '/domains/'
| '/services/'
| '/settings/'
| '/domains/$domainId/dns'
| '/services/$serviceId/health'
| '/services/$serviceId/nodes'
| '/services/$serviceId/routing'
| '/services/$serviceId/subdomains'
| '/domains/$domainId/'
| '/services/$serviceId/'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/certificates'
| '/groups'
| '/services'
| '/auth/callback'
| '/'
| '/groups/$groupId'
| '/settings/appearance'
| '/settings/integrations'
| '/domains'
| '/services'
| '/settings'
| '/domains/$domainId/dns'
| '/services/$serviceId/health'
| '/services/$serviceId/nodes'
| '/services/$serviceId/routing'
| '/services/$serviceId/subdomains'
| '/domains/$domainId'
| '/services/$serviceId'
id:
| '__root__'
| '/_auth'
@@ -190,16 +260,22 @@ export interface FileRouteTypes {
| '/_auth/settings'
| '/_auth/certificates'
| '/_auth/groups'
| '/_auth/services'
| '/auth/callback'
| '/_auth/'
| '/_auth/services/$serviceId'
| '/_auth/groups/$groupId'
| '/_auth/settings/appearance'
| '/_auth/settings/integrations'
| '/_auth/domains/'
| '/_auth/services/'
| '/_auth/settings/'
| '/_auth/domains/$domainId/dns'
| '/_auth/services/$serviceId/health'
| '/_auth/services/$serviceId/nodes'
| '/_auth/services/$serviceId/routing'
| '/_auth/services/$serviceId/subdomains'
| '/_auth/domains/$domainId/'
| '/_auth/services/$serviceId/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -210,13 +286,6 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@@ -224,6 +293,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/': {
id: '/_auth/'
path: '/'
@@ -231,12 +307,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/certificates': {
id: '/_auth/certificates'
path: '/certificates'
fullPath: '/certificates'
preLoaderRoute: typeof AuthCertificatesRouteImport
parentRoute: typeof AuthRoute
'/auth/callback': {
id: '/auth/callback'
path: '/auth/callback'
fullPath: '/auth/callback'
preLoaderRoute: typeof AuthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/groups': {
id: '/_auth/groups'
@@ -245,11 +321,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthGroupsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/services': {
id: '/_auth/services'
path: '/services'
fullPath: '/services'
preLoaderRoute: typeof AuthServicesRouteImport
'/_auth/certificates': {
id: '/_auth/certificates'
path: '/certificates'
fullPath: '/certificates'
preLoaderRoute: typeof AuthCertificatesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/settings': {
@@ -259,12 +335,19 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsRouteRouteImport
parentRoute: typeof AuthRoute
}
'/auth/callback': {
id: '/auth/callback'
path: '/auth/callback'
fullPath: '/auth/callback'
preLoaderRoute: typeof AuthCallbackRouteImport
parentRoute: typeof rootRouteImport
'/_auth/settings/': {
id: '/_auth/settings/'
path: '/'
fullPath: '/settings/'
preLoaderRoute: typeof AuthSettingsIndexRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/services/': {
id: '/_auth/services/'
path: '/services'
fullPath: '/services/'
preLoaderRoute: typeof AuthServicesIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/domains/': {
id: '/_auth/domains/'
@@ -273,18 +356,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthDomainsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/groups/$groupId': {
id: '/_auth/groups/$groupId'
path: '/$groupId'
fullPath: '/groups/$groupId'
preLoaderRoute: typeof AuthGroupsGroupIdRouteImport
parentRoute: typeof AuthGroupsRoute
}
'/_auth/settings/': {
id: '/_auth/settings/'
path: '/'
fullPath: '/settings/'
preLoaderRoute: typeof AuthSettingsIndexRouteImport
'/_auth/settings/integrations': {
id: '/_auth/settings/integrations'
path: '/integrations'
fullPath: '/settings/integrations'
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/appearance': {
@@ -294,12 +370,26 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsAppearanceRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/integrations': {
id: '/_auth/settings/integrations'
path: '/integrations'
fullPath: '/settings/integrations'
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
parentRoute: typeof AuthSettingsRouteRoute
'/_auth/groups/$groupId': {
id: '/_auth/groups/$groupId'
path: '/$groupId'
fullPath: '/groups/$groupId'
preLoaderRoute: typeof AuthGroupsGroupIdRouteImport
parentRoute: typeof AuthGroupsRoute
}
'/_auth/services/$serviceId': {
id: '/_auth/services/$serviceId'
path: '/services/$serviceId'
fullPath: '/services/$serviceId'
preLoaderRoute: typeof AuthServicesServiceIdRouteRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/services/$serviceId/': {
id: '/_auth/services/$serviceId/'
path: '/'
fullPath: '/services/$serviceId/'
preLoaderRoute: typeof AuthServicesServiceIdIndexRouteImport
parentRoute: typeof AuthServicesServiceIdRouteRoute
}
'/_auth/domains/$domainId/': {
id: '/_auth/domains/$domainId/'
@@ -308,6 +398,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthDomainsDomainIdIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/services/$serviceId/subdomains': {
id: '/_auth/services/$serviceId/subdomains'
path: '/subdomains'
fullPath: '/services/$serviceId/subdomains'
preLoaderRoute: typeof AuthServicesServiceIdSubdomainsRouteImport
parentRoute: typeof AuthServicesServiceIdRouteRoute
}
'/_auth/services/$serviceId/routing': {
id: '/_auth/services/$serviceId/routing'
path: '/routing'
fullPath: '/services/$serviceId/routing'
preLoaderRoute: typeof AuthServicesServiceIdRoutingRouteImport
parentRoute: typeof AuthServicesServiceIdRouteRoute
}
'/_auth/services/$serviceId/nodes': {
id: '/_auth/services/$serviceId/nodes'
path: '/nodes'
fullPath: '/services/$serviceId/nodes'
preLoaderRoute: typeof AuthServicesServiceIdNodesRouteImport
parentRoute: typeof AuthServicesServiceIdRouteRoute
}
'/_auth/services/$serviceId/health': {
id: '/_auth/services/$serviceId/health'
path: '/health'
fullPath: '/services/$serviceId/health'
preLoaderRoute: typeof AuthServicesServiceIdHealthRouteImport
parentRoute: typeof AuthServicesServiceIdRouteRoute
}
'/_auth/domains/$domainId/dns': {
id: '/_auth/domains/$domainId/dns'
path: '/domains/$domainId/dns'
@@ -345,13 +463,36 @@ const AuthGroupsRouteWithChildren = AuthGroupsRoute._addFileChildren(
AuthGroupsRouteChildren,
)
interface AuthServicesServiceIdRouteRouteChildren {
AuthServicesServiceIdHealthRoute: typeof AuthServicesServiceIdHealthRoute
AuthServicesServiceIdNodesRoute: typeof AuthServicesServiceIdNodesRoute
AuthServicesServiceIdRoutingRoute: typeof AuthServicesServiceIdRoutingRoute
AuthServicesServiceIdSubdomainsRoute: typeof AuthServicesServiceIdSubdomainsRoute
AuthServicesServiceIdIndexRoute: typeof AuthServicesServiceIdIndexRoute
}
const AuthServicesServiceIdRouteRouteChildren: AuthServicesServiceIdRouteRouteChildren =
{
AuthServicesServiceIdHealthRoute: AuthServicesServiceIdHealthRoute,
AuthServicesServiceIdNodesRoute: AuthServicesServiceIdNodesRoute,
AuthServicesServiceIdRoutingRoute: AuthServicesServiceIdRoutingRoute,
AuthServicesServiceIdSubdomainsRoute: AuthServicesServiceIdSubdomainsRoute,
AuthServicesServiceIdIndexRoute: AuthServicesServiceIdIndexRoute,
}
const AuthServicesServiceIdRouteRouteWithChildren =
AuthServicesServiceIdRouteRoute._addFileChildren(
AuthServicesServiceIdRouteRouteChildren,
)
interface AuthRouteChildren {
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
AuthCertificatesRoute: typeof AuthCertificatesRoute
AuthGroupsRoute: typeof AuthGroupsRouteWithChildren
AuthServicesRoute: typeof AuthServicesRoute
AuthIndexRoute: typeof AuthIndexRoute
AuthServicesServiceIdRouteRoute: typeof AuthServicesServiceIdRouteRouteWithChildren
AuthDomainsIndexRoute: typeof AuthDomainsIndexRoute
AuthServicesIndexRoute: typeof AuthServicesIndexRoute
AuthDomainsDomainIdDnsRoute: typeof AuthDomainsDomainIdDnsRoute
AuthDomainsDomainIdIndexRoute: typeof AuthDomainsDomainIdIndexRoute
}
@@ -360,9 +501,10 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren,
AuthCertificatesRoute: AuthCertificatesRoute,
AuthGroupsRoute: AuthGroupsRouteWithChildren,
AuthServicesRoute: AuthServicesRoute,
AuthIndexRoute: AuthIndexRoute,
AuthServicesServiceIdRouteRoute: AuthServicesServiceIdRouteRouteWithChildren,
AuthDomainsIndexRoute: AuthDomainsIndexRoute,
AuthServicesIndexRoute: AuthServicesIndexRoute,
AuthDomainsDomainIdDnsRoute: AuthDomainsDomainIdDnsRoute,
AuthDomainsDomainIdIndexRoute: AuthDomainsDomainIdIndexRoute,
}
+36 -30
View File
@@ -7,6 +7,7 @@ import {
FolderTreeIcon,
GlobeIcon,
PlugIcon,
HeartPulseIcon,
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react'
@@ -15,6 +16,7 @@ import {
certSummaryQueryOptions,
domainsListQueryOptions,
groupsQueryOptions,
opsSummaryQueryOptions,
serviceGroupsQueryOptions,
} from '@/queries'
import { PageShell } from '@/components/page-shell'
@@ -50,23 +52,18 @@ export const Route = createFileRoute('/_auth/')({
queryClient.ensureQueryData(groupsQueryOptions()),
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
queryClient.ensureQueryData(certificatesQueryOptions()),
queryClient.ensureQueryData(opsSummaryQueryOptions()),
]),
component: DashboardPage,
})
function countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
if (!summary) return 0
return summary
.filter(([status]) => statuses.includes(status))
.reduce((sum, [, count]) => sum + count, 0)
}
function DashboardPage() {
const { data: domains, isLoading: domainsLoading } = useQuery(domainsListQueryOptions())
const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions())
const { data: certs } = useQuery(certificatesQueryOptions())
const { data: groups } = useQuery(groupsQueryOptions())
const { data: serviceData } = useQuery(serviceGroupsQueryOptions())
const { data: ops } = useQuery(opsSummaryQueryOptions())
const { data: appSettings } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'),
@@ -202,8 +199,7 @@ function DashboardPage() {
const attentionCount = attentionServices.length
const certWarnings = countByStatus(summary, ['warning', 'expired', 'error'])
const certOk = countByStatus(summary, ['active', 'ok'])
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
useEffect(() => {
if (isLoading) return
@@ -224,7 +220,6 @@ function DashboardPage() {
)
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
const kpiCards: KpiStatCard[] = [
{
id: 'domains',
@@ -239,19 +234,10 @@ function DashboardPage() {
variant: ungroupedCount > 0 ? 'warning' : 'default',
to: '/domains',
},
{
id: 'groups',
label: 'Группы',
value: groups?.length ?? 0,
hint: `${groupChartData.filter((g) => g.count > 0).length} с зонами`,
icon: <FolderTreeIcon aria-hidden />,
iconClassName: 'text-primary',
to: '/groups',
},
{
id: 'services',
label: 'Сервисы',
value: serviceCount,
value: ops?.services ?? serviceCount,
hint: attentionCount
? `${attentionCount} требуют внимания`
: ungroupedServiceCount
@@ -264,17 +250,37 @@ function DashboardPage() {
search: { domainId: undefined },
},
{
id: 'certs',
label: 'Сертификаты',
value: certs?.length ?? 0,
hint:
certWarnings > 0
? `${certOk} в норме · ${certWarnings} внимания`
: `${certOk} в норме`,
icon: <ShieldCheckIcon aria-hidden />,
id: 'nodes',
label: 'Ноды',
value: ops?.nodes ?? 0,
hint: `${ops?.healthy ?? 0} healthy`,
icon: <HeartPulseIcon aria-hidden />,
iconClassName: 'text-info',
to: '/services',
},
{
id: 'healthy',
label: 'Healthy',
value: ops?.healthy ?? 0,
icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-success',
},
{
id: 'unhealthy',
label: 'Unhealthy',
value: ops?.unhealthy ?? 0,
variant: (ops?.unhealthy ?? 0) > 0 ? 'destructive' : 'default',
icon: <AlertTriangleIcon aria-hidden />,
iconClassName: 'text-destructive',
},
{
id: 'failovers',
label: 'Failover',
value: ops?.active_failovers ?? 0,
hint: 'активные переключения',
variant: (ops?.active_failovers ?? 0) > 0 ? 'warning' : 'default',
icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-warning',
variant: certWarnings > 0 ? 'warning' : 'default',
to: '/certificates',
},
]
@@ -0,0 +1,158 @@
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 { 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import {
createOriginHealthCheck,
listOriginHealthChecks,
serviceOverviewQueryOptions,
} from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
component: ServiceHealthPage,
})
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 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 недоступны для этой зоны',
),
})
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>
}
/>
{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">
<Select
value={provider}
onValueChange={(value) =>
form.setValue('provider', (value as 'local' | 'cloudflare') ?? 'local')
}
>
<SelectTrigger id="hc-provider">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="cloudflare">Cloudflare</SelectItem>
</SelectContent>
</Select>
</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>
</DetailPanel>
)
}
@@ -0,0 +1,94 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { Badge } from '@/components/reui/badge'
import { serviceOverviewQueryOptions } from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId/')({
component: ServiceOverviewPage,
})
function ServiceOverviewPage() {
const { serviceId } = Route.useParams()
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
const overview = data as {
service: {
name: string
enabled: boolean
health_status: 'up' | 'down' | 'degraded' | 'unknown'
domains: Array<{ fqdn: string; zone_name: string }>
}
nodes: Array<{ id: number; address: string; health_status: string }>
routing_strategy: string
active_addresses: string[]
} | undefined
if (!overview) {
return (
<EmptyState
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
)
}
const nodes = overview.nodes ?? []
const domains = overview.service.domains ?? []
return (
<DetailPanel>
<DetailPanel.Header
title={overview.service.name}
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${
overview.active_addresses.join(', ') || '—'
}`}
actions={
<HealthCheckBadge status={overview.service.health_status} />
}
/>
<DetailPanel.Metrics
cards={[
{
id: 'subdomains',
icon: <GlobeIcon />,
label: 'Поддомены',
description:
domains.length > 0
? domains.map((d) => d.fqdn).join(', ')
: 'Нет привязанных FQDN',
footer: <Badge variant="outline">{domains.length}</Badge>,
},
{
id: 'nodes',
icon: <ServerIcon />,
label: 'Ноды',
description:
nodes.length > 0
? nodes.map((n) => n.address).join(', ')
: 'Добавьте ноду, чтобы публиковать DNS',
footer: <Badge variant="outline">{nodes.length}</Badge>,
},
{
id: 'health',
icon: <ActivityIcon />,
label: 'Пул',
description:
overview.active_addresses.length > 0
? 'Здоровые адреса участвуют в DNS'
: 'unknown не попадает в пул, пока не станет healthy',
},
]}
/>
{domains.length === 0 && nodes.length === 0 ? (
<EmptyState
title="Пустой сервис"
description="Добавьте поддомен и ноду, затем настройте health-check."
stackedIcon
/>
) : null}
</DetailPanel>
)
}
@@ -0,0 +1,143 @@
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 { PlusIcon } from 'lucide-react'
import { DetailPanel } 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 { HealthCheckBadge } from '@/components/health-check-badge'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { createServiceNode, deleteServiceNode, serviceNodesQueryOptions } from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
component: ServiceNodesPage,
})
interface NodeRow {
id: number
address: string
port: number | null
protocol: string
health_status: 'up' | 'down' | 'degraded' | 'unknown' | 'healthy' | 'unhealthy' | 'checking' | 'disabled'
weight: number
priority: number
}
function mapHealth(
status: NodeRow['health_status'],
): 'up' | 'down' | 'degraded' | 'unknown' {
if (status === 'healthy' || status === 'up') return 'up'
if (status === 'unhealthy' || status === 'down') return 'down'
if (status === 'degraded') return 'degraded'
return 'unknown'
}
export function ServiceNodesPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const queryClient = useQueryClient()
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const nodes = (nodesQuery.data ?? []) as NodeRow[]
const [open, setOpen] = useState(false)
const form = useForm<{ address: string; port: string }>({
defaultValues: { address: '', port: '' },
})
const createMut = useMutation({
mutationFn: (values: { address: string; port: string }) =>
createServiceNode(id, {
address: values.address.trim(),
port: values.port ? Number(values.port) : null,
}),
onSuccess: async () => {
toast.success('Нода добавлена, статус CHECKING')
await queryClient.invalidateQueries({ queryKey: ['services'] })
setOpen(false)
form.reset()
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
})
const deleteMut = useMutation({
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
onSuccess: async () => {
toast.success('Нода удалена')
await queryClient.invalidateQueries({ queryKey: ['services'] })
},
})
return (
<DetailPanel>
<DetailPanel.Header
title="Ноды"
description="Адреса происхождения сервиса."
actions={
<Button size="sm" onClick={() => setOpen(true)}>
<PlusIcon className="size-4" aria-hidden />
Добавить ноду
</Button>
}
/>
{nodes.length === 0 ? (
<EmptyState
title="Нет нод"
description="Добавьте IP, затем настройте health-check."
/>
) : (
<div className="flex flex-col gap-2">
{nodes.map((node) => (
<div
key={node.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">{node.address}</span>
<span className="text-muted-foreground text-xs">
{node.protocol}
{node.port ? `:${node.port}` : ''} · вес {node.weight} · приоритет{' '}
{node.priority}
</span>
</div>
<div className="flex items-center gap-2">
<HealthCheckBadge status={mapHealth(node.health_status)} />
<Button
size="sm"
variant="outline"
onClick={() => deleteMut.mutate(node.id)}
>
Удалить
</Button>
</div>
</div>
))}
</div>
)}
<FormSheet
open={open}
onOpenChange={setOpen}
title="Добавить ноду"
description="IP станет CHECKING до порога успешных проверок."
form={form}
onSubmit={(values) => createMut.mutate(values)}
footer={
<LoadingButton type="submit" isLoading={createMut.isPending}>
Добавить
</LoadingButton>
}
>
<FormFieldSimple label="IP" htmlFor="address">
<Input id="address" {...form.register('address')} placeholder="10.0.0.10" />
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
<Input id="port" {...form.register('port')} placeholder="443" />
</FormFieldSimple>
</FormSheet>
</DetailPanel>
)
}
@@ -0,0 +1,79 @@
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeftIcon } from 'lucide-react'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { Button } from '@cfdm/ui/components/button'
import { serviceOverviewQueryOptions } from '@/queries'
import { cn } from '@cfdm/ui/lib/utils'
export const Route = createFileRoute('/_auth/services/$serviceId')({
loader: ({ context: { queryClient }, params }) =>
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))),
component: ServiceLayout,
})
const tabs = [
{ to: '/services/$serviceId', label: 'Обзор', exact: true },
{ to: '/services/$serviceId/subdomains', label: 'Поддомены', exact: false },
{ to: '/services/$serviceId/nodes', label: 'Ноды', exact: false },
{ to: '/services/$serviceId/health', label: 'Health', exact: false },
{ to: '/services/$serviceId/routing', label: 'Маршрутизация', exact: false },
] as const
function ServiceLayout() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const pathname = useRouterState({ select: (s) => s.location.pathname })
const overview = useQuery(serviceOverviewQueryOptions(id))
const name = (overview.data as { service?: { name?: string } } | undefined)?.service?.name
return (
<PageShell>
<PageHeader
title={name ?? 'Сервис'}
description="Domain → Service → Node → Health → Failover"
actions={
<Button
variant="outline"
size="sm"
render={<Link to="/services" />}
>
<ArrowLeftIcon className="size-4" aria-hidden />
К каталогу
</Button>
}
/>
<nav className="flex flex-wrap gap-4 border-b">
{tabs.map((tab) => {
const href = tab.to.replace('$serviceId', serviceId)
const active = tab.exact
? pathname === `/services/${serviceId}` || pathname === `/services/${serviceId}/`
: pathname.startsWith(href)
return (
<Link
key={tab.to}
to={tab.to}
params={{ serviceId }}
className={cn(
'text-muted-foreground hover:text-foreground pb-3 text-sm font-medium',
active && 'text-foreground border-b-2 border-primary',
)}
>
{tab.label}
</Link>
)
})}
</nav>
<QueryState
isLoading={overview.isLoading}
isError={overview.isError}
error={overview.error}
onRetry={() => void overview.refetch()}
>
<Outlet />
</QueryState>
</PageShell>
)
}
@@ -0,0 +1,59 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { DetailPanel } from '@/components/reui-kit'
import { FailoverTimeline } from '@/components/failover-timeline'
import { Badge } from '@/components/reui/badge'
import { serviceOverviewQueryOptions } from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
component: ServiceRoutingPage,
})
export function ServiceRoutingPage() {
const { serviceId } = Route.useParams()
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
const overview = data as {
routing_strategy: string
active_addresses: string[]
nodes: Array<{
address: string
health_status: string
consecutive_failures: number
last_failure_reason: string | null
}>
} | undefined
const events =
overview?.nodes
.filter(
(node) =>
node.health_status === 'unhealthy' ||
node.health_status === 'down' ||
node.health_status === 'checking',
)
.map((node) => ({
id: node.address,
title: `${node.address}: ${node.health_status}`,
detail: node.last_failure_reason
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
: `fail ${node.consecutive_failures}`,
})) ?? []
return (
<DetailPanel>
<DetailPanel.Header
title="Маршрутизация"
description="Round Robin / Failover. Weighted на DNS = alias Round Robin."
actions={<Badge variant="outline">{overview?.routing_strategy ?? 'round_robin'}</Badge>}
/>
<p className="text-sm">
Активные адреса:{' '}
{overview?.active_addresses.join(', ') || 'нет (unknown не в пуле)'}
</p>
<p className="text-muted-foreground text-xs">
Запись обновляется в Cloudflare. Распространение зависит от TTL.
</p>
<FailoverTimeline events={events} />
</DetailPanel>
)
}
@@ -0,0 +1,114 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ArrowRightLeftIcon } from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@/components/reui/badge'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
import { serviceOverviewQueryOptions } from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
component: ServiceSubdomainsPage,
})
export function ServiceSubdomainsPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const { data } = useQuery(serviceOverviewQueryOptions(id))
const overview = data as {
service: {
domains: Array<{
binding_id: number
domain_id: number
fqdn: string
zone_name: string
target_ips: string[]
}>
}
} | undefined
const rows = overview?.service.domains ?? []
const [changeIp, setChangeIp] = useState<{
bindingId: number
ip?: string
} | null>(null)
const [changeDomain, setChangeDomain] = useState(false)
const fromDomainId = useMemo(
() => rows[0]?.domain_id ?? null,
[rows],
)
return (
<DetailPanel>
<DetailPanel.Header
title="Поддомены"
description="FQDN сервиса в одной или нескольких зонах Cloudflare."
actions={
<Button
variant="outline"
size="sm"
onClick={() => setChangeDomain(true)}
disabled={rows.length === 0}
>
<ArrowRightLeftIcon className="size-4" aria-hidden />
Сменить домен
</Button>
}
/>
{rows.length === 0 ? (
<EmptyState
title="Нет поддоменов"
description="Привяжите FQDN к сервису из карточки редактирования."
/>
) : (
<div className="flex flex-col gap-2">
{rows.map((row) => (
<div
key={row.binding_id}
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
>
<div className="flex min-w-0 flex-col gap-1">
<span className="font-medium">{row.fqdn}</span>
<span className="text-muted-foreground text-xs">
{row.zone_name} · {row.target_ips.join(', ') || 'нет IP'}
</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{row.target_ips.length} IP</Badge>
<Button
size="sm"
variant="outline"
onClick={() =>
setChangeIp({
bindingId: row.binding_id,
ip: row.target_ips[0],
})
}
>
Сменить IP
</Button>
</div>
</div>
))}
</div>
)}
<ChangeIpSheet
open={changeIp != null}
onOpenChange={(open) => {
if (!open) setChangeIp(null)
}}
bindingId={changeIp?.bindingId ?? null}
serviceId={id}
currentIp={changeIp?.ip}
/>
<ChangeDomainSheet
open={changeDomain}
onOpenChange={setChangeDomain}
serviceId={id}
fromDomainId={fromDomainId}
/>
</DetailPanel>
)
}
@@ -43,7 +43,7 @@ import {
import { ServiceKanbanCard } from '@/components/kanban/service-kanban-card'
import { Button } from '@cfdm/ui/components/button'
export const Route = createFileRoute('/_auth/services')({
export const Route = createFileRoute('/_auth/services/')({
validateSearch: (
search: Record<string, unknown>,
): { domainId?: number; serviceId?: number; view?: 'board' } => ({