Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a13888db7 | ||
|
|
f5d97e463a |
@@ -16,8 +16,8 @@ import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format'
|
|||||||
import {
|
import {
|
||||||
failoverEventCopy,
|
failoverEventCopy,
|
||||||
type FailoverEvent,
|
type FailoverEvent,
|
||||||
|
type FailoverHistoryItem,
|
||||||
} from '@/lib/failover-events'
|
} from '@/lib/failover-events'
|
||||||
import type { FailoverLogEntry } from '@/lib/schemas'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import type { ComponentProps } from 'react'
|
import type { ComponentProps } from 'react'
|
||||||
|
|
||||||
@@ -45,12 +45,6 @@ function separatorClass(tone: 'down' | 'added' | 'removed'): string {
|
|||||||
return 'bg-destructive/25'
|
return 'bg-destructive/25'
|
||||||
}
|
}
|
||||||
|
|
||||||
function failoverHistoryCopy(item: FailoverLogEntry): string {
|
|
||||||
return item.action === 'removed'
|
|
||||||
? `${item.ip} убрана с ${item.fqdn}`
|
|
||||||
: `${item.ip} добавлена на ${item.fqdn}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Failover как sibling «Смены статуса»: ReUI Timeline + Badge.
|
* Failover как sibling «Смены статуса»: ReUI Timeline + Badge.
|
||||||
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
||||||
@@ -63,14 +57,14 @@ export function FailoverTimeline({
|
|||||||
history = [],
|
history = [],
|
||||||
}: {
|
}: {
|
||||||
events: FailoverEvent[]
|
events: FailoverEvent[]
|
||||||
history?: readonly FailoverLogEntry[]
|
history?: readonly FailoverHistoryItem[]
|
||||||
}) {
|
}) {
|
||||||
if (events.length === 0 && history.length === 0) {
|
if (events.length === 0 && history.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ShieldCheckIcon}
|
icon={ShieldCheckIcon}
|
||||||
title="Нет инцидентов Failover"
|
title="Нет инцидентов Failover"
|
||||||
description="Нет Down и нет смен A-записей по FQDN"
|
description="Нет Down и нет выходов из пула"
|
||||||
stackedIcon={false}
|
stackedIcon={false}
|
||||||
centered={false}
|
centered={false}
|
||||||
/>
|
/>
|
||||||
@@ -142,6 +136,10 @@ export function FailoverTimeline({
|
|||||||
<TimelineHeader>
|
<TimelineHeader>
|
||||||
<TimelineTitle className="flex flex-wrap items-center gap-2">
|
<TimelineTitle className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-mono text-sm">{item.ip}</span>
|
<span className="font-mono text-sm">{item.ip}</span>
|
||||||
|
<HealthCheckBadge
|
||||||
|
status={item.action === 'added' ? 'up' : 'down'}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{item.fqdn}
|
{item.fqdn}
|
||||||
</span>
|
</span>
|
||||||
@@ -151,9 +149,7 @@ export function FailoverTimeline({
|
|||||||
</TimelineDate>
|
</TimelineDate>
|
||||||
</TimelineHeader>
|
</TimelineHeader>
|
||||||
<TimelineContent>
|
<TimelineContent>
|
||||||
<p className="text-foreground text-sm">
|
<p className="text-foreground text-sm">{item.copy}</p>
|
||||||
{failoverHistoryCopy(item)}
|
|
||||||
</p>
|
|
||||||
</TimelineContent>
|
</TimelineContent>
|
||||||
</TimelineItem>
|
</TimelineItem>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
||||||
import { CableIcon, GlobeIcon } from 'lucide-react'
|
import { CableIcon, GlobeIcon } from 'lucide-react'
|
||||||
@@ -62,6 +62,11 @@ const defaultLbModeOptions = [
|
|||||||
{ value: 'weighted', label: 'Weighted (веса)' },
|
{ value: 'weighted', label: 'Weighted (веса)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export interface LbPoolMetaChange {
|
||||||
|
weight?: number
|
||||||
|
priority?: number
|
||||||
|
}
|
||||||
|
|
||||||
function CompactNumberField({
|
function CompactNumberField({
|
||||||
id,
|
id,
|
||||||
value,
|
value,
|
||||||
@@ -95,6 +100,89 @@ function CompactNumberField({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PoolLbMetaFields({
|
||||||
|
idPrefix,
|
||||||
|
mode,
|
||||||
|
ips,
|
||||||
|
weights,
|
||||||
|
priorities,
|
||||||
|
onMetaChange,
|
||||||
|
}: {
|
||||||
|
idPrefix: string
|
||||||
|
mode: Exclude<LbMode, 'round_robin'>
|
||||||
|
ips: readonly string[]
|
||||||
|
weights: Record<string, number>
|
||||||
|
priorities: Record<string, number>
|
||||||
|
onMetaChange?: (ip: string, meta: LbPoolMetaChange) => void
|
||||||
|
}) {
|
||||||
|
const isWeighted = mode === 'weighted'
|
||||||
|
const minPriority =
|
||||||
|
ips.length === 0
|
||||||
|
? 1
|
||||||
|
: Math.min(...ips.map((ip) => priorities[ip] ?? 1))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingRow
|
||||||
|
title={isWeighted ? 'Вес IP' : 'Приоритет IP'}
|
||||||
|
description={
|
||||||
|
isWeighted
|
||||||
|
? 'Больше — чаще в пуле'
|
||||||
|
: '1 — основной, больше — запасной'
|
||||||
|
}
|
||||||
|
compact
|
||||||
|
stacked
|
||||||
|
className="gap-3 px-0 py-3"
|
||||||
|
contentClassName="min-w-0"
|
||||||
|
>
|
||||||
|
{ips.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Сначала добавьте IP выше</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
{ips.map((ip) => {
|
||||||
|
const isPrimary = (priorities[ip] ?? 1) === minPriority
|
||||||
|
const fieldId = isWeighted
|
||||||
|
? `${idPrefix}-weight-${ip}`
|
||||||
|
: `${idPrefix}-priority-${ip}`
|
||||||
|
return (
|
||||||
|
<div key={ip} className="flex items-center gap-3">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-sm">{ip}</span>
|
||||||
|
{!isWeighted ? (
|
||||||
|
<Badge
|
||||||
|
variant={isPrimary ? 'success-light' : 'outline'}
|
||||||
|
size="xs"
|
||||||
|
radius="full"
|
||||||
|
>
|
||||||
|
{isPrimary ? 'Основной' : 'Запасной'}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<Field className="w-28 gap-0">
|
||||||
|
<FieldLabel htmlFor={fieldId} className="sr-only">
|
||||||
|
{isWeighted ? `Вес ${ip}` : `Приоритет ${ip}`}
|
||||||
|
</FieldLabel>
|
||||||
|
<CompactNumberField
|
||||||
|
id={fieldId}
|
||||||
|
value={isWeighted ? (weights[ip] ?? 1) : (priorities[ip] ?? 1)}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
onMetaChange?.(
|
||||||
|
ip,
|
||||||
|
isWeighted
|
||||||
|
? { weight: next ?? 1 }
|
||||||
|
: { priority: next ?? 1 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function HealthCheckConfigFields({
|
export function HealthCheckConfigFields({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -102,6 +190,10 @@ export function HealthCheckConfigFields({
|
|||||||
lbModeOptions = defaultLbModeOptions,
|
lbModeOptions = defaultLbModeOptions,
|
||||||
idPrefix = 'health',
|
idPrefix = 'health',
|
||||||
showLbMode = true,
|
showLbMode = true,
|
||||||
|
ips = [],
|
||||||
|
weights = {},
|
||||||
|
priorities = {},
|
||||||
|
onMetaChange,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
value: LbAndHealthConfig
|
value: LbAndHealthConfig
|
||||||
@@ -110,6 +202,10 @@ export function HealthCheckConfigFields({
|
|||||||
lbModeOptions?: { value: string; label: string }[]
|
lbModeOptions?: { value: string; label: string }[]
|
||||||
idPrefix?: string
|
idPrefix?: string
|
||||||
showLbMode?: boolean
|
showLbMode?: boolean
|
||||||
|
ips?: readonly string[]
|
||||||
|
weights?: Record<string, number>
|
||||||
|
priorities?: Record<string, number>
|
||||||
|
onMetaChange?: (ip: string, meta: LbPoolMetaChange) => void
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
function patch(next: Partial<LbAndHealthConfig>) {
|
function patch(next: Partial<LbAndHealthConfig>) {
|
||||||
@@ -153,6 +249,17 @@ export function HealthCheckConfigFields({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showLbMode && value.lb_mode !== 'round_robin' ? (
|
||||||
|
<PoolLbMetaFields
|
||||||
|
idPrefix={idPrefix}
|
||||||
|
mode={value.lb_mode}
|
||||||
|
ips={ips}
|
||||||
|
weights={weights}
|
||||||
|
priorities={priorities}
|
||||||
|
onMetaChange={onMetaChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Провайдер health-check"
|
title="Провайдер health-check"
|
||||||
description="Кто пробирует цель. Можно выбрать несколько источников."
|
description="Кто пробирует цель. Можно выбрать несколько источников."
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ import { UnplugIcon } from 'lucide-react'
|
|||||||
|
|
||||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||||
import {
|
import {
|
||||||
|
mergeFailoverHistory,
|
||||||
toFailoverEvents,
|
toFailoverEvents,
|
||||||
type FailoverBindingPool,
|
type FailoverBindingPool,
|
||||||
type FailoverHealthInput,
|
type FailoverHealthInput,
|
||||||
} from '@/lib/failover-events'
|
} from '@/lib/failover-events'
|
||||||
|
import {
|
||||||
|
latestHealthByIp,
|
||||||
|
resolveIpDisplayHealth,
|
||||||
|
type HealthLogProbe,
|
||||||
|
type HealthLogStatus,
|
||||||
|
} from '@/lib/health-log'
|
||||||
import type { FailoverLogEntry } from '@/lib/schemas'
|
import type { FailoverLogEntry } from '@/lib/schemas'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +39,7 @@ function failoverCountLabel(count: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Failover — текущие Down + журнал add/remove по FQDN.
|
* Failover — текущие Down + кто вышел из пула и кто вернулся.
|
||||||
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
||||||
* Preview: https://reui.io/preview/base/empty-state-12
|
* Preview: https://reui.io/preview/base/empty-state-12
|
||||||
* Docs: https://reui.io/docs/components/base/frame
|
* Docs: https://reui.io/docs/components/base/frame
|
||||||
@@ -44,12 +51,32 @@ export function ServiceFailoverPanel({
|
|||||||
ipHealth,
|
ipHealth,
|
||||||
bindings,
|
bindings,
|
||||||
history,
|
history,
|
||||||
|
probes = [],
|
||||||
}: {
|
}: {
|
||||||
ipHealth: readonly FailoverHealthInput[]
|
ipHealth: readonly FailoverHealthInput[]
|
||||||
bindings: readonly FailoverBindingPool[]
|
bindings: readonly FailoverBindingPool[]
|
||||||
history: readonly FailoverLogEntry[]
|
history: readonly FailoverLogEntry[]
|
||||||
|
probes?: readonly HealthLogProbe[]
|
||||||
}) {
|
}) {
|
||||||
const events = toFailoverEvents(ipHealth, bindings)
|
const liveByIp = latestHealthByIp(probes)
|
||||||
|
const overlayHealth = ipHealth.map((row) => {
|
||||||
|
const live = liveByIp.get(row.ip)
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
status: resolveIpDisplayHealth(
|
||||||
|
row.status as HealthLogStatus,
|
||||||
|
live?.status,
|
||||||
|
),
|
||||||
|
last_error:
|
||||||
|
live && live.status !== 'unknown' ? live.last_error : row.last_error,
|
||||||
|
last_checked_at:
|
||||||
|
live && live.status !== 'unknown'
|
||||||
|
? live.last_checked_at
|
||||||
|
: row.last_checked_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const events = toFailoverEvents(overlayHealth, bindings)
|
||||||
|
const mergedHistory = mergeFailoverHistory(history, probes, bindings)
|
||||||
const removedCount = events.filter((event) => event.kind === 'removed').length
|
const removedCount = events.filter((event) => event.kind === 'removed').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,7 +96,7 @@ export function ServiceFailoverPanel({
|
|||||||
)}
|
)}
|
||||||
</FrameTitle>
|
</FrameTitle>
|
||||||
<FrameDescription>
|
<FrameDescription>
|
||||||
Текущие Down и история A-записей по FQDN
|
Текущие Down и история: кто вышел из пула и кто вернулся
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
|
|
||||||
@@ -85,7 +112,7 @@ export function ServiceFailoverPanel({
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<FailoverTimeline events={events} history={history} />
|
<FailoverTimeline events={events} history={mergedHistory} />
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
DEFAULT_BINDING_HEALTH,
|
DEFAULT_BINDING_HEALTH,
|
||||||
emptyAddressBlock,
|
emptyAddressBlock,
|
||||||
hydrateAddressBlock,
|
hydrateAddressBlock,
|
||||||
|
patchAddressIpMeta,
|
||||||
toBindingDrafts,
|
toBindingDrafts,
|
||||||
toDomainsPayload,
|
toDomainsPayload,
|
||||||
type AddressBlockState,
|
type AddressBlockState,
|
||||||
@@ -282,6 +283,12 @@ export function ServiceEditSheet({
|
|||||||
idPrefix="service-health"
|
idPrefix="service-health"
|
||||||
value={primaryHealthValue}
|
value={primaryHealthValue}
|
||||||
onChange={handlePrimaryHealthChange}
|
onChange={handlePrimaryHealthChange}
|
||||||
|
ips={address.nodes.map((node) => node.ip)}
|
||||||
|
weights={address.target_ip_weights}
|
||||||
|
priorities={address.target_ip_priorities}
|
||||||
|
onMetaChange={(ip, meta) =>
|
||||||
|
setAddress((current) => patchAddressIpMeta(current, ip, meta))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
|
|||||||
import { IconTile } from '@/components/reui/icon-tile'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
|
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import { ResourcePage } from '@/components/reui-kit'
|
import { ResourcePage } from '@/components/reui-kit'
|
||||||
|
import {
|
||||||
|
latestHealthByIp,
|
||||||
|
resolveIpDisplayHealth,
|
||||||
|
type HealthLogProbe,
|
||||||
|
} from '@/lib/health-log'
|
||||||
import { certRelativeBadge } from '@/components/columns/certificates-columns'
|
import { certRelativeBadge } from '@/components/columns/certificates-columns'
|
||||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||||
import { formatDate } from '@/lib/format'
|
import { formatDate } from '@/lib/format'
|
||||||
@@ -99,8 +104,12 @@ function mapNodeHealth(status: string): HealthStatus {
|
|||||||
return 'unknown'
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildIpRows(service: ServiceView): ServiceIpRow[] {
|
function buildIpRows(
|
||||||
|
service: ServiceView,
|
||||||
|
probes: readonly HealthLogProbe[] = [],
|
||||||
|
): ServiceIpRow[] {
|
||||||
const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row]))
|
const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row]))
|
||||||
|
const liveByIp = latestHealthByIp(probes)
|
||||||
const weights = Object.assign(
|
const weights = Object.assign(
|
||||||
{},
|
{},
|
||||||
...service.domains.map((domain) => domain.target_ip_weights ?? {}),
|
...service.domains.map((domain) => domain.target_ip_weights ?? {}),
|
||||||
@@ -113,19 +122,25 @@ function buildIpRows(service: ServiceView): ServiceIpRow[] {
|
|||||||
|
|
||||||
return service.ips.map((ip) => {
|
return service.ips.map((ip) => {
|
||||||
const health = healthByIp.get(ip)
|
const health = healthByIp.get(ip)
|
||||||
|
const live = liveByIp.get(ip)
|
||||||
|
const status = resolveIpDisplayHealth(health?.status, live?.status)
|
||||||
|
const extras = live && live.status !== 'unknown' ? live : health
|
||||||
return {
|
return {
|
||||||
id: ip,
|
id: ip,
|
||||||
ip,
|
ip,
|
||||||
status: health?.status ?? 'unknown',
|
status,
|
||||||
enabled: service.ip_enabled[ip] !== false,
|
enabled: service.ip_enabled[ip] !== false,
|
||||||
active: activeSet.has(ip),
|
active: activeSet.has(ip),
|
||||||
weight: weights[ip] ?? 1,
|
weight: weights[ip] ?? 1,
|
||||||
priority: priorities[ip] ?? 1,
|
priority: priorities[ip] ?? 1,
|
||||||
latency_ms: health?.latency_ms ?? null,
|
latency_ms: extras?.latency_ms ?? null,
|
||||||
last_checked_at: health?.last_checked_at ?? null,
|
last_checked_at: extras?.last_checked_at ?? null,
|
||||||
last_error: health?.last_error ?? null,
|
last_error:
|
||||||
colo: health?.colo ?? null,
|
live && live.status !== 'unknown'
|
||||||
provider: health?.provider ?? null,
|
? live.last_error
|
||||||
|
: (health?.last_error ?? null),
|
||||||
|
colo: extras?.colo ?? null,
|
||||||
|
provider: extras?.provider ?? null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -151,17 +166,23 @@ function buildNodeRows(
|
|||||||
weight: number
|
weight: number
|
||||||
priority: number
|
priority: number
|
||||||
}>,
|
}>,
|
||||||
|
probes: readonly HealthLogProbe[] = [],
|
||||||
): ServiceNodeRow[] {
|
): ServiceNodeRow[] {
|
||||||
return nodes.map((node) => ({
|
const liveByIp = latestHealthByIp(probes)
|
||||||
id: String(node.id),
|
return nodes.map((node) => {
|
||||||
nodeId: node.id,
|
const stored = mapNodeHealth(node.health_status)
|
||||||
address: node.address,
|
const live = liveByIp.get(node.address)
|
||||||
protocol: node.protocol,
|
return {
|
||||||
port: node.port,
|
id: String(node.id),
|
||||||
health_status: mapNodeHealth(node.health_status),
|
nodeId: node.id,
|
||||||
weight: node.weight,
|
address: node.address,
|
||||||
priority: node.priority,
|
protocol: node.protocol,
|
||||||
}))
|
port: node.port,
|
||||||
|
health_status: resolveIpDisplayHealth(stored, live?.status),
|
||||||
|
weight: node.weight,
|
||||||
|
priority: node.priority,
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function NameCell({
|
function NameCell({
|
||||||
@@ -199,6 +220,7 @@ interface ServiceDetailGridProps {
|
|||||||
weight: number
|
weight: number
|
||||||
priority: number
|
priority: number
|
||||||
}>
|
}>
|
||||||
|
probes?: readonly HealthLogProbe[]
|
||||||
togglingIp: string | null
|
togglingIp: string | null
|
||||||
onToggleIp: (ip: string, enabled: boolean) => void
|
onToggleIp: (ip: string, enabled: boolean) => void
|
||||||
onChangeIp: (row: ServiceFqdnRow) => void
|
onChangeIp: (row: ServiceFqdnRow) => void
|
||||||
@@ -211,6 +233,7 @@ interface ServiceDetailGridProps {
|
|||||||
export function ServiceDetailGrid({
|
export function ServiceDetailGrid({
|
||||||
service,
|
service,
|
||||||
nodes,
|
nodes,
|
||||||
|
probes = [],
|
||||||
togglingIp,
|
togglingIp,
|
||||||
onToggleIp,
|
onToggleIp,
|
||||||
onChangeIp,
|
onChangeIp,
|
||||||
@@ -283,9 +306,9 @@ export function ServiceDetailGrid({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const ipRows = useMemo(() => buildIpRows(service), [service])
|
const ipRows = useMemo(() => buildIpRows(service, probes), [service, probes])
|
||||||
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
|
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
|
||||||
const nodeRows = useMemo(() => buildNodeRows(nodes), [nodes])
|
const nodeRows = useMemo(() => buildNodeRows(nodes, probes), [nodes, probes])
|
||||||
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
|
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
|
||||||
|
|
||||||
const tabs = TABS.map((entry) => ({
|
const tabs = TABS.map((entry) => ({
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
failoverEventCopy,
|
failoverEventCopy,
|
||||||
isFailoverEventStatus,
|
isFailoverEventStatus,
|
||||||
|
mergeFailoverHistory,
|
||||||
toFailoverEvents,
|
toFailoverEvents,
|
||||||
|
toIpAliveTransitions,
|
||||||
type FailoverBindingPool,
|
type FailoverBindingPool,
|
||||||
type FailoverHealthInput,
|
type FailoverHealthInput,
|
||||||
} from '@/lib/failover-events'
|
} from '@/lib/failover-events'
|
||||||
|
import type { FailoverLogEntry } from '@/lib/schemas'
|
||||||
|
import type { HealthLogProbe } from '@/lib/health-log'
|
||||||
|
|
||||||
function row(
|
function row(
|
||||||
overrides: Partial<FailoverHealthInput> & Pick<FailoverHealthInput, 'ip'>,
|
overrides: Partial<FailoverHealthInput> & Pick<FailoverHealthInput, 'ip'>,
|
||||||
@@ -186,3 +190,105 @@ describe('toFailoverEvents', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function probe(
|
||||||
|
overrides: Partial<HealthLogProbe> & Pick<HealthLogProbe, 'id' | 'status' | 'checked_at'>,
|
||||||
|
): HealthLogProbe {
|
||||||
|
return {
|
||||||
|
ip: '130.49.213.153',
|
||||||
|
provider: 'local',
|
||||||
|
ok: overrides.status === 'up',
|
||||||
|
latency_ms: 12,
|
||||||
|
colo: null,
|
||||||
|
error: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dns(
|
||||||
|
overrides: Partial<FailoverLogEntry> & Pick<FailoverLogEntry, 'id' | 'action' | 'created_at'>,
|
||||||
|
): FailoverLogEntry {
|
||||||
|
return {
|
||||||
|
service_id: 13,
|
||||||
|
binding_id: 1,
|
||||||
|
fqdn: 'gt.rkns.top',
|
||||||
|
ip: '130.49.213.153',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('toIpAliveTransitions', () => {
|
||||||
|
it('emits leave then return, not the initial up', () => {
|
||||||
|
const transitions = toIpAliveTransitions([
|
||||||
|
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
|
||||||
|
probe({ id: 2, status: 'up', checked_at: '2026-08-20T09:01:00Z' }),
|
||||||
|
probe({ id: 3, status: 'down', checked_at: '2026-08-20T09:02:00Z' }),
|
||||||
|
probe({ id: 4, status: 'down', checked_at: '2026-08-20T09:03:00Z' }),
|
||||||
|
probe({ id: 5, status: 'up', checked_at: '2026-08-20T09:04:00Z' }),
|
||||||
|
])
|
||||||
|
expect(transitions).toEqual([
|
||||||
|
{ id: 'probe:3', ip: '130.49.213.153', alive: false, at: '2026-08-20T09:02:00Z' },
|
||||||
|
{ id: 'probe:5', ip: '130.49.213.153', alive: true, at: '2026-08-20T09:04:00Z' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('any-up across providers: leave only when every source is down', () => {
|
||||||
|
const transitions = toIpAliveTransitions([
|
||||||
|
probe({
|
||||||
|
id: 1,
|
||||||
|
provider: 'local',
|
||||||
|
status: 'up',
|
||||||
|
checked_at: '2026-08-20T09:00:00Z',
|
||||||
|
}),
|
||||||
|
probe({
|
||||||
|
id: 2,
|
||||||
|
provider: 'cloudflare',
|
||||||
|
status: 'down',
|
||||||
|
checked_at: '2026-08-20T09:01:00Z',
|
||||||
|
}),
|
||||||
|
probe({
|
||||||
|
id: 3,
|
||||||
|
provider: 'local',
|
||||||
|
status: 'down',
|
||||||
|
checked_at: '2026-08-20T09:02:00Z',
|
||||||
|
}),
|
||||||
|
probe({
|
||||||
|
id: 4,
|
||||||
|
provider: 'local',
|
||||||
|
status: 'up',
|
||||||
|
checked_at: '2026-08-20T09:03:00Z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
expect(transitions.map((item) => item.id)).toEqual(['probe:3', 'probe:4'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeFailoverHistory', () => {
|
||||||
|
it('fills leave/return from probes when DNS has only the add', () => {
|
||||||
|
const history = mergeFailoverHistory(
|
||||||
|
[dns({ id: 10, action: 'added', created_at: '2026-08-20 09:26:00' })],
|
||||||
|
[
|
||||||
|
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
|
||||||
|
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
|
||||||
|
probe({ id: 3, status: 'up', checked_at: '2026-08-20T09:26:30Z' }),
|
||||||
|
],
|
||||||
|
mskHip,
|
||||||
|
)
|
||||||
|
expect(history.map((item) => `${item.action}:${item.source}`)).toEqual([
|
||||||
|
'added:dns',
|
||||||
|
'removed:probe',
|
||||||
|
])
|
||||||
|
expect(history[0]?.copy).toBe('130.49.213.153 добавлена на gt.rkns.top')
|
||||||
|
expect(history[1]?.copy).toContain('вышла из пула')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps DNS over a probe return in the same 2-minute window', () => {
|
||||||
|
const history = mergeFailoverHistory(
|
||||||
|
[dns({ id: 10, action: 'added', created_at: '2026-08-20 09:26:00' })],
|
||||||
|
[probe({ id: 3, status: 'up', checked_at: '2026-08-20T09:26:30Z' })],
|
||||||
|
mskHip,
|
||||||
|
)
|
||||||
|
expect(history).toHaveLength(1)
|
||||||
|
expect(history[0]?.source).toBe('dns')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
|
import {
|
||||||
|
bestAliveHealthStatus,
|
||||||
|
probeTime,
|
||||||
|
type HealthLogProbe,
|
||||||
|
type HealthLogStatus,
|
||||||
|
} from '@/lib/health-log'
|
||||||
|
import type { FailoverLogEntry } from '@/lib/schemas'
|
||||||
|
|
||||||
export type FailoverEventKind = 'removed' | 'last-resort'
|
export type FailoverEventKind = 'removed' | 'last-resort'
|
||||||
|
|
||||||
|
export interface FailoverHistoryItem {
|
||||||
|
id: string
|
||||||
|
ip: string
|
||||||
|
fqdn: string
|
||||||
|
action: 'added' | 'removed'
|
||||||
|
created_at: string
|
||||||
|
copy: string
|
||||||
|
source: 'dns' | 'probe'
|
||||||
|
}
|
||||||
|
|
||||||
export interface FailoverEvent {
|
export interface FailoverEvent {
|
||||||
id: string
|
id: string
|
||||||
address: string
|
address: string
|
||||||
@@ -73,3 +91,141 @@ export function toFailoverEvents(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEDUPE_WINDOW_MS = 2 * 60 * 1000
|
||||||
|
|
||||||
|
function fqdnsForIp(
|
||||||
|
ip: string,
|
||||||
|
bindings: readonly FailoverBindingPool[],
|
||||||
|
): string[] {
|
||||||
|
return bindings.filter((binding) => binding.configured.includes(ip)).map((binding) => binding.fqdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fqdnLabel(fqdns: string[]): string {
|
||||||
|
return fqdns.join(', ') || 'пул'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAliveStatus(status: HealthLogStatus): boolean {
|
||||||
|
return status === 'up' || status === 'degraded'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-IP any-up flips: down → вышла из пула, up после down → вернулась.
|
||||||
|
* Initial state is not an event.
|
||||||
|
*/
|
||||||
|
export function toIpAliveTransitions(
|
||||||
|
probes: readonly HealthLogProbe[],
|
||||||
|
): Array<{ id: string; ip: string; alive: boolean; at: string }> {
|
||||||
|
const byIp = new Map<string, HealthLogProbe[]>()
|
||||||
|
for (const item of probes) {
|
||||||
|
const list = byIp.get(item.ip)
|
||||||
|
if (list) list.push(item)
|
||||||
|
else byIp.set(item.ip, [item])
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Array<{ id: string; ip: string; alive: boolean; at: string }> = []
|
||||||
|
for (const [ip, list] of byIp) {
|
||||||
|
list.sort(
|
||||||
|
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
|
||||||
|
)
|
||||||
|
const latestByProvider = new Map<string, HealthLogProbe>()
|
||||||
|
let prevAlive: boolean | undefined
|
||||||
|
for (const probe of list) {
|
||||||
|
latestByProvider.set(probe.provider, probe)
|
||||||
|
const status = bestAliveHealthStatus(
|
||||||
|
[...latestByProvider.values()].map((item) => item.status),
|
||||||
|
)
|
||||||
|
if (status === 'unknown') continue
|
||||||
|
const alive = isAliveStatus(status)
|
||||||
|
if (prevAlive !== undefined && alive !== prevAlive) {
|
||||||
|
out.push({
|
||||||
|
id: `probe:${probe.id}`,
|
||||||
|
ip,
|
||||||
|
alive,
|
||||||
|
at: probe.checked_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
prevAlive = alive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function dnsHistoryItem(item: FailoverLogEntry): FailoverHistoryItem {
|
||||||
|
return {
|
||||||
|
id: `dns:${item.id}`,
|
||||||
|
ip: item.ip,
|
||||||
|
fqdn: item.fqdn,
|
||||||
|
action: item.action,
|
||||||
|
created_at: item.created_at,
|
||||||
|
copy:
|
||||||
|
item.action === 'removed'
|
||||||
|
? `${item.ip} убрана с ${item.fqdn}`
|
||||||
|
: `${item.ip} добавлена на ${item.fqdn}`,
|
||||||
|
source: 'dns',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeHistoryItem(
|
||||||
|
transition: { id: string; ip: string; alive: boolean; at: string },
|
||||||
|
bindings: readonly FailoverBindingPool[],
|
||||||
|
): FailoverHistoryItem {
|
||||||
|
const fqdns = fqdnsForIp(transition.ip, bindings)
|
||||||
|
const fqdn = fqdnLabel(fqdns)
|
||||||
|
const action = transition.alive ? 'added' : 'removed'
|
||||||
|
return {
|
||||||
|
id: transition.id,
|
||||||
|
ip: transition.ip,
|
||||||
|
fqdn,
|
||||||
|
action,
|
||||||
|
created_at: transition.at,
|
||||||
|
copy:
|
||||||
|
action === 'removed'
|
||||||
|
? `${transition.ip} вышла из пула (${fqdn})`
|
||||||
|
: `${transition.ip} вернулась в пул (${fqdn})`,
|
||||||
|
source: 'probe',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTime(value: string): number {
|
||||||
|
return probeTime(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DNS add/remove + health leave/return, newest first.
|
||||||
|
* Same IP+action within 2 minutes: keep the DNS row (it has a concrete FQDN).
|
||||||
|
*/
|
||||||
|
export function mergeFailoverHistory(
|
||||||
|
dns: readonly FailoverLogEntry[],
|
||||||
|
probes: readonly HealthLogProbe[],
|
||||||
|
bindings: readonly FailoverBindingPool[] = [],
|
||||||
|
): FailoverHistoryItem[] {
|
||||||
|
const merged = [
|
||||||
|
...dns.map(dnsHistoryItem),
|
||||||
|
...toIpAliveTransitions(probes).map((transition) =>
|
||||||
|
probeHistoryItem(transition, bindings),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
merged.sort(
|
||||||
|
(a, b) => eventTime(b.created_at) - eventTime(a.created_at) || a.id.localeCompare(b.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
const kept: FailoverHistoryItem[] = []
|
||||||
|
for (const item of merged) {
|
||||||
|
const duplicate = kept.find(
|
||||||
|
(other) =>
|
||||||
|
other.ip === item.ip &&
|
||||||
|
other.action === item.action &&
|
||||||
|
Math.abs(eventTime(other.created_at) - eventTime(item.created_at)) <=
|
||||||
|
DEDUPE_WINDOW_MS,
|
||||||
|
)
|
||||||
|
if (!duplicate) {
|
||||||
|
kept.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (duplicate.source === 'probe' && item.source === 'dns') {
|
||||||
|
kept[kept.indexOf(duplicate)] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
bestAliveHealthStatus,
|
bestAliveHealthStatus,
|
||||||
collapseStatusChanges,
|
collapseStatusChanges,
|
||||||
enabledHealthProviders,
|
enabledHealthProviders,
|
||||||
|
latestHealthByIp,
|
||||||
providerHealthStatuses,
|
providerHealthStatuses,
|
||||||
|
resolveIpDisplayHealth,
|
||||||
worstHealthStatus,
|
worstHealthStatus,
|
||||||
type HealthLogProbe,
|
type HealthLogProbe,
|
||||||
} from '@/lib/health-log'
|
} from '@/lib/health-log'
|
||||||
@@ -105,3 +107,42 @@ describe('bestAliveHealthStatus', () => {
|
|||||||
expect(bestAliveHealthStatus([])).toBe('unknown')
|
expect(bestAliveHealthStatus([])).toBe('unknown')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('latestHealthByIp', () => {
|
||||||
|
it('any-up among latest-per-provider probes', () => {
|
||||||
|
const items = [
|
||||||
|
probe({
|
||||||
|
id: 1,
|
||||||
|
ip: '130.49.213.153',
|
||||||
|
provider: 'local',
|
||||||
|
status: 'up',
|
||||||
|
checked_at: '2026-08-20T09:00:00Z',
|
||||||
|
}),
|
||||||
|
probe({
|
||||||
|
id: 2,
|
||||||
|
ip: '130.49.213.153',
|
||||||
|
provider: 'cloudflare',
|
||||||
|
status: 'down',
|
||||||
|
checked_at: '2026-08-20T09:00:01Z',
|
||||||
|
}),
|
||||||
|
probe({
|
||||||
|
id: 3,
|
||||||
|
ip: '93.115.203.183',
|
||||||
|
status: 'unknown',
|
||||||
|
checked_at: '2026-08-20T08:59:00Z',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const byIp = latestHealthByIp(items)
|
||||||
|
expect(byIp.get('130.49.213.153')?.status).toBe('up')
|
||||||
|
expect(byIp.get('93.115.203.183')?.status).toBe('unknown')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveIpDisplayHealth', () => {
|
||||||
|
it('prefers a live probe over stored unknown', () => {
|
||||||
|
expect(resolveIpDisplayHealth('unknown', 'up')).toBe('up')
|
||||||
|
expect(resolveIpDisplayHealth('down', 'up')).toBe('up')
|
||||||
|
expect(resolveIpDisplayHealth('up', 'unknown')).toBe('up')
|
||||||
|
expect(resolveIpDisplayHealth('unknown', undefined)).toBe('unknown')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -136,3 +136,61 @@ export function providerHealthStatuses(
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IpDisplayHealth {
|
||||||
|
status: HealthLogStatus
|
||||||
|
latency_ms: number | null
|
||||||
|
last_checked_at: string
|
||||||
|
last_error: string | null
|
||||||
|
colo: string | null
|
||||||
|
provider: HealthCheckProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest probe per provider+IP, then any-up among those providers.
|
||||||
|
* Used by the IP table so hysteresis `unknown` in ip_health does not hide a live OK.
|
||||||
|
*/
|
||||||
|
export function latestHealthByIp(
|
||||||
|
items: readonly HealthLogProbe[],
|
||||||
|
): Map<string, IpDisplayHealth> {
|
||||||
|
const latest = new Map<string, HealthLogProbe>()
|
||||||
|
const sorted = [...items].sort(
|
||||||
|
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
|
||||||
|
)
|
||||||
|
for (const item of sorted) {
|
||||||
|
const key = `${item.provider}\0${item.ip}`
|
||||||
|
if (!latest.has(key)) latest.set(key, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byIp = new Map<string, HealthLogProbe[]>()
|
||||||
|
for (const item of latest.values()) {
|
||||||
|
const list = byIp.get(item.ip)
|
||||||
|
if (list) list.push(item)
|
||||||
|
else byIp.set(item.ip, [item])
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Map<string, IpDisplayHealth>()
|
||||||
|
for (const [ip, probes] of byIp) {
|
||||||
|
const status = bestAliveHealthStatus(probes.map((probe) => probe.status))
|
||||||
|
const preferred =
|
||||||
|
probes.find((probe) => probe.status === status) ?? probes[0]!
|
||||||
|
result.set(ip, {
|
||||||
|
status,
|
||||||
|
latency_ms: preferred.latency_ms,
|
||||||
|
last_checked_at: preferred.checked_at,
|
||||||
|
last_error: preferred.error,
|
||||||
|
colo: preferred.colo,
|
||||||
|
provider: preferred.provider,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer a concrete live probe over stored hysteresis `unknown`. */
|
||||||
|
export function resolveIpDisplayHealth(
|
||||||
|
stored: HealthLogStatus | undefined,
|
||||||
|
live: HealthLogStatus | undefined,
|
||||||
|
): HealthLogStatus {
|
||||||
|
if (live && live !== 'unknown') return live
|
||||||
|
return stored ?? live ?? 'unknown'
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
emptyAddressBlock,
|
emptyAddressBlock,
|
||||||
emptyBindingDraft,
|
emptyBindingDraft,
|
||||||
hydrateAddressBlock,
|
hydrateAddressBlock,
|
||||||
|
patchAddressIpMeta,
|
||||||
removeAddressNode,
|
removeAddressNode,
|
||||||
toAddressBindings,
|
toAddressBindings,
|
||||||
toDomainsPayload,
|
toDomainsPayload,
|
||||||
@@ -82,6 +83,26 @@ describe('hydrateAddressBlock', () => {
|
|||||||
expect(state.preservedBindings).toHaveLength(1)
|
expect(state.preservedBindings).toHaveLength(1)
|
||||||
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
|
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('поднимает веса и приоритеты с общего FQDN', () => {
|
||||||
|
const drafts = [
|
||||||
|
aRecord('gt.rkns.top', ['130.49.213.153', '93.115.203.183'], {
|
||||||
|
target_ip_weights: { '130.49.213.153': 3, '93.115.203.183': 1 },
|
||||||
|
target_ip_priorities: { '130.49.213.153': 2, '93.115.203.183': 1 },
|
||||||
|
}),
|
||||||
|
aRecord('nsgt.rkns.top', ['130.49.213.153']),
|
||||||
|
]
|
||||||
|
const state = hydrateAddressBlock(drafts, ['130.49.213.153', '93.115.203.183'])
|
||||||
|
expect(state.target_ip_weights).toEqual({
|
||||||
|
'130.49.213.153': 3,
|
||||||
|
'93.115.203.183': 1,
|
||||||
|
})
|
||||||
|
expect(state.target_ip_priorities).toEqual({
|
||||||
|
'130.49.213.153': 2,
|
||||||
|
'93.115.203.183': 1,
|
||||||
|
})
|
||||||
|
expect(state.nodes[0]?.extraFqdn).toBe('nsgt.rkns.top')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('toDomainsPayload', () => {
|
describe('toDomainsPayload', () => {
|
||||||
@@ -161,6 +182,35 @@ describe('addAddressNode / addCommonFqdn', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('patchAddressIpMeta', () => {
|
||||||
|
it('меняет вес одного IP и не трогает extraFqdn', () => {
|
||||||
|
const state = {
|
||||||
|
...addAddressNode(addAddressNode(emptyAddressBlock(), '1.1.1.1'), '2.2.2.2'),
|
||||||
|
nodes: [
|
||||||
|
{ ip: '1.1.1.1', extraFqdn: 'msk.example.com' },
|
||||||
|
{ ip: '2.2.2.2', extraFqdn: '' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const next = patchAddressIpMeta(state, '1.1.1.1', { weight: 7 })
|
||||||
|
expect(next.target_ip_weights).toEqual({ '1.1.1.1': 7, '2.2.2.2': 1 })
|
||||||
|
expect(next.target_ip_priorities).toEqual(state.target_ip_priorities)
|
||||||
|
expect(next.nodes).toEqual(state.nodes)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamp веса и приоритета в 1–100', () => {
|
||||||
|
const state = addAddressNode(emptyAddressBlock(), '10.0.0.1')
|
||||||
|
expect(patchAddressIpMeta(state, '10.0.0.1', { weight: 0 }).target_ip_weights['10.0.0.1']).toBe(1)
|
||||||
|
expect(
|
||||||
|
patchAddressIpMeta(state, '10.0.0.1', { priority: 999 }).target_ip_priorities['10.0.0.1'],
|
||||||
|
).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('игнорирует IP вне пула', () => {
|
||||||
|
const state = addAddressNode(emptyAddressBlock(), '10.0.0.1')
|
||||||
|
expect(patchAddressIpMeta(state, '8.8.8.8', { weight: 5 })).toBe(state)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('CNAME / preservedBindings', () => {
|
describe('CNAME / preservedBindings', () => {
|
||||||
it('сохраняет CNAME в preserved при круге hydrate → payload', () => {
|
it('сохраняет CNAME в preserved при круге hydrate → payload', () => {
|
||||||
const cname: ServiceBindingDraft = {
|
const cname: ServiceBindingDraft = {
|
||||||
|
|||||||
@@ -243,6 +243,33 @@ export function addAddressNode(state: AddressBlockState, ip: string): AddressBlo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LB_META_MIN = 1
|
||||||
|
const LB_META_MAX = 100
|
||||||
|
|
||||||
|
function clampLbMeta(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return LB_META_MIN
|
||||||
|
return Math.min(LB_META_MAX, Math.max(LB_META_MIN, Math.round(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchAddressIpMeta(
|
||||||
|
state: AddressBlockState,
|
||||||
|
ip: string,
|
||||||
|
meta: { weight?: number; priority?: number },
|
||||||
|
): AddressBlockState {
|
||||||
|
if (!state.nodes.some((node) => node.ip === ip)) return state
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
target_ip_weights:
|
||||||
|
meta.weight === undefined
|
||||||
|
? state.target_ip_weights
|
||||||
|
: { ...state.target_ip_weights, [ip]: clampLbMeta(meta.weight) },
|
||||||
|
target_ip_priorities:
|
||||||
|
meta.priority === undefined
|
||||||
|
? state.target_ip_priorities
|
||||||
|
: { ...state.target_ip_priorities, [ip]: clampLbMeta(meta.priority) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fqdnKey(value: string): string {
|
function fqdnKey(value: string): string {
|
||||||
return value.trim().toLowerCase()
|
return value.trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ function ServiceDetailPage() {
|
|||||||
ipHealth={service.ip_health}
|
ipHealth={service.ip_health}
|
||||||
bindings={failoverBindings}
|
bindings={failoverBindings}
|
||||||
history={failoverHistory}
|
history={failoverHistory}
|
||||||
|
probes={logItems}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -344,6 +345,7 @@ function ServiceDetailPage() {
|
|||||||
<ServiceDetailGrid
|
<ServiceDetailGrid
|
||||||
service={service}
|
service={service}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
|
probes={logItems}
|
||||||
togglingIp={togglingIp}
|
togglingIp={togglingIp}
|
||||||
onToggleIp={(ip, enabled) => {
|
onToggleIp={(ip, enabled) => {
|
||||||
setTogglingIp(ip)
|
setTogglingIp(ip)
|
||||||
|
|||||||
Reference in New Issue
Block a user