diff --git a/apps/web/src/components/failover-timeline.tsx b/apps/web/src/components/failover-timeline.tsx index 21571d1..d4b52e6 100644 --- a/apps/web/src/components/failover-timeline.tsx +++ b/apps/web/src/components/failover-timeline.tsx @@ -1,40 +1,114 @@ +import { ShieldCheckIcon } from 'lucide-react' + import { Timeline, TimelineContent, + TimelineDate, TimelineHeader, TimelineIndicator, TimelineItem, TimelineSeparator, TimelineTitle, } from '@/components/reui/timeline' +import { HealthCheckBadge } from '@/components/health-check-badge' +import { EmptyState } from '@/components/empty-state' +import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format' +import type { FailoverEvent } from '@/lib/failover-events' +import { cn } from '@cfdm/ui/lib/utils' +import type { ComponentProps } from 'react' -export interface FailoverEvent { - id: string - title: string - detail: string +type HealthBadgeStatus = ComponentProps['status'] + +function failStreakLabel(count: number): string { + const mod10 = count % 10 + const mod100 = count % 100 + if (mod10 === 1 && mod100 !== 11) return `${count} ошибка подряд` + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) { + return `${count} ошибки подряд` + } + return `${count} ошибок подряд` } +function indicatorClass(status: string): string { + if (status === 'checking') { + return 'border-warning bg-warning/15 group-data-completed/timeline-item:border-warning' + } + return 'border-destructive bg-destructive/15 group-data-completed/timeline-item:border-destructive' +} + +function separatorClass(status: string): string { + if (status === 'checking') return 'bg-warning/25' + return 'bg-destructive/25' +} + +/** + * Failover как sibling «Смены статуса»: ReUI Timeline + Badge, не степпер. + * Preview: https://reui.io/preview/base/components/c-timeline-10 + * Preview: https://reui.io/preview/base/empty-state-12 + * Docs: https://reui.io/docs/components/base/timeline + * Docs: https://reui.io/docs/components/base/badge + */ export function FailoverTimeline({ events }: { events: FailoverEvent[] }) { if (events.length === 0) { return ( -

- Событий failover пока нет. -

+ ) } return ( - - {events.map((event, index) => ( - - - - - {event.title} - - {event.detail} - - ))} + + {events.map((event, index) => { + const checkedIso = event.lastCheckAt + ? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt) + : null + const isChecking = event.status === 'checking' + + return ( + + + + + + {event.address} + + + + {failStreakLabel(event.consecutiveFailures)} + {checkedIso + ? ` · ${formatRelative(checkedIso)} · ${formatDate(checkedIso)}` + : null} + + + +

+ {isChecking + ? 'Health-check ещё не завершён — нода не в активном пуле' + : 'Нода выведена из активного пула'} +

+ {event.lastFailureReason ? ( + + {event.lastFailureReason} + + ) : null} +
+
+ ) + })}
) } diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index a0cd3af..b741c4c 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -1,5 +1,6 @@ export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart' export { ServiceHealthMonitor } from './service-health-monitor' +export { ServiceFailoverPanel } from './service-failover-panel' 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/service-failover-panel.tsx b/apps/web/src/components/reui-kit/service-failover-panel.tsx new file mode 100644 index 0000000..28b97c6 --- /dev/null +++ b/apps/web/src/components/reui-kit/service-failover-panel.tsx @@ -0,0 +1,83 @@ +import { UnplugIcon } from 'lucide-react' + +import { FailoverTimeline } from '@/components/failover-timeline' +import { + toFailoverEvents, + type FailoverNodeInput, +} from '@/lib/failover-events' +import { Badge } from '@/components/reui/badge' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { + Alert, + AlertDescription, + AlertTitle, +} from '@/components/reui/alert' + +function failoverCountLabel(count: number): string { + const mod10 = count % 10 + const mod100 = count % 100 + if (mod10 === 1 && mod100 !== 11) return `${count} нода вне пула` + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) { + return `${count} ноды вне пула` + } + return `${count} нод вне пула` +} + +/** + * Failover — sibling ServiceHealthMonitor: Frame stacked + Alert + Timeline. + * Preview: https://reui.io/preview/base/components/c-timeline-10 + * Preview: https://reui.io/preview/base/empty-state-12 + * Docs: https://reui.io/docs/components/base/frame + * Docs: https://reui.io/docs/components/base/timeline + * Docs: https://reui.io/docs/components/base/badge + * Docs: https://reui.io/docs/components/base/alert + */ +export function ServiceFailoverPanel({ + nodes, +}: { + nodes: readonly FailoverNodeInput[] +}) { + const events = toFailoverEvents(nodes) + + return ( + + + + + Failover + {events.length > 0 ? ( + + {events.length} + + ) : ( + + OK + + )} + + + Нездоровые ноды выведены из пула · причина последней ошибки + + + + {events.length > 0 ? ( + + + ) : null} + + + + + ) +} diff --git a/apps/web/src/lib/failover-events.test.ts b/apps/web/src/lib/failover-events.test.ts new file mode 100644 index 0000000..a3a41b9 --- /dev/null +++ b/apps/web/src/lib/failover-events.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { + isFailoverEventStatus, + toFailoverEvents, + type FailoverNodeInput, +} from '@/lib/failover-events' + +function node( + overrides: Partial & Pick, +): FailoverNodeInput { + return { + id: overrides.id ?? overrides.address, + health_status: 'healthy', + consecutive_failures: 0, + last_failure_reason: null, + last_check_at: null, + ...overrides, + } +} + +describe('toFailoverEvents', () => { + it('оставляет только unhealthy / down / checking', () => { + const events = toFailoverEvents([ + node({ address: '10.0.0.1', health_status: 'healthy' }), + node({ + address: '130.49.213.153', + health_status: 'unhealthy', + consecutive_failures: 9, + last_failure_reason: 'fetch failed', + last_check_at: '2026-08-20 07:00:00', + }), + node({ address: '10.0.0.3', health_status: 'checking' }), + node({ address: '10.0.0.4', health_status: 'disabled' }), + ]) + + expect(events.map((event) => event.address)).toEqual([ + '130.49.213.153', + '10.0.0.3', + ]) + expect(events[0]).toMatchObject({ + consecutiveFailures: 9, + lastFailureReason: 'fetch failed', + lastCheckAt: '2026-08-20 07:00:00', + status: 'unhealthy', + }) + }) + + it('не считает healthy failover-событием', () => { + expect(isFailoverEventStatus('healthy')).toBe(false) + expect(isFailoverEventStatus('unhealthy')).toBe(true) + }) +}) diff --git a/apps/web/src/lib/failover-events.ts b/apps/web/src/lib/failover-events.ts new file mode 100644 index 0000000..bc87a82 --- /dev/null +++ b/apps/web/src/lib/failover-events.ts @@ -0,0 +1,36 @@ +export interface FailoverEvent { + id: string + address: string + status: string + consecutiveFailures: number + lastFailureReason: string | null + lastCheckAt?: string | null +} + +export interface FailoverNodeInput { + id?: number | string + address: string + health_status: string + consecutive_failures: number + last_failure_reason: string | null + last_check_at?: string | null +} + +const FAILOVER_STATUSES = new Set(['unhealthy', 'down', 'checking']) + +export function isFailoverEventStatus(status: string): boolean { + return FAILOVER_STATUSES.has(status) +} + +export function toFailoverEvents( + nodes: readonly FailoverNodeInput[], +): FailoverEvent[] { + return nodes.filter((node) => isFailoverEventStatus(node.health_status)).map((node) => ({ + id: String(node.id ?? node.address), + address: node.address, + status: node.health_status, + consecutiveFailures: node.consecutive_failures, + lastFailureReason: node.last_failure_reason, + lastCheckAt: node.last_check_at ?? null, + })) +} diff --git a/apps/web/src/routes/_auth/services/$serviceId/index.tsx b/apps/web/src/routes/_auth/services/$serviceId/index.tsx index e3e3ea7..53525ce 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/index.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/index.tsx @@ -14,7 +14,6 @@ import { 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' @@ -29,15 +28,9 @@ import { import { LbModeTile } from '@/components/services/service-unit-card' import { KpiStatGrid, + ServiceFailoverPanel, ServiceHealthMonitor, } from '@/components/reui-kit' -import { - Frame, - FrameDescription, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' import { api } from '@/lib/api-client' import { enabledHealthProviders, @@ -84,6 +77,7 @@ interface OverviewPayload { priority: number consecutive_failures: number last_failure_reason: string | null + last_check_at?: string | null }> } @@ -209,21 +203,7 @@ function ServiceDetailPage() { 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}`, - })) + const failoverNodes = nodes.length > 0 ? nodes : (overview?.nodes ?? []) const enabledProviders = useMemo( () => enabledHealthProviders(service?.domains ?? []), @@ -333,17 +313,7 @@ function ServiceDetailPage() { statuses={providerStatuses} isLoading={logQuery.isLoading} /> - - - Failover - - Нездоровые ноды и причины последней ошибки - - - - - - + {service.ips.length === 0 && service.domains.length === 0 ? (