Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87b0f1a894 |
@@ -1,40 +1,114 @@
|
|||||||
|
import { ShieldCheckIcon } from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Timeline,
|
Timeline,
|
||||||
TimelineContent,
|
TimelineContent,
|
||||||
|
TimelineDate,
|
||||||
TimelineHeader,
|
TimelineHeader,
|
||||||
TimelineIndicator,
|
TimelineIndicator,
|
||||||
TimelineItem,
|
TimelineItem,
|
||||||
TimelineSeparator,
|
TimelineSeparator,
|
||||||
TimelineTitle,
|
TimelineTitle,
|
||||||
} from '@/components/reui/timeline'
|
} 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 {
|
type HealthBadgeStatus = ComponentProps<typeof HealthCheckBadge>['status']
|
||||||
id: string
|
|
||||||
title: string
|
function failStreakLabel(count: number): string {
|
||||||
detail: 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[] }) {
|
export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-muted-foreground text-sm">
|
<EmptyState
|
||||||
Событий failover пока нет.
|
icon={ShieldCheckIcon}
|
||||||
</p>
|
title="Пул стабилен"
|
||||||
|
description="События появятся, когда нода станет unhealthy или down и будет выведена из пула"
|
||||||
|
stackedIcon={false}
|
||||||
|
centered={false}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Timeline defaultValue={events.length} className="w-full">
|
<Timeline defaultValue={0} className="gap-0">
|
||||||
{events.map((event, index) => (
|
{events.map((event, index) => {
|
||||||
<TimelineItem key={event.id} step={index + 1}>
|
const checkedIso = event.lastCheckAt
|
||||||
<TimelineSeparator />
|
? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt)
|
||||||
<TimelineIndicator />
|
: null
|
||||||
<TimelineHeader>
|
const isChecking = event.status === 'checking'
|
||||||
<TimelineTitle>{event.title}</TimelineTitle>
|
|
||||||
</TimelineHeader>
|
return (
|
||||||
<TimelineContent>{event.detail}</TimelineContent>
|
<TimelineItem key={event.id} step={index + 1}>
|
||||||
</TimelineItem>
|
<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>
|
</Timeline>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
|
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
|
||||||
export { ServiceHealthMonitor } from './service-health-monitor'
|
export { ServiceHealthMonitor } from './service-health-monitor'
|
||||||
|
export { ServiceFailoverPanel } from './service-failover-panel'
|
||||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
||||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
|
||||||
import { FormFieldSimple } from '@/components/form-field'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
@@ -29,15 +28,9 @@ import {
|
|||||||
import { LbModeTile } from '@/components/services/service-unit-card'
|
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||||
import {
|
import {
|
||||||
KpiStatGrid,
|
KpiStatGrid,
|
||||||
|
ServiceFailoverPanel,
|
||||||
ServiceHealthMonitor,
|
ServiceHealthMonitor,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import {
|
import {
|
||||||
enabledHealthProviders,
|
enabledHealthProviders,
|
||||||
@@ -84,6 +77,7 @@ interface OverviewPayload {
|
|||||||
priority: number
|
priority: number
|
||||||
consecutive_failures: number
|
consecutive_failures: number
|
||||||
last_failure_reason: string | null
|
last_failure_reason: string | null
|
||||||
|
last_check_at?: string | null
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,21 +203,7 @@ function ServiceDetailPage() {
|
|||||||
const isError = viewQuery.isError || overviewQuery.isError
|
const isError = viewQuery.isError || overviewQuery.isError
|
||||||
const error = viewQuery.error ?? overviewQuery.error
|
const error = viewQuery.error ?? overviewQuery.error
|
||||||
|
|
||||||
const failoverEvents =
|
const failoverNodes = nodes.length > 0 ? nodes : (overview?.nodes ?? [])
|
||||||
(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 enabledProviders = useMemo(
|
const enabledProviders = useMemo(
|
||||||
() => enabledHealthProviders(service?.domains ?? []),
|
() => enabledHealthProviders(service?.domains ?? []),
|
||||||
@@ -333,17 +313,7 @@ function ServiceDetailPage() {
|
|||||||
statuses={providerStatuses}
|
statuses={providerStatuses}
|
||||||
isLoading={logQuery.isLoading}
|
isLoading={logQuery.isLoading}
|
||||||
/>
|
/>
|
||||||
<Frame dense spacing="sm" className="min-w-0 w-full">
|
<ServiceFailoverPanel nodes={failoverNodes} />
|
||||||
<FrameHeader>
|
|
||||||
<FrameTitle>Failover</FrameTitle>
|
|
||||||
<FrameDescription>
|
|
||||||
Нездоровые ноды и причины последней ошибки
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel>
|
|
||||||
<FailoverTimeline events={failoverEvents} />
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{service.ips.length === 0 && service.domains.length === 0 ? (
|
{service.ips.length === 0 && service.domains.length === 0 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user