Персональные A не крутить и не трогать при падении. Панель называется по режиму балансировки. Co-authored-by: Cursor <[email protected]>
162 lines
5.8 KiB
TypeScript
162 lines
5.8 KiB
TypeScript
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 {
|
|
failoverEventCopy,
|
|
type FailoverEvent,
|
|
type FailoverHistoryItem,
|
|
} from '@/lib/failover-events'
|
|
import { cn } from '@cfdm/ui/lib/utils'
|
|
import type { ComponentProps } from 'react'
|
|
|
|
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(tone: 'down' | 'added' | 'removed'): string {
|
|
if (tone === 'added') {
|
|
return 'border-success bg-success/15 group-data-completed/timeline-item:border-success'
|
|
}
|
|
return 'border-destructive bg-destructive/15 group-data-completed/timeline-item:border-destructive'
|
|
}
|
|
|
|
function separatorClass(tone: 'down' | 'added' | 'removed'): string {
|
|
if (tone === 'added') return 'bg-success/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,
|
|
history = [],
|
|
}: {
|
|
events: FailoverEvent[]
|
|
history?: readonly FailoverHistoryItem[]
|
|
}) {
|
|
if (events.length === 0 && history.length === 0) {
|
|
return (
|
|
<EmptyState
|
|
icon={ShieldCheckIcon}
|
|
title="Нет инцидентов"
|
|
description="Нет Down и нет выходов из общего пула"
|
|
stackedIcon={false}
|
|
centered={false}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{events.length > 0 ? (
|
|
<Timeline defaultValue={0} className="gap-0">
|
|
{events.map((event, index) => {
|
|
const checkedIso = event.lastCheckAt
|
|
? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt)
|
|
: null
|
|
|
|
return (
|
|
<TimelineItem key={event.id} step={index + 1}>
|
|
<TimelineSeparator className={separatorClass('down')} />
|
|
<TimelineIndicator className={indicatorClass('down')} />
|
|
<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>
|
|
{event.consecutiveFailures > 0
|
|
? failStreakLabel(event.consecutiveFailures)
|
|
: null}
|
|
{checkedIso
|
|
? `${event.consecutiveFailures > 0 ? ' · ' : ''}${formatRelative(checkedIso)} · ${formatDate(checkedIso)}`
|
|
: null}
|
|
</TimelineDate>
|
|
</TimelineHeader>
|
|
<TimelineContent className="flex flex-col gap-2">
|
|
<p className="text-foreground text-sm">
|
|
{failoverEventCopy(event)}
|
|
</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>
|
|
) : null}
|
|
|
|
{history.length > 0 ? (
|
|
<Timeline defaultValue={0} className="gap-0">
|
|
{history.map((item, index) => {
|
|
const checkedIso = sqliteUtcToIso(item.created_at) ?? item.created_at
|
|
const tone = item.action === 'added' ? 'added' : 'removed'
|
|
|
|
return (
|
|
<TimelineItem key={item.id} step={index + 1}>
|
|
<TimelineSeparator className={separatorClass(tone)} />
|
|
<TimelineIndicator className={indicatorClass(tone)} />
|
|
<TimelineHeader>
|
|
<TimelineTitle className="flex flex-wrap items-center gap-2">
|
|
<span className="font-mono text-sm">{item.ip}</span>
|
|
<HealthCheckBadge
|
|
status={item.action === 'added' ? 'up' : 'down'}
|
|
size="xs"
|
|
/>
|
|
<span className="text-muted-foreground text-xs">
|
|
{item.fqdn}
|
|
</span>
|
|
</TimelineTitle>
|
|
<TimelineDate>
|
|
{formatRelative(checkedIso)} · {formatDate(checkedIso)}
|
|
</TimelineDate>
|
|
</TimelineHeader>
|
|
<TimelineContent>
|
|
<p className="text-foreground text-sm">{item.copy}</p>
|
|
</TimelineContent>
|
|
</TimelineItem>
|
|
)
|
|
})}
|
|
</Timeline>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|