diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index e9b60bf..fa1e15c 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -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, + ), }; }); diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index ac23a49..0b48b27 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -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' diff --git a/apps/web/src/components/reui-kit/uptime-chart.tsx b/apps/web/src/components/reui-kit/uptime-chart.tsx new file mode 100644 index 0000000..24cfb21 --- /dev/null +++ b/apps/web/src/components/reui-kit/uptime-chart.tsx @@ -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 ( +
+

+ {point.latency} мс · {point.ok ? 'OK' : 'Down'} +

+

{point.period}

+
+ ) +} + +interface UptimeChartProps { + items: UptimeProbe[] + isLoading?: boolean +} + +export function UptimeChart({ items, isLoading = false }: UptimeChartProps) { + const gradientId = useId().replace(/:/g, '') + const [period, setPeriod] = useState('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 ( + + +
+
+ +
+

Uptime

+

+ Пробы health-check за период +

+
+
+ + + + } + > + + +

Доля успешных проб и задержка (мс) по журналу health-log.

+
+
+
+
+ + {isLoading ? ( +
+ ) : points.length === 0 ? ( + + ) : ( +
+
+
+ {uptime == null ? '—' : `${uptime.toFixed(uptime >= 99.95 ? 2 : 1)}%`} +
+
+ {delta == null ? ( + + {points.length} проб + + ) : delta >= 0 ? ( + <> +
+
+ +
+ + + + + + + + + + } /> + { + const { cx, cy, payload, index } = dotProps as { + cx?: number + cy?: number + index?: number + payload?: ChartPoint + } + if (cx == null || cy == null) return + const fill = payload?.ok + ? 'var(--color-latency)' + : 'var(--destructive)' + return ( + + ) + }} + activeDot={{ + r: 6, + stroke: 'var(--background)', + strokeWidth: 2, + }} + /> + + +
+
+ )} + + setPeriod(value as UptimePeriodKey)} + > + + {PERIODS.map((entry) => ( + + {entry.label} + + ))} + + + + + ) +} diff --git a/apps/web/src/components/services/service-detail-grid.tsx b/apps/web/src/components/services/service-detail-grid.tsx new file mode 100644 index 0000000..0fa1b1b --- /dev/null +++ b/apps/web/src/components/services/service-detail-grid.tsx @@ -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 + const priorities = Object.assign( + {}, + ...service.domains.map((domain) => domain.target_ip_priorities ?? {}), + ) as Record + 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 ( +
+ + {label} +
+ ) +} + +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(() => [ + createFilter('ip', 'contains', ['']), + createFilter('status', 'is', ['']), + ]) + const [fqdnFilters, setFqdnFilters] = useState(() => [ + createFilter('fqdn', 'contains', ['']), + ]) + const [nodeFilters, setNodeFilters] = useState(() => [ + 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( + () => [ + { + key: 'ip', + label: 'IP', + icon: , + type: 'text', + className: 'w-52', + placeholder: 'Поиск по IP…', + }, + { + key: 'status', + label: 'Статус', + type: 'select', + searchable: true, + className: 'w-[168px]', + options: HEALTH_OPTIONS, + }, + ], + [], + ) + + const fqdnFilterFields = useMemo( + () => [ + { + key: 'fqdn', + label: 'FQDN', + icon: , + type: 'text', + className: 'w-52', + placeholder: 'Поиск по FQDN…', + }, + ], + [], + ) + + const nodeFilterFields = useMemo( + () => [ + { + key: 'address', + label: 'Адрес', + icon: , + type: 'text', + className: 'w-52', + placeholder: 'Поиск по адресу…', + }, + { + key: 'health_status', + label: 'Статус', + type: 'select', + searchable: true, + className: 'w-[168px]', + options: HEALTH_OPTIONS, + }, + ], + [], + ) + + const ipColumns = useMemo[]>( + () => [ + { + id: 'ip', + accessorKey: 'ip', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + } + label={row.original.ip} + iconClassName="text-info" + /> + ), + }, + { + id: 'status', + accessorKey: 'status', + header: 'Health', + cell: ({ row }) => ( + + ), + }, + { + id: 'active', + header: 'Пул', + cell: ({ row }) => + markActive && row.original.active ? ( + + ) : ( + + ), + }, + { + id: 'weight', + accessorKey: 'weight', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {service.lb_mode === 'weighted' ? `w${row.original.weight}` : row.original.weight} + + ), + }, + { + id: 'priority', + accessorKey: 'priority', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.priority} + ), + }, + { + id: 'enabled', + header: 'Вкл', + cell: ({ row }) => ( + + 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[]>( + () => [ + { + id: 'fqdn', + accessorKey: 'fqdn', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + } + label={row.original.fqdn} + iconClassName="text-foreground" + /> + ), + }, + { + id: 'zone', + accessorKey: 'zone_name', + header: 'Зона', + }, + { + id: 'ips', + header: 'Target IP', + cell: ({ row }) => ( +
+ + {row.original.target_ips.join(', ') || '—'} + + + {row.original.target_ips.length} IP + +
+ ), + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( + + ), + }, + ], + [onChangeIp], + ) + + const nodeColumns = useMemo[]>( + () => [ + { + id: 'address', + accessorKey: 'address', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + } + label={row.original.address} + iconClassName="text-foreground" + /> + ), + }, + { + id: 'health', + accessorKey: 'health_status', + header: 'Health', + cell: ({ row }) => ( + + ), + }, + { + id: 'meta', + header: 'Вес / приоритет', + cell: ({ row }) => ( + + {row.original.protocol} + {row.original.port ? `:${row.original.port}` : ''} · w + {row.original.weight} · p{row.original.priority} + + ), + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( + + ), + }, + ], + [onDeleteNode], + ) + + const sharedTabs = { + tabs, + activeTab: tab, + onTabChange: (id: string) => setTab(id as typeof tab), + } + + if (tab === 'fqdn') { + return ( + 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={ + + } + /> + ) + } + + if (tab === 'nodes') { + return ( + + 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={ + + } + /> + ) + } + + return ( + + 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} + /> + ) +} diff --git a/apps/web/src/components/services/service-unit-card.tsx b/apps/web/src/components/services/service-unit-card.tsx index 5e3ce30..c3ef300 100644 --- a/apps/web/src/components/services/service-unit-card.tsx +++ b/apps/web/src/components/services/service-unit-card.tsx @@ -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 diff --git a/apps/web/src/routes/_auth/services/$serviceId/health.tsx b/apps/web/src/routes/_auth/services/$serviceId/health.tsx index 6fa95c8..b179199 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/health.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/health.tsx @@ -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' ? : , - variant, - footer: ( - - ), - } - }) - - return ( - - - - XOR провайдеров - - Local ходит с API CFDM; Cloudflare — через Worker. Cron и пороги Slow/Down общие, в{' '} - - Настройках → Health-check - - . Если Worker не задан, цель не пробируется как Local. - - - {kpiCards.length > 0 ? ( - - ) : ( - - )} - - ({ - 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, - }))} - /> - - ) -} diff --git a/apps/web/src/routes/_auth/services/$serviceId/index.tsx b/apps/web/src/routes/_auth/services/$serviceId/index.tsx index 42fc9df..625f9dd 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/index.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/index.tsx @@ -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 ( - - ) +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(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(`/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(`/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 ( - - - } - /> - , - label: 'Поддомены', - description: - domains.length > 0 - ? domains.map((d) => d.fqdn).join(', ') - : 'Нет привязанных FQDN', - footer: {domains.length}, - }, - { - id: 'nodes', - icon: , - label: 'Ноды', - description: - nodes.length > 0 - ? nodes.map((n) => n.address).join(', ') - : 'Добавьте ноду, чтобы публиковать DNS', - footer: {nodes.length}, - }, - { - id: 'health', - icon: , - label: 'Пул', - description: - overview.active_addresses.length > 0 - ? 'Здоровые адреса участвуют в DNS' - : 'unknown не попадает в пул, пока не станет healthy', - }, - ]} - /> - {domains.length === 0 && nodes.length === 0 ? ( + { + void viewQuery.refetch() + void overviewQuery.refetch() + }} + > + {!service ? ( - ) : null} - + ) : ( +
+ + + + + + + } + /> + + , + 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: , + }, + { + id: 'fqdn', + icon: , + label: 'FQDN', + value: String(service.domains.length), + hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN', + }, + { + id: 'ip', + icon: , + label: 'IP', + value: String(service.ips.length), + hint: `${service.active_ips.length} в пуле`, + }, + { + id: 'pool', + icon: , + label: 'Активный пул', + value: String((overview?.active_addresses ?? service.active_ips).length), + hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет', + }, + ]} + /> + +
+ + + + Failover + + Нездоровые ноды и причины последней ошибки + + + + + + +
+ + + + Журнал проб + + Cloudflare = Worker с edge, не Health Checks API + + + + ({ + 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, + }))} + /> + + + + {service.ips.length === 0 && service.domains.length === 0 ? ( + + ) : ( + { + 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} + /> + )} + + { + setSaving(true) + updateMutation.mutate({ body }) + }} + onDelete={() => deleteMutation.mutate()} + /> + { + if (!open) setChangeIp(null) + }} + bindingId={changeIp?.bindingId ?? null} + serviceId={id} + currentIp={changeIp?.ip} + /> + + createNodeMut.mutate(values)} + footer={ + + Добавить + + } + > + + + + + + + +
+ )} + ) } diff --git a/apps/web/src/routes/_auth/services/$serviceId/nodes.tsx b/apps/web/src/routes/_auth/services/$serviceId/nodes.tsx index 8c416d9..68a6709 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/nodes.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/nodes.tsx @@ -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 ( - - setOpen(true)}> - - Добавить ноду - - } - /> - {nodes.length === 0 ? ( - - ) : ( -
- {nodes.map((node) => ( -
-
- {node.address} - - {node.protocol} - {node.port ? `:${node.port}` : ''} · вес {node.weight} · приоритет{' '} - {node.priority} - -
-
- - -
-
- ))} -
- )} - createMut.mutate(values)} - footer={ - - Добавить - - } - > - - - - - - - -
- ) -} diff --git a/apps/web/src/routes/_auth/services/$serviceId/route.tsx b/apps/web/src/routes/_auth/services/$serviceId/route.tsx index 74d7fd5..0bf5095 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/route.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/route.tsx @@ -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 ( - } - > - - К каталогу - - } - /> - - void overview.refetch()} - > - - + ) } diff --git a/apps/web/src/routes/_auth/services/$serviceId/routing.tsx b/apps/web/src/routes/_auth/services/$serviceId/routing.tsx index 0b77080..d820664 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/routing.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/routing.tsx @@ -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 ( - - {overview?.routing_strategy ?? 'round_robin'}} - /> -

- Активные адреса:{' '} - {overview?.active_addresses.join(', ') || 'нет (unknown не в пуле)'} -

-

- Запись обновляется в Cloudflare. Распространение зависит от TTL. -

- -
- ) -} diff --git a/apps/web/src/routes/_auth/services/$serviceId/subdomains.tsx b/apps/web/src/routes/_auth/services/$serviceId/subdomains.tsx index 0231d82..9fafbf7 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/subdomains.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/subdomains.tsx @@ -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 ( - - setChangeDomain(true)} - disabled={rows.length === 0} - > - - Сменить домен - - } - /> - {rows.length === 0 ? ( - - ) : ( -
- {rows.map((row) => ( -
-
- {row.fqdn} - - {row.zone_name} · {row.target_ips.join(', ') || 'нет IP'} - -
-
- {row.target_ips.length} IP - -
-
- ))} -
- )} - { - if (!open) setChangeIp(null) - }} - bindingId={changeIp?.bindingId ?? null} - serviceId={id} - currentIp={changeIp?.ip} - /> - -
- ) -}