Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87b0f1a894 | ||
|
|
ba4e04a224 | ||
|
|
4c59780ff0 | ||
|
|
1457389ae7 | ||
|
|
153be28799 | ||
|
|
39fac7834f | ||
|
|
d267e40157 |
@@ -29,6 +29,7 @@ import * as domainService from "./domain-service.js";
|
|||||||
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
||||||
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
||||||
import {
|
import {
|
||||||
|
isHealthy,
|
||||||
selectActiveIpsByMode,
|
selectActiveIpsByMode,
|
||||||
withBindingLock,
|
withBindingLock,
|
||||||
type LbIpRow,
|
type LbIpRow,
|
||||||
|
|||||||
@@ -78,4 +78,53 @@ describe("service bindings prune", () => {
|
|||||||
expect(bindings).toHaveLength(2);
|
expect(bindings).toHaveLength(2);
|
||||||
expect(bindings.map((b) => b.hostname).sort()).toEqual(["api", "www"]);
|
expect(bindings.map((b) => b.hostname).sort()).toEqual(["api", "www"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updateConfig with extra FQDN per IP does not throw when group health-check is on", async () => {
|
||||||
|
const db = setupDb();
|
||||||
|
const cf = mockCf();
|
||||||
|
const health = {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp" as const,
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_providers: ["local", "cloudflare", "globalping"] as const,
|
||||||
|
health_check_aggregate: "majority" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
repos.createDomain(db, null, "example.com", "cf-zone-example");
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
db,
|
||||||
|
"VPN",
|
||||||
|
"vpn",
|
||||||
|
null,
|
||||||
|
"vpn.example.com",
|
||||||
|
{ ...health },
|
||||||
|
);
|
||||||
|
const service = repos.createService(db, "GT", "gt");
|
||||||
|
repos.setServiceGroup(db, service.id, group.id);
|
||||||
|
repos.setServiceEnabled(db, service.id, true);
|
||||||
|
|
||||||
|
const view = await updateConfig(db, cf, service.id, {
|
||||||
|
ips: ["93.115.203.183", "130.49.213.153"],
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
fqdn: "gt.example.com",
|
||||||
|
target_ips: ["93.115.203.183", "130.49.213.153"],
|
||||||
|
...health,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fqdn: "rutg.example.com",
|
||||||
|
target_ips: ["93.115.203.183"],
|
||||||
|
...health,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fqdn: "nsgt.example.com",
|
||||||
|
target_ips: ["130.49.213.153"],
|
||||||
|
...health,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.domains).toHaveLength(3);
|
||||||
|
expect(view.ips.sort()).toEqual(["130.49.213.153", "93.115.203.183"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +1,114 @@
|
|||||||
|
import { ShieldCheckIcon } from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Timeline,
|
Timeline,
|
||||||
TimelineContent,
|
TimelineContent,
|
||||||
|
TimelineDate,
|
||||||
TimelineHeader,
|
TimelineHeader,
|
||||||
TimelineIndicator,
|
TimelineIndicator,
|
||||||
TimelineItem,
|
TimelineItem,
|
||||||
TimelineSeparator,
|
TimelineSeparator,
|
||||||
TimelineTitle,
|
TimelineTitle,
|
||||||
} from '@/components/reui/timeline'
|
} 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 {
|
type HealthBadgeStatus = ComponentProps<typeof HealthCheckBadge>['status']
|
||||||
id: string
|
|
||||||
title: string
|
function failStreakLabel(count: number): string {
|
||||||
detail: 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[] }) {
|
export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-muted-foreground text-sm">
|
<EmptyState
|
||||||
Событий failover пока нет.
|
icon={ShieldCheckIcon}
|
||||||
</p>
|
title="Пул стабилен"
|
||||||
|
description="События появятся, когда нода станет unhealthy или down и будет выведена из пула"
|
||||||
|
stackedIcon={false}
|
||||||
|
centered={false}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Timeline defaultValue={events.length} className="w-full">
|
<Timeline defaultValue={0} className="gap-0">
|
||||||
{events.map((event, index) => (
|
{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}>
|
<TimelineItem key={event.id} step={index + 1}>
|
||||||
<TimelineSeparator />
|
<TimelineSeparator className={separatorClass(event.status)} />
|
||||||
<TimelineIndicator />
|
<TimelineIndicator className={indicatorClass(event.status)} />
|
||||||
<TimelineHeader>
|
<TimelineHeader>
|
||||||
<TimelineTitle>{event.title}</TimelineTitle>
|
<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>
|
</TimelineHeader>
|
||||||
<TimelineContent>{event.detail}</TimelineContent>
|
<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>
|
</TimelineItem>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</Timeline>
|
</Timeline>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { Fragment, useMemo } from 'react'
|
||||||
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -12,82 +12,12 @@ import { Separator } from '@cfdm/ui/components/separator'
|
|||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||||
|
import { getBreadcrumbs } from '@/lib/breadcrumbs'
|
||||||
|
|
||||||
export interface RouteBreadcrumbLoaderData {
|
export interface RouteBreadcrumbLoaderData {
|
||||||
breadcrumb?: string
|
breadcrumb?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeTitles: Record<string, string> = {
|
|
||||||
'/': 'Панель управления',
|
|
||||||
'/domains': 'Домены',
|
|
||||||
'/groups': 'Группы доменов',
|
|
||||||
'/services': 'Сервисы',
|
|
||||||
'/certificates': 'Сертификаты',
|
|
||||||
'/settings/appearance': 'Внешний вид',
|
|
||||||
'/settings/health': 'Health-check',
|
|
||||||
'/settings/integrations': 'Интеграции',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBreadcrumbs(
|
|
||||||
pathname: string,
|
|
||||||
dynamicLabels: Record<string, string>,
|
|
||||||
) {
|
|
||||||
if (pathname === '/') {
|
|
||||||
return [{ label: 'Панель управления', href: '/' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/services\/\d+$/)) {
|
|
||||||
return [
|
|
||||||
{ label: 'Сервисы', href: '/services' },
|
|
||||||
{ label: dynamicLabels[pathname] ?? 'Сервис', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/groups\/\d+$/)) {
|
|
||||||
return [
|
|
||||||
{ label: 'Группы доменов', href: '/groups' },
|
|
||||||
{ label: dynamicLabels[pathname] ?? 'Группа', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
|
|
||||||
const domainId = pathname.split('/')[2]
|
|
||||||
const domainPath = `/domains/${domainId}`
|
|
||||||
return [
|
|
||||||
{ label: 'Домены', href: '/domains' },
|
|
||||||
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
|
|
||||||
{ label: 'DNS', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/domains\/\d+$/)) {
|
|
||||||
return [
|
|
||||||
{ label: 'Домены', href: '/domains' },
|
|
||||||
{ label: dynamicLabels[pathname] ?? 'Обзор домена', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.startsWith('/settings')) {
|
|
||||||
return [
|
|
||||||
{ label: 'Настройки', href: '/settings/appearance' },
|
|
||||||
...(pathname === '/settings/integrations'
|
|
||||||
? [{ label: 'Интеграции', href: pathname }]
|
|
||||||
: pathname === '/settings/health'
|
|
||||||
? [{ label: 'Health-check', href: pathname }]
|
|
||||||
: pathname === '/settings/appearance'
|
|
||||||
? [{ label: 'Внешний вид', href: pathname }]
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = routeTitles[pathname]
|
|
||||||
if (title) {
|
|
||||||
return [{ label: title, href: pathname }]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{ label: 'Панель управления', href: '/' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDynamicBreadcrumbLabels() {
|
function useDynamicBreadcrumbLabels() {
|
||||||
const matches = useMatches()
|
const matches = useMatches()
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
@@ -106,7 +36,10 @@ function useDynamicBreadcrumbLabels() {
|
|||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||||
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
const crumbs = useMemo(
|
||||||
|
() => getBreadcrumbs(pathname, dynamicLabels),
|
||||||
|
[pathname, dynamicLabels],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||||
@@ -117,10 +50,10 @@ export function SiteHeader() {
|
|||||||
{crumbs.map((crumb, index) => {
|
{crumbs.map((crumb, index) => {
|
||||||
const isLast = index === crumbs.length - 1
|
const isLast = index === crumbs.length - 1
|
||||||
return (
|
return (
|
||||||
<span key={crumb.href} className="contents">
|
<Fragment key={`${index}-${crumb.href}`}>
|
||||||
{index > 0 && (
|
{index > 0 ? (
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
)}
|
) : null}
|
||||||
<BreadcrumbItem
|
<BreadcrumbItem
|
||||||
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
||||||
>
|
>
|
||||||
@@ -132,7 +65,7 @@ export function SiteHeader() {
|
|||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
)}
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</span>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
|
|||||||
@@ -298,11 +298,12 @@ export function HealthSourceFilterBar({
|
|||||||
const active = selected.length > 0 ? selected : enabled
|
const active = selected.length > 0 ? selected : enabled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="@container min-w-0 w-full">
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
multiple
|
multiple
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full min-w-0"
|
className="flex w-full min-w-0 flex-wrap justify-start"
|
||||||
value={active}
|
value={active}
|
||||||
aria-label="Тип пробы"
|
aria-label="Тип пробы"
|
||||||
onValueChange={(next) => {
|
onValueChange={(next) => {
|
||||||
@@ -318,7 +319,8 @@ export function HealthSourceFilterBar({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
value={item.id}
|
value={item.id}
|
||||||
aria-label={item.title}
|
aria-label={item.title}
|
||||||
className="min-w-0 flex-1 gap-1.5"
|
title={item.title}
|
||||||
|
className="max-w-full min-w-0 flex-none justify-start gap-1.5 @[16rem]:min-w-[8.5rem]"
|
||||||
>
|
>
|
||||||
<IconTile
|
<IconTile
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
@@ -328,7 +330,9 @@ export function HealthSourceFilterBar({
|
|||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</IconTile>
|
</IconTile>
|
||||||
<span className="truncate">{item.title}</span>
|
<span className="hidden min-w-0 truncate @[16rem]:inline">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
<HealthCheckBadge
|
<HealthCheckBadge
|
||||||
status={statuses[item.id] ?? 'unknown'}
|
status={statuses[item.id] ?? 'unknown'}
|
||||||
provider={item.id}
|
provider={item.id}
|
||||||
@@ -337,6 +341,7 @@ export function HealthSourceFilterBar({
|
|||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
|
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
|
||||||
export { ServiceHealthMonitor } from './service-health-monitor'
|
export { ServiceHealthMonitor } from './service-health-monitor'
|
||||||
|
export { ServiceFailoverPanel } from './service-failover-panel'
|
||||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||||
@@ -28,3 +29,4 @@ export {
|
|||||||
type HealthProvider,
|
type HealthProvider,
|
||||||
type HealthAggregate,
|
type HealthAggregate,
|
||||||
} from './health-source-tiles'
|
} from './health-source-tiles'
|
||||||
|
export { ServiceAddressBlock } from './service-address-block'
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
|||||||
if (item.footer) return item.footer
|
if (item.footer) return item.footer
|
||||||
if (typeof item.hint === 'string') {
|
if (typeof item.hint === 'string') {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||||
{item.hint}
|
{item.hint}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
@@ -81,30 +81,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
|||||||
const valueVariant = item.variant ?? 'default'
|
const valueVariant = item.variant ?? 'default'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
|
||||||
{item.icon ? (
|
{item.icon ? (
|
||||||
<IconTile
|
<IconTile
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</IconTile>
|
</IconTile>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
{item.label}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-2xl leading-none font-bold tabular-nums',
|
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
|
||||||
VALUE_VARIANT_CLASS[valueVariant],
|
VALUE_VARIANT_CLASS[valueVariant],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.value}
|
{item.value}
|
||||||
</div>
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -116,7 +125,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
|
|||||||
const selected = isSelected(item)
|
const selected = isSelected(item)
|
||||||
|
|
||||||
return cn(
|
return cn(
|
||||||
'relative isolate flex h-full flex-col',
|
'relative isolate flex h-full min-w-0 flex-col',
|
||||||
clickable &&
|
clickable &&
|
||||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||||
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||||
|
import { ServerIcon, Trash2Icon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { isValidIpv4 } from '@/components/tagged-input'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { parseFqdn } from '@/lib/parse-fqdn'
|
||||||
|
import {
|
||||||
|
addAddressNode,
|
||||||
|
addCommonFqdn,
|
||||||
|
addressHasFqdn,
|
||||||
|
removeAddressNode,
|
||||||
|
removeCommonFqdn,
|
||||||
|
updateCommonFqdn,
|
||||||
|
type AddressBlockState,
|
||||||
|
} from '@/lib/service-address'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Field, FieldLabel } from '@cfdm/ui/components/field'
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from '@cfdm/ui/components/input-group'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@cfdm/ui/components/item'
|
||||||
|
|
||||||
|
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 с доп. доменом.
|
||||||
|
* Preview: https://reui.io/preview/base/settings-3
|
||||||
|
* Preview: https://reui.io/preview/base/list-9
|
||||||
|
* Preview: https://reui.io/preview/base/form-7
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame
|
||||||
|
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||||
|
* Docs: https://reui.io/docs/components/base/badge
|
||||||
|
*/
|
||||||
|
export function ServiceAddressBlock({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
zoneHints,
|
||||||
|
}: {
|
||||||
|
value: AddressBlockState
|
||||||
|
onChange: (next: AddressBlockState) => void
|
||||||
|
zoneHints: string[]
|
||||||
|
}) {
|
||||||
|
const [pendingIp, setPendingIp] = useState('')
|
||||||
|
const [ipInvalid, setIpInvalid] = useState(false)
|
||||||
|
const [pendingFqdn, setPendingFqdn] = useState('')
|
||||||
|
const [fqdnInvalid, setFqdnInvalid] = useState(false)
|
||||||
|
|
||||||
|
const pool = value.nodes.map((node) => node.ip)
|
||||||
|
const pendingIpTrimmed = pendingIp.trim()
|
||||||
|
const pendingFqdnTrimmed = pendingFqdn.trim()
|
||||||
|
const pendingIpInvalid =
|
||||||
|
ipInvalid && pendingIpTrimmed.length > 0 && !isValidIpv4(pendingIpTrimmed)
|
||||||
|
const pendingFqdnInvalid =
|
||||||
|
fqdnInvalid && pendingFqdnTrimmed.length > 0
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setIpInvalid(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isValidIpv4(trimmed) || pool.includes(trimmed)) {
|
||||||
|
setIpInvalid(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(addAddressNode(value, trimmed))
|
||||||
|
setPendingIp('')
|
||||||
|
setIpInvalid(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNodeFqdn(ip: string, extraFqdn: string) {
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
nodes: value.nodes.map((node) =>
|
||||||
|
node.ip === ip ? { ...node, extraFqdn } : node,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 свой доп. домен
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<Field>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<FramePanel fit className="flex flex-col gap-3">
|
||||||
|
<FrameHeader className="px-0 pt-0">
|
||||||
|
<FrameTitle>IP-адреса</FrameTitle>
|
||||||
|
</FrameHeader>
|
||||||
|
{value.nodes.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ServerIcon}
|
||||||
|
title="Добавьте IP пула"
|
||||||
|
description="IPv4 сервиса. Для каждого адреса можно указать доп. FQDN."
|
||||||
|
stackedIcon={false}
|
||||||
|
centered={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ItemGroup className="gap-2">
|
||||||
|
{value.nodes.map((node) => {
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={node.ip}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="items-stretch"
|
||||||
|
>
|
||||||
|
<ItemMedia>
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="xs"
|
||||||
|
className="text-info"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ServerIcon />
|
||||||
|
</IconTile>
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent className="flex min-w-0 flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ItemTitle className="font-mono">{node.ip}</ItemTitle>
|
||||||
|
<ItemActions className="ml-auto shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Удалить ${node.ip}`}
|
||||||
|
onClick={() => onChange(removeAddressNode(value, node.ip))}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
</ItemActions>
|
||||||
|
</div>
|
||||||
|
<Field className="gap-1.5">
|
||||||
|
<FieldLabel
|
||||||
|
htmlFor={`service-ip-extra-${node.ip}`}
|
||||||
|
className="text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
Доп. FQDN
|
||||||
|
</FieldLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
id={`service-ip-extra-${node.ip}`}
|
||||||
|
className="font-mono"
|
||||||
|
value={node.extraFqdn}
|
||||||
|
placeholder={
|
||||||
|
zoneHints[0]
|
||||||
|
? `необязательно · spb.${zoneHints[0]}`
|
||||||
|
: 'необязательно · spb.example.com'
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleNodeFqdn(node.ip, event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ZoneAddon fqdn={node.extraFqdn} zoneHints={zoneHints} />
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ItemGroup>
|
||||||
|
)}
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
id="service-pool-ip-add"
|
||||||
|
className="font-mono"
|
||||||
|
value={pendingIp}
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
aria-invalid={pendingIpInvalid || undefined}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPendingIp(event.target.value)
|
||||||
|
setIpInvalid(false)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleIpKeyDown}
|
||||||
|
onBlur={() => tryAddIp(pendingIp)}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<InputGroupButton size="sm" onClick={() => tryAddIp(pendingIp)}>
|
||||||
|
Добавить
|
||||||
|
</InputGroupButton>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</FramePanel>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { toAlignedSeries, type UptimeProbe } from './uptime-chart'
|
||||||
|
|
||||||
|
function probe(
|
||||||
|
overrides: Partial<UptimeProbe> & Pick<UptimeProbe, 'id'>,
|
||||||
|
): UptimeProbe {
|
||||||
|
return {
|
||||||
|
status: 'up',
|
||||||
|
ok: true,
|
||||||
|
latency_ms: 10,
|
||||||
|
checked_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
provider: 'local',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('toAlignedSeries', () => {
|
||||||
|
it('puts mixed-source probes in one 60s bucket instead of a sawtooth series', () => {
|
||||||
|
const { points, keys } = toAlignedSeries([
|
||||||
|
probe({
|
||||||
|
id: 1,
|
||||||
|
provider: 'local',
|
||||||
|
latency_ms: 4,
|
||||||
|
checked_at: '2026-01-01T00:00:10.000Z',
|
||||||
|
}),
|
||||||
|
probe({
|
||||||
|
id: 2,
|
||||||
|
provider: 'cloudflare',
|
||||||
|
latency_ms: 284,
|
||||||
|
checked_at: '2026-01-01T00:00:12.000Z',
|
||||||
|
}),
|
||||||
|
probe({
|
||||||
|
id: 3,
|
||||||
|
provider: 'globalping',
|
||||||
|
latency_ms: 38,
|
||||||
|
checked_at: '2026-01-01T00:00:40.000Z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(points).toHaveLength(1)
|
||||||
|
expect(points[0]?.local).toBe(4)
|
||||||
|
expect(points[0]?.cloudflare).toBe(284)
|
||||||
|
expect(points[0]?.globalping).toBe(38)
|
||||||
|
expect(keys).toEqual(['local', 'cloudflare', 'globalping'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not plot down probes as latency 0', () => {
|
||||||
|
const { points } = toAlignedSeries([
|
||||||
|
probe({
|
||||||
|
id: 1,
|
||||||
|
status: 'down',
|
||||||
|
ok: false,
|
||||||
|
latency_ms: 12,
|
||||||
|
provider: 'local',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(points).toHaveLength(1)
|
||||||
|
expect(points[0]?.local).toBeNull()
|
||||||
|
expect(points[0]?.localOk).toBe(false)
|
||||||
|
expect(points[0]?.ok).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits probes that fall into adjacent minutes', () => {
|
||||||
|
const { points } = toAlignedSeries([
|
||||||
|
probe({ id: 1, latency_ms: 10, checked_at: '2026-01-01T00:00:50.000Z' }),
|
||||||
|
probe({ id: 2, latency_ms: 20, checked_at: '2026-01-01T00:01:10.000Z' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(points).toHaveLength(2)
|
||||||
|
expect(points[0]?.local).toBe(10)
|
||||||
|
expect(points[1]?.local).toBe(20)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useId, useMemo, useState } from 'react'
|
import { useId, useMemo, useState } from 'react'
|
||||||
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
||||||
import { Area, AreaChart, XAxis } from 'recharts'
|
import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
@@ -22,12 +22,13 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@cfdm/ui/components/tooltip'
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
|
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
|
||||||
* Preview: https://reui.io/preview/base/chart-17
|
* Preview: https://reui.io/preview/base/chart-17
|
||||||
* Frame: https://reui.io/docs/components/base/frame
|
* Frame: https://reui.io/docs/components/base/frame
|
||||||
* Chart: shadcn Chart + Recharts AreaChart
|
* Chart: shadcn Chart + Recharts ComposedChart
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface UptimeProbe {
|
export interface UptimeProbe {
|
||||||
@@ -36,6 +37,7 @@ export interface UptimeProbe {
|
|||||||
ok: boolean
|
ok: boolean
|
||||||
latency_ms: number | null
|
latency_ms: number | null
|
||||||
checked_at: string
|
checked_at: string
|
||||||
|
provider?: HealthCheckProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UptimePeriodKey = '5D' | '2W' | '1M'
|
export type UptimePeriodKey = '5D' | '2W' | '1M'
|
||||||
@@ -46,40 +48,107 @@ export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number
|
|||||||
{ key: '1M', label: '1M', days: 30 },
|
{ key: '1M', label: '1M', days: 30 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const UPTIME_BUCKET_MS = 60_000
|
||||||
|
|
||||||
|
export const UPTIME_PROVIDER_KEYS = ['local', 'cloudflare', 'globalping'] as const
|
||||||
|
|
||||||
|
export type UptimeProviderKey = (typeof UPTIME_PROVIDER_KEYS)[number]
|
||||||
|
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
latency: {
|
local: {
|
||||||
label: 'Задержка',
|
label: 'Local',
|
||||||
color: 'var(--chart-1)',
|
color: 'var(--info)',
|
||||||
|
},
|
||||||
|
cloudflare: {
|
||||||
|
label: 'Cloudflare',
|
||||||
|
color: 'var(--warning)',
|
||||||
|
},
|
||||||
|
globalping: {
|
||||||
|
label: 'Globalping',
|
||||||
|
color: 'var(--success)',
|
||||||
},
|
},
|
||||||
} satisfies ChartConfig
|
} satisfies ChartConfig
|
||||||
|
|
||||||
interface ChartPoint {
|
export interface AlignedChartPoint {
|
||||||
period: string
|
period: string
|
||||||
latency: number
|
|
||||||
ok: boolean
|
|
||||||
at: string
|
at: string
|
||||||
status: UptimeProbe['status']
|
ok: boolean
|
||||||
|
local?: number | null
|
||||||
|
cloudflare?: number | null
|
||||||
|
globalping?: number | null
|
||||||
|
localOk?: boolean
|
||||||
|
cloudflareOk?: boolean
|
||||||
|
globalpingOk?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSeries(items: UptimeProbe[]): ChartPoint[] {
|
function isProviderKey(value: string | undefined): value is UptimeProviderKey {
|
||||||
return [...items]
|
return value === 'local' || value === 'cloudflare' || value === 'globalping'
|
||||||
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
|
}
|
||||||
.map((item) => ({
|
|
||||||
|
function bucketStart(time: number): number {
|
||||||
|
return Math.floor(time / UPTIME_BUCKET_MS) * UPTIME_BUCKET_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerOf(item: UptimeProbe): UptimeProviderKey {
|
||||||
|
return isProviderKey(item.provider) ? item.provider : 'local'
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeOk(item: UptimeProbe): boolean {
|
||||||
|
return item.ok && item.status !== 'down'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Align mixed-source probes onto a 60s time axis so Local/CF/GP do not zigzag. */
|
||||||
|
export function toAlignedSeries(items: UptimeProbe[]): {
|
||||||
|
points: AlignedChartPoint[]
|
||||||
|
keys: UptimeProviderKey[]
|
||||||
|
} {
|
||||||
|
const buckets = new Map<number, AlignedChartPoint>()
|
||||||
|
const used = new Set<UptimeProviderKey>()
|
||||||
|
|
||||||
|
const sorted = [...items].sort(
|
||||||
|
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const item of sorted) {
|
||||||
|
const key = providerOf(item)
|
||||||
|
used.add(key)
|
||||||
|
const start = bucketStart(probeTime(item.checked_at))
|
||||||
|
let row = buckets.get(start)
|
||||||
|
if (!row) {
|
||||||
|
row = {
|
||||||
period: formatDate(item.checked_at),
|
period: formatDate(item.checked_at),
|
||||||
latency: item.latency_ms ?? 0,
|
|
||||||
ok: item.ok && item.status !== 'down',
|
|
||||||
at: item.checked_at,
|
at: item.checked_at,
|
||||||
status: item.status,
|
ok: true,
|
||||||
}))
|
}
|
||||||
|
buckets.set(start, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = probeOk(item)
|
||||||
|
row[`${key}Ok`] = ok
|
||||||
|
row[key] = ok ? item.latency_ms : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = [...buckets.entries()]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([, row]) => {
|
||||||
|
const present = UPTIME_PROVIDER_KEYS.filter((key) => row[`${key}Ok`] !== undefined)
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
ok: present.length === 0 ? row.ok : present.every((key) => row[`${key}Ok`] !== false),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const keys = UPTIME_PROVIDER_KEYS.filter((key) => used.has(key))
|
||||||
|
return { points, keys }
|
||||||
}
|
}
|
||||||
|
|
||||||
function uptimePercent(points: ChartPoint[]): number | null {
|
function uptimePercent(points: AlignedChartPoint[]): number | null {
|
||||||
if (points.length === 0) return null
|
if (points.length === 0) return null
|
||||||
const okCount = points.filter((point) => point.ok).length
|
const okCount = points.filter((point) => point.ok).length
|
||||||
return (okCount / points.length) * 100
|
return (okCount / points.length) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
function deltaPercent(points: ChartPoint[]): number | null {
|
function deltaPercent(points: AlignedChartPoint[]): number | null {
|
||||||
if (points.length < 4) return null
|
if (points.length < 4) return null
|
||||||
const mid = Math.floor(points.length / 2)
|
const mid = Math.floor(points.length / 2)
|
||||||
const prev = uptimePercent(points.slice(0, mid))
|
const prev = uptimePercent(points.slice(0, mid))
|
||||||
@@ -113,7 +182,7 @@ function UptimeDelta({ delta }: { delta: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function probeUptimePercent(items: UptimeProbe[]): number | null {
|
export function probeUptimePercent(items: UptimeProbe[]): number | null {
|
||||||
return uptimePercent(toSeries(items))
|
return uptimePercent(toAlignedSeries(items).points)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lastProbeLatency(items: UptimeProbe[]): number | null {
|
export function lastProbeLatency(items: UptimeProbe[]): number | null {
|
||||||
@@ -129,6 +198,12 @@ function formatUptime(value: number | null): string {
|
|||||||
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
|
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPing(value: unknown, ok: boolean | undefined): string {
|
||||||
|
if (ok === false) return '—'
|
||||||
|
const ping = typeof value === 'number' ? value : Number(value)
|
||||||
|
return Number.isFinite(ping) ? `${ping} мс` : '—'
|
||||||
|
}
|
||||||
|
|
||||||
interface UptimeChartProps {
|
interface UptimeChartProps {
|
||||||
items: UptimeProbe[]
|
items: UptimeProbe[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
@@ -151,30 +226,45 @@ export function UptimeChart({
|
|||||||
}: UptimeChartProps) {
|
}: UptimeChartProps) {
|
||||||
const gradientId = useId().replace(/:/g, '')
|
const gradientId = useId().replace(/:/g, '')
|
||||||
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||||||
const [tooltipPortal, setTooltipPortal] = useState<HTMLElement | null>(null)
|
const [hovered, setHovered] = useState<AlignedChartPoint | null>(null)
|
||||||
const period = periodProp ?? internalPeriod
|
const period = periodProp ?? internalPeriod
|
||||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTooltipPortal(document.body)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
function handlePeriodChange(next: UptimePeriodKey) {
|
function handlePeriodChange(next: UptimePeriodKey) {
|
||||||
onPeriodChange?.(next)
|
onPeriodChange?.(next)
|
||||||
if (periodProp == null) setInternalPeriod(next)
|
if (periodProp == null) setInternalPeriod(next)
|
||||||
|
setHovered(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const points = useMemo(
|
const { points, keys } = useMemo(
|
||||||
() => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
|
() => toAlignedSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
|
||||||
[items, days, skipPeriodFilter],
|
[items, days, skipPeriodFilter],
|
||||||
)
|
)
|
||||||
const uptime = uptimePercent(points)
|
const uptime = uptimePercent(points)
|
||||||
const delta = deltaPercent(points)
|
const delta = deltaPercent(points)
|
||||||
const lastOk = points.at(-1)?.ok ?? true
|
const lastOk = points.at(-1)?.ok ?? true
|
||||||
const tileClass = lastOk ? 'text-success' : 'text-destructive'
|
const tileClass = lastOk ? 'text-success' : 'text-destructive'
|
||||||
|
const single = keys.length <= 1
|
||||||
|
const areaKey = keys[0] ?? 'local'
|
||||||
|
const hoverPings = hovered
|
||||||
|
? keys.map((key) => {
|
||||||
|
const ok = hovered[`${key}Ok`]
|
||||||
|
const label = chartConfig[key].label
|
||||||
|
return `${label} ${formatPing(hovered[key], ok)}`
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
function syncHover(state: {
|
||||||
|
activeTooltipIndex?: unknown
|
||||||
|
activeIndex?: unknown
|
||||||
|
}) {
|
||||||
|
const index = Number(state.activeTooltipIndex ?? state.activeIndex)
|
||||||
|
if (!Number.isFinite(index)) return
|
||||||
|
setHovered(points[index] ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
const panel = (
|
const panel = (
|
||||||
<FramePanel className="flex flex-col gap-6">
|
<FramePanel className="flex flex-col gap-6 overflow-visible">
|
||||||
{hideHeader ? null : (
|
{hideHeader ? null : (
|
||||||
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
|
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -242,48 +332,78 @@ export function UptimeChart({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hoverPings.length > 0 ? (
|
||||||
|
<p className="text-muted-foreground min-h-4 min-w-0 text-xs tabular-nums">
|
||||||
|
<span className="text-foreground font-medium">Пинг</span>
|
||||||
|
{' · '}
|
||||||
|
{formatDate(hovered?.at)}
|
||||||
|
{' · '}
|
||||||
|
{hoverPings.join(' · ')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground min-h-4 text-xs">
|
||||||
|
Наведите на точку графика
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="h-40 w-full overflow-visible">
|
<div className="h-40 w-full overflow-visible">
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={chartConfig}
|
config={chartConfig}
|
||||||
className="h-full w-full overflow-visible rounded-b-xl"
|
className="[&_.recharts-tooltip-wrapper]:z-50 [&_.recharts-wrapper]:overflow-visible h-full w-full overflow-visible rounded-b-xl"
|
||||||
initialDimension={{ width: 320, height: 160 }}
|
initialDimension={{ width: 320, height: 160 }}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<ComposedChart
|
||||||
data={points}
|
data={points}
|
||||||
margin={{ top: 16, left: 8, right: 8, bottom: 4 }}
|
margin={{ top: 24, left: 8, right: 8, bottom: 8 }}
|
||||||
|
accessibilityLayer
|
||||||
|
onMouseMove={syncHover}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
onClick={syncHover}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop
|
<stop
|
||||||
offset="5%"
|
offset="5%"
|
||||||
stopColor="var(--color-latency)"
|
stopColor={`var(--color-${areaKey})`}
|
||||||
stopOpacity={0.8}
|
stopOpacity={0.8}
|
||||||
/>
|
/>
|
||||||
<stop
|
<stop
|
||||||
offset="95%"
|
offset="95%"
|
||||||
stopColor="var(--color-latency)"
|
stopColor={`var(--color-${areaKey})`}
|
||||||
stopOpacity={0.1}
|
stopOpacity={0.1}
|
||||||
/>
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<XAxis dataKey="period" hide />
|
<XAxis dataKey="at" hide />
|
||||||
|
<YAxis hide domain={['auto', 'auto']} />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
|
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
|
||||||
|
filterNull={false}
|
||||||
|
shared
|
||||||
|
isAnimationActive={false}
|
||||||
allowEscapeViewBox={{ x: true, y: true }}
|
allowEscapeViewBox={{ x: true, y: true }}
|
||||||
portal={tooltipPortal ?? undefined}
|
|
||||||
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
|
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
formatter={(value, _name, item) => {
|
labelFormatter={(_label, payload) => {
|
||||||
const point = item.payload as ChartPoint | undefined
|
const at = (payload?.[0]?.payload as AlignedChartPoint | undefined)?.at
|
||||||
const ping = Number(value)
|
return at ? formatDate(at) : String(_label ?? '')
|
||||||
|
}}
|
||||||
|
formatter={(value, name, item) => {
|
||||||
|
const key = String(name)
|
||||||
|
const row = item.payload as AlignedChartPoint | undefined
|
||||||
|
const ok =
|
||||||
|
key === 'local' || key === 'cloudflare' || key === 'globalping'
|
||||||
|
? row?.[`${key}Ok`]
|
||||||
|
: row?.ok
|
||||||
|
const label = chartConfig[key as UptimeProviderKey]?.label ?? key
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-between gap-4">
|
<div className="flex flex-1 items-center justify-between gap-4">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{point?.ok === false ? 'Down' : 'Пинг'}
|
{ok === false ? `${label} · Down` : `Пинг · ${label}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
{Number.isFinite(ping) ? `${ping} мс` : '—'}
|
{formatPing(value, ok)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -291,42 +411,44 @@ export function UptimeChart({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{single ? (
|
||||||
<Area
|
<Area
|
||||||
dataKey="latency"
|
dataKey={areaKey}
|
||||||
name="latency"
|
name={areaKey}
|
||||||
type="natural"
|
type="monotone"
|
||||||
fill={`url(#${gradientId})`}
|
fill={`url(#${gradientId})`}
|
||||||
stroke="var(--color-latency)"
|
stroke={`var(--color-${areaKey})`}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
connectNulls={false}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
dot={(dotProps) => {
|
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
|
||||||
const { cx, cy, payload, index } = dotProps
|
|
||||||
if (cx == null || cy == null) return <g key={index} />
|
|
||||||
const point = payload as ChartPoint | undefined
|
|
||||||
return (
|
|
||||||
<circle
|
|
||||||
key={index}
|
|
||||||
cx={cx}
|
|
||||||
cy={cy}
|
|
||||||
r={4}
|
|
||||||
fill={
|
|
||||||
point?.ok
|
|
||||||
? 'var(--color-latency)'
|
|
||||||
: 'var(--destructive)'
|
|
||||||
}
|
|
||||||
stroke="var(--background)"
|
|
||||||
strokeWidth={2}
|
|
||||||
pointerEvents="none"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
activeDot={{
|
activeDot={{
|
||||||
r: 6,
|
r: 6,
|
||||||
stroke: 'var(--background)',
|
stroke: 'var(--background)',
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
) : (
|
||||||
|
keys.map((key) => (
|
||||||
|
<Line
|
||||||
|
key={key}
|
||||||
|
dataKey={key}
|
||||||
|
name={key}
|
||||||
|
type="monotone"
|
||||||
|
stroke={`var(--color-${key})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
|
||||||
|
activeDot={{
|
||||||
|
r: 6,
|
||||||
|
stroke: 'var(--background)',
|
||||||
|
strokeWidth: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ComposedChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { PlusIcon, Trash2Icon } from 'lucide-react'
|
import { Trash2Icon } from 'lucide-react'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
import { ServiceAddressBlock } from '@/components/reui-kit/service-address-block'
|
||||||
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
|
||||||
import {
|
import {
|
||||||
HealthCheckConfigFields,
|
HealthCheckConfigFields,
|
||||||
type LbAndHealthConfig,
|
type LbAndHealthConfig,
|
||||||
type LbMode,
|
|
||||||
type HealthCheckType,
|
|
||||||
type HealthProvider,
|
|
||||||
type HealthAggregate,
|
|
||||||
} from '@/components/health-check-config-fields'
|
} from '@/components/health-check-config-fields'
|
||||||
import type {
|
import type {
|
||||||
CreateServiceWithConfigInput,
|
CreateServiceWithConfigInput,
|
||||||
@@ -18,9 +13,16 @@ import type {
|
|||||||
ServiceView,
|
ServiceView,
|
||||||
UpdateServiceConfigInput,
|
UpdateServiceConfigInput,
|
||||||
} from '@/lib/schemas'
|
} from '@/lib/schemas'
|
||||||
import { parseHealthProviders } from '@cfdm/shared'
|
import {
|
||||||
import { bindingToFqdn, parseFqdn } from '@/lib/parse-fqdn'
|
DEFAULT_BINDING_HEALTH,
|
||||||
import { Badge } from '@/components/reui/badge'
|
emptyAddressBlock,
|
||||||
|
hydrateAddressBlock,
|
||||||
|
toBindingDrafts,
|
||||||
|
toDomainsPayload,
|
||||||
|
type AddressBlockState,
|
||||||
|
type BindingHealthConfig,
|
||||||
|
type ServiceBindingDraft,
|
||||||
|
} from '@/lib/service-address'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -30,14 +32,8 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@cfdm/ui/components/sheet'
|
} from '@cfdm/ui/components/sheet'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
|
||||||
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import {
|
|
||||||
Item,
|
|
||||||
ItemContent,
|
|
||||||
ItemGroup,
|
|
||||||
} from '@cfdm/ui/components/item'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -47,44 +43,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
|
|
||||||
interface BindingHealthConfig {
|
export type { ServiceBindingDraft }
|
||||||
enabled: boolean
|
|
||||||
type: HealthCheckType
|
|
||||||
port: number | null
|
|
||||||
path: string | null
|
|
||||||
expected_status: number | null
|
|
||||||
interval_sec: number
|
|
||||||
timeout_ms: number
|
|
||||||
verify_tls: boolean
|
|
||||||
provider: HealthProvider
|
|
||||||
providers: HealthProvider[]
|
|
||||||
aggregate: HealthAggregate
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceBindingDraft {
|
|
||||||
fqdn: string
|
|
||||||
record_type: 'A' | 'CNAME'
|
|
||||||
target_ips: string[]
|
|
||||||
target_cname: string
|
|
||||||
lb_mode: LbMode
|
|
||||||
health: BindingHealthConfig
|
|
||||||
target_ip_weights: Record<string, number>
|
|
||||||
target_ip_priorities: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultHealth: BindingHealthConfig = {
|
|
||||||
enabled: false,
|
|
||||||
type: 'tcp',
|
|
||||||
port: null,
|
|
||||||
path: null,
|
|
||||||
expected_status: null,
|
|
||||||
interval_sec: 30,
|
|
||||||
timeout_ms: 3000,
|
|
||||||
verify_tls: false,
|
|
||||||
provider: 'local',
|
|
||||||
providers: ['local'],
|
|
||||||
aggregate: 'majority',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceEditSheetProps {
|
interface ServiceEditSheetProps {
|
||||||
mode: 'create' | 'edit'
|
mode: 'create' | 'edit'
|
||||||
@@ -101,104 +60,19 @@ interface ServiceEditSheetProps {
|
|||||||
onDelete?: (id: number) => void
|
onDelete?: (id: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
|
||||||
return (service.domains ?? []).map((binding) => ({
|
|
||||||
fqdn: bindingToFqdn(binding),
|
|
||||||
record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'),
|
|
||||||
target_ips: binding.target_ips ?? [],
|
|
||||||
target_cname: binding.target_cname ?? '',
|
|
||||||
lb_mode: binding.lb_mode,
|
|
||||||
health: {
|
|
||||||
enabled: Boolean(binding.health_check_enabled),
|
|
||||||
type: binding.health_check_type === 'http' ? 'http' : 'tcp',
|
|
||||||
port: binding.health_check_port,
|
|
||||||
path: binding.health_check_path,
|
|
||||||
expected_status: binding.health_check_expected_status,
|
|
||||||
interval_sec: binding.health_check_interval_sec,
|
|
||||||
timeout_ms: binding.health_check_timeout_ms,
|
|
||||||
verify_tls: Boolean(binding.health_check_verify_tls),
|
|
||||||
provider: binding.health_check_provider ?? 'local',
|
|
||||||
providers: parseHealthProviders(
|
|
||||||
binding.health_check_providers,
|
|
||||||
binding.health_check_provider ?? 'local',
|
|
||||||
),
|
|
||||||
aggregate: binding.health_check_aggregate ?? 'majority',
|
|
||||||
},
|
|
||||||
target_ip_weights: binding.target_ip_weights ?? {},
|
|
||||||
target_ip_priorities: binding.target_ip_priorities ?? {},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
|
||||||
return bindings
|
|
||||||
.filter((binding) => {
|
|
||||||
if (!binding.fqdn.trim()) return false
|
|
||||||
if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
|
|
||||||
return binding.target_ips.length > 0
|
|
||||||
})
|
|
||||||
.map((binding) =>
|
|
||||||
binding.record_type === 'CNAME'
|
|
||||||
? {
|
|
||||||
fqdn: binding.fqdn.trim(),
|
|
||||||
target_cname: binding.target_cname.trim(),
|
|
||||||
lb_mode: binding.lb_mode,
|
|
||||||
health_check_enabled: binding.health.enabled,
|
|
||||||
health_check_type: binding.health.type,
|
|
||||||
health_check_port: binding.health.port,
|
|
||||||
health_check_path: binding.health.path,
|
|
||||||
health_check_expected_status: binding.health.expected_status,
|
|
||||||
health_check_interval_sec: binding.health.interval_sec,
|
|
||||||
health_check_timeout_ms: binding.health.timeout_ms,
|
|
||||||
health_check_verify_tls: binding.health.verify_tls,
|
|
||||||
health_check_provider: binding.health.provider,
|
|
||||||
health_check_providers: binding.health.providers,
|
|
||||||
health_check_aggregate: binding.health.aggregate,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
fqdn: binding.fqdn.trim(),
|
|
||||||
target_ips: binding.target_ips,
|
|
||||||
target_ip_weights: binding.target_ip_weights,
|
|
||||||
target_ip_priorities: binding.target_ip_priorities,
|
|
||||||
lb_mode: binding.lb_mode,
|
|
||||||
health_check_enabled: binding.health.enabled,
|
|
||||||
health_check_type: binding.health.type,
|
|
||||||
health_check_port: binding.health.port,
|
|
||||||
health_check_path: binding.health.path,
|
|
||||||
health_check_expected_status: binding.health.expected_status,
|
|
||||||
health_check_interval_sec: binding.health.interval_sec,
|
|
||||||
health_check_timeout_ms: binding.health.timeout_ms,
|
|
||||||
health_check_verify_tls: binding.health.verify_tls,
|
|
||||||
health_check_provider: binding.health.provider,
|
|
||||||
health_check_providers: binding.health.providers,
|
|
||||||
health_check_aggregate: binding.health.aggregate,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
|
|
||||||
return {
|
return {
|
||||||
fqdn,
|
enabled: next.enabled,
|
||||||
record_type: 'A',
|
type: next.type,
|
||||||
target_ips: [],
|
port: next.port,
|
||||||
target_cname: '',
|
path: next.path,
|
||||||
lb_mode: 'round_robin',
|
expected_status: next.expected_status,
|
||||||
health: { ...defaultHealth },
|
interval_sec: next.interval_sec,
|
||||||
target_ip_weights: {},
|
timeout_ms: next.timeout_ms,
|
||||||
target_ip_priorities: {},
|
verify_tls: next.verify_tls,
|
||||||
}
|
provider: next.provider,
|
||||||
}
|
providers: next.providers,
|
||||||
|
aggregate: next.aggregate,
|
||||||
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]),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,9 +93,11 @@ export function ServiceEditSheet({
|
|||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [slug, setSlug] = useState('')
|
const [slug, setSlug] = useState('')
|
||||||
const [serviceGroupId, setServiceGroupId] = useState('none')
|
const [serviceGroupId, setServiceGroupId] = useState('none')
|
||||||
const [ips, setIps] = useState<string[]>([])
|
const [address, setAddress] = useState<AddressBlockState>(() => emptyAddressBlock())
|
||||||
const [commonFqdn, setCommonFqdn] = useState('')
|
const [health, setHealth] = useState<BindingHealthConfig>(() => ({
|
||||||
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
...DEFAULT_BINDING_HEALTH,
|
||||||
|
}))
|
||||||
|
const [lbMode, setLbMode] = useState<LbAndHealthConfig['lb_mode']>('round_robin')
|
||||||
const [lbWeight, setLbWeight] = useState(1)
|
const [lbWeight, setLbWeight] = useState(1)
|
||||||
const [lbPriority, setLbPriority] = useState(1)
|
const [lbPriority, setLbPriority] = useState(1)
|
||||||
|
|
||||||
@@ -243,10 +119,10 @@ export function ServiceEditSheet({
|
|||||||
setServiceGroupId(
|
setServiceGroupId(
|
||||||
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
||||||
)
|
)
|
||||||
setIps(service.ips ?? [])
|
|
||||||
const drafts = toBindingDrafts(service)
|
const drafts = toBindingDrafts(service)
|
||||||
setBindings(drafts)
|
setAddress(hydrateAddressBlock(drafts, service.ips ?? []))
|
||||||
setCommonFqdn(drafts[0]?.fqdn ?? '')
|
setHealth(drafts[0]?.health ?? { ...DEFAULT_BINDING_HEALTH })
|
||||||
|
setLbMode(drafts[0]?.lb_mode ?? service.lb_mode ?? 'round_robin')
|
||||||
setLbWeight(service.lb_weight ?? 1)
|
setLbWeight(service.lb_weight ?? 1)
|
||||||
setLbPriority(service.lb_priority ?? 1)
|
setLbPriority(service.lb_priority ?? 1)
|
||||||
return
|
return
|
||||||
@@ -257,9 +133,9 @@ export function ServiceEditSheet({
|
|||||||
setServiceGroupId(
|
setServiceGroupId(
|
||||||
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
||||||
)
|
)
|
||||||
setIps([])
|
setAddress(emptyAddressBlock())
|
||||||
setCommonFqdn('')
|
setHealth({ ...DEFAULT_BINDING_HEALTH })
|
||||||
setBindings([])
|
setLbMode('round_robin')
|
||||||
setLbWeight(1)
|
setLbWeight(1)
|
||||||
setLbPriority(1)
|
setLbPriority(1)
|
||||||
}
|
}
|
||||||
@@ -270,136 +146,27 @@ export function ServiceEditSheet({
|
|||||||
[knownDomains],
|
[knownDomains],
|
||||||
)
|
)
|
||||||
|
|
||||||
const extraBindings = bindings.slice(1)
|
|
||||||
|
|
||||||
function handleCommonFqdnChange(value: string) {
|
|
||||||
setCommonFqdn(value)
|
|
||||||
setBindings((current) => {
|
|
||||||
if (current.length === 0) return current
|
|
||||||
return current.map((item, i) => (i === 0 ? { ...item, fqdn: value } : item))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddExtraBinding() {
|
|
||||||
setBindings((current) => {
|
|
||||||
const extra = withPoolIps(emptyBindingDraft(), ips)
|
|
||||||
if (current.length === 0) {
|
|
||||||
return [emptyBindingDraft(commonFqdn), extra]
|
|
||||||
}
|
|
||||||
return [...current, extra]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemoveExtraBinding(extraIndex: number) {
|
|
||||||
const index = extraIndex + 1
|
|
||||||
setBindings((current) => current.filter((_, i) => i !== index))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFqdnChange(index: number, fqdn: string) {
|
|
||||||
setBindings((current) =>
|
|
||||||
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRecordTypeChange(index: number, recordType: 'A' | 'CNAME') {
|
|
||||||
setBindings((current) =>
|
|
||||||
current.map((item, i) =>
|
|
||||||
i === index
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
record_type: recordType,
|
|
||||||
target_ips: recordType === 'A' ? item.target_ips : [],
|
|
||||||
target_cname: recordType === 'CNAME' ? item.target_cname : '',
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCnameChange(index: number, value: string) {
|
|
||||||
setBindings((current) =>
|
|
||||||
current.map((item, i) => (i === index ? { ...item, target_cname: value } : item)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleIpsChange(index: number, targetIps: string[]) {
|
|
||||||
setBindings((current) =>
|
|
||||||
current.map((item, i) =>
|
|
||||||
i === index
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
target_ips: targetIps,
|
|
||||||
target_ip_weights: Object.fromEntries(
|
|
||||||
targetIps.map((ip) => [ip, item.target_ip_weights[ip] ?? 1]),
|
|
||||||
),
|
|
||||||
target_ip_priorities: Object.fromEntries(
|
|
||||||
targetIps.map((ip) => [ip, item.target_ip_priorities[ip] ?? 1]),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
|
|
||||||
return {
|
|
||||||
enabled: next.enabled,
|
|
||||||
type: next.type,
|
|
||||||
port: next.port,
|
|
||||||
path: next.path,
|
|
||||||
expected_status: next.expected_status,
|
|
||||||
interval_sec: next.interval_sec,
|
|
||||||
timeout_ms: next.timeout_ms,
|
|
||||||
verify_tls: next.verify_tls,
|
|
||||||
provider: next.provider,
|
|
||||||
providers: next.providers,
|
|
||||||
aggregate: next.aggregate,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePrimaryHealthChange(next: LbAndHealthConfig) {
|
function handlePrimaryHealthChange(next: LbAndHealthConfig) {
|
||||||
const health = healthFromConfig(next)
|
setLbMode(next.lb_mode)
|
||||||
setBindings((current) => {
|
setHealth(healthFromConfig(next))
|
||||||
if (current.length === 0) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...withPoolIps(emptyBindingDraft(commonFqdn), ips),
|
|
||||||
lb_mode: next.lb_mode,
|
|
||||||
health,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return current.map((item, index) =>
|
|
||||||
index === 0 ? { ...item, lb_mode: next.lb_mode, health } : item,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryHealthValue: LbAndHealthConfig = {
|
const primaryHealthValue: LbAndHealthConfig = {
|
||||||
lb_mode: bindings[0]?.lb_mode ?? 'round_robin',
|
lb_mode: lbMode,
|
||||||
...(bindings[0]?.health ?? defaultHealth),
|
...health,
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveServiceGroupId(): number | null {
|
function resolveServiceGroupId(): number | null {
|
||||||
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncCommonDomain(current: ServiceBindingDraft[]): ServiceBindingDraft[] {
|
|
||||||
const trimmed = commonFqdn.trim()
|
|
||||||
if (!trimmed) return current
|
|
||||||
if (current.length === 0) {
|
|
||||||
return [withPoolIps(emptyBindingDraft(trimmed), ips)]
|
|
||||||
}
|
|
||||||
return current.map((item, index) => {
|
|
||||||
if (index !== 0) return item
|
|
||||||
return withPoolIps({ ...item, fqdn: trimmed }, ips)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
const syncedBindings = syncCommonDomain(bindings)
|
const ips = address.nodes.map((node) => node.ip)
|
||||||
const domains = buildDomainsPayload(syncedBindings)
|
const domains = toDomainsPayload(address, {
|
||||||
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
|
lb_mode: lbMode,
|
||||||
|
health,
|
||||||
|
})
|
||||||
|
const normalizedFqdns = domains.map((item) => item.fqdn.trim().toLowerCase())
|
||||||
const hasDuplicateFqdn =
|
const hasDuplicateFqdn =
|
||||||
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
||||||
if (hasDuplicateFqdn) {
|
if (hasDuplicateFqdn) {
|
||||||
@@ -443,6 +210,7 @@ export function ServiceEditSheet({
|
|||||||
const canSubmit = isCreate
|
const canSubmit = isCreate
|
||||||
? name.trim().length > 0 && slug.trim().length > 0
|
? name.trim().length > 0 && slug.trim().length > 0
|
||||||
: Boolean(service)
|
: Boolean(service)
|
||||||
|
const addressResetKey = `${mode}-${service?.id ?? 'new'}-${open ? 'open' : 'closed'}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -450,8 +218,8 @@ export function ServiceEditSheet({
|
|||||||
<SheetHeader className="shrink-0 border-b pb-4">
|
<SheetHeader className="shrink-0 border-b pb-4">
|
||||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Общий домен и IP задаются у сервиса. Дополнительные FQDN — ниже, зона
|
Общие FQDN на весь пул IP. У каждого адреса можно указать свой доп.
|
||||||
определяется автоматически.
|
FQDN.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -498,33 +266,16 @@ export function ServiceEditSheet({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="edit-service-common-domain">
|
|
||||||
Общий домен (FQDN)
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="edit-service-common-domain"
|
|
||||||
className="font-mono"
|
|
||||||
value={commonFqdn}
|
|
||||||
placeholder={
|
|
||||||
zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'
|
|
||||||
}
|
|
||||||
onChange={(e) => handleCommonFqdnChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
|
||||||
<TaggedInput
|
|
||||||
id="edit-service-ips"
|
|
||||||
value={ips}
|
|
||||||
onChange={setIps}
|
|
||||||
placeholder="192.168.1.1"
|
|
||||||
validate={isValidIpv4}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ServiceAddressBlock
|
||||||
|
key={addressResetKey}
|
||||||
|
value={address}
|
||||||
|
onChange={setAddress}
|
||||||
|
zoneHints={zoneHints}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<h3 className="text-sm font-medium">Health check</h3>
|
<h3 className="text-sm font-medium">Health check</h3>
|
||||||
<HealthCheckConfigFields
|
<HealthCheckConfigFields
|
||||||
@@ -533,127 +284,6 @@ export function ServiceEditSheet({
|
|||||||
onChange={handlePrimaryHealthChange}
|
onChange={handlePrimaryHealthChange}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<h3 className="text-sm font-medium">Доп. FQDN</h3>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleAddExtraBinding}
|
|
||||||
>
|
|
||||||
<PlusIcon data-icon="inline-start" />
|
|
||||||
Добавить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{extraBindings.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Нет дополнительных FQDN
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ItemGroup className="gap-2">
|
|
||||||
{extraBindings.map((binding, extraIndex) => {
|
|
||||||
const index = extraIndex + 1
|
|
||||||
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
|
||||||
return (
|
|
||||||
<Item
|
|
||||||
key={`extra-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={() => handleRemoveExtraBinding(extraIndex)}
|
|
||||||
>
|
|
||||||
<Trash2Icon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
|
|
||||||
<Input
|
|
||||||
id={`extra-fqdn-${index}`}
|
|
||||||
className="font-mono"
|
|
||||||
value={binding.fqdn}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleFqdnChange(index, 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={(value) =>
|
|
||||||
handleRecordTypeChange(
|
|
||||||
index,
|
|
||||||
(value ?? 'A') as 'A' | 'CNAME',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id={`extra-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={`extra-cname-${index}`}
|
|
||||||
value={binding.target_cname}
|
|
||||||
placeholder="mmsk.rkns.top"
|
|
||||||
onChange={(event) =>
|
|
||||||
handleCnameChange(index, event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ServiceBindingIpInput
|
|
||||||
id={`extra-ip-${index}`}
|
|
||||||
value={binding.target_ips}
|
|
||||||
pool={ips}
|
|
||||||
onChange={(targetIps) =>
|
|
||||||
handleIpsChange(index, targetIps)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ItemGroup>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
|
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { dedupeBreadcrumbs, getBreadcrumbs } from './breadcrumbs'
|
||||||
|
|
||||||
|
describe('getBreadcrumbs', () => {
|
||||||
|
it('keeps a single Настройки parent plus the active section', () => {
|
||||||
|
expect(getBreadcrumbs('/settings/appearance')).toEqual([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||||
|
])
|
||||||
|
expect(getBreadcrumbs('/settings/health')).toEqual([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Health-check', href: '/settings/health' },
|
||||||
|
])
|
||||||
|
expect(getBreadcrumbs('/settings/integrations')).toEqual([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Интеграции', href: '/settings/integrations' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reuse the section href for the parent crumb', () => {
|
||||||
|
const crumbs = getBreadcrumbs('/settings/appearance')
|
||||||
|
const hrefs = crumbs.map((crumb) => crumb.href)
|
||||||
|
expect(new Set(hrefs).size).toBe(hrefs.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dedupeBreadcrumbs', () => {
|
||||||
|
it('collapses stacked identical labels from repeated navigations', () => {
|
||||||
|
expect(
|
||||||
|
dedupeBreadcrumbs([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Настройки', href: '/settings/appearance' },
|
||||||
|
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export interface BreadcrumbCrumb {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeTitles: Record<string, string> = {
|
||||||
|
'/': 'Панель управления',
|
||||||
|
'/domains': 'Домены',
|
||||||
|
'/groups': 'Группы доменов',
|
||||||
|
'/services': 'Сервисы',
|
||||||
|
'/certificates': 'Сертификаты',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTINGS_SECTIONS: Record<string, string> = {
|
||||||
|
'/settings/appearance': 'Внешний вид',
|
||||||
|
'/settings/health': 'Health-check',
|
||||||
|
'/settings/integrations': 'Интеграции',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
|
||||||
|
export function dedupeBreadcrumbs(crumbs: BreadcrumbCrumb[]): BreadcrumbCrumb[] {
|
||||||
|
const out: BreadcrumbCrumb[] = []
|
||||||
|
for (const crumb of crumbs) {
|
||||||
|
const prev = out.at(-1)
|
||||||
|
if (prev && prev.label === crumb.label) continue
|
||||||
|
out.push(crumb)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBreadcrumbs(
|
||||||
|
pathname: string,
|
||||||
|
dynamicLabels: Record<string, string> = {},
|
||||||
|
): BreadcrumbCrumb[] {
|
||||||
|
const path = pathname.replace(/\/+$/, '') || '/'
|
||||||
|
|
||||||
|
if (path === '/') {
|
||||||
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.match(/^\/services\/\d+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Сервисы', href: '/services' },
|
||||||
|
{ label: dynamicLabels[path] ?? 'Сервис', href: path },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.match(/^\/groups\/\d+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Группы доменов', href: '/groups' },
|
||||||
|
{ label: dynamicLabels[path] ?? 'Группа', href: path },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.match(/^\/domains\/\d+\/dns$/)) {
|
||||||
|
const domainId = path.split('/')[2]
|
||||||
|
const domainPath = `/domains/${domainId}`
|
||||||
|
return [
|
||||||
|
{ label: 'Домены', href: '/domains' },
|
||||||
|
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
|
||||||
|
{ label: 'DNS', href: path },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.match(/^\/domains\/\d+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Домены', href: '/domains' },
|
||||||
|
{ label: dynamicLabels[path] ?? 'Обзор домена', href: path },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/settings' || path.startsWith('/settings/')) {
|
||||||
|
const section = SETTINGS_SECTIONS[path]
|
||||||
|
return dedupeBreadcrumbs([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
...(section ? [{ label: section, href: path }] : []),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = routeTitles[path]
|
||||||
|
if (title) {
|
||||||
|
return [{ label: title, href: path }]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_BINDING_HEALTH,
|
||||||
|
addAddressNode,
|
||||||
|
addCommonFqdn,
|
||||||
|
emptyAddressBlock,
|
||||||
|
emptyBindingDraft,
|
||||||
|
hydrateAddressBlock,
|
||||||
|
removeAddressNode,
|
||||||
|
toAddressBindings,
|
||||||
|
toDomainsPayload,
|
||||||
|
type ServiceBindingDraft,
|
||||||
|
} from '@/lib/service-address'
|
||||||
|
|
||||||
|
const primaryMeta = {
|
||||||
|
lb_mode: 'round_robin' as const,
|
||||||
|
health: { ...DEFAULT_BINDING_HEALTH, enabled: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
function aRecord(
|
||||||
|
fqdn: string,
|
||||||
|
target_ips: string[],
|
||||||
|
overrides: Partial<ServiceBindingDraft> = {},
|
||||||
|
): ServiceBindingDraft {
|
||||||
|
return {
|
||||||
|
...emptyBindingDraft(fqdn),
|
||||||
|
record_type: 'A',
|
||||||
|
target_ips,
|
||||||
|
target_ip_weights: Object.fromEntries(target_ips.map((ip) => [ip, 1])),
|
||||||
|
target_ip_priorities: Object.fromEntries(target_ips.map((ip) => [ip, 1])),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('hydrateAddressBlock', () => {
|
||||||
|
it('схлопывает extra A с одним IP пула в extraFqdn узла (MSK Macloud)', () => {
|
||||||
|
const drafts = [
|
||||||
|
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
|
||||||
|
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
|
||||||
|
]
|
||||||
|
|
||||||
|
const state = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
|
||||||
|
|
||||||
|
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.preservedBindings).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('кладёт A на весь пул в commonFqdns, CNAME — в preserved', () => {
|
||||||
|
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', '2.2.2.2']),
|
||||||
|
aRecord('both.rkns.top', ['1.1.1.1', '2.2.2.2']),
|
||||||
|
cname,
|
||||||
|
]
|
||||||
|
|
||||||
|
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.preservedBindings.map((item) => item.fqdn)).toEqual(['alias.rkns.top'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('кладёт extra A с IP вне пула в preservedBindings', () => {
|
||||||
|
const drafts = [
|
||||||
|
aRecord('gw.example.com', ['10.0.0.1']),
|
||||||
|
aRecord('edge.example.com', ['8.8.8.8']),
|
||||||
|
]
|
||||||
|
|
||||||
|
const state = hydrateAddressBlock(drafts, ['10.0.0.1'])
|
||||||
|
|
||||||
|
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdn: '' }])
|
||||||
|
expect(state.commonFqdns).toEqual(['gw.example.com'])
|
||||||
|
expect(state.preservedBindings).toHaveLength(1)
|
||||||
|
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toDomainsPayload', () => {
|
||||||
|
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']),
|
||||||
|
]
|
||||||
|
const state = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
|
||||||
|
const payload = toDomainsPayload(state, primaryMeta)
|
||||||
|
|
||||||
|
expect(payload).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
fqdn: 'rutg.rkns.top',
|
||||||
|
target_ips: ['93.115.203.183', '185.244.181.61'],
|
||||||
|
health_check_enabled: true,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
fqdn: 'msk.rutg.rkns.top',
|
||||||
|
target_ips: ['93.115.203.183'],
|
||||||
|
health_check_enabled: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('круг hydrate → payload → hydrate сохраняет два common и extra FQDN', () => {
|
||||||
|
const drafts = [
|
||||||
|
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.commonFqdns).toEqual(first.commonFqdns)
|
||||||
|
expect(second.nodes).toEqual(first.nodes)
|
||||||
|
expect(second.preservedBindings).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('removeAddressNode', () => {
|
||||||
|
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('edge.example.com', ['9.9.9.9', '10.0.0.1']),
|
||||||
|
],
|
||||||
|
['10.0.0.1', '10.0.0.2'],
|
||||||
|
)
|
||||||
|
|
||||||
|
const next = removeAddressNode(state, '10.0.0.1')
|
||||||
|
|
||||||
|
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdn: '' }])
|
||||||
|
expect(next.preservedBindings).toHaveLength(1)
|
||||||
|
expect(next.preservedBindings[0]?.target_ips).toEqual(['9.9.9.9'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('addAddressNode / addCommonFqdn', () => {
|
||||||
|
it('не добавляет дубликат IP', () => {
|
||||||
|
const withIp = addAddressNode(
|
||||||
|
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdn: '' }] },
|
||||||
|
'1.1.1.1',
|
||||||
|
)
|
||||||
|
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 / 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', '2.2.2.2']),
|
||||||
|
aRecord('msk.rkns.top', ['1.1.1.1']),
|
||||||
|
cname,
|
||||||
|
]
|
||||||
|
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
|
||||||
|
expect(state.nodes[0]?.extraFqdn).toBe('msk.rkns.top')
|
||||||
|
expect(state.preservedBindings).toHaveLength(1)
|
||||||
|
|
||||||
|
const payload = toDomainsPayload(state, primaryMeta)
|
||||||
|
expect(payload.map((item) => item.fqdn)).toEqual([
|
||||||
|
'rutg.rkns.top',
|
||||||
|
'msk.rkns.top',
|
||||||
|
'alias.rkns.top',
|
||||||
|
])
|
||||||
|
expect(payload[2]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
fqdn: 'alias.rkns.top',
|
||||||
|
target_cname: 'rutg.rkns.top',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { parseHealthProviders } from '@cfdm/shared'
|
||||||
|
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
import { bindingToFqdn } from '@/lib/parse-fqdn'
|
||||||
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
|
|
||||||
|
export type AddressLbMode = 'round_robin' | 'failover' | 'weighted'
|
||||||
|
export type AddressHealthCheckType = 'tcp' | 'http'
|
||||||
|
|
||||||
|
export interface BindingHealthConfig {
|
||||||
|
enabled: boolean
|
||||||
|
type: AddressHealthCheckType
|
||||||
|
port: number | null
|
||||||
|
path: string | null
|
||||||
|
expected_status: number | null
|
||||||
|
interval_sec: number
|
||||||
|
timeout_ms: number
|
||||||
|
verify_tls: boolean
|
||||||
|
provider: HealthCheckProvider
|
||||||
|
providers: HealthCheckProvider[]
|
||||||
|
aggregate: HealthCheckAggregate
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceBindingDraft {
|
||||||
|
fqdn: string
|
||||||
|
record_type: 'A' | 'CNAME'
|
||||||
|
target_ips: string[]
|
||||||
|
target_cname: string
|
||||||
|
lb_mode: AddressLbMode
|
||||||
|
health: BindingHealthConfig
|
||||||
|
target_ip_weights: Record<string, number>
|
||||||
|
target_ip_priorities: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressNode {
|
||||||
|
ip: string
|
||||||
|
extraFqdn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressBlockState {
|
||||||
|
commonFqdns: string[]
|
||||||
|
nodes: AddressNode[]
|
||||||
|
preservedBindings: ServiceBindingDraft[]
|
||||||
|
target_ip_weights: Record<string, number>
|
||||||
|
target_ip_priorities: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressPrimaryMeta {
|
||||||
|
lb_mode: AddressLbMode
|
||||||
|
health: BindingHealthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_BINDING_HEALTH: BindingHealthConfig = {
|
||||||
|
enabled: false,
|
||||||
|
type: 'tcp',
|
||||||
|
port: null,
|
||||||
|
path: null,
|
||||||
|
expected_status: null,
|
||||||
|
interval_sec: 30,
|
||||||
|
timeout_ms: 3000,
|
||||||
|
verify_tls: false,
|
||||||
|
provider: 'local',
|
||||||
|
providers: ['local'],
|
||||||
|
aggregate: 'majority',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
|
||||||
|
return {
|
||||||
|
fqdn,
|
||||||
|
record_type: 'A',
|
||||||
|
target_ips: [],
|
||||||
|
target_cname: '',
|
||||||
|
lb_mode: 'round_robin',
|
||||||
|
health: { ...DEFAULT_BINDING_HEALTH },
|
||||||
|
target_ip_weights: {},
|
||||||
|
target_ip_priorities: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyAddressBlock(): AddressBlockState {
|
||||||
|
return {
|
||||||
|
commonFqdns: [],
|
||||||
|
nodes: [],
|
||||||
|
preservedBindings: [],
|
||||||
|
target_ip_weights: {},
|
||||||
|
target_ip_priorities: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueIps(...lists: string[][]): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const ip of list) {
|
||||||
|
const trimmed = ip.trim()
|
||||||
|
if (!trimmed || seen.has(trimmed)) continue
|
||||||
|
seen.add(trimmed)
|
||||||
|
out.push(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function omitKey(record: Record<string, number>, key: string): Record<string, number> {
|
||||||
|
const next = { ...record }
|
||||||
|
delete next[key]
|
||||||
|
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),
|
||||||
|
record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'),
|
||||||
|
target_ips: binding.target_ips ?? [],
|
||||||
|
target_cname: binding.target_cname ?? '',
|
||||||
|
lb_mode: binding.lb_mode,
|
||||||
|
health: {
|
||||||
|
enabled: Boolean(binding.health_check_enabled),
|
||||||
|
type: binding.health_check_type === 'http' ? 'http' : 'tcp',
|
||||||
|
port: binding.health_check_port,
|
||||||
|
path: binding.health_check_path,
|
||||||
|
expected_status: binding.health_check_expected_status,
|
||||||
|
interval_sec: binding.health_check_interval_sec,
|
||||||
|
timeout_ms: binding.health_check_timeout_ms,
|
||||||
|
verify_tls: Boolean(binding.health_check_verify_tls),
|
||||||
|
provider: binding.health_check_provider ?? 'local',
|
||||||
|
providers: parseHealthProviders(
|
||||||
|
binding.health_check_providers,
|
||||||
|
binding.health_check_provider ?? 'local',
|
||||||
|
),
|
||||||
|
aggregate: binding.health_check_aggregate ?? 'majority',
|
||||||
|
},
|
||||||
|
target_ip_weights: binding.target_ip_weights ?? {},
|
||||||
|
target_ip_priorities: binding.target_ip_priorities ?? {},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 preservedBindings: ServiceBindingDraft[] = []
|
||||||
|
let weights: Record<string, number> = {}
|
||||||
|
let priorities: Record<string, number> = {}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
commonFqdns,
|
||||||
|
nodes: ips.map((ip) => ({
|
||||||
|
ip,
|
||||||
|
extraFqdn: extraByIp.get(ip) ?? '',
|
||||||
|
})),
|
||||||
|
preservedBindings,
|
||||||
|
target_ip_weights: weights,
|
||||||
|
target_ip_priorities: priorities,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneIpFromBindings(
|
||||||
|
bindings: ServiceBindingDraft[],
|
||||||
|
ip: string,
|
||||||
|
): ServiceBindingDraft[] {
|
||||||
|
return bindings.flatMap((binding) => {
|
||||||
|
if (binding.record_type !== 'A') return [binding]
|
||||||
|
if (!binding.target_ips.includes(ip)) return [binding]
|
||||||
|
const target_ips = binding.target_ips.filter((item) => item !== ip)
|
||||||
|
if (target_ips.length === 0) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...binding,
|
||||||
|
target_ips,
|
||||||
|
target_ip_weights: omitKey(binding.target_ip_weights, ip),
|
||||||
|
target_ip_priorities: omitKey(binding.target_ip_priorities, ip),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeAddressNode(state: AddressBlockState, ip: string): AddressBlockState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
nodes: state.nodes.filter((node) => node.ip !== ip),
|
||||||
|
preservedBindings: pruneIpFromBindings(state.preservedBindings, ip),
|
||||||
|
target_ip_weights: omitKey(state.target_ip_weights, ip),
|
||||||
|
target_ip_priorities: omitKey(state.target_ip_priorities, ip),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addAddressNode(state: AddressBlockState, ip: string): AddressBlockState {
|
||||||
|
const trimmed = ip.trim()
|
||||||
|
if (!trimmed || state.nodes.some((node) => node.ip === trimmed)) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
nodes: [...state.nodes, { ip: trimmed, extraFqdn: '' }],
|
||||||
|
target_ip_weights: { ...state.target_ip_weights, [trimmed]: 1 },
|
||||||
|
target_ip_priorities: { ...state.target_ip_priorities, [trimmed]: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
): ServiceBindingDraft[] {
|
||||||
|
const ips = state.nodes.map((node) => node.ip)
|
||||||
|
const weights = Object.fromEntries(
|
||||||
|
ips.map((ip) => [ip, state.target_ip_weights[ip] ?? 1]),
|
||||||
|
)
|
||||||
|
const priorities = Object.fromEntries(
|
||||||
|
ips.map((ip) => [ip, state.target_ip_priorities[ip] ?? 1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const drafts: ServiceBindingDraft[] = []
|
||||||
|
for (const raw of state.commonFqdns) {
|
||||||
|
const fqdn = raw.trim()
|
||||||
|
if (!fqdn || ips.length === 0) continue
|
||||||
|
drafts.push({
|
||||||
|
fqdn,
|
||||||
|
record_type: 'A',
|
||||||
|
target_ips: ips,
|
||||||
|
target_cname: '',
|
||||||
|
lb_mode: primary.lb_mode,
|
||||||
|
health: { ...primary.health },
|
||||||
|
target_ip_weights: weights,
|
||||||
|
target_ip_priorities: priorities,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of state.nodes) {
|
||||||
|
const extraFqdn = node.extraFqdn.trim()
|
||||||
|
if (!extraFqdn) continue
|
||||||
|
drafts.push({
|
||||||
|
fqdn: extraFqdn,
|
||||||
|
record_type: 'A',
|
||||||
|
target_ips: [node.ip],
|
||||||
|
target_cname: '',
|
||||||
|
lb_mode: primary.lb_mode,
|
||||||
|
health: { ...primary.health },
|
||||||
|
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
|
||||||
|
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
drafts.push(...state.preservedBindings)
|
||||||
|
return drafts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||||
|
return bindings
|
||||||
|
.filter((binding) => {
|
||||||
|
if (!binding.fqdn.trim()) return false
|
||||||
|
if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
|
||||||
|
return binding.target_ips.length > 0
|
||||||
|
})
|
||||||
|
.map((binding) =>
|
||||||
|
binding.record_type === 'CNAME'
|
||||||
|
? {
|
||||||
|
fqdn: binding.fqdn.trim(),
|
||||||
|
target_cname: binding.target_cname.trim(),
|
||||||
|
lb_mode: binding.lb_mode,
|
||||||
|
health_check_enabled: binding.health.enabled,
|
||||||
|
health_check_type: binding.health.type,
|
||||||
|
health_check_port: binding.health.port,
|
||||||
|
health_check_path: binding.health.path,
|
||||||
|
health_check_expected_status: binding.health.expected_status,
|
||||||
|
health_check_interval_sec: binding.health.interval_sec,
|
||||||
|
health_check_timeout_ms: binding.health.timeout_ms,
|
||||||
|
health_check_verify_tls: binding.health.verify_tls,
|
||||||
|
health_check_provider: binding.health.provider,
|
||||||
|
health_check_providers: binding.health.providers,
|
||||||
|
health_check_aggregate: binding.health.aggregate,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
fqdn: binding.fqdn.trim(),
|
||||||
|
target_ips: binding.target_ips,
|
||||||
|
target_ip_weights: binding.target_ip_weights,
|
||||||
|
target_ip_priorities: binding.target_ip_priorities,
|
||||||
|
lb_mode: binding.lb_mode,
|
||||||
|
health_check_enabled: binding.health.enabled,
|
||||||
|
health_check_type: binding.health.type,
|
||||||
|
health_check_port: binding.health.port,
|
||||||
|
health_check_path: binding.health.path,
|
||||||
|
health_check_expected_status: binding.health.expected_status,
|
||||||
|
health_check_interval_sec: binding.health.interval_sec,
|
||||||
|
health_check_timeout_ms: binding.health.timeout_ms,
|
||||||
|
health_check_verify_tls: binding.health.verify_tls,
|
||||||
|
health_check_provider: binding.health.provider,
|
||||||
|
health_check_providers: binding.health.providers,
|
||||||
|
health_check_aggregate: binding.health.aggregate,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDomainsPayload(
|
||||||
|
state: AddressBlockState,
|
||||||
|
primary: AddressPrimaryMeta,
|
||||||
|
) {
|
||||||
|
return buildDomainsPayload(toAddressBindings(state, primary))
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
||||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
|
||||||
import { FormFieldSimple } from '@/components/form-field'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
@@ -29,15 +28,9 @@ import {
|
|||||||
import { LbModeTile } from '@/components/services/service-unit-card'
|
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||||
import {
|
import {
|
||||||
KpiStatGrid,
|
KpiStatGrid,
|
||||||
|
ServiceFailoverPanel,
|
||||||
ServiceHealthMonitor,
|
ServiceHealthMonitor,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import {
|
import {
|
||||||
enabledHealthProviders,
|
enabledHealthProviders,
|
||||||
@@ -84,6 +77,7 @@ interface OverviewPayload {
|
|||||||
priority: number
|
priority: number
|
||||||
consecutive_failures: number
|
consecutive_failures: number
|
||||||
last_failure_reason: string | null
|
last_failure_reason: string | null
|
||||||
|
last_check_at?: string | null
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,21 +203,7 @@ function ServiceDetailPage() {
|
|||||||
const isError = viewQuery.isError || overviewQuery.isError
|
const isError = viewQuery.isError || overviewQuery.isError
|
||||||
const error = viewQuery.error ?? overviewQuery.error
|
const error = viewQuery.error ?? overviewQuery.error
|
||||||
|
|
||||||
const failoverEvents =
|
const failoverNodes = nodes.length > 0 ? nodes : (overview?.nodes ?? [])
|
||||||
(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 enabledProviders = useMemo(
|
const enabledProviders = useMemo(
|
||||||
() => enabledHealthProviders(service?.domains ?? []),
|
() => enabledHealthProviders(service?.domains ?? []),
|
||||||
@@ -333,17 +313,7 @@ function ServiceDetailPage() {
|
|||||||
statuses={providerStatuses}
|
statuses={providerStatuses}
|
||||||
isLoading={logQuery.isLoading}
|
isLoading={logQuery.isLoading}
|
||||||
/>
|
/>
|
||||||
<Frame dense spacing="sm" className="min-w-0 w-full">
|
<ServiceFailoverPanel nodes={failoverNodes} />
|
||||||
<FrameHeader>
|
|
||||||
<FrameTitle>Failover</FrameTitle>
|
|
||||||
<FrameDescription>
|
|
||||||
Нездоровые ноды и причины последней ошибки
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel>
|
|
||||||
<FailoverTimeline events={failoverEvents} />
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{service.ips.length === 0 && service.domains.length === 0 ? (
|
{service.ips.length === 0 && service.domains.length === 0 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user