fix(services): показывать живой health и историю выхода из пула
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m14s
CD / publish (push) Successful in 2m3s

Таблица IP брала hysteresis unknown вместо последней пробы. Failover теперь показывает выход и возврат в пул, а не только DNS add.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-20 16:43:22 +07:00
co-authored by Cursor
parent be8f94143f
commit f5d97e463a
8 changed files with 444 additions and 35 deletions
+8 -12
View File
@@ -16,8 +16,8 @@ import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format'
import {
failoverEventCopy,
type FailoverEvent,
type FailoverHistoryItem,
} from '@/lib/failover-events'
import type { FailoverLogEntry } from '@/lib/schemas'
import { cn } from '@cfdm/ui/lib/utils'
import type { ComponentProps } from 'react'
@@ -45,12 +45,6 @@ function separatorClass(tone: 'down' | 'added' | 'removed'): string {
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.
* Preview: https://reui.io/preview/base/components/c-timeline-10
@@ -63,14 +57,14 @@ export function FailoverTimeline({
history = [],
}: {
events: FailoverEvent[]
history?: readonly FailoverLogEntry[]
history?: readonly FailoverHistoryItem[]
}) {
if (events.length === 0 && history.length === 0) {
return (
<EmptyState
icon={ShieldCheckIcon}
title="Нет инцидентов Failover"
description="Нет Down и нет смен A-записей по FQDN"
description="Нет Down и нет выходов из пула"
stackedIcon={false}
centered={false}
/>
@@ -142,6 +136,10 @@ export function FailoverTimeline({
<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>
@@ -151,9 +149,7 @@ export function FailoverTimeline({
</TimelineDate>
</TimelineHeader>
<TimelineContent>
<p className="text-foreground text-sm">
{failoverHistoryCopy(item)}
</p>
<p className="text-foreground text-sm">{item.copy}</p>
</TimelineContent>
</TimelineItem>
)
@@ -2,10 +2,17 @@ import { UnplugIcon } from 'lucide-react'
import { FailoverTimeline } from '@/components/failover-timeline'
import {
mergeFailoverHistory,
toFailoverEvents,
type FailoverBindingPool,
type FailoverHealthInput,
} from '@/lib/failover-events'
import {
latestHealthByIp,
resolveIpDisplayHealth,
type HealthLogProbe,
type HealthLogStatus,
} from '@/lib/health-log'
import type { FailoverLogEntry } from '@/lib/schemas'
import { Badge } from '@/components/reui/badge'
import {
@@ -32,7 +39,7 @@ function failoverCountLabel(count: number): string {
}
/**
* Failover — текущие Down + журнал add/remove по FQDN.
* Failover — текущие Down + кто вышел из пула и кто вернулся.
* 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
@@ -44,12 +51,32 @@ export function ServiceFailoverPanel({
ipHealth,
bindings,
history,
probes = [],
}: {
ipHealth: readonly FailoverHealthInput[]
bindings: readonly FailoverBindingPool[]
history: readonly FailoverLogEntry[]
probes?: readonly HealthLogProbe[]
}) {
const events = toFailoverEvents(ipHealth, bindings)
const liveByIp = latestHealthByIp(probes)
const overlayHealth = ipHealth.map((row) => {
const live = liveByIp.get(row.ip)
return {
...row,
status: resolveIpDisplayHealth(
row.status as HealthLogStatus,
live?.status,
),
last_error:
live && live.status !== 'unknown' ? live.last_error : row.last_error,
last_checked_at:
live && live.status !== 'unknown'
? live.last_checked_at
: row.last_checked_at,
}
})
const events = toFailoverEvents(overlayHealth, bindings)
const mergedHistory = mergeFailoverHistory(history, probes, bindings)
const removedCount = events.filter((event) => event.kind === 'removed').length
return (
@@ -69,7 +96,7 @@ export function ServiceFailoverPanel({
)}
</FrameTitle>
<FrameDescription>
Текущие Down и история A-записей по FQDN
Текущие Down и история: кто вышел из пула и кто вернулся
</FrameDescription>
</FrameHeader>
@@ -85,7 +112,7 @@ export function ServiceFailoverPanel({
</Alert>
) : null}
<FailoverTimeline events={events} history={history} />
<FailoverTimeline events={events} history={mergedHistory} />
</FramePanel>
</Frame>
)
@@ -18,6 +18,11 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
import { IconTile } from '@/components/reui/icon-tile'
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit'
import {
latestHealthByIp,
resolveIpDisplayHealth,
type HealthLogProbe,
} from '@/lib/health-log'
import { certRelativeBadge } from '@/components/columns/certificates-columns'
import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format'
@@ -99,8 +104,12 @@ function mapNodeHealth(status: string): HealthStatus {
return 'unknown'
}
function buildIpRows(service: ServiceView): ServiceIpRow[] {
function buildIpRows(
service: ServiceView,
probes: readonly HealthLogProbe[] = [],
): ServiceIpRow[] {
const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row]))
const liveByIp = latestHealthByIp(probes)
const weights = Object.assign(
{},
...service.domains.map((domain) => domain.target_ip_weights ?? {}),
@@ -113,19 +122,25 @@ function buildIpRows(service: ServiceView): ServiceIpRow[] {
return service.ips.map((ip) => {
const health = healthByIp.get(ip)
const live = liveByIp.get(ip)
const status = resolveIpDisplayHealth(health?.status, live?.status)
const extras = live && live.status !== 'unknown' ? live : health
return {
id: ip,
ip,
status: health?.status ?? 'unknown',
status,
enabled: service.ip_enabled[ip] !== false,
active: activeSet.has(ip),
weight: weights[ip] ?? 1,
priority: priorities[ip] ?? 1,
latency_ms: health?.latency_ms ?? null,
last_checked_at: health?.last_checked_at ?? null,
last_error: health?.last_error ?? null,
colo: health?.colo ?? null,
provider: health?.provider ?? null,
latency_ms: extras?.latency_ms ?? null,
last_checked_at: extras?.last_checked_at ?? null,
last_error:
live && live.status !== 'unknown'
? live.last_error
: (health?.last_error ?? null),
colo: extras?.colo ?? null,
provider: extras?.provider ?? null,
}
})
}
@@ -151,17 +166,23 @@ function buildNodeRows(
weight: number
priority: number
}>,
probes: readonly HealthLogProbe[] = [],
): ServiceNodeRow[] {
return nodes.map((node) => ({
id: String(node.id),
nodeId: node.id,
address: node.address,
protocol: node.protocol,
port: node.port,
health_status: mapNodeHealth(node.health_status),
weight: node.weight,
priority: node.priority,
}))
const liveByIp = latestHealthByIp(probes)
return nodes.map((node) => {
const stored = mapNodeHealth(node.health_status)
const live = liveByIp.get(node.address)
return {
id: String(node.id),
nodeId: node.id,
address: node.address,
protocol: node.protocol,
port: node.port,
health_status: resolveIpDisplayHealth(stored, live?.status),
weight: node.weight,
priority: node.priority,
}
})
}
function NameCell({
@@ -199,6 +220,7 @@ interface ServiceDetailGridProps {
weight: number
priority: number
}>
probes?: readonly HealthLogProbe[]
togglingIp: string | null
onToggleIp: (ip: string, enabled: boolean) => void
onChangeIp: (row: ServiceFqdnRow) => void
@@ -211,6 +233,7 @@ interface ServiceDetailGridProps {
export function ServiceDetailGrid({
service,
nodes,
probes = [],
togglingIp,
onToggleIp,
onChangeIp,
@@ -283,9 +306,9 @@ export function ServiceDetailGrid({
},
})
const ipRows = useMemo(() => buildIpRows(service), [service])
const ipRows = useMemo(() => buildIpRows(service, probes), [service, probes])
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
const nodeRows = useMemo(() => buildNodeRows(nodes), [nodes])
const nodeRows = useMemo(() => buildNodeRows(nodes, probes), [nodes, probes])
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
const tabs = TABS.map((entry) => ({