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
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:
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export const serviceGroupsQueryOptions = () =>
|
||||
}
|
||||
return parsed.data
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
export const servicesQueryOptions = () =>
|
||||
@@ -101,6 +103,8 @@ export const serviceViewQueryOptions = (id: number) =>
|
||||
const data = await api.get<unknown>(`/api/v1/services/${id}`)
|
||||
return serviceViewSchema.parse(data)
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
export const serviceHealthLogQueryOptions = (id: number) =>
|
||||
@@ -110,6 +114,8 @@ export const serviceHealthLogQueryOptions = (id: number) =>
|
||||
const data = await api.get<unknown>(`/api/v1/services/${id}/health-log`)
|
||||
return z.object({ items: z.array(healthProbeLogSchema) }).parse(data)
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
export const serviceFailoverLogQueryOptions = (id: number) =>
|
||||
|
||||
@@ -32,9 +32,11 @@ import {
|
||||
ServiceHealthMonitor,
|
||||
} from '@/components/reui-kit'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { hasSharedPool } from '@/lib/failover-events'
|
||||
import {
|
||||
enabledHealthProviders,
|
||||
providerHealthStatuses,
|
||||
resolveServiceDisplayHealth,
|
||||
} from '@/lib/health-log'
|
||||
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||
import {
|
||||
@@ -115,6 +117,22 @@ function ServiceDetailPage() {
|
||||
})),
|
||||
[service?.domains],
|
||||
)
|
||||
const uniqueEnabledIps = useMemo(
|
||||
() =>
|
||||
(service?.ips ?? []).filter((ip) => service?.ip_enabled[ip] !== false),
|
||||
[service?.ips, service?.ip_enabled],
|
||||
)
|
||||
const displayHealth = useMemo(
|
||||
() =>
|
||||
resolveServiceDisplayHealth(
|
||||
service?.health_status,
|
||||
service?.ip_health ?? [],
|
||||
logItems,
|
||||
),
|
||||
[service?.health_status, service?.ip_health, logItems],
|
||||
)
|
||||
const showPoolPanel =
|
||||
uniqueEnabledIps.length >= 2 && hasSharedPool(failoverBindings)
|
||||
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
@@ -251,7 +269,7 @@ function ServiceDetailPage() {
|
||||
actions={
|
||||
<>
|
||||
<LbModeTile mode={service.lb_mode} />
|
||||
<HealthCheckBadge status={service.health_status} />
|
||||
<HealthCheckBadge status={displayHealth} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
@@ -278,20 +296,20 @@ function ServiceDetailPage() {
|
||||
id: 'status',
|
||||
icon: <ActivityIcon />,
|
||||
label: 'Статус',
|
||||
value: service.health_status === 'up' ? 'OK' : service.health_status,
|
||||
value: displayHealth === 'up' ? 'OK' : displayHealth,
|
||||
variant:
|
||||
service.health_status === 'down'
|
||||
displayHealth === 'down'
|
||||
? 'destructive'
|
||||
: service.health_status === 'degraded'
|
||||
: displayHealth === 'degraded'
|
||||
? 'warning'
|
||||
: 'default',
|
||||
iconClassName:
|
||||
service.health_status === 'down'
|
||||
displayHealth === 'down'
|
||||
? 'text-destructive'
|
||||
: service.health_status === 'degraded'
|
||||
: displayHealth === 'degraded'
|
||||
? 'text-warning'
|
||||
: 'text-success',
|
||||
hint: <HealthCheckBadge status={service.health_status} size="xs" />,
|
||||
hint: <HealthCheckBadge status={displayHealth} size="xs" />,
|
||||
},
|
||||
{
|
||||
id: 'fqdn',
|
||||
@@ -305,21 +323,33 @@ function ServiceDetailPage() {
|
||||
icon: <NetworkIcon />,
|
||||
label: 'IP',
|
||||
value: String(service.ips.length),
|
||||
hint: `${service.active_ips.length} в пуле`,
|
||||
hint: showPoolPanel
|
||||
? `${service.active_ips.length} в пуле`
|
||||
: uniqueEnabledIps.length <= 1
|
||||
? 'без балансировки'
|
||||
: service.ips.join(', ') || 'нет',
|
||||
},
|
||||
{
|
||||
id: 'pool',
|
||||
icon: <ServerIcon />,
|
||||
label: 'Активный пул',
|
||||
value: String((overview?.active_addresses ?? service.active_ips).length),
|
||||
hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет',
|
||||
label: showPoolPanel ? 'Активный пул' : 'Адреса',
|
||||
value: String(
|
||||
(overview?.active_addresses ?? service.active_ips).length,
|
||||
),
|
||||
hint:
|
||||
(overview?.active_addresses ?? service.active_ips).join(', ') ||
|
||||
'нет',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<section
|
||||
aria-label="Мониторинг"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
className={
|
||||
showPoolPanel
|
||||
? 'grid min-w-0 items-start gap-2 @3xl:grid-cols-2'
|
||||
: 'grid min-w-0 items-start gap-2'
|
||||
}
|
||||
>
|
||||
<ServiceHealthMonitor
|
||||
items={logItems}
|
||||
@@ -327,13 +357,15 @@ function ServiceDetailPage() {
|
||||
statuses={providerStatuses}
|
||||
isLoading={logQuery.isLoading}
|
||||
/>
|
||||
<ServiceFailoverPanel
|
||||
lbMode={service.lb_mode}
|
||||
ipHealth={service.ip_health}
|
||||
bindings={failoverBindings}
|
||||
history={failoverHistory}
|
||||
probes={logItems}
|
||||
/>
|
||||
{showPoolPanel ? (
|
||||
<ServiceFailoverPanel
|
||||
lbMode={service.lb_mode}
|
||||
ipHealth={service.ip_health}
|
||||
bindings={failoverBindings}
|
||||
history={failoverHistory}
|
||||
probes={logItems}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{service.ips.length === 0 && service.domains.length === 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user