fix(services): оформить failover timeline в стиле страницы сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m6s
CD / publish (push) Successful in 1m39s

Frame stacked как у мониторинга, Alert и semantic timeline вместо плоского степпера.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-20 14:31:30 +07:00
co-authored by Cursor
parent ba4e04a224
commit 87b0f1a894
6 changed files with 269 additions and 52 deletions
+92 -18
View File
@@ -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<typeof HealthCheckBadge>['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 (
<p className="text-muted-foreground text-sm">
Событий failover пока нет.
</p>
<EmptyState
icon={ShieldCheckIcon}
title="Пул стабилен"
description="События появятся, когда нода станет unhealthy или down и будет выведена из пула"
stackedIcon={false}
centered={false}
/>
)
}
return (
<Timeline defaultValue={events.length} className="w-full">
{events.map((event, index) => (
<TimelineItem key={event.id} step={index + 1}>
<TimelineSeparator />
<TimelineIndicator />
<TimelineHeader>
<TimelineTitle>{event.title}</TimelineTitle>
</TimelineHeader>
<TimelineContent>{event.detail}</TimelineContent>
</TimelineItem>
))}
<Timeline defaultValue={0} className="gap-0">
{events.map((event, index) => {
const checkedIso = event.lastCheckAt
? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt)
: null
const isChecking = event.status === 'checking'
return (
<TimelineItem key={event.id} step={index + 1}>
<TimelineSeparator className={separatorClass(event.status)} />
<TimelineIndicator className={indicatorClass(event.status)} />
<TimelineHeader>
<TimelineTitle className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">{event.address}</span>
<HealthCheckBadge
status={event.status as HealthBadgeStatus}
lastError={event.lastFailureReason}
lastCheckedAt={checkedIso}
size="xs"
/>
</TimelineTitle>
<TimelineDate>
{failStreakLabel(event.consecutiveFailures)}
{checkedIso
? ` · ${formatRelative(checkedIso)} · ${formatDate(checkedIso)}`
: null}
</TimelineDate>
</TimelineHeader>
<TimelineContent className="flex flex-col gap-2">
<p className="text-foreground text-sm">
{isChecking
? 'Health-check ещё не завершён — нода не в активном пуле'
: 'Нода выведена из активного пула'}
</p>
{event.lastFailureReason ? (
<code
className={cn(
'bg-muted block overflow-x-auto rounded-md px-2 py-1.5 font-mono text-xs',
)}
>
{event.lastFailureReason}
</code>
) : null}
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
)
}
@@ -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'
@@ -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 (
<Frame stacked spacing="sm" className="min-w-0 w-full">
<FramePanel className="flex flex-col gap-3">
<FrameHeader className="gap-1 px-0 py-0">
<FrameTitle className="flex flex-wrap items-center gap-2">
Failover
{events.length > 0 ? (
<Badge variant="destructive-light" size="xs" radius="full">
{events.length}
</Badge>
) : (
<Badge variant="success-light" size="xs" radius="full">
OK
</Badge>
)}
</FrameTitle>
<FrameDescription>
Нездоровые ноды выведены из пула · причина последней ошибки
</FrameDescription>
</FrameHeader>
{events.length > 0 ? (
<Alert variant="destructive">
<UnplugIcon aria-hidden="true" />
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
<AlertDescription>
Трафик на эти адреса не идёт, пока health не восстановится
</AlertDescription>
</Alert>
) : null}
<FailoverTimeline events={events} />
</FramePanel>
</Frame>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
isFailoverEventStatus,
toFailoverEvents,
type FailoverNodeInput,
} from '@/lib/failover-events'
function node(
overrides: Partial<FailoverNodeInput> & Pick<FailoverNodeInput, 'address'>,
): 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)
})
})
+36
View File
@@ -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,
}))
}
@@ -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}
/>
<Frame dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<FrameTitle>Failover</FrameTitle>
<FrameDescription>
Нездоровые ноды и причины последней ошибки
</FrameDescription>
</FrameHeader>
<FramePanel>
<FailoverTimeline events={failoverEvents} />
</FramePanel>
</Frame>
<ServiceFailoverPanel nodes={failoverNodes} />
</section>
{service.ips.length === 0 && service.domains.length === 0 ? (