{isChecking - ? 'Health-check ещё не завершён — нода не в активном пуле' - : 'Нода выведена из активного пула'} + ? 'Снята с DNS, идёт восстановление' + : 'Снята с DNS — в A-записях этого адреса нет'}
{event.lastFailureReason ? (
@@ -62,7 +64,7 @@ export function ServiceFailoverPanel({
)}
- Нездоровые ноды выведены из пула · причина последней ошибки
+ Только адреса, которых нет в DNS-пуле · не статус строки ноды
@@ -71,7 +73,7 @@ export function ServiceFailoverPanel({
{failoverCountLabel(events.length)}
- Трафик на эти адреса не идёт, пока health не восстановится
+ Эти IP сняты с A-записей
) : null}
diff --git a/apps/web/src/lib/failover-events.test.ts b/apps/web/src/lib/failover-events.test.ts
index a3a41b9..89d1e73 100644
--- a/apps/web/src/lib/failover-events.test.ts
+++ b/apps/web/src/lib/failover-events.test.ts
@@ -20,34 +20,72 @@ function node(
}
describe('toFailoverEvents', () => {
- it('оставляет только unhealthy / down / checking', () => {
+ it('не считает healthy failover-событием', () => {
+ expect(isFailoverEventStatus('healthy')).toBe(false)
+ expect(isFailoverEventStatus('unhealthy')).toBe(true)
+ })
+
+ it('без DNS-пула ничего не показывает — нельзя врать про вывод', () => {
const events = toFailoverEvents([
- node({ address: '10.0.0.1', health_status: 'healthy' }),
node({
address: '130.49.213.153',
health_status: 'unhealthy',
consecutive_failures: 9,
last_failure_reason: 'fetch failed',
- last_check_at: '2026-08-20 07:00:00',
}),
- node({ address: '10.0.0.3', health_status: 'checking' }),
- node({ address: '10.0.0.4', health_status: 'disabled' }),
])
-
- expect(events.map((event) => event.address)).toEqual([
- '130.49.213.153',
- '10.0.0.3',
- ])
- expect(events[0]).toMatchObject({
- consecutiveFailures: 9,
- lastFailureReason: 'fetch failed',
- lastCheckAt: '2026-08-20 07:00:00',
- status: 'unhealthy',
- })
+ expect(events).toEqual([])
})
- it('не считает healthy failover-событием', () => {
- expect(isFailoverEventStatus('healthy')).toBe(false)
- expect(isFailoverEventStatus('unhealthy')).toBe(true)
+ it('нездоровый адрес в DNS-пуле не инцидент (last-resort / ещё в A-записи)', () => {
+ 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' }),
+ ],
+ ['10.0.0.1'],
+ )
+ expect(events).toEqual([])
+ })
+
+ it('нездоровый адрес без A-записи — реальный вывод из пула', () => {
+ const events = toFailoverEvents(
+ [
+ node({
+ address: '130.49.213.153',
+ health_status: 'unhealthy',
+ consecutive_failures: 9,
+ last_failure_reason: 'fetch failed',
+ last_check_at: '2026-08-20 07:00:00',
+ }),
+ ],
+ ['10.0.0.1'],
+ )
+ expect(events).toEqual([
+ {
+ id: '130.49.213.153',
+ address: '130.49.213.153',
+ status: 'unhealthy',
+ consecutiveFailures: 9,
+ lastFailureReason: 'fetch failed',
+ lastCheckAt: '2026-08-20 07:00:00',
+ },
+ ])
})
})
diff --git a/apps/web/src/lib/failover-events.ts b/apps/web/src/lib/failover-events.ts
index bc87a82..8d833d5 100644
--- a/apps/web/src/lib/failover-events.ts
+++ b/apps/web/src/lib/failover-events.ts
@@ -22,15 +22,25 @@ export function isFailoverEventStatus(status: string): boolean {
return FAILOVER_STATUSES.has(status)
}
+/**
+ * Инцидент failover = нездоровый адрес, которого нет в DNS-пуле.
+ * Нода в activeAddresses (в т.ч. last-resort) — не «выведена».
+ */
export function toFailoverEvents(
nodes: readonly FailoverNodeInput[],
+ activeAddresses: readonly string[] = [],
): FailoverEvent[] {
- return nodes.filter((node) => isFailoverEventStatus(node.health_status)).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,
- }))
+ 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,
+ }))
}
diff --git a/apps/web/src/routes/_auth/services/$serviceId/index.tsx b/apps/web/src/routes/_auth/services/$serviceId/index.tsx
index 53525ce..3213e14 100644
--- a/apps/web/src/routes/_auth/services/$serviceId/index.tsx
+++ b/apps/web/src/routes/_auth/services/$serviceId/index.tsx
@@ -313,7 +313,10 @@ function ServiceDetailPage() {
statuses={providerStatuses}
isLoading={logQuery.isLoading}
/>
-
+
{service.ips.length === 0 && service.domains.length === 0 ? (