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
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:
@@ -69,7 +69,11 @@ export async function serviceRoutes(app: FastifyInstance) {
|
|||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
repos.getService(request.server.db, Number(id));
|
repos.getService(request.server.db, Number(id));
|
||||||
return {
|
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 { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
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 meta = LB_MODE_META[mode]
|
||||||
const Icon = meta.icon
|
const Icon = meta.icon
|
||||||
|
|
||||||
|
|||||||
@@ -1,104 +1,11 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, redirect } 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'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
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 { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useState } from 'react'
|
||||||
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
import { useForm } from 'react-hook-form'
|
||||||
import { DetailPanel } from '@/components/reui-kit'
|
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 { 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 { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||||
import { serviceOverviewQueryOptions } from '@/queries'
|
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/')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
||||||
component: ServiceOverviewPage,
|
component: ServiceDetailPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function ServiceOverviewPage() {
|
interface OverviewPayload {
|
||||||
const { serviceId } = Route.useParams()
|
routing_strategy?: string
|
||||||
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
|
active_addresses?: string[]
|
||||||
const overview = data as {
|
nodes?: Array<{
|
||||||
service: {
|
id: number
|
||||||
name: string
|
address: string
|
||||||
enabled: boolean
|
protocol: string
|
||||||
health_status: 'up' | 'down' | 'degraded' | 'unknown'
|
port: number | null
|
||||||
domains: Array<{ fqdn: string; zone_name: string }>
|
health_status: string
|
||||||
}
|
weight: number
|
||||||
nodes: Array<{ id: number; address: string; health_status: string }>
|
priority: number
|
||||||
routing_strategy: string
|
consecutive_failures: number
|
||||||
active_addresses: string[]
|
last_failure_reason: string | null
|
||||||
} | undefined
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
if (!overview) {
|
function ServiceDetailPage() {
|
||||||
return (
|
const { serviceId } = Route.useParams()
|
||||||
<EmptyState
|
const id = Number(serviceId)
|
||||||
title="Сервис не найден"
|
const navigate = useNavigate()
|
||||||
description="Вернитесь в каталог и выберите сервис."
|
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 updateMutation = useMutation({
|
||||||
const domains = overview.service.domains ?? []
|
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 (
|
return (
|
||||||
<DetailPanel>
|
<QueryState
|
||||||
<DetailPanel.Header
|
isLoading={isLoading}
|
||||||
title={overview.service.name}
|
isError={isError}
|
||||||
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${
|
error={error}
|
||||||
overview.active_addresses.join(', ') || '—'
|
onRetry={() => {
|
||||||
}`}
|
void viewQuery.refetch()
|
||||||
actions={
|
void overviewQuery.refetch()
|
||||||
<HealthCheckBadge status={overview.service.health_status} />
|
}}
|
||||||
}
|
>
|
||||||
/>
|
{!service ? (
|
||||||
<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
|
<EmptyState
|
||||||
title="Пустой сервис"
|
title="Сервис не найден"
|
||||||
description="Добавьте поддомен и ноду, затем настройте health-check."
|
description="Вернитесь в каталог и выберите сервис."
|
||||||
stackedIcon
|
|
||||||
/>
|
/>
|
||||||
) : 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 { createFileRoute, redirect } 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')({
|
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 { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { ArrowLeftIcon } from 'lucide-react'
|
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import {
|
||||||
import { QueryState } from '@/components/query-state'
|
serviceHealthLogQueryOptions,
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
serviceNodesQueryOptions,
|
||||||
import { serviceOverviewQueryOptions } from '@/queries'
|
serviceOverviewQueryOptions,
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
serviceViewQueryOptions,
|
||||||
|
} from '@/queries'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
||||||
loader: ({ context: { queryClient }, params }) =>
|
loader: ({ context: { queryClient }, params }) => {
|
||||||
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))),
|
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,
|
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() {
|
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 (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<Outlet />
|
||||||
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>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,11 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, redirect } 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')({
|
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 { createFileRoute, redirect } 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')({
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user