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:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user