fix(services): считать failover по IP Health а не по строке ноды
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / web (push) Successful in 52s
quality / api (push) Successful in 42s
CD / quality (push) Successful in 1m50s
CD / publish (push) Successful in 1m43s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / web (push) Successful in 52s
quality / api (push) Successful in 42s
CD / quality (push) Successful in 1m50s
CD / publish (push) Successful in 1m43s
Панель брала service_nodes, которую затирал group apply. Теперь тот же binding ip_health, что таблица активов; group не пишет в ноду. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -3,76 +3,90 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isFailoverEventStatus,
|
||||
toFailoverEvents,
|
||||
type FailoverNodeInput,
|
||||
type FailoverHealthInput,
|
||||
} from '@/lib/failover-events'
|
||||
|
||||
function node(
|
||||
overrides: Partial<FailoverNodeInput> & Pick<FailoverNodeInput, 'address'>,
|
||||
): FailoverNodeInput {
|
||||
function row(
|
||||
overrides: Partial<FailoverHealthInput> & Pick<FailoverHealthInput, 'ip'>,
|
||||
): FailoverHealthInput {
|
||||
return {
|
||||
id: overrides.id ?? overrides.address,
|
||||
health_status: 'healthy',
|
||||
status: 'up',
|
||||
consecutive_failures: 0,
|
||||
last_failure_reason: null,
|
||||
last_check_at: null,
|
||||
last_error: null,
|
||||
last_checked_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('toFailoverEvents', () => {
|
||||
it('не считает healthy failover-событием', () => {
|
||||
expect(isFailoverEventStatus('healthy')).toBe(false)
|
||||
expect(isFailoverEventStatus('unhealthy')).toBe(true)
|
||||
it('инцидент только при binding down, не при unhealthy ноды', () => {
|
||||
expect(isFailoverEventStatus('down')).toBe(true)
|
||||
expect(isFailoverEventStatus('up')).toBe(false)
|
||||
expect(isFailoverEventStatus('degraded')).toBe(false)
|
||||
expect(isFailoverEventStatus('unknown')).toBe(false)
|
||||
expect(isFailoverEventStatus('unhealthy')).toBe(false)
|
||||
})
|
||||
|
||||
it('без DNS-пула ничего не показывает — нельзя врать про вывод', () => {
|
||||
const events = toFailoverEvents([
|
||||
node({
|
||||
address: '130.49.213.153',
|
||||
health_status: 'unhealthy',
|
||||
row({
|
||||
ip: '130.49.213.153',
|
||||
status: 'down',
|
||||
consecutive_failures: 9,
|
||||
last_failure_reason: 'fetch failed',
|
||||
last_error: 'fetch failed',
|
||||
}),
|
||||
])
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('нездоровый адрес в DNS-пуле не инцидент (last-resort / ещё в A-записи)', () => {
|
||||
it('OK вне пула не инцидент (standby)', () => {
|
||||
const events = toFailoverEvents(
|
||||
[
|
||||
node({
|
||||
address: '130.49.213.153',
|
||||
health_status: 'unhealthy',
|
||||
consecutive_failures: 9,
|
||||
last_failure_reason: 'fetch failed',
|
||||
}),
|
||||
node({ address: '10.0.0.3', health_status: 'checking' }),
|
||||
],
|
||||
['130.49.213.153', '10.0.0.2'],
|
||||
)
|
||||
expect(events.map((event) => event.address)).toEqual(['10.0.0.3'])
|
||||
})
|
||||
|
||||
it('здоровый standby вне пула не инцидент', () => {
|
||||
const events = toFailoverEvents(
|
||||
[
|
||||
node({ address: '10.0.0.1', health_status: 'healthy' }),
|
||||
node({ address: '10.0.0.2', health_status: 'healthy' }),
|
||||
row({ ip: '10.0.0.1', status: 'up' }),
|
||||
row({ ip: '130.49.213.153', status: 'up' }),
|
||||
],
|
||||
['10.0.0.1'],
|
||||
)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('нездоровый адрес без A-записи — реальный вывод из пула', () => {
|
||||
it('unknown и degraded вне пула не инцидент', () => {
|
||||
const events = toFailoverEvents(
|
||||
[
|
||||
node({
|
||||
address: '130.49.213.153',
|
||||
health_status: 'unhealthy',
|
||||
row({ ip: '10.0.0.1', status: 'up' }),
|
||||
row({ ip: '10.0.0.2', status: 'unknown' }),
|
||||
row({ ip: '10.0.0.3', status: 'degraded' }),
|
||||
],
|
||||
['10.0.0.1'],
|
||||
)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('down в DNS-пуле не инцидент (last-resort / ещё в A-записи)', () => {
|
||||
const events = toFailoverEvents(
|
||||
[
|
||||
row({
|
||||
ip: '130.49.213.153',
|
||||
status: 'down',
|
||||
consecutive_failures: 9,
|
||||
last_failure_reason: 'fetch failed',
|
||||
last_check_at: '2026-08-20 07:00:00',
|
||||
last_error: 'fetch failed',
|
||||
}),
|
||||
row({ ip: '10.0.0.3', status: 'down' }),
|
||||
],
|
||||
['130.49.213.153', '10.0.0.2'],
|
||||
)
|
||||
expect(events.map((event) => event.address)).toEqual(['10.0.0.3'])
|
||||
})
|
||||
|
||||
it('down без A-записи — реальный вывод из пула', () => {
|
||||
const events = toFailoverEvents(
|
||||
[
|
||||
row({
|
||||
ip: '130.49.213.153',
|
||||
status: 'down',
|
||||
consecutive_failures: 9,
|
||||
last_error: 'fetch failed',
|
||||
last_checked_at: '2026-08-20 07:00:00',
|
||||
}),
|
||||
],
|
||||
['10.0.0.1'],
|
||||
@@ -81,7 +95,7 @@ describe('toFailoverEvents', () => {
|
||||
{
|
||||
id: '130.49.213.153',
|
||||
address: '130.49.213.153',
|
||||
status: 'unhealthy',
|
||||
status: 'down',
|
||||
consecutiveFailures: 9,
|
||||
lastFailureReason: 'fetch failed',
|
||||
lastCheckAt: '2026-08-20 07:00:00',
|
||||
|
||||
@@ -7,40 +7,40 @@ export interface FailoverEvent {
|
||||
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
|
||||
/** Binding IP health — тот же контур, что таблица активов. */
|
||||
export interface FailoverHealthInput {
|
||||
ip: string
|
||||
status: string
|
||||
consecutive_failures?: number
|
||||
last_error?: string | null
|
||||
last_checked_at?: string | null
|
||||
}
|
||||
|
||||
const FAILOVER_STATUSES = new Set(['unhealthy', 'down', 'checking'])
|
||||
|
||||
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
|
||||
export function isFailoverEventStatus(status: string): boolean {
|
||||
return FAILOVER_STATUSES.has(status)
|
||||
return status === 'down'
|
||||
}
|
||||
|
||||
/**
|
||||
* Инцидент failover = нездоровый адрес, которого нет в DNS-пуле.
|
||||
* Нода в activeAddresses (в т.ч. last-resort) — не «выведена».
|
||||
* Инцидент failover = binding health down и адреса нет в DNS-пуле.
|
||||
* OK / unknown / degraded вне пула — standby, не инцидент.
|
||||
* Down в activeAddresses (last-resort) — не «снята с DNS».
|
||||
*/
|
||||
export function toFailoverEvents(
|
||||
nodes: readonly FailoverNodeInput[],
|
||||
ipHealth: readonly FailoverHealthInput[],
|
||||
activeAddresses: readonly string[] = [],
|
||||
): FailoverEvent[] {
|
||||
const pool = new Set(activeAddresses)
|
||||
if (pool.size === 0) return []
|
||||
|
||||
return nodes
|
||||
.filter((node) => isFailoverEventStatus(node.health_status) && !pool.has(node.address))
|
||||
.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,
|
||||
return ipHealth
|
||||
.filter((row) => isFailoverEventStatus(row.status) && !pool.has(row.ip))
|
||||
.map((row) => ({
|
||||
id: row.ip,
|
||||
address: row.ip,
|
||||
status: row.status,
|
||||
consecutiveFailures: row.consecutive_failures ?? 0,
|
||||
lastFailureReason: row.last_error ?? null,
|
||||
lastCheckAt: row.last_checked_at ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user