feat(services): считать сервис живым при одном OK и писать историю failover
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 1m1s
CD / quality (push) Successful in 2m30s
CD / publish (push) Successful in 2m4s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 1m1s
CD / quality (push) Successful in 2m30s
CD / publish (push) Successful in 2m4s
Сервис up, если жив хотя бы один IP. Failover показывает Down по FQDN и журнал add/remove A-записей. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -13,7 +13,11 @@ import {
|
||||
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 {
|
||||
failoverEventCopy,
|
||||
type FailoverEvent,
|
||||
} from '@/lib/failover-events'
|
||||
import type { FailoverLogEntry } from '@/lib/schemas'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
@@ -29,32 +33,44 @@ function failStreakLabel(count: number): string {
|
||||
return `${count} ошибок подряд`
|
||||
}
|
||||
|
||||
function indicatorClass(status: string): string {
|
||||
if (status === 'checking') {
|
||||
return 'border-warning bg-warning/15 group-data-completed/timeline-item:border-warning'
|
||||
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(status: string): string {
|
||||
if (status === 'checking') return 'bg-warning/25'
|
||||
function separatorClass(tone: 'down' | 'added' | 'removed'): string {
|
||||
if (tone === 'added') return 'bg-success/25'
|
||||
return 'bg-destructive/25'
|
||||
}
|
||||
|
||||
function failoverHistoryCopy(item: FailoverLogEntry): string {
|
||||
return item.action === 'removed'
|
||||
? `${item.ip} убрана с ${item.fqdn}`
|
||||
: `${item.ip} добавлена на ${item.fqdn}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Failover как sibling «Смены статуса»: ReUI Timeline + Badge, не степпер.
|
||||
* 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) {
|
||||
export function FailoverTimeline({
|
||||
events,
|
||||
history = [],
|
||||
}: {
|
||||
events: FailoverEvent[]
|
||||
history?: readonly FailoverLogEntry[]
|
||||
}) {
|
||||
if (events.length === 0 && history.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={ShieldCheckIcon}
|
||||
title="Пул в DNS на месте"
|
||||
description="Standby и last-resort не красные — только down, снятые с A-записей"
|
||||
title="Нет инцидентов Failover"
|
||||
description="Нет Down и нет смен A-записей по FQDN"
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
@@ -62,55 +78,88 @@ export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<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'
|
||||
<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(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>
|
||||
{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">
|
||||
{isChecking
|
||||
? 'Снята с DNS, идёт восстановление'
|
||||
: 'Снята с DNS — в A-записях этого адреса нет'}
|
||||
</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>
|
||||
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>
|
||||
<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">
|
||||
{failoverHistoryCopy(item)}
|
||||
</p>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { UnplugIcon } from 'lucide-react'
|
||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||
import {
|
||||
toFailoverEvents,
|
||||
type FailoverBindingPool,
|
||||
type FailoverHealthInput,
|
||||
} from '@/lib/failover-events'
|
||||
import type { FailoverLogEntry } from '@/lib/schemas'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
@@ -22,15 +24,15 @@ import {
|
||||
function failoverCountLabel(count: number): string {
|
||||
const mod10 = count % 10
|
||||
const mod100 = count % 100
|
||||
if (mod10 === 1 && mod100 !== 11) return `${count} нода вне пула`
|
||||
if (mod10 === 1 && mod100 !== 11) return `${count} адрес Down`
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
|
||||
return `${count} ноды вне пула`
|
||||
return `${count} адреса Down`
|
||||
}
|
||||
return `${count} нод вне пула`
|
||||
return `${count} адресов Down`
|
||||
}
|
||||
|
||||
/**
|
||||
* Failover — sibling ServiceHealthMonitor: Frame stacked + Alert + Timeline.
|
||||
* Failover — текущие Down + журнал add/remove по FQDN.
|
||||
* 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
|
||||
@@ -40,12 +42,15 @@ function failoverCountLabel(count: number): string {
|
||||
*/
|
||||
export function ServiceFailoverPanel({
|
||||
ipHealth,
|
||||
activeAddresses,
|
||||
bindings,
|
||||
history,
|
||||
}: {
|
||||
ipHealth: readonly FailoverHealthInput[]
|
||||
activeAddresses: readonly string[]
|
||||
bindings: readonly FailoverBindingPool[]
|
||||
history: readonly FailoverLogEntry[]
|
||||
}) {
|
||||
const events = toFailoverEvents(ipHealth, activeAddresses)
|
||||
const events = toFailoverEvents(ipHealth, bindings)
|
||||
const removedCount = events.filter((event) => event.kind === 'removed').length
|
||||
|
||||
return (
|
||||
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||
@@ -64,7 +69,7 @@ export function ServiceFailoverPanel({
|
||||
)}
|
||||
</FrameTitle>
|
||||
<FrameDescription>
|
||||
Только down из IP Health вне DNS-пула · как таблица активов
|
||||
Текущие Down и история A-записей по FQDN
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
@@ -73,12 +78,14 @@ export function ServiceFailoverPanel({
|
||||
<UnplugIcon aria-hidden="true" />
|
||||
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Эти IP сняты с A-записей
|
||||
{removedCount > 0
|
||||
? 'Сняты с FQDN или остались last-resort'
|
||||
: 'Остались в A-записях как last-resort'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<FailoverTimeline events={events} />
|
||||
<FailoverTimeline events={events} history={history} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user