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

Панель брала service_nodes, которую затирал group apply.
Теперь тот же binding ip_health, что таблица активов; group не пишет в ноду.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-20 15:08:10 +07:00
co-authored by Cursor
parent 994e79e118
commit 7938d2f707
7 changed files with 161 additions and 75 deletions
@@ -386,7 +386,8 @@ function applyAggregatedStatus(
{ colo, provider: statusProvider }, { colo, provider: statusProvider },
); );
const matchedNode = repos.findNodeByIp(db, target.ip); const matchedNode = repos.findNodeByIp(db, target.ip);
if (matchedNode && matchedNode.enabled) { // Binding-scope only: group apply must not clobber node with its own fetch failed.
if (matchedNode && matchedNode.enabled && target.scope === "binding") {
repos.updateNode(db, matchedNode.id, { repos.updateNode(db, matchedNode.id, {
health_status: node, health_status: node,
consecutive_failures: failures, consecutive_failures: failures,
+71
View File
@@ -291,6 +291,77 @@ describe("health-check state derivation via runAllChecks", () => {
expect(targets[0]?.ip).toBe("2.59.161.102"); expect(targets[0]?.ip).toBe("2.59.161.102");
expect(targets[0]?.hostname).toBe("s.rkns.top"); expect(targets[0]?.hostname).toBe("s.rkns.top");
}); });
it("does not mark node unhealthy when binding majority is OK and group local fails", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const tcp = await startTcpServer();
try {
const domain = repos.createDomain(db, null, "example.com", "zone-id");
const group = repos.createServiceGroup(
db,
"VPN",
"vpn",
null,
"vpn.example.com",
{
health_check_enabled: true,
health_check_type: "http",
health_check_port: 1,
health_check_timeout_ms: 200,
health_check_path: "/",
},
);
const service = repos.createService(db, "Svc", "svc");
repos.setServiceGroup(db, service.id, group.id);
repos.setServiceEnabled(db, service.id, true);
const binding = repos.insertBinding(db, domain.id, service.id, "@", null);
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: tcp.port,
health_check_timeout_ms: 500,
});
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "127.0.0.1", weight: 1, priority: 1 },
]);
const node = repos.findNodeByIp(db, "127.0.0.1");
expect(node).not.toBeNull();
await healthCheckService.runAllChecks(db, {
probeGapMs: 0,
thresholds: {
degradedFailures: 1,
downFailures: 1,
latencyWarnMs: 1000,
},
});
const bindingHealth = repos.getIpHealthStatusRow(
db,
"binding",
binding.id,
"127.0.0.1",
);
const groupHealth = repos.getIpHealthStatusRow(
db,
"group",
group.id,
"127.0.0.1",
);
const after = repos.getNode(db, node!.id);
expect(bindingHealth?.status).toBe("up");
expect(groupHealth?.status).toBe("down");
expect(after.health_status).toBe("healthy");
expect(after.consecutive_failures).toBe(0);
expect(after.last_failure_reason).toBeNull();
} finally {
await new Promise<void>((resolve) => tcp.server.close(() => resolve()));
}
});
}); });
describe("CNAME health mapped onto service IPs", () => { describe("CNAME health mapped onto service IPs", () => {
@@ -54,7 +54,7 @@ export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
<EmptyState <EmptyState
icon={ShieldCheckIcon} icon={ShieldCheckIcon}
title="Пул в DNS на месте" title="Пул в DNS на месте"
description="Красным только адреса, которых нет в активном пуле A-записей" description="Standby и last-resort не красные — только down, снятые с A-записей"
stackedIcon={false} stackedIcon={false}
centered={false} centered={false}
/> />
@@ -84,9 +84,11 @@ export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
/> />
</TimelineTitle> </TimelineTitle>
<TimelineDate> <TimelineDate>
{failStreakLabel(event.consecutiveFailures)} {event.consecutiveFailures > 0
? failStreakLabel(event.consecutiveFailures)
: null}
{checkedIso {checkedIso
? ` · ${formatRelative(checkedIso)} · ${formatDate(checkedIso)}` ? `${event.consecutiveFailures > 0 ? ' · ' : ''}${formatRelative(checkedIso)} · ${formatDate(checkedIso)}`
: null} : null}
</TimelineDate> </TimelineDate>
</TimelineHeader> </TimelineHeader>
@@ -3,7 +3,7 @@ import { UnplugIcon } from 'lucide-react'
import { FailoverTimeline } from '@/components/failover-timeline' import { FailoverTimeline } from '@/components/failover-timeline'
import { import {
toFailoverEvents, toFailoverEvents,
type FailoverNodeInput, type FailoverHealthInput,
} from '@/lib/failover-events' } from '@/lib/failover-events'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { import {
@@ -39,13 +39,13 @@ function failoverCountLabel(count: number): string {
* Docs: https://reui.io/docs/components/base/alert * Docs: https://reui.io/docs/components/base/alert
*/ */
export function ServiceFailoverPanel({ export function ServiceFailoverPanel({
nodes, ipHealth,
activeAddresses, activeAddresses,
}: { }: {
nodes: readonly FailoverNodeInput[] ipHealth: readonly FailoverHealthInput[]
activeAddresses: readonly string[] activeAddresses: readonly string[]
}) { }) {
const events = toFailoverEvents(nodes, activeAddresses) const events = toFailoverEvents(ipHealth, activeAddresses)
return ( return (
<Frame stacked spacing="sm" className="min-w-0 w-full"> <Frame stacked spacing="sm" className="min-w-0 w-full">
@@ -64,7 +64,7 @@ export function ServiceFailoverPanel({
)} )}
</FrameTitle> </FrameTitle>
<FrameDescription> <FrameDescription>
Только адреса, которых нет в DNS-пуле · не статус строки ноды Только down из IP Health вне DNS-пула · как таблица активов
</FrameDescription> </FrameDescription>
</FrameHeader> </FrameHeader>
+55 -41
View File
@@ -3,76 +3,90 @@ import { describe, expect, it } from 'vitest'
import { import {
isFailoverEventStatus, isFailoverEventStatus,
toFailoverEvents, toFailoverEvents,
type FailoverNodeInput, type FailoverHealthInput,
} from '@/lib/failover-events' } from '@/lib/failover-events'
function node( function row(
overrides: Partial<FailoverNodeInput> & Pick<FailoverNodeInput, 'address'>, overrides: Partial<FailoverHealthInput> & Pick<FailoverHealthInput, 'ip'>,
): FailoverNodeInput { ): FailoverHealthInput {
return { return {
id: overrides.id ?? overrides.address, status: 'up',
health_status: 'healthy',
consecutive_failures: 0, consecutive_failures: 0,
last_failure_reason: null, last_error: null,
last_check_at: null, last_checked_at: null,
...overrides, ...overrides,
} }
} }
describe('toFailoverEvents', () => { describe('toFailoverEvents', () => {
it('не считает healthy failover-событием', () => { it('инцидент только при binding down, не при unhealthy ноды', () => {
expect(isFailoverEventStatus('healthy')).toBe(false) expect(isFailoverEventStatus('down')).toBe(true)
expect(isFailoverEventStatus('unhealthy')).toBe(true) expect(isFailoverEventStatus('up')).toBe(false)
expect(isFailoverEventStatus('degraded')).toBe(false)
expect(isFailoverEventStatus('unknown')).toBe(false)
expect(isFailoverEventStatus('unhealthy')).toBe(false)
}) })
it('без DNS-пула ничего не показывает — нельзя врать про вывод', () => { it('без DNS-пула ничего не показывает — нельзя врать про вывод', () => {
const events = toFailoverEvents([ const events = toFailoverEvents([
node({ row({
address: '130.49.213.153', ip: '130.49.213.153',
health_status: 'unhealthy', status: 'down',
consecutive_failures: 9, consecutive_failures: 9,
last_failure_reason: 'fetch failed', last_error: 'fetch failed',
}), }),
]) ])
expect(events).toEqual([]) expect(events).toEqual([])
}) })
it('нездоровый адрес в DNS-пуле не инцидент (last-resort / ещё в A-записи)', () => { it('OK вне пула не инцидент (standby)', () => {
const events = toFailoverEvents( const events = toFailoverEvents(
[ [
node({ row({ ip: '10.0.0.1', status: 'up' }),
address: '130.49.213.153', row({ ip: '130.49.213.153', status: 'up' }),
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' }),
], ],
['10.0.0.1'], ['10.0.0.1'],
) )
expect(events).toEqual([]) expect(events).toEqual([])
}) })
it('нездоровый адрес без A-записи — реальный вывод из пула', () => { it('unknown и degraded вне пула не инцидент', () => {
const events = toFailoverEvents( const events = toFailoverEvents(
[ [
node({ row({ ip: '10.0.0.1', status: 'up' }),
address: '130.49.213.153', row({ ip: '10.0.0.2', status: 'unknown' }),
health_status: 'unhealthy', 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, consecutive_failures: 9,
last_failure_reason: 'fetch failed', last_error: 'fetch failed',
last_check_at: '2026-08-20 07:00:00', }),
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'], ['10.0.0.1'],
@@ -81,7 +95,7 @@ describe('toFailoverEvents', () => {
{ {
id: '130.49.213.153', id: '130.49.213.153',
address: '130.49.213.153', address: '130.49.213.153',
status: 'unhealthy', status: 'down',
consecutiveFailures: 9, consecutiveFailures: 9,
lastFailureReason: 'fetch failed', lastFailureReason: 'fetch failed',
lastCheckAt: '2026-08-20 07:00:00', lastCheckAt: '2026-08-20 07:00:00',
+22 -22
View File
@@ -7,40 +7,40 @@ export interface FailoverEvent {
lastCheckAt?: string | null lastCheckAt?: string | null
} }
export interface FailoverNodeInput { /** Binding IP health — тот же контур, что таблица активов. */
id?: number | string export interface FailoverHealthInput {
address: string ip: string
health_status: string status: string
consecutive_failures: number consecutive_failures?: number
last_failure_reason: string | null last_error?: string | null
last_check_at?: 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 { export function isFailoverEventStatus(status: string): boolean {
return FAILOVER_STATUSES.has(status) return status === 'down'
} }
/** /**
* Инцидент failover = нездоровый адрес, которого нет в DNS-пуле. * Инцидент failover = binding health down и адреса нет в DNS-пуле.
* Нода в activeAddresses (в т.ч. last-resort) — не «выведена». * OK / unknown / degraded вне пула — standby, не инцидент.
* Down в activeAddresses (last-resort) — не «снята с DNS».
*/ */
export function toFailoverEvents( export function toFailoverEvents(
nodes: readonly FailoverNodeInput[], ipHealth: readonly FailoverHealthInput[],
activeAddresses: readonly string[] = [], activeAddresses: readonly string[] = [],
): FailoverEvent[] { ): FailoverEvent[] {
const pool = new Set(activeAddresses) const pool = new Set(activeAddresses)
if (pool.size === 0) return [] if (pool.size === 0) return []
return nodes return ipHealth
.filter((node) => isFailoverEventStatus(node.health_status) && !pool.has(node.address)) .filter((row) => isFailoverEventStatus(row.status) && !pool.has(row.ip))
.map((node) => ({ .map((row) => ({
id: String(node.id ?? node.address), id: row.ip,
address: node.address, address: row.ip,
status: node.health_status, status: row.status,
consecutiveFailures: node.consecutive_failures, consecutiveFailures: row.consecutive_failures ?? 0,
lastFailureReason: node.last_failure_reason, lastFailureReason: row.last_error ?? null,
lastCheckAt: node.last_check_at ?? null, lastCheckAt: row.last_checked_at ?? null,
})) }))
} }
@@ -203,8 +203,6 @@ function ServiceDetailPage() {
const isError = viewQuery.isError || overviewQuery.isError const isError = viewQuery.isError || overviewQuery.isError
const error = viewQuery.error ?? overviewQuery.error const error = viewQuery.error ?? overviewQuery.error
const failoverNodes = nodes.length > 0 ? nodes : (overview?.nodes ?? [])
const enabledProviders = useMemo( const enabledProviders = useMemo(
() => enabledHealthProviders(service?.domains ?? []), () => enabledHealthProviders(service?.domains ?? []),
[service], [service],
@@ -314,7 +312,7 @@ function ServiceDetailPage() {
isLoading={logQuery.isLoading} isLoading={logQuery.isLoading}
/> />
<ServiceFailoverPanel <ServiceFailoverPanel
nodes={failoverNodes} ipHealth={service.ip_health}
activeAddresses={overview?.active_addresses ?? service.active_ips} activeAddresses={overview?.active_addresses ?? service.active_ips}
/> />
</section> </section>