fix(services): снимать Down только с общего FQDN

Персональные A не крутить и не трогать при падении. Панель называется по режиму балансировки.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-20 17:51:49 +07:00
co-authored by Cursor
parent 3d0ea33baf
commit 2b150fa3a6
8 changed files with 221 additions and 48 deletions
@@ -63,8 +63,8 @@ export function FailoverTimeline({
return (
<EmptyState
icon={ShieldCheckIcon}
title="Нет инцидентов Failover"
description="Нет Down и нет выходов из пула"
title="Нет инцидентов"
description="Нет Down и нет выходов из общего пула"
stackedIcon={false}
centered={false}
/>
@@ -13,7 +13,7 @@ import {
type HealthLogProbe,
type HealthLogStatus,
} from '@/lib/health-log'
import type { FailoverLogEntry } from '@/lib/schemas'
import type { FailoverLogEntry, ServiceView } from '@/lib/schemas'
import { Badge } from '@/components/reui/badge'
import {
Frame,
@@ -28,6 +28,23 @@ import {
AlertTitle,
} from '@/components/reui/alert'
type LbMode = ServiceView['lb_mode']
const PANEL_COPY: Record<LbMode, { title: string; description: string }> = {
failover: {
title: 'Failover (приоритет)',
description: 'Down снимается с общего FQDN. История: кто вышел из пула и кто вернулся',
},
weighted: {
title: 'Веса (подмена IP)',
description: 'На общем FQDN один IP по весам. Down выводится из цикла',
},
round_robin: {
title: 'Round Robin',
description: 'На общем FQDN все живые A. Down снимается с пула',
},
}
function failoverCountLabel(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
@@ -38,8 +55,17 @@ function failoverCountLabel(count: number): string {
return `${count} адресов Down`
}
function alertDescription(events: { kind: string; fqdns: string[] }[]): string {
const removedCount = events.filter((event) => event.kind === 'removed').length
if (removedCount > 0) return 'Сняты с общего FQDN'
if (events.some((event) => event.fqdns.length > 0)) {
return 'Остались в A-записях общего FQDN как last-resort'
}
return 'Down: персональные FQDN не меняются'
}
/**
* Failover — текущие Down + кто вышел из пула и кто вернулся.
* Балансировка — текущие 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
@@ -48,16 +74,19 @@ function failoverCountLabel(count: number): string {
* Docs: https://reui.io/docs/components/base/alert
*/
export function ServiceFailoverPanel({
lbMode = 'round_robin',
ipHealth,
bindings,
history,
probes = [],
}: {
lbMode?: LbMode
ipHealth: readonly FailoverHealthInput[]
bindings: readonly FailoverBindingPool[]
history: readonly FailoverLogEntry[]
probes?: readonly HealthLogProbe[]
}) {
const copy = PANEL_COPY[lbMode] ?? PANEL_COPY.round_robin
const liveByIp = latestHealthByIp(probes)
const overlayHealth = ipHealth.map((row) => {
const live = liveByIp.get(row.ip)
@@ -77,14 +106,13 @@ export function ServiceFailoverPanel({
})
const events = toFailoverEvents(overlayHealth, bindings)
const mergedHistory = mergeFailoverHistory(history, probes, bindings)
const removedCount = events.filter((event) => event.kind === 'removed').length
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
{copy.title}
{events.length > 0 ? (
<Badge variant="destructive-light" size="xs" radius="full">
{events.length}
@@ -95,20 +123,14 @@ export function ServiceFailoverPanel({
</Badge>
)}
</FrameTitle>
<FrameDescription>
Текущие Down и история: кто вышел из пула и кто вернулся
</FrameDescription>
<FrameDescription>{copy.description}</FrameDescription>
</FrameHeader>
{events.length > 0 ? (
<Alert variant="destructive">
<UnplugIcon aria-hidden="true" />
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
<AlertDescription>
{removedCount > 0
? 'Сняты с FQDN или остались last-resort'
: 'Остались в A-записях как last-resort'}
</AlertDescription>
<AlertDescription>{alertDescription(events)}</AlertDescription>
</Alert>
) : null}
+22 -5
View File
@@ -137,6 +137,7 @@ describe('toFailoverEvents', () => {
},
])
expect(failoverEventCopy(events[0]!)).toBe('Снята с gt.rkns.top')
expect(events[0]?.fqdns).not.toContain('nsgt.rkns.top')
})
it('Down только last-resort на своём FQDN', () => {
@@ -151,10 +152,8 @@ describe('toFailoverEvents', () => {
],
)
expect(events[0]?.kind).toBe('last-resort')
expect(events[0]?.fqdns).toEqual(['nsgt.rkns.top'])
expect(failoverEventCopy(events[0]!)).toBe(
'Down, в A-записях last-resort на nsgt.rkns.top',
)
expect(events[0]?.fqdns).toEqual([])
expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort')
})
it('down без A-записи на configured FQDN — снятие', () => {
@@ -279,7 +278,9 @@ describe('mergeFailoverHistory', () => {
'removed:probe',
])
expect(history[0]?.copy).toBe('130.49.213.153 добавлена на gt.rkns.top')
expect(history[1]?.copy).toContain('вышла из пула')
expect(history[1]?.copy).toBe(
'130.49.213.153 вышла из пула (gt.rkns.top)',
)
})
it('keeps DNS over a probe return in the same 2-minute window', () => {
@@ -291,4 +292,20 @@ describe('mergeFailoverHistory', () => {
expect(history).toHaveLength(1)
expect(history[0]?.source).toBe('dns')
})
it('drops dedicated extra-FQDN DNS rows', () => {
const history = mergeFailoverHistory(
[
dns({
id: 11,
fqdn: 'nsgt.rkns.top',
action: 'added',
created_at: '2026-08-20 09:26:00',
}),
],
[],
mskHip,
)
expect(history).toEqual([])
})
})
+21 -2
View File
@@ -44,6 +44,11 @@ export interface FailoverBindingPool {
active: readonly string[]
}
/** Shared pool FQDN — two or more A targets. Dedicated extra-FQDN has one IP. */
export function isSharedPoolBinding(binding: FailoverBindingPool): boolean {
return binding.configured.length >= 2
}
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
export function isFailoverEventStatus(status: string): boolean {
return status === 'down'
@@ -72,6 +77,7 @@ export function toFailoverEvents(
const removedFqdns: string[] = []
const lastResortFqdns: string[] = []
for (const binding of bindings) {
if (!isSharedPoolBinding(binding)) continue
const configured = binding.configured.includes(row.ip)
const active = binding.active.includes(row.ip)
if (configured && !active) removedFqdns.push(binding.fqdn)
@@ -98,7 +104,18 @@ function fqdnsForIp(
ip: string,
bindings: readonly FailoverBindingPool[],
): string[] {
return bindings.filter((binding) => binding.configured.includes(ip)).map((binding) => binding.fqdn)
return bindings
.filter((binding) => isSharedPoolBinding(binding) && binding.configured.includes(ip))
.map((binding) => binding.fqdn)
}
function isPoolFqdn(
fqdn: string,
bindings: readonly FailoverBindingPool[],
): boolean {
const binding = bindings.find((item) => item.fqdn === fqdn)
if (!binding) return true
return isSharedPoolBinding(binding)
}
function fqdnLabel(fqdns: string[]): string {
@@ -201,7 +218,9 @@ export function mergeFailoverHistory(
bindings: readonly FailoverBindingPool[] = [],
): FailoverHistoryItem[] {
const merged = [
...dns.map(dnsHistoryItem),
...dns
.filter((item) => isPoolFqdn(item.fqdn, bindings))
.map(dnsHistoryItem),
...toIpAliveTransitions(probes).map((transition) =>
probeHistoryItem(transition, bindings),
),