feat(services): enhance service health logging and component structure
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 59s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 2m0s
CD / publish (push) Successful in 1m37s

- Updated the service health log retrieval to include a limit parameter, improving performance and control over log data.
- Refactored the ServiceUnitCard component to export the LbModeTile function, enhancing reusability across the application.
- Simplified routing for service health and nodes pages by redirecting to the service overview, improving user navigation.
- Enhanced the service detail page with additional state management and mutation hooks for better service configuration handling.
- Removed unused components and streamlined the service routing and subdomains pages for improved clarity and maintainability.
This commit is contained in:
Denozordec
2026-08-19 23:45:53 +07:00
parent d8fc4ac949
commit ba03d2be9d
11 changed files with 1334 additions and 556 deletions
+5 -1
View File
@@ -69,7 +69,11 @@ export async function serviceRoutes(app: FastifyInstance) {
const { id } = request.params as { id: string };
repos.getService(request.server.db, Number(id));
return {
items: repos.listHealthProbeLogForService(request.server.db, Number(id)),
items: repos.listHealthProbeLogForService(
request.server.db,
Number(id),
200,
),
};
});
@@ -1,3 +1,4 @@
export { UptimeChart, type UptimeProbe } from './uptime-chart'
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
@@ -0,0 +1,301 @@
import { useId, useMemo, useState } from 'react'
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
import { Area, AreaChart, XAxis } from 'recharts'
import { EmptyState } from '@/components/empty-state'
import { Badge } from '@/components/reui/badge'
import { Frame, FramePanel } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { formatDate, sqliteUtcToIso } from '@/lib/format'
import { Button } from '@cfdm/ui/components/button'
import {
ChartContainer,
ChartTooltip,
type ChartConfig,
} from '@cfdm/ui/components/chart'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
/**
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
* Preview: https://reui.io/preview/base/chart-17
* Frame: https://reui.io/docs/components/base/frame
* Chart: shadcn Chart + Recharts AreaChart
*/
export interface UptimeProbe {
id: number
status: 'up' | 'down' | 'degraded' | 'unknown'
ok: boolean
latency_ms: number | null
checked_at: string
}
export type UptimePeriodKey = '5D' | '2W' | '1M'
const PERIODS: { key: UptimePeriodKey; label: string; days: number }[] = [
{ key: '5D', label: '5D', days: 5 },
{ key: '2W', label: '2W', days: 14 },
{ key: '1M', label: '1M', days: 30 },
]
const chartConfig = {
latency: {
label: 'Задержка',
color: 'var(--chart-1)',
},
} satisfies ChartConfig
interface ChartPoint {
period: string
latency: number
ok: boolean
at: string
status: UptimeProbe['status']
}
function probeTime(checkedAt: string): number {
const iso = sqliteUtcToIso(checkedAt) ?? checkedAt
const time = new Date(iso).getTime()
return Number.isNaN(time) ? 0 : time
}
function filterByPeriod(items: UptimeProbe[], days: number): UptimeProbe[] {
const cutoff = Date.now() - days * 86_400_000
return items.filter((item) => probeTime(item.checked_at) >= cutoff)
}
function toSeries(items: UptimeProbe[]): ChartPoint[] {
return [...items]
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
.map((item) => ({
period: formatDate(item.checked_at),
latency: item.latency_ms ?? 0,
ok: item.ok && item.status !== 'down',
at: item.checked_at,
status: item.status,
}))
}
function uptimePercent(points: ChartPoint[]): number | null {
if (points.length === 0) return null
const okCount = points.filter((point) => point.ok).length
return (okCount / points.length) * 100
}
function deltaPercent(points: ChartPoint[]): number | null {
if (points.length < 4) return null
const mid = Math.floor(points.length / 2)
const prev = uptimePercent(points.slice(0, mid))
const next = uptimePercent(points.slice(mid))
if (prev == null || next == null) return null
return next - prev
}
interface UptimeTooltipProps {
active?: boolean
payload?: Array<{ payload: ChartPoint }>
}
function UptimeTooltip({ active, payload }: UptimeTooltipProps) {
if (!active || !payload?.[0]) return null
const point = payload[0].payload
return (
<div className="bg-popover text-popover-foreground rounded-md px-3 py-2 text-sm shadow-md">
<p className="font-medium tabular-nums">
{point.latency} мс · {point.ok ? 'OK' : 'Down'}
</p>
<p className="text-muted-foreground text-xs">{point.period}</p>
</div>
)
}
interface UptimeChartProps {
items: UptimeProbe[]
isLoading?: boolean
}
export function UptimeChart({ items, isLoading = false }: UptimeChartProps) {
const gradientId = useId().replace(/:/g, '')
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
const days = PERIODS.find((entry) => entry.key === period)?.days ?? 5
const points = useMemo(
() => toSeries(filterByPeriod(items, days)),
[items, days],
)
const uptime = uptimePercent(points)
const delta = deltaPercent(points)
const lastOk = points.at(-1)?.ok ?? true
const tileClass = lastOk ? 'text-success' : 'text-destructive'
return (
<Frame spacing="sm" className="min-w-0 w-full">
<FramePanel className="flex flex-col gap-6">
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
<div className="flex items-center gap-2.5">
<IconTile
variant="elevated"
className={`size-10.5 ${tileClass}`}
aria-hidden="true"
>
<ActivityIcon />
</IconTile>
<div className="flex flex-col justify-center">
<h3 className="text-base font-semibold">Uptime</h3>
<p className="text-muted-foreground text-sm">
Пробы health-check за период
</p>
</div>
</div>
<TooltipProvider delay={150}>
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="О графике uptime"
className="text-muted-foreground/70 -mr-1"
size="icon-sm"
type="button"
variant="ghost"
/>
}
>
<InfoIcon data-icon="inline-start" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{isLoading ? (
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
) : points.length === 0 ? (
<EmptyState
icon={ActivityIcon}
title="Нет проб за период"
description="Результаты появятся после health-check"
stackedIcon={false}
centered={false}
/>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="text-foreground text-3xl font-semibold tabular-nums">
{uptime == null ? '—' : `${uptime.toFixed(uptime >= 99.95 ? 2 : 1)}%`}
</div>
<div className="flex items-center gap-2 text-sm">
{delta == null ? (
<Badge variant="outline" size="sm">
{points.length} проб
</Badge>
) : delta >= 0 ? (
<>
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
<span className="text-success font-medium">
+{delta.toFixed(1)} п.п.
</span>
<span className="text-muted-foreground">к первой половине окна</span>
</>
) : (
<>
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
<span className="text-destructive font-medium">
{delta.toFixed(1)} п.п.
</span>
<span className="text-muted-foreground">к первой половине окна</span>
</>
)}
</div>
</div>
<div className="h-40 w-full">
<ChartContainer
config={chartConfig}
className="h-full w-full overflow-hidden rounded-b-xl"
initialDimension={{ width: 320, height: 160 }}
>
<AreaChart
data={points}
margin={{ top: 10, left: 0, right: 0, bottom: 0 }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-latency)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-latency)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<XAxis dataKey="period" hide />
<ChartTooltip content={<UptimeTooltip />} />
<Area
dataKey="latency"
type="natural"
fill={`url(#${gradientId})`}
stroke="var(--color-latency)"
strokeWidth={2}
dot={(dotProps) => {
const { cx, cy, payload, index } = dotProps as {
cx?: number
cy?: number
index?: number
payload?: ChartPoint
}
if (cx == null || cy == null) return <g key={index} />
const fill = payload?.ok
? 'var(--color-latency)'
: 'var(--destructive)'
return (
<circle
key={index}
cx={cx}
cy={cy}
r={4}
fill={fill}
stroke="var(--background)"
strokeWidth={2}
/>
)
}}
activeDot={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
/>
</AreaChart>
</ChartContainer>
</div>
</div>
)}
<Tabs
value={period}
onValueChange={(value) => setPeriod(value as UptimePeriodKey)}
>
<TabsList className="w-full">
{PERIODS.map((entry) => (
<TabsTrigger key={entry.key} value={entry.key} className="flex-1">
{entry.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,572 @@
import { useMemo, useState, type ReactNode } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import {
GlobeIcon,
NetworkIcon,
PlusIcon,
SearchIcon,
ServerIcon,
} from 'lucide-react'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { IconTile } from '@/components/reui/icon-tile'
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch'
type HealthStatus = 'up' | 'down' | 'degraded' | 'unknown'
interface ServiceIpRow {
id: string
ip: string
status: HealthStatus
enabled: boolean
active: boolean
weight: number
priority: number
latency_ms: number | null
last_checked_at: string | null
last_error: string | null
colo: string | null
provider: string | null
}
export interface ServiceFqdnRow {
id: string
fqdn: string
zone_name: string
target_ips: string[]
binding_id: number
domain_id: number
}
interface ServiceNodeRow {
id: string
nodeId: number
address: string
protocol: string
port: number | null
health_status: HealthStatus
weight: number
priority: number
}
const TABS = [
{ id: 'ip', label: 'IP' },
{ id: 'fqdn', label: 'FQDN' },
{ id: 'nodes', label: 'Ноды' },
] as const
const HEALTH_OPTIONS = [
{ value: 'up', label: 'OK' },
{ value: 'degraded', label: 'Slow' },
{ value: 'down', label: 'Down' },
{ value: 'unknown', label: '—' },
]
function mapNodeHealth(status: string): HealthStatus {
if (status === 'healthy' || status === 'up') return 'up'
if (status === 'unhealthy' || status === 'down') return 'down'
if (status === 'degraded') return 'degraded'
return 'unknown'
}
function buildIpRows(service: ServiceView): ServiceIpRow[] {
const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row]))
const weights = Object.assign(
{},
...service.domains.map((domain) => domain.target_ip_weights ?? {}),
) as Record<string, number>
const priorities = Object.assign(
{},
...service.domains.map((domain) => domain.target_ip_priorities ?? {}),
) as Record<string, number>
const activeSet = new Set(service.active_ips)
return service.ips.map((ip) => {
const health = healthByIp.get(ip)
return {
id: ip,
ip,
status: health?.status ?? 'unknown',
enabled: service.ip_enabled[ip] !== false,
active: activeSet.has(ip),
weight: weights[ip] ?? 1,
priority: priorities[ip] ?? 1,
latency_ms: health?.latency_ms ?? null,
last_checked_at: health?.last_checked_at ?? null,
last_error: health?.last_error ?? null,
colo: health?.colo ?? null,
provider: health?.provider ?? null,
}
})
}
function buildFqdnRows(service: ServiceView): ServiceFqdnRow[] {
return service.domains.map((domain) => ({
id: String(domain.binding_id),
fqdn: domain.fqdn,
zone_name: domain.zone_name,
target_ips: domain.target_ips ?? [],
binding_id: domain.binding_id,
domain_id: domain.domain_id,
}))
}
function buildNodeRows(
nodes: Array<{
id: number
address: string
protocol: string
port: number | null
health_status: string
weight: number
priority: number
}>,
): ServiceNodeRow[] {
return nodes.map((node) => ({
id: String(node.id),
nodeId: node.id,
address: node.address,
protocol: node.protocol,
port: node.port,
health_status: mapNodeHealth(node.health_status),
weight: node.weight,
priority: node.priority,
}))
}
function NameCell({
icon,
label,
iconClassName,
}: {
icon: ReactNode
label: string
iconClassName?: string
}) {
return (
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="xs"
className={iconClassName ?? 'text-muted-foreground'}
aria-hidden="true"
>
{icon}
</IconTile>
<span className="truncate font-mono text-sm">{label}</span>
</div>
)
}
interface ServiceDetailGridProps {
service: ServiceView
nodes: Array<{
id: number
address: string
protocol: string
port: number | null
health_status: string
weight: number
priority: number
}>
togglingIp: string | null
onToggleIp: (ip: string, enabled: boolean) => void
onChangeIp: (row: ServiceFqdnRow) => void
onChangeDomain: () => void
onAddNode: () => void
onDeleteNode: (nodeId: number) => void
isLoading?: boolean
}
export function ServiceDetailGrid({
service,
nodes,
togglingIp,
onToggleIp,
onChangeIp,
onChangeDomain,
onAddNode,
onDeleteNode,
isLoading = false,
}: ServiceDetailGridProps) {
const [tab, setTab] = useState<(typeof TABS)[number]['id']>('ip')
const [ipFilters, setIpFilters] = useState<Filter[]>(() => [
createFilter('ip', 'contains', ['']),
createFilter('status', 'is', ['']),
])
const [fqdnFilters, setFqdnFilters] = useState<Filter[]>(() => [
createFilter('fqdn', 'contains', ['']),
])
const [nodeFilters, setNodeFilters] = useState<Filter[]>(() => [
createFilter('address', 'contains', ['']),
createFilter('health_status', 'is', ['']),
])
const ipRows = useMemo(() => buildIpRows(service), [service])
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
const nodeRows = useMemo(() => buildNodeRows(nodes), [nodes])
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
const tabs = TABS.map((entry) => ({
...entry,
count:
entry.id === 'ip'
? ipRows.length
: entry.id === 'fqdn'
? fqdnRows.length
: nodeRows.length,
}))
const ipFilterFields = useMemo<FilterFieldConfig[]>(
() => [
{
key: 'ip',
label: 'IP',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по IP…',
},
{
key: 'status',
label: 'Статус',
type: 'select',
searchable: true,
className: 'w-[168px]',
options: HEALTH_OPTIONS,
},
],
[],
)
const fqdnFilterFields = useMemo<FilterFieldConfig[]>(
() => [
{
key: 'fqdn',
label: 'FQDN',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по FQDN…',
},
],
[],
)
const nodeFilterFields = useMemo<FilterFieldConfig[]>(
() => [
{
key: 'address',
label: 'Адрес',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по адресу…',
},
{
key: 'health_status',
label: 'Статус',
type: 'select',
searchable: true,
className: 'w-[168px]',
options: HEALTH_OPTIONS,
},
],
[],
)
const ipColumns = useMemo<ColumnDef<ServiceIpRow>[]>(
() => [
{
id: 'ip',
accessorKey: 'ip',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="IP" />
),
cell: ({ row }) => (
<NameCell
icon={<NetworkIcon />}
label={row.original.ip}
iconClassName="text-info"
/>
),
},
{
id: 'status',
accessorKey: 'status',
header: 'Health',
cell: ({ row }) => (
<HealthCheckBadge
status={row.original.status}
latencyMs={row.original.latency_ms}
lastCheckedAt={row.original.last_checked_at}
lastError={row.original.last_error}
colo={row.original.colo}
provider={row.original.provider}
size="xs"
/>
),
},
{
id: 'active',
header: 'Пул',
cell: ({ row }) =>
markActive && row.original.active ? (
<StatusBadge status="active" />
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: 'weight',
accessorKey: 'weight',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Вес" />
),
cell: ({ row }) => (
<span className="tabular-nums">
{service.lb_mode === 'weighted' ? `w${row.original.weight}` : row.original.weight}
</span>
),
},
{
id: 'priority',
accessorKey: 'priority',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Приоритет" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.priority}</span>
),
},
{
id: 'enabled',
header: 'Вкл',
cell: ({ row }) => (
<Switch
size="sm"
checked={row.original.enabled}
disabled={togglingIp === row.original.ip}
onCheckedChange={(checked) =>
onToggleIp(row.original.ip, Boolean(checked))
}
aria-label={
row.original.enabled
? `Выключить IP ${row.original.ip}`
: `Включить IP ${row.original.ip}`
}
/>
),
},
],
[markActive, onToggleIp, service.lb_mode, togglingIp],
)
const fqdnColumns = useMemo<ColumnDef<ServiceFqdnRow>[]>(
() => [
{
id: 'fqdn',
accessorKey: 'fqdn',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="FQDN" />
),
cell: ({ row }) => (
<NameCell
icon={<GlobeIcon />}
label={row.original.fqdn}
iconClassName="text-foreground"
/>
),
},
{
id: 'zone',
accessorKey: 'zone_name',
header: 'Зона',
},
{
id: 'ips',
header: 'Target IP',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="text-muted-foreground font-mono text-xs">
{row.original.target_ips.join(', ') || '—'}
</span>
<Badge variant="outline" size="xs">
{row.original.target_ips.length} IP
</Badge>
</div>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Button
size="sm"
variant="outline"
onClick={() => onChangeIp(row.original)}
>
Сменить IP
</Button>
),
},
],
[onChangeIp],
)
const nodeColumns = useMemo<ColumnDef<ServiceNodeRow>[]>(
() => [
{
id: 'address',
accessorKey: 'address',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Адрес" />
),
cell: ({ row }) => (
<NameCell
icon={<ServerIcon />}
label={row.original.address}
iconClassName="text-foreground"
/>
),
},
{
id: 'health',
accessorKey: 'health_status',
header: 'Health',
cell: ({ row }) => (
<HealthCheckBadge status={row.original.health_status} size="xs" />
),
},
{
id: 'meta',
header: 'Вес / приоритет',
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{row.original.protocol}
{row.original.port ? `:${row.original.port}` : ''} · w
{row.original.weight} · p{row.original.priority}
</span>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Button
size="sm"
variant="outline"
onClick={() => onDeleteNode(row.original.nodeId)}
>
Удалить
</Button>
),
},
],
[onDeleteNode],
)
const sharedTabs = {
tabs,
activeTab: tab,
onTabChange: (id: string) => setTab(id as typeof tab),
}
if (tab === 'fqdn') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
{...sharedTabs}
filterFields={fqdnFilterFields}
filters={fqdnFilters}
onFiltersChange={setFqdnFilters}
onClearFilters={() => setFqdnFilters([createFilter('fqdn', 'contains', [''])])}
getFilterFieldValue={(item, field) =>
field === 'fqdn' ? `${item.fqdn} ${item.zone_name}` : ''
}
columns={fqdnColumns}
data={fqdnRows}
getRowId={(row) => row.id}
isLoading={isLoading}
primaryAction={
<Button
variant="outline"
size="sm"
onClick={onChangeDomain}
disabled={fqdnRows.length === 0}
>
Сменить домен
</Button>
}
/>
)
}
if (tab === 'nodes') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
{...sharedTabs}
filterFields={nodeFilterFields}
filters={nodeFilters}
onFiltersChange={setNodeFilters}
onClearFilters={() =>
setNodeFilters([
createFilter('address', 'contains', ['']),
createFilter('health_status', 'is', ['']),
])
}
getFilterFieldValue={(item, field) => {
if (field === 'address') return item.address
if (field === 'health_status') return item.health_status
return ''
}}
columns={nodeColumns}
data={nodeRows}
getRowId={(row) => row.id}
isLoading={isLoading}
primaryAction={
<Button size="sm" onClick={onAddNode}>
<PlusIcon className="size-4" aria-hidden />
Добавить ноду
</Button>
}
/>
)
}
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
{...sharedTabs}
filterFields={ipFilterFields}
filters={ipFilters}
onFiltersChange={setIpFilters}
onClearFilters={() =>
setIpFilters([
createFilter('ip', 'contains', ['']),
createFilter('status', 'is', ['']),
])
}
getFilterFieldValue={(item, field) => {
if (field === 'ip') return item.ip
if (field === 'status') return item.status
return ''
}}
columns={ipColumns}
data={ipRows}
getRowId={(row) => row.id}
isLoading={isLoading}
/>
)
}
@@ -75,7 +75,7 @@ const LB_MODE_META: Record<
},
}
function LbModeTile({ mode }: { mode: LbMode }) {
export function LbModeTile({ mode }: { mode: LbMode }) {
const meta = LB_MODE_META[mode]
const Icon = meta.icon
@@ -1,104 +1,11 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { HealthTimeline } from '@/components/health/health-timeline'
import { HealthCheckBadge } from '@/components/health-check-badge'
import {
serviceHealthLogQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
import { formatDate } from '@/lib/format'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
component: ServiceHealthPage,
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
})
export function ServiceHealthPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const serviceQuery = useQuery(serviceViewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const service = serviceQuery.data
const items = logQuery.data?.items ?? []
const ipHealth = service?.ip_health ?? []
const kpiCards = ipHealth.map((row) => {
const variant =
row.status === 'down'
? ('destructive' as const)
: row.status === 'degraded'
? ('warning' as const)
: ('default' as const)
return {
id: row.ip,
label: row.ip,
value: row.latency_ms != null ? `${row.latency_ms} мс` : '—',
hint: row.colo ? `colo ${row.colo}` : row.provider === 'cloudflare' ? 'Worker' : 'Local',
icon: row.provider === 'cloudflare' ? <GlobeIcon /> : <ServerIcon />,
variant,
footer: (
<HealthCheckBadge
status={row.status}
latencyMs={row.latency_ms}
lastCheckedAt={row.last_checked_at}
lastError={row.last_error}
colo={row.colo}
provider={row.provider}
size="xs"
/>
),
}
})
return (
<DetailPanel>
<DetailPanel.Header
title="Health"
description="Снимок проб этого сервиса. Cloudflare = Worker с edge, не Health Checks API."
/>
<Alert>
<AlertTitle>XOR провайдеров</AlertTitle>
<AlertDescription>
Local ходит с API CFDM; Cloudflare через Worker. Cron и пороги Slow/Down общие, в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
. Если Worker не задан, цель не пробируется как Local.
</AlertDescription>
</Alert>
{kpiCards.length > 0 ? (
<KpiStatGrid cards={kpiCards} />
) : (
<EmptyState
icon={ActivityIcon}
title="Нет проб"
description="Включите health-check на привязке — статус IP появится после cron."
/>
)}
<DetailPanel.Header
title="Журнал проб"
description={
items[0]?.checked_at
? `Последняя: ${formatDate(items[0].checked_at)}`
: 'Последние пробы по IP этого сервиса'
}
/>
<HealthTimeline
events={items.map((row) => ({
id: row.id,
hostname: row.ip,
type: row.provider,
status: row.status,
latency_ms: row.latency_ms,
error: row.error,
checked_at: row.checked_at,
colo: row.colo,
provider: row.provider,
}))}
/>
</DetailPanel>
)
}
@@ -1,94 +1,420 @@
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 { createFileRoute, Link, useNavigate } 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 {
ActivityIcon,
ArrowLeftIcon,
GlobeIcon,
NetworkIcon,
PencilIcon,
ServerIcon,
} from 'lucide-react'
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { EmptyState } from '@/components/empty-state'
import { FailoverTimeline } from '@/components/failover-timeline'
import { FormFieldSimple } from '@/components/form-field'
import { FormSheet } from '@/components/form-sheet'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { Badge } from '@/components/reui/badge'
import { serviceOverviewQueryOptions } from '@/queries'
import { HealthTimeline } from '@/components/health/health-timeline'
import { LoadingButton } from '@/components/loading-button'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
import {
ServiceDetailGrid,
type ServiceFqdnRow,
} from '@/components/services/service-detail-grid'
import { LbModeTile } from '@/components/services/service-unit-card'
import { KpiStatGrid, UptimeChart } from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { api } from '@/lib/api-client'
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
import {
createServiceNode,
deleteServiceNode,
domainKeys,
domainsListQueryOptions,
serviceBindingKeys,
serviceDetailKeys,
serviceGroupKeys,
serviceGroupsQueryOptions,
serviceHealthLogQueryOptions,
serviceKeys,
serviceNodesQueryOptions,
serviceOverviewQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
export const Route = createFileRoute('/_auth/services/$serviceId/')({
component: ServiceOverviewPage,
component: ServiceDetailPage,
})
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
interface OverviewPayload {
routing_strategy?: string
active_addresses?: string[]
nodes?: Array<{
id: number
address: string
protocol: string
port: number | null
health_status: string
weight: number
priority: number
consecutive_failures: number
last_failure_reason: string | null
}>
}
if (!overview) {
return (
<EmptyState
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
)
function ServiceDetailPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const navigate = useNavigate()
const queryClient = useQueryClient()
const viewQuery = useQuery(serviceViewQueryOptions(id))
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const groupsQuery = useQuery(serviceGroupsQueryOptions())
const domainsQuery = useQuery(domainsListQueryOptions())
const service = viewQuery.data
const overview = overviewQuery.data as OverviewPayload | undefined
const logItems = logQuery.data?.items ?? []
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
const [editOpen, setEditOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [togglingIp, setTogglingIp] = useState<string | null>(null)
const [changeIp, setChangeIp] = useState<{
bindingId: number
ip?: string
} | null>(null)
const [changeDomain, setChangeDomain] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false)
const nodeForm = useForm<{ address: string; port: string }>({
defaultValues: { address: '', port: '' },
})
const groups = groupsQuery.data
? [...groupsQuery.data.groups]
: []
async function invalidateService() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
])
}
const nodes = overview.nodes ?? []
const domains = overview.service.domains ?? []
const updateMutation = useMutation({
mutationFn: ({ body }: { body: UpdateServiceConfigInput }) =>
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
onSuccess: async () => {
await invalidateService()
setEditOpen(false)
toast.success('Сервис сохранён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
},
onSettled: () => setSaving(false),
})
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/api/v1/services/${id}`),
onSuccess: async () => {
await invalidateService()
toast.success('Сервис удалён')
await navigate({ to: '/services' })
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
},
})
const toggleIpMutation = useMutation({
mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) =>
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }),
onSuccess: async (_data, { enabled }) => {
await invalidateService()
toast.success(
enabled
? 'IP включён и добавлен в DNS-привязки'
: 'IP выключен и снят с DNS-привязок',
)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
},
onSettled: () => setTogglingIp(null),
})
const createNodeMut = 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 invalidateService()
setAddNodeOpen(false)
nodeForm.reset()
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
})
const deleteNodeMut = useMutation({
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
onSuccess: async () => {
toast.success('Нода удалена')
await invalidateService()
},
})
const isLoading = viewQuery.isLoading || overviewQuery.isLoading
const isError = viewQuery.isError || overviewQuery.isError
const error = viewQuery.error ?? overviewQuery.error
const failoverEvents =
(nodes.length > 0 ? nodes : (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={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 ? (
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void viewQuery.refetch()
void overviewQuery.refetch()
}}
>
{!service ? (
<EmptyState
title="Пустой сервис"
description="Добавьте поддомен и ноду, затем настройте health-check."
stackedIcon
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
) : null}
</DetailPanel>
) : (
<div className="@container flex w-full flex-col gap-4 md:gap-6">
<PageHeader
title={service.name}
description="Domain → Service → Node → Health → Failover"
actions={
<>
<LbModeTile mode={service.lb_mode} />
<HealthCheckBadge status={service.health_status} />
<Button size="sm" onClick={() => setEditOpen(true)}>
<PencilIcon className="size-4" aria-hidden />
Изменить
</Button>
<Button variant="outline" size="sm" render={<Link to="/services" />}>
<ArrowLeftIcon className="size-4" aria-hidden />
К каталогу
</Button>
</>
}
/>
<KpiStatGrid
cards={[
{
id: 'status',
icon: <ActivityIcon />,
label: 'Статус',
value: service.health_status === 'up' ? 'OK' : service.health_status,
variant:
service.health_status === 'down'
? 'destructive'
: service.health_status === 'degraded'
? 'warning'
: 'default',
iconClassName:
service.health_status === 'down'
? 'text-destructive'
: service.health_status === 'degraded'
? 'text-warning'
: 'text-success',
hint: <HealthCheckBadge status={service.health_status} size="xs" />,
},
{
id: 'fqdn',
icon: <GlobeIcon />,
label: 'FQDN',
value: String(service.domains.length),
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
},
{
id: 'ip',
icon: <NetworkIcon />,
label: 'IP',
value: String(service.ips.length),
hint: `${service.active_ips.length} в пуле`,
},
{
id: 'pool',
icon: <ServerIcon />,
label: 'Активный пул',
value: String((overview?.active_addresses ?? service.active_ips).length),
hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет',
},
]}
/>
<section
aria-label="Мониторинг"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
<UptimeChart items={logItems} isLoading={logQuery.isLoading} />
<Frame dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<FrameTitle>Failover</FrameTitle>
<FrameDescription>
Нездоровые ноды и причины последней ошибки
</FrameDescription>
</FrameHeader>
<FramePanel>
<FailoverTimeline events={failoverEvents} />
</FramePanel>
</Frame>
</section>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Журнал проб</FrameTitle>
<FrameDescription>
Cloudflare = Worker с edge, не Health Checks API
</FrameDescription>
</FrameHeader>
<FramePanel>
<HealthTimeline
events={logItems.map((row) => ({
id: row.id,
hostname: row.ip,
type: row.provider,
status: row.status,
latency_ms: row.latency_ms,
error: row.error,
checked_at: row.checked_at,
colo: row.colo,
provider: row.provider,
}))}
/>
</FramePanel>
</Frame>
{service.ips.length === 0 && service.domains.length === 0 ? (
<EmptyState
title="Пустой сервис"
description="Добавьте поддомен и ноду, затем настройте health-check."
stackedIcon
/>
) : (
<ServiceDetailGrid
service={service}
nodes={nodes}
togglingIp={togglingIp}
onToggleIp={(ip, enabled) => {
setTogglingIp(ip)
toggleIpMutation.mutate({ ip, enabled })
}}
onChangeIp={(row: ServiceFqdnRow) =>
setChangeIp({
bindingId: row.binding_id,
ip: row.target_ips[0],
})
}
onChangeDomain={() => setChangeDomain(true)}
onAddNode={() => setAddNodeOpen(true)}
onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)}
isLoading={nodesQuery.isLoading}
/>
)}
<ServiceEditSheet
mode="edit"
service={service}
groups={groups}
open={editOpen}
knownDomains={domainsQuery.data ?? []}
isSaving={saving}
isDeleting={deleteMutation.isPending}
onOpenChange={setEditOpen}
onSave={(_serviceId, body) => {
setSaving(true)
updateMutation.mutate({ body })
}}
onDelete={() => deleteMutation.mutate()}
/>
<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={service.domains[0]?.domain_id ?? null}
/>
<FormSheet
open={addNodeOpen}
onOpenChange={setAddNodeOpen}
title="Добавить ноду"
description="IP станет CHECKING до порога успешных проверок."
form={nodeForm}
onSubmit={(values) => createNodeMut.mutate(values)}
footer={
<LoadingButton type="submit" isLoading={createNodeMut.isPending}>
Добавить
</LoadingButton>
}
>
<FormFieldSimple label="IP" htmlFor="address">
<Input id="address" {...nodeForm.register('address')} placeholder="10.0.0.10" />
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
<Input id="port" {...nodeForm.register('port')} placeholder="443" />
</FormFieldSimple>
</FormSheet>
</div>
)}
</QueryState>
)
}
@@ -1,143 +1,11 @@
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'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
component: ServiceNodesPage,
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
})
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>
)
}
@@ -1,79 +1,29 @@
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeftIcon } from 'lucide-react'
import { createFileRoute, Outlet } from '@tanstack/react-router'
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'
import {
serviceHealthLogQueryOptions,
serviceNodesQueryOptions,
serviceOverviewQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId')({
loader: ({ context: { queryClient }, params }) =>
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))),
loader: ({ context: { queryClient }, params }) => {
const id = Number(params.serviceId)
return Promise.all([
queryClient.ensureQueryData(serviceViewQueryOptions(id)),
queryClient.ensureQueryData(serviceOverviewQueryOptions(id)),
queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)),
queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
])
},
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>
<Outlet />
</PageShell>
)
}
@@ -1,59 +1,11 @@
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'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
component: ServiceRoutingPage,
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
})
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>
)
}
@@ -1,114 +1,11 @@
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'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
component: ServiceSubdomainsPage,
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
})
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>
)
}