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 ? (
+ <>
+
+
+ +{delta.toFixed(1)} п.п.
+
+ к первой половине окна
+ >
+ ) : (
+ <>
+
+
+ {delta.toFixed(1)} п.п.
+
+ к первой половине окна
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } />
+ {
+ 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 (
+
+
+ {icon}
+
+ {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}
- />
-
-
- )
-}