Compare commits

...
2 Commits
Author SHA1 Message Date
DenozordecandCursor 87b0f1a894 fix(services): оформить failover timeline в стиле страницы сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m6s
CD / publish (push) Successful in 1m39s
Frame stacked как у мониторинга, Alert и semantic timeline вместо плоского степпера.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:31:30 +07:00
DenozordecandCursor ba4e04a224 feat(services): заменить один общий FQDN списком и убрать Другие FQDN
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m6s
CD / quality (push) Successful in 1m18s
CD / publish (push) Successful in 2m1s
Каждый общий домен смотрит на весь пул IP; CNAME без UI сохраняются.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:21:15 +07:00
10 changed files with 518 additions and 369 deletions
+92 -18
View File
@@ -1,40 +1,114 @@
import { ShieldCheckIcon } from 'lucide-react'
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { EmptyState } from '@/components/empty-state'
import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format'
import type { FailoverEvent } from '@/lib/failover-events'
import { cn } from '@cfdm/ui/lib/utils'
import type { ComponentProps } from 'react'
export interface FailoverEvent {
id: string
title: string
detail: string
type HealthBadgeStatus = ComponentProps<typeof HealthCheckBadge>['status']
function failStreakLabel(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return `${count} ошибка подряд`
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return `${count} ошибки подряд`
}
return `${count} ошибок подряд`
}
function indicatorClass(status: string): string {
if (status === 'checking') {
return 'border-warning bg-warning/15 group-data-completed/timeline-item:border-warning'
}
return 'border-destructive bg-destructive/15 group-data-completed/timeline-item:border-destructive'
}
function separatorClass(status: string): string {
if (status === 'checking') return 'bg-warning/25'
return 'bg-destructive/25'
}
/**
* Failover как sibling «Смены статуса»: ReUI Timeline + Badge, не степпер.
* Preview: https://reui.io/preview/base/components/c-timeline-10
* Preview: https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/docs/components/base/timeline
* Docs: https://reui.io/docs/components/base/badge
*/
export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
if (events.length === 0) {
return (
<p className="text-muted-foreground text-sm">
Событий failover пока нет.
</p>
<EmptyState
icon={ShieldCheckIcon}
title="Пул стабилен"
description="События появятся, когда нода станет unhealthy или down и будет выведена из пула"
stackedIcon={false}
centered={false}
/>
)
}
return (
<Timeline defaultValue={events.length} className="w-full">
{events.map((event, index) => (
<TimelineItem key={event.id} step={index + 1}>
<TimelineSeparator />
<TimelineIndicator />
<TimelineHeader>
<TimelineTitle>{event.title}</TimelineTitle>
</TimelineHeader>
<TimelineContent>{event.detail}</TimelineContent>
</TimelineItem>
))}
<Timeline defaultValue={0} className="gap-0">
{events.map((event, index) => {
const checkedIso = event.lastCheckAt
? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt)
: null
const isChecking = event.status === 'checking'
return (
<TimelineItem key={event.id} step={index + 1}>
<TimelineSeparator className={separatorClass(event.status)} />
<TimelineIndicator className={indicatorClass(event.status)} />
<TimelineHeader>
<TimelineTitle className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">{event.address}</span>
<HealthCheckBadge
status={event.status as HealthBadgeStatus}
lastError={event.lastFailureReason}
lastCheckedAt={checkedIso}
size="xs"
/>
</TimelineTitle>
<TimelineDate>
{failStreakLabel(event.consecutiveFailures)}
{checkedIso
? ` · ${formatRelative(checkedIso)} · ${formatDate(checkedIso)}`
: null}
</TimelineDate>
</TimelineHeader>
<TimelineContent className="flex flex-col gap-2">
<p className="text-foreground text-sm">
{isChecking
? 'Health-check ещё не завершён — нода не в активном пуле'
: 'Нода выведена из активного пула'}
</p>
{event.lastFailureReason ? (
<code
className={cn(
'bg-muted block overflow-x-auto rounded-md px-2 py-1.5 font-mono text-xs',
)}
>
{event.lastFailureReason}
</code>
) : null}
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
)
}
@@ -1,5 +1,6 @@
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
export { ServiceHealthMonitor } from './service-health-monitor'
export { ServiceFailoverPanel } from './service-failover-panel'
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
@@ -1,8 +1,7 @@
import { useState, type KeyboardEvent } from 'react'
import { PlusIcon, ServerIcon, Trash2Icon } from 'lucide-react'
import { useState, type KeyboardEvent, type ReactNode } from 'react'
import { ServerIcon, Trash2Icon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import { isValidIpv4 } from '@/components/tagged-input'
import { Badge } from '@/components/reui/badge'
import {
@@ -16,15 +15,15 @@ import { IconTile } from '@/components/reui/icon-tile'
import { parseFqdn } from '@/lib/parse-fqdn'
import {
addAddressNode,
emptyBindingDraft,
addCommonFqdn,
addressHasFqdn,
removeAddressNode,
withPoolIps,
removeCommonFqdn,
updateCommonFqdn,
type AddressBlockState,
type ServiceBindingDraft,
} from '@/lib/service-address'
import { Button } from '@cfdm/ui/components/button'
import { Field, FieldLabel } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
InputGroup,
InputGroupAddon,
@@ -39,16 +38,36 @@ import {
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
function ZoneAddon({
fqdn,
zoneHints,
trailing,
}: {
fqdn: string
zoneHints: string[]
trailing?: ReactNode
}) {
const parsed = parseFqdn(fqdn, zoneHints)
if (!parsed && !fqdn.trim() && !trailing) return null
return (
<InputGroupAddon align="inline-end">
{parsed ? (
<Badge variant="outline" size="xs" className="font-mono">
{parsed.zoneName}
</Badge>
) : fqdn.trim() ? (
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
) : null}
{trailing}
</InputGroupAddon>
)
}
/**
* Единый блок адресов сервиса: общий FQDN + пул IP с опциональным доп. доменом.
* Единый блок адресов сервиса: список общих FQDN на весь пул + IP с доп. доменом.
* Preview: https://reui.io/preview/base/settings-3
* Preview: https://reui.io/preview/base/list-9
* Preview: https://reui.io/preview/base/form-7
@@ -67,17 +86,30 @@ export function ServiceAddressBlock({
}) {
const [pendingIp, setPendingIp] = useState('')
const [ipInvalid, setIpInvalid] = useState(false)
const [otherOpen, setOtherOpen] = useState(value.otherBindings.length > 0)
const [pendingFqdn, setPendingFqdn] = useState('')
const [fqdnInvalid, setFqdnInvalid] = useState(false)
const pool = value.nodes.map((node) => node.ip)
const parsedCommon = parseFqdn(value.commonFqdn, zoneHints)
const showOthers = otherOpen || value.otherBindings.length > 0
const pendingTrimmed = pendingIp.trim()
const pendingInvalid =
ipInvalid && pendingTrimmed.length > 0 && !isValidIpv4(pendingTrimmed)
const pendingIpTrimmed = pendingIp.trim()
const pendingFqdnTrimmed = pendingFqdn.trim()
const pendingIpInvalid =
ipInvalid && pendingIpTrimmed.length > 0 && !isValidIpv4(pendingIpTrimmed)
const pendingFqdnInvalid =
fqdnInvalid && pendingFqdnTrimmed.length > 0
function handleCommonFqdn(next: string) {
onChange({ ...value, commonFqdn: next })
function tryAddFqdn(raw: string) {
const trimmed = raw.trim()
if (!trimmed) {
setFqdnInvalid(false)
return
}
if (addressHasFqdn(value, trimmed)) {
setFqdnInvalid(true)
return
}
onChange(addCommonFqdn(value, trimmed))
setPendingFqdn('')
setFqdnInvalid(false)
}
function tryAddIp(raw: string) {
@@ -95,7 +127,14 @@ export function ServiceAddressBlock({
setIpInvalid(false)
}
function handlePendingKeyDown(event: KeyboardEvent<HTMLInputElement>) {
function handleFqdnKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
event.preventDefault()
tryAddFqdn(pendingFqdn)
}
}
function handleIpKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
event.preventDefault()
tryAddIp(pendingIp)
@@ -111,64 +150,69 @@ export function ServiceAddressBlock({
})
}
function handleRemoveIp(ip: string) {
onChange(removeAddressNode(value, ip))
}
function handleAddOther() {
setOtherOpen(true)
onChange({
...value,
otherBindings: [...value.otherBindings, withPoolIps(emptyBindingDraft(), pool)],
})
}
function handleOtherChange(index: number, next: ServiceBindingDraft) {
onChange({
...value,
otherBindings: value.otherBindings.map((item, i) => (i === index ? next : item)),
})
}
function handleRemoveOther(index: number) {
const otherBindings = value.otherBindings.filter((_, i) => i !== index)
onChange({ ...value, otherBindings })
if (otherBindings.length === 0) setOtherOpen(false)
}
return (
<Frame stacked dense spacing="sm" className="w-full min-w-0">
<FramePanel fit className="flex flex-col gap-3">
<FrameHeader className="px-0 pt-0">
<FrameTitle>Адреса</FrameTitle>
<FrameDescription>
Общий FQDN на весь пул · у каждого IP свой доп. домен
Общие FQDN на весь пул · у IP свой доп. домен
</FrameDescription>
</FrameHeader>
<Field>
<FieldLabel htmlFor="service-common-fqdn">Общий домен (FQDN)</FieldLabel>
<InputGroup>
<InputGroupInput
id="service-common-fqdn"
className="font-mono"
value={value.commonFqdn}
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
onChange={(event) => handleCommonFqdn(event.target.value)}
/>
{parsedCommon ? (
<InputGroupAddon align="inline-end">
<Badge variant="outline" size="xs" className="font-mono">
{parsedCommon.zoneName}
</Badge>
</InputGroupAddon>
) : value.commonFqdn.trim() ? (
<InputGroupAddon align="inline-end">
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
</InputGroupAddon>
) : null}
</InputGroup>
<FieldLabel htmlFor="service-common-fqdn-add">Общие домены (FQDN)</FieldLabel>
<div className="flex w-full flex-col gap-2">
{value.commonFqdns.map((fqdn, index) => (
<InputGroup key={`common-fqdn-${index}`}>
<InputGroupInput
id={`service-common-fqdn-${index}`}
className="font-mono"
value={fqdn}
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
onChange={(event) =>
onChange(updateCommonFqdn(value, index, event.target.value))
}
/>
<ZoneAddon
fqdn={fqdn}
zoneHints={zoneHints}
trailing={
<InputGroupButton
size="icon-xs"
aria-label={`Удалить ${fqdn || 'FQDN'}`}
onClick={() => onChange(removeCommonFqdn(value, index))}
>
<Trash2Icon />
</InputGroupButton>
}
/>
</InputGroup>
))}
<InputGroup>
<InputGroupInput
id="service-common-fqdn-add"
className="font-mono"
value={pendingFqdn}
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
aria-invalid={pendingFqdnInvalid || undefined}
onChange={(event) => {
setPendingFqdn(event.target.value)
setFqdnInvalid(false)
}}
onKeyDown={handleFqdnKeyDown}
onBlur={() => tryAddFqdn(pendingFqdn)}
/>
<ZoneAddon
fqdn={pendingFqdn}
zoneHints={zoneHints}
trailing={
<InputGroupButton size="sm" onClick={() => tryAddFqdn(pendingFqdn)}>
Добавить
</InputGroupButton>
}
/>
</InputGroup>
</div>
</Field>
</FramePanel>
@@ -187,7 +231,6 @@ export function ServiceAddressBlock({
) : (
<ItemGroup className="gap-2">
{value.nodes.map((node) => {
const parsedExtra = parseFqdn(node.extraFqdn, zoneHints)
return (
<Item
key={node.ip}
@@ -214,7 +257,7 @@ export function ServiceAddressBlock({
variant="ghost"
size="icon-sm"
aria-label={`Удалить ${node.ip}`}
onClick={() => handleRemoveIp(node.ip)}
onClick={() => onChange(removeAddressNode(value, node.ip))}
>
<Trash2Icon />
</Button>
@@ -241,19 +284,7 @@ export function ServiceAddressBlock({
handleNodeFqdn(node.ip, event.target.value)
}
/>
{parsedExtra ? (
<InputGroupAddon align="inline-end">
<Badge variant="outline" size="xs" className="font-mono">
{parsedExtra.zoneName}
</Badge>
</InputGroupAddon>
) : node.extraFqdn.trim() ? (
<InputGroupAddon align="inline-end">
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
</InputGroupAddon>
) : null}
<ZoneAddon fqdn={node.extraFqdn} zoneHints={zoneHints} />
</InputGroup>
</Field>
</ItemContent>
@@ -268,12 +299,12 @@ export function ServiceAddressBlock({
className="font-mono"
value={pendingIp}
placeholder="192.168.1.1"
aria-invalid={pendingInvalid || undefined}
aria-invalid={pendingIpInvalid || undefined}
onChange={(event) => {
setPendingIp(event.target.value)
setIpInvalid(false)
}}
onKeyDown={handlePendingKeyDown}
onKeyDown={handleIpKeyDown}
onBlur={() => tryAddIp(pendingIp)}
/>
<InputGroupAddon align="inline-end">
@@ -282,156 +313,7 @@ export function ServiceAddressBlock({
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
{showOthers ? null : (
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleAddOther}
>
<PlusIcon data-icon="inline-start" />
Другой FQDN
</Button>
</div>
)}
</FramePanel>
{showOthers ? (
<FramePanel fit className="flex flex-col gap-3">
<FrameHeader className="flex flex-row items-start justify-between gap-2 px-0 pt-0">
<div className="flex min-w-0 flex-col gap-1">
<FrameTitle>Другие FQDN</FrameTitle>
<FrameDescription>CNAME и A не 1:1 с IP пула</FrameDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleAddOther}>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</FrameHeader>
{value.otherBindings.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет дополнительных FQDN</p>
) : (
<ItemGroup className="gap-2">
{value.otherBindings.map((binding, index) => {
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
return (
<Item
key={`other-binding-${index}`}
variant="outline"
size="sm"
className="items-stretch"
>
<ItemContent className="flex w-full min-w-0 flex-col gap-2">
<div className="flex items-center gap-2">
{parsedZone ? (
<Badge variant="outline" size="xs" className="font-mono">
{parsedZone.zoneName}
</Badge>
) : binding.fqdn.trim() ? (
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
) : (
<span className="text-muted-foreground text-xs">FQDN</span>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
className="ml-auto shrink-0"
aria-label="Удалить FQDN"
onClick={() => handleRemoveOther(index)}
>
<Trash2Icon />
</Button>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
<Input
id={`other-fqdn-${index}`}
className="font-mono"
value={binding.fqdn}
onChange={(event) =>
handleOtherChange(index, {
...binding,
fqdn: event.target.value,
})
}
placeholder={
zoneHints[0] ? `api.${zoneHints[0]}` : 'api.ivx.su'
}
/>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(next) => {
const recordType = (next ?? 'A') as 'A' | 'CNAME'
handleOtherChange(index, {
...binding,
record_type: recordType,
target_ips: recordType === 'A' ? binding.target_ips : [],
target_cname:
recordType === 'CNAME' ? binding.target_cname : '',
})
}}
>
<SelectTrigger id={`other-type-${index}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</div>
{binding.record_type === 'CNAME' ? (
<Input
id={`other-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) =>
handleOtherChange(index, {
...binding,
target_cname: event.target.value,
})
}
/>
) : (
<ServiceBindingIpInput
id={`other-ip-${index}`}
value={binding.target_ips}
pool={pool}
onChange={(targetIps) =>
handleOtherChange(index, {
...binding,
target_ips: targetIps,
target_ip_weights: Object.fromEntries(
targetIps.map((ip) => [
ip,
binding.target_ip_weights[ip] ?? 1,
]),
),
target_ip_priorities: Object.fromEntries(
targetIps.map((ip) => [
ip,
binding.target_ip_priorities[ip] ?? 1,
]),
),
})
}
/>
)}
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
</FramePanel>
) : null}
</Frame>
)
}
@@ -0,0 +1,83 @@
import { UnplugIcon } from 'lucide-react'
import { FailoverTimeline } from '@/components/failover-timeline'
import {
toFailoverEvents,
type FailoverNodeInput,
} from '@/lib/failover-events'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
function failoverCountLabel(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return `${count} нода вне пула`
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return `${count} ноды вне пула`
}
return `${count} нод вне пула`
}
/**
* Failover — sibling ServiceHealthMonitor: Frame stacked + Alert + Timeline.
* Preview: https://reui.io/preview/base/components/c-timeline-10
* Preview: https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/docs/components/base/frame
* Docs: https://reui.io/docs/components/base/timeline
* Docs: https://reui.io/docs/components/base/badge
* Docs: https://reui.io/docs/components/base/alert
*/
export function ServiceFailoverPanel({
nodes,
}: {
nodes: readonly FailoverNodeInput[]
}) {
const events = toFailoverEvents(nodes)
return (
<Frame stacked spacing="sm" className="min-w-0 w-full">
<FramePanel className="flex flex-col gap-3">
<FrameHeader className="gap-1 px-0 py-0">
<FrameTitle className="flex flex-wrap items-center gap-2">
Failover
{events.length > 0 ? (
<Badge variant="destructive-light" size="xs" radius="full">
{events.length}
</Badge>
) : (
<Badge variant="success-light" size="xs" radius="full">
OK
</Badge>
)}
</FrameTitle>
<FrameDescription>
Нездоровые ноды выведены из пула · причина последней ошибки
</FrameDescription>
</FrameHeader>
{events.length > 0 ? (
<Alert variant="destructive">
<UnplugIcon aria-hidden="true" />
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
<AlertDescription>
Трафик на эти адреса не идёт, пока health не восстановится
</AlertDescription>
</Alert>
) : null}
<FailoverTimeline events={events} />
</FramePanel>
</Frame>
)
}
@@ -218,8 +218,8 @@ export function ServiceEditSheet({
<SheetHeader className="shrink-0 border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Общий домен и пул IP в одном блоке. У каждого адреса можно указать
свой доп. FQDN.
Общие FQDN на весь пул IP. У каждого адреса можно указать свой доп.
FQDN.
</SheetDescription>
</SheetHeader>
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
isFailoverEventStatus,
toFailoverEvents,
type FailoverNodeInput,
} from '@/lib/failover-events'
function node(
overrides: Partial<FailoverNodeInput> & Pick<FailoverNodeInput, 'address'>,
): FailoverNodeInput {
return {
id: overrides.id ?? overrides.address,
health_status: 'healthy',
consecutive_failures: 0,
last_failure_reason: null,
last_check_at: null,
...overrides,
}
}
describe('toFailoverEvents', () => {
it('оставляет только unhealthy / down / checking', () => {
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',
})
})
it('не считает healthy failover-событием', () => {
expect(isFailoverEventStatus('healthy')).toBe(false)
expect(isFailoverEventStatus('unhealthy')).toBe(true)
})
})
+36
View File
@@ -0,0 +1,36 @@
export interface FailoverEvent {
id: string
address: string
status: string
consecutiveFailures: number
lastFailureReason: string | null
lastCheckAt?: string | null
}
export interface FailoverNodeInput {
id?: number | string
address: string
health_status: string
consecutive_failures: number
last_failure_reason: string | null
last_check_at?: string | null
}
const FAILOVER_STATUSES = new Set(['unhealthy', 'down', 'checking'])
export function isFailoverEventStatus(status: string): boolean {
return FAILOVER_STATUSES.has(status)
}
export function toFailoverEvents(
nodes: readonly FailoverNodeInput[],
): 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,
}))
}
+36 -26
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
DEFAULT_BINDING_HEALTH,
addAddressNode,
addCommonFqdn,
emptyAddressBlock,
emptyBindingDraft,
hydrateAddressBlock,
@@ -41,15 +42,15 @@ describe('hydrateAddressBlock', () => {
const state = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
expect(state.commonFqdn).toBe('rutg.rkns.top')
expect(state.commonFqdns).toEqual(['rutg.rkns.top'])
expect(state.nodes).toEqual([
{ ip: '93.115.203.183', extraFqdn: 'msk.rutg.rkns.top' },
{ ip: '185.244.181.61', extraFqdn: '' },
])
expect(state.otherBindings).toEqual([])
expect(state.preservedBindings).toEqual([])
})
it('не схлопывает CNAME и A на несколько IP', () => {
it('кладёт A на весь пул в commonFqdns, CNAME — в preserved', () => {
const cname: ServiceBindingDraft = {
...emptyBindingDraft('alias.rkns.top'),
record_type: 'CNAME',
@@ -63,14 +64,12 @@ describe('hydrateAddressBlock', () => {
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
expect(state.commonFqdns).toEqual(['rutg.rkns.top', 'both.rkns.top'])
expect(state.nodes.every((node) => node.extraFqdn === '')).toBe(true)
expect(state.otherBindings.map((item) => item.fqdn)).toEqual([
'both.rkns.top',
'alias.rkns.top',
])
expect(state.preservedBindings.map((item) => item.fqdn)).toEqual(['alias.rkns.top'])
})
it('кладёт extra A с IP вне пула в otherBindings', () => {
it('кладёт extra A с IP вне пула в preservedBindings', () => {
const drafts = [
aRecord('gw.example.com', ['10.0.0.1']),
aRecord('edge.example.com', ['8.8.8.8']),
@@ -79,13 +78,14 @@ describe('hydrateAddressBlock', () => {
const state = hydrateAddressBlock(drafts, ['10.0.0.1'])
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdn: '' }])
expect(state.otherBindings).toHaveLength(1)
expect(state.otherBindings[0]?.fqdn).toBe('edge.example.com')
expect(state.commonFqdns).toEqual(['gw.example.com'])
expect(state.preservedBindings).toHaveLength(1)
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
})
})
describe('toDomainsPayload', () => {
it('собирает primary на весь пул и extra binding на один IP', () => {
it('собирает каждый common на весь пул и extra binding на один IP', () => {
const drafts = [
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
@@ -107,28 +107,30 @@ describe('toDomainsPayload', () => {
])
})
it('круг hydrate → payload → hydrate сохраняет extra FQDN', () => {
it('круг hydrate → payload → hydrate сохраняет два common и extra FQDN', () => {
const drafts = [
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
aRecord('gt.rkns.top', ['93.115.203.183', '185.244.181.61']),
aRecord('msk.rkns.top', ['93.115.203.183', '185.244.181.61']),
aRecord('nsgt.rkns.top', ['93.115.203.183']),
]
const first = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
expect(first.commonFqdns).toEqual(['gt.rkns.top', 'msk.rkns.top'])
const rebound = toAddressBindings(first, primaryMeta)
const second = hydrateAddressBlock(rebound, rebound[0]?.target_ips ?? [])
expect(second.commonFqdn).toBe(first.commonFqdn)
expect(second.commonFqdns).toEqual(first.commonFqdns)
expect(second.nodes).toEqual(first.nodes)
expect(second.otherBindings).toEqual([])
expect(second.preservedBindings).toEqual([])
})
})
describe('removeAddressNode', () => {
it('удаляет extra FQDN узла и IP из other A-bindings', () => {
it('удаляет extra FQDN узла и IP из preserved A-bindings', () => {
const state = hydrateAddressBlock(
[
aRecord('gw.example.com', ['10.0.0.1', '10.0.0.2']),
aRecord('msk.example.com', ['10.0.0.1']),
aRecord('pair.example.com', ['10.0.0.1', '10.0.0.2']),
aRecord('edge.example.com', ['9.9.9.9', '10.0.0.1']),
],
['10.0.0.1', '10.0.0.2'],
)
@@ -136,12 +138,12 @@ describe('removeAddressNode', () => {
const next = removeAddressNode(state, '10.0.0.1')
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdn: '' }])
expect(next.otherBindings).toHaveLength(1)
expect(next.otherBindings[0]?.target_ips).toEqual(['10.0.0.2'])
expect(next.preservedBindings).toHaveLength(1)
expect(next.preservedBindings[0]?.target_ips).toEqual(['9.9.9.9'])
})
})
describe('addAddressNode', () => {
describe('addAddressNode / addCommonFqdn', () => {
it('не добавляет дубликат IP', () => {
const withIp = addAddressNode(
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdn: '' }] },
@@ -149,23 +151,31 @@ describe('addAddressNode', () => {
)
expect(withIp.nodes).toHaveLength(1)
})
it('не добавляет дубликат common FQDN', () => {
const state = addCommonFqdn(
{ ...emptyAddressBlock(), commonFqdns: ['gt.rkns.top'] },
'GT.rkns.top',
)
expect(state.commonFqdns).toEqual(['gt.rkns.top'])
})
})
describe('CNAME / otherBindings', () => {
it('сохраняет CNAME в otherBindings при круге hydrate → payload', () => {
describe('CNAME / preservedBindings', () => {
it('сохраняет CNAME в preserved при круге hydrate → payload', () => {
const cname: ServiceBindingDraft = {
...emptyBindingDraft('alias.rkns.top'),
record_type: 'CNAME',
target_cname: 'rutg.rkns.top',
}
const drafts = [
aRecord('rutg.rkns.top', ['1.1.1.1']),
aRecord('rutg.rkns.top', ['1.1.1.1', '2.2.2.2']),
aRecord('msk.rkns.top', ['1.1.1.1']),
cname,
]
const state = hydrateAddressBlock(drafts, ['1.1.1.1'])
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
expect(state.nodes[0]?.extraFqdn).toBe('msk.rkns.top')
expect(state.otherBindings).toHaveLength(1)
expect(state.preservedBindings).toHaveLength(1)
const payload = toDomainsPayload(state, primaryMeta)
expect(payload.map((item) => item.fqdn)).toEqual([
+90 -50
View File
@@ -37,9 +37,9 @@ export interface AddressNode {
}
export interface AddressBlockState {
commonFqdn: string
commonFqdns: string[]
nodes: AddressNode[]
otherBindings: ServiceBindingDraft[]
preservedBindings: ServiceBindingDraft[]
target_ip_weights: Record<string, number>
target_ip_priorities: Record<string, number>
}
@@ -78,31 +78,14 @@ export function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
export function emptyAddressBlock(): AddressBlockState {
return {
commonFqdn: '',
commonFqdns: [],
nodes: [],
otherBindings: [],
preservedBindings: [],
target_ip_weights: {},
target_ip_priorities: {},
}
}
export function withPoolIps(
draft: ServiceBindingDraft,
pool: string[],
): ServiceBindingDraft {
if (draft.record_type !== 'A' || draft.target_ips.length > 0 || pool.length === 0) {
return draft
}
return {
...draft,
target_ips: pool,
target_ip_weights: Object.fromEntries(pool.map((ip) => [ip, draft.target_ip_weights[ip] ?? 1])),
target_ip_priorities: Object.fromEntries(
pool.map((ip) => [ip, draft.target_ip_priorities[ip] ?? 1]),
),
}
}
function uniqueIps(...lists: string[][]): string[] {
const seen = new Set<string>()
const out: string[] = []
@@ -123,6 +106,13 @@ function omitKey(record: Record<string, number>, key: string): Record<string, nu
return next
}
function sameIpSet(left: string[], right: string[]): boolean {
if (left.length === 0 || left.length !== right.length) return false
const set = new Set(left.map((ip) => ip.trim()).filter(Boolean))
if (set.size !== left.length) return false
return right.every((ip) => set.has(ip.trim()))
}
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
return (service.domains ?? []).map((binding) => ({
fqdn: bindingToFqdn(binding),
@@ -151,49 +141,62 @@ export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
}))
}
function canCollapseToNode(
extra: ServiceBindingDraft,
pool: Set<string>,
claimed: Set<string>,
): string | null {
if (extra.record_type !== 'A') return null
if (extra.target_ips.length !== 1) return null
const ip = extra.target_ips[0]?.trim() ?? ''
if (!ip || !pool.has(ip) || claimed.has(ip)) return null
if (!extra.fqdn.trim()) return null
return ip
function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean {
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
}
export function hydrateAddressBlock(
drafts: ServiceBindingDraft[],
pool: string[] = [],
): AddressBlockState {
const primary = drafts[0]
const ips = uniqueIps(pool, primary?.target_ips ?? [])
const multiIpTargets = drafts
.filter((draft) => draft.record_type === 'A' && draft.target_ips.length > 1)
.map((draft) => draft.target_ips)
const allAIps = drafts
.filter((draft) => draft.record_type === 'A')
.map((draft) => draft.target_ips)
const ips =
pool.length > 0
? uniqueIps(pool)
: uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps))
const poolSet = new Set(ips)
const commonFqdns: string[] = []
const claimed = new Set<string>()
const extraByIp = new Map<string, string>()
const otherBindings: ServiceBindingDraft[] = []
const preservedBindings: ServiceBindingDraft[] = []
let weights: Record<string, number> = {}
let priorities: Record<string, number> = {}
for (const extra of drafts.slice(1)) {
const ip = canCollapseToNode(extra, poolSet, claimed)
if (ip) {
claimed.add(ip)
extraByIp.set(ip, extra.fqdn)
for (const draft of drafts) {
const fqdn = draft.fqdn.trim()
if (isFullPoolA(draft, ips)) {
if (fqdn) commonFqdns.push(draft.fqdn)
if (Object.keys(weights).length === 0) {
weights = { ...draft.target_ip_weights }
priorities = { ...draft.target_ip_priorities }
}
continue
}
otherBindings.push(extra)
if (draft.record_type === 'A' && draft.target_ips.length === 1) {
const ip = draft.target_ips[0]?.trim() ?? ''
if (ip && poolSet.has(ip) && fqdn && !claimed.has(ip)) {
claimed.add(ip)
extraByIp.set(ip, draft.fqdn)
continue
}
}
preservedBindings.push(draft)
}
return {
commonFqdn: primary?.fqdn ?? '',
commonFqdns,
nodes: ips.map((ip) => ({
ip,
extraFqdn: extraByIp.get(ip) ?? '',
})),
otherBindings,
target_ip_weights: { ...(primary?.target_ip_weights ?? {}) },
target_ip_priorities: { ...(primary?.target_ip_priorities ?? {}) },
preservedBindings,
target_ip_weights: weights,
target_ip_priorities: priorities,
}
}
@@ -221,7 +224,7 @@ export function removeAddressNode(state: AddressBlockState, ip: string): Address
return {
...state,
nodes: state.nodes.filter((node) => node.ip !== ip),
otherBindings: pruneIpFromBindings(state.otherBindings, ip),
preservedBindings: pruneIpFromBindings(state.preservedBindings, ip),
target_ip_weights: omitKey(state.target_ip_weights, ip),
target_ip_priorities: omitKey(state.target_ip_priorities, ip),
}
@@ -240,6 +243,42 @@ export function addAddressNode(state: AddressBlockState, ip: string): AddressBlo
}
}
function fqdnKey(value: string): string {
return value.trim().toLowerCase()
}
export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean {
const key = fqdnKey(fqdn)
if (!key) return false
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return true
if (state.nodes.some((node) => fqdnKey(node.extraFqdn) === key)) return true
return false
}
export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState {
const trimmed = fqdn.trim()
if (!trimmed || addressHasFqdn(state, trimmed)) return state
return { ...state, commonFqdns: [...state.commonFqdns, trimmed] }
}
export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState {
return {
...state,
commonFqdns: state.commonFqdns.filter((_, i) => i !== index),
}
}
export function updateCommonFqdn(
state: AddressBlockState,
index: number,
fqdn: string,
): AddressBlockState {
return {
...state,
commonFqdns: state.commonFqdns.map((item, i) => (i === index ? fqdn : item)),
}
}
export function toAddressBindings(
state: AddressBlockState,
primary: AddressPrimaryMeta,
@@ -253,10 +292,11 @@ export function toAddressBindings(
)
const drafts: ServiceBindingDraft[] = []
const hasPrimary = Boolean(state.commonFqdn.trim()) || ips.length > 0
if (hasPrimary) {
for (const raw of state.commonFqdns) {
const fqdn = raw.trim()
if (!fqdn || ips.length === 0) continue
drafts.push({
fqdn: state.commonFqdn,
fqdn,
record_type: 'A',
target_ips: ips,
target_cname: '',
@@ -282,7 +322,7 @@ export function toAddressBindings(
})
}
drafts.push(...state.otherBindings)
drafts.push(...state.preservedBindings)
return drafts
}
@@ -14,7 +14,6 @@ import {
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { EmptyState } from '@/components/empty-state'
import { FailoverTimeline } from '@/components/failover-timeline'
import { FormFieldSimple } from '@/components/form-field'
import { FormSheet } from '@/components/form-sheet'
import { HealthCheckBadge } from '@/components/health-check-badge'
@@ -29,15 +28,9 @@ import {
import { LbModeTile } from '@/components/services/service-unit-card'
import {
KpiStatGrid,
ServiceFailoverPanel,
ServiceHealthMonitor,
} from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { api } from '@/lib/api-client'
import {
enabledHealthProviders,
@@ -84,6 +77,7 @@ interface OverviewPayload {
priority: number
consecutive_failures: number
last_failure_reason: string | null
last_check_at?: string | null
}>
}
@@ -209,21 +203,7 @@ function ServiceDetailPage() {
const isError = viewQuery.isError || overviewQuery.isError
const error = viewQuery.error ?? overviewQuery.error
const failoverEvents =
(nodes.length > 0 ? nodes : (overview?.nodes ?? []))
.filter(
(node) =>
node.health_status === 'unhealthy' ||
node.health_status === 'down' ||
node.health_status === 'checking',
)
.map((node) => ({
id: node.address,
title: `${node.address}: ${node.health_status}`,
detail: node.last_failure_reason
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
: `fail ${node.consecutive_failures}`,
}))
const failoverNodes = nodes.length > 0 ? nodes : (overview?.nodes ?? [])
const enabledProviders = useMemo(
() => enabledHealthProviders(service?.domains ?? []),
@@ -333,17 +313,7 @@ function ServiceDetailPage() {
statuses={providerStatuses}
isLoading={logQuery.isLoading}
/>
<Frame dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<FrameTitle>Failover</FrameTitle>
<FrameDescription>
Нездоровые ноды и причины последней ошибки
</FrameDescription>
</FrameHeader>
<FramePanel>
<FailoverTimeline events={failoverEvents} />
</FramePanel>
</Frame>
<ServiceFailoverPanel nodes={failoverNodes} />
</section>
{service.ips.length === 0 && service.domains.length === 0 ? (