fix(services): не балансировать сервис с одним IP
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
quality / api (push) Successful in 52s
CD / quality (push) Successful in 2m4s
CD / publish (push) Successful in 1m51s

KPI и карточка показывают живой OK, а не гистерезис unknown; DNS по-прежнему ждёт повторные успехи.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-20 18:13:06 +07:00
co-authored by Cursor
parent a9a84fabac
commit f1443f1db5
13 changed files with 464 additions and 72 deletions
+42 -17
View File
@@ -51,7 +51,28 @@ describe('toFailoverEvents', () => {
expect(isFailoverEventStatus('unhealthy')).toBe(false)
})
it('Down всегда виден, даже без A-записей', () => {
it('один IP у сервиса — не инцидент пула', () => {
const events = toFailoverEvents(
[
row({
ip: '10.0.0.1',
status: 'down',
consecutive_failures: 9,
last_error: 'fetch failed',
}),
],
[
{
fqdn: 'solo.example.com',
configured: ['10.0.0.1'],
active: [],
},
],
)
expect(events).toEqual([])
})
it('Down без shared pool не событие балансировки', () => {
const events = toFailoverEvents([
row({
ip: '130.49.213.153',
@@ -60,19 +81,7 @@ describe('toFailoverEvents', () => {
last_error: 'fetch failed',
}),
])
expect(events).toEqual([
{
id: '130.49.213.153',
address: '130.49.213.153',
status: 'down',
kind: 'last-resort',
fqdns: [],
consecutiveFailures: 9,
lastFailureReason: 'fetch failed',
lastCheckAt: null,
},
])
expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort')
expect(events).toEqual([])
})
it('OK вне пула не инцидент (standby)', () => {
@@ -151,9 +160,7 @@ describe('toFailoverEvents', () => {
},
],
)
expect(events[0]?.kind).toBe('last-resort')
expect(events[0]?.fqdns).toEqual([])
expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort')
expect(events).toEqual([])
})
it('down без A-записи на configured FQDN — снятие', () => {
@@ -293,6 +300,24 @@ describe('mergeFailoverHistory', () => {
expect(history[0]?.source).toBe('dns')
})
it('drops probe leave/return when the service has no shared pool', () => {
const history = mergeFailoverHistory(
[],
[
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
],
[
{
fqdn: 'solo.example.com',
configured: ['130.49.213.153'],
active: ['130.49.213.153'],
},
],
)
expect(history).toEqual([])
})
it('drops dedicated extra-FQDN DNS rows', () => {
const history = mergeFailoverHistory(
[
+19 -7
View File
@@ -44,9 +44,20 @@ export interface FailoverBindingPool {
active: readonly string[]
}
/** Shared pool FQDN — two or more A targets. Dedicated extra-FQDN has one IP. */
/** Unique A targets. Duplicates of the same IP are not a pool. */
export function uniqueIpCount(ips: readonly string[]): number {
return new Set(ips.filter(Boolean)).size
}
/** Shared pool FQDN — two or more unique A targets. */
export function isSharedPoolBinding(binding: FailoverBindingPool): boolean {
return binding.configured.length >= 2
return uniqueIpCount(binding.configured) >= 2
}
export function hasSharedPool(
bindings: readonly FailoverBindingPool[],
): boolean {
return bindings.some(isSharedPoolBinding)
}
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
@@ -66,13 +77,14 @@ export function failoverEventCopy(event: FailoverEvent): string {
}
/**
* Текущие Down всегда в панели. Per-FQDN: снята с hostname или last-resort.
* Standby (up / degraded / unknown) — не инцидент.
* Инциденты пула только если у сервиса есть shared FQDN (2+ уникальных IP).
* Один IP — не балансировка и не вывод из пула; Down смотрит health-монитор.
*/
export function toFailoverEvents(
ipHealth: readonly FailoverHealthInput[],
bindings: readonly FailoverBindingPool[] = [],
): FailoverEvent[] {
if (!hasSharedPool(bindings)) return []
return ipHealth.filter((row) => isFailoverEventStatus(row.status)).map((row) => {
const removedFqdns: string[] = []
const lastResortFqdns: string[] = []
@@ -221,9 +233,9 @@ export function mergeFailoverHistory(
...dns
.filter((item) => isPoolFqdn(item.fqdn, bindings))
.map(dnsHistoryItem),
...toIpAliveTransitions(probes).map((transition) =>
probeHistoryItem(transition, bindings),
),
...toIpAliveTransitions(probes)
.filter((transition) => fqdnsForIp(transition.ip, bindings).length > 0)
.map((transition) => probeHistoryItem(transition, bindings)),
]
merged.sort(
(a, b) => eventTime(b.created_at) - eventTime(a.created_at) || a.id.localeCompare(b.id),
+20
View File
@@ -7,6 +7,7 @@ import {
latestHealthByIp,
providerHealthStatuses,
resolveIpDisplayHealth,
resolveServiceDisplayHealth,
worstHealthStatus,
type HealthLogProbe,
} from '@/lib/health-log'
@@ -146,3 +147,22 @@ describe('resolveIpDisplayHealth', () => {
expect(resolveIpDisplayHealth('unknown', undefined)).toBe('unknown')
})
})
describe('resolveServiceDisplayHealth', () => {
it('shows OK when live probes recovered and stored is still unknown', () => {
expect(
resolveServiceDisplayHealth(
'unknown',
[{ ip: '2.59.161.102', status: 'unknown' }],
[
probe({
id: 1,
ip: '2.59.161.102',
status: 'up',
checked_at: '2026-08-20T18:00:00Z',
}),
],
),
).toBe('up')
})
})
+19
View File
@@ -194,3 +194,22 @@ export function resolveIpDisplayHealth(
if (live && live !== 'unknown') return live
return stored ?? live ?? 'unknown'
}
/** Service KPI / card: any-up of per-IP overlay (live probes beat hysteresis). */
export function resolveServiceDisplayHealth(
stored: HealthLogStatus | undefined,
ipHealth: readonly { ip: string; status: string }[],
probes: readonly HealthLogProbe[] = [],
): HealthLogStatus {
const liveByIp = latestHealthByIp(probes)
const statuses =
ipHealth.length > 0
? ipHealth.map((row) =>
resolveIpDisplayHealth(
row.status as HealthLogStatus,
liveByIp.get(row.ip)?.status,
),
)
: [...liveByIp.values()].map((row) => row.status)
return resolveIpDisplayHealth(stored, bestAliveHealthStatus(statuses))
}