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
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:
@@ -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()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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' } => ({
|
||||
Reference in New Issue
Block a user