Compare commits

...
5 Commits
Author SHA1 Message Date
DenozordecandCursor 4c59780ff0 fix(health): импортировать isHealthy при нескольких FQDN на одни IP
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / api (push) Successful in 42s
CD / quality (push) Successful in 54s
CD / publish (push) Successful in 1m36s
Схлопывание дубликатов в группе вызывало ReferenceError при сохранении сервиса.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:01:43 +07:00
DenozordecandCursor 1457389ae7 feat(services): объединить адреса сервиса в один блок формы
CD / update-wiki (push) Successful in 7s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m6s
CD / publish (push) Successful in 1m36s
Общий FQDN, пул IP и доп. домен на строке IP — в одном Frame, без смены API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:33:02 +07:00
DenozordecandCursor 153be28799 fix(health): показывать пинг при наведении на график uptime
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
CD / quality (push) Successful in 1m23s
CD / publish (push) Successful in 1m57s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:44:17 +07:00
DenozordecandCursor 39fac7834f fix(ui): не дублировать крошку Настройки при смене раздела
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m15s
CD / publish (push) Successful in 1m58s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:28:06 +07:00
DenozordecandCursor d267e40157 fix(health): выровнять график источников и KPI на карточке сервиса
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:27:21 +07:00
14 changed files with 1541 additions and 628 deletions
@@ -29,6 +29,7 @@ import * as domainService from "./domain-service.js";
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
import {
isHealthy,
selectActiveIpsByMode,
withBindingLock,
type LbIpRow,
@@ -78,4 +78,53 @@ describe("service bindings prune", () => {
expect(bindings).toHaveLength(2);
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"]);
});
});
+10 -77
View File
@@ -1,5 +1,5 @@
import { Fragment, useMemo } from 'react'
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
import { useMemo } from 'react'
import {
Breadcrumb,
BreadcrumbItem,
@@ -12,82 +12,12 @@ import { Separator } from '@cfdm/ui/components/separator'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
import { getBreadcrumbs } from '@/lib/breadcrumbs'
export interface RouteBreadcrumbLoaderData {
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() {
const matches = useMatches()
return useMemo(() => {
@@ -106,7 +36,10 @@ function useDynamicBreadcrumbLabels() {
export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const dynamicLabels = useDynamicBreadcrumbLabels()
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
const crumbs = useMemo(
() => getBreadcrumbs(pathname, dynamicLabels),
[pathname, dynamicLabels],
)
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">
@@ -117,10 +50,10 @@ export function SiteHeader() {
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return (
<span key={crumb.href} className="contents">
{index > 0 && (
<Fragment key={`${index}-${crumb.href}`}>
{index > 0 ? (
<BreadcrumbSeparator className="hidden md:block" />
)}
) : null}
<BreadcrumbItem
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
>
@@ -132,7 +65,7 @@ export function SiteHeader() {
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
</Fragment>
)
})}
</BreadcrumbList>
@@ -298,45 +298,50 @@ export function HealthSourceFilterBar({
const active = selected.length > 0 ? selected : enabled
return (
<ToggleGroup
multiple
variant="outline"
size="sm"
className="w-full min-w-0"
value={active}
aria-label="Тип пробы"
onValueChange={(next) => {
const values = next.filter((value): value is HealthProvider =>
visible.some((item) => item.id === value),
)
if (values.length === 0) return
onChange(values)
}}
>
{visible.map((item) => (
<ToggleGroupItem
key={item.id}
value={item.id}
aria-label={item.title}
className="min-w-0 flex-1 gap-1.5"
>
<IconTile
variant="elevated"
size="xs"
className={item.iconClassName}
aria-hidden="true"
<div className="@container min-w-0 w-full">
<ToggleGroup
multiple
variant="outline"
size="sm"
className="flex w-full min-w-0 flex-wrap justify-start"
value={active}
aria-label="Тип пробы"
onValueChange={(next) => {
const values = next.filter((value): value is HealthProvider =>
visible.some((item) => item.id === value),
)
if (values.length === 0) return
onChange(values)
}}
>
{visible.map((item) => (
<ToggleGroupItem
key={item.id}
value={item.id}
aria-label={item.title}
title={item.title}
className="max-w-full min-w-0 flex-none justify-start gap-1.5 @[16rem]:min-w-[8.5rem]"
>
{item.icon}
</IconTile>
<span className="truncate">{item.title}</span>
<HealthCheckBadge
status={statuses[item.id] ?? 'unknown'}
provider={item.id}
size="xs"
/>
</ToggleGroupItem>
))}
</ToggleGroup>
<IconTile
variant="elevated"
size="xs"
className={item.iconClassName}
aria-hidden="true"
>
{item.icon}
</IconTile>
<span className="hidden min-w-0 truncate @[16rem]:inline">
{item.title}
</span>
<HealthCheckBadge
status={statuses[item.id] ?? 'unknown'}
provider={item.id}
size="xs"
/>
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)
}
@@ -28,3 +28,4 @@ export {
type HealthProvider,
type HealthAggregate,
} 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 (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
{item.hint}
</Badge>
)
@@ -81,30 +81,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const valueVariant = item.variant ?? 'default'
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 ? (
<IconTile
variant="elevated"
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}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div>
<div
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],
)}
>
{item.value}
</div>
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
</div>
</div>
)
@@ -116,7 +125,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
const selected = isSelected(item)
return cn(
'relative isolate flex h-full flex-col',
'relative isolate flex h-full min-w-0 flex-col',
clickable &&
'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',
@@ -0,0 +1,437 @@
import { useState, type KeyboardEvent } from 'react'
import { PlusIcon, ServerIcon, Trash2Icon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import { isValidIpv4 } from '@/components/tagged-input'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { parseFqdn } from '@/lib/parse-fqdn'
import {
addAddressNode,
emptyBindingDraft,
removeAddressNode,
withPoolIps,
type AddressBlockState,
type ServiceBindingDraft,
} from '@/lib/service-address'
import { Button } from '@cfdm/ui/components/button'
import { Field, FieldLabel } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@cfdm/ui/components/input-group'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
/**
* Единый блок адресов сервиса: общий 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 [otherOpen, setOtherOpen] = useState(value.otherBindings.length > 0)
const pool = value.nodes.map((node) => node.ip)
const parsedCommon = parseFqdn(value.commonFqdn, zoneHints)
const showOthers = otherOpen || value.otherBindings.length > 0
const pendingTrimmed = pendingIp.trim()
const pendingInvalid =
ipInvalid && pendingTrimmed.length > 0 && !isValidIpv4(pendingTrimmed)
function handleCommonFqdn(next: string) {
onChange({ ...value, commonFqdn: next })
}
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 handlePendingKeyDown(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,
),
})
}
function handleRemoveIp(ip: string) {
onChange(removeAddressNode(value, ip))
}
function handleAddOther() {
setOtherOpen(true)
onChange({
...value,
otherBindings: [...value.otherBindings, withPoolIps(emptyBindingDraft(), pool)],
})
}
function handleOtherChange(index: number, next: ServiceBindingDraft) {
onChange({
...value,
otherBindings: value.otherBindings.map((item, i) => (i === index ? next : item)),
})
}
function handleRemoveOther(index: number) {
const otherBindings = value.otherBindings.filter((_, i) => i !== index)
onChange({ ...value, otherBindings })
if (otherBindings.length === 0) setOtherOpen(false)
}
return (
<Frame stacked dense spacing="sm" className="w-full min-w-0">
<FramePanel fit className="flex flex-col gap-3">
<FrameHeader className="px-0 pt-0">
<FrameTitle>Адреса</FrameTitle>
<FrameDescription>
Общий FQDN на весь пул · у каждого IP свой доп. домен
</FrameDescription>
</FrameHeader>
<Field>
<FieldLabel htmlFor="service-common-fqdn">Общий домен (FQDN)</FieldLabel>
<InputGroup>
<InputGroupInput
id="service-common-fqdn"
className="font-mono"
value={value.commonFqdn}
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
onChange={(event) => handleCommonFqdn(event.target.value)}
/>
{parsedCommon ? (
<InputGroupAddon align="inline-end">
<Badge variant="outline" size="xs" className="font-mono">
{parsedCommon.zoneName}
</Badge>
</InputGroupAddon>
) : value.commonFqdn.trim() ? (
<InputGroupAddon align="inline-end">
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
</InputGroupAddon>
) : null}
</InputGroup>
</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) => {
const parsedExtra = parseFqdn(node.extraFqdn, zoneHints)
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={() => handleRemoveIp(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)
}
/>
{parsedExtra ? (
<InputGroupAddon align="inline-end">
<Badge variant="outline" size="xs" className="font-mono">
{parsedExtra.zoneName}
</Badge>
</InputGroupAddon>
) : node.extraFqdn.trim() ? (
<InputGroupAddon align="inline-end">
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
</InputGroupAddon>
) : null}
</InputGroup>
</Field>
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
<InputGroup>
<InputGroupInput
id="service-pool-ip-add"
className="font-mono"
value={pendingIp}
placeholder="192.168.1.1"
aria-invalid={pendingInvalid || undefined}
onChange={(event) => {
setPendingIp(event.target.value)
setIpInvalid(false)
}}
onKeyDown={handlePendingKeyDown}
onBlur={() => tryAddIp(pendingIp)}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton size="sm" onClick={() => tryAddIp(pendingIp)}>
Добавить
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
{showOthers ? null : (
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleAddOther}
>
<PlusIcon data-icon="inline-start" />
Другой FQDN
</Button>
</div>
)}
</FramePanel>
{showOthers ? (
<FramePanel fit className="flex flex-col gap-3">
<FrameHeader className="flex flex-row items-start justify-between gap-2 px-0 pt-0">
<div className="flex min-w-0 flex-col gap-1">
<FrameTitle>Другие FQDN</FrameTitle>
<FrameDescription>CNAME и A не 1:1 с IP пула</FrameDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleAddOther}>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</FrameHeader>
{value.otherBindings.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет дополнительных FQDN</p>
) : (
<ItemGroup className="gap-2">
{value.otherBindings.map((binding, index) => {
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
return (
<Item
key={`other-binding-${index}`}
variant="outline"
size="sm"
className="items-stretch"
>
<ItemContent className="flex w-full min-w-0 flex-col gap-2">
<div className="flex items-center gap-2">
{parsedZone ? (
<Badge variant="outline" size="xs" className="font-mono">
{parsedZone.zoneName}
</Badge>
) : binding.fqdn.trim() ? (
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
) : (
<span className="text-muted-foreground text-xs">FQDN</span>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
className="ml-auto shrink-0"
aria-label="Удалить FQDN"
onClick={() => handleRemoveOther(index)}
>
<Trash2Icon />
</Button>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
<Input
id={`other-fqdn-${index}`}
className="font-mono"
value={binding.fqdn}
onChange={(event) =>
handleOtherChange(index, {
...binding,
fqdn: event.target.value,
})
}
placeholder={
zoneHints[0] ? `api.${zoneHints[0]}` : 'api.ivx.su'
}
/>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(next) => {
const recordType = (next ?? 'A') as 'A' | 'CNAME'
handleOtherChange(index, {
...binding,
record_type: recordType,
target_ips: recordType === 'A' ? binding.target_ips : [],
target_cname:
recordType === 'CNAME' ? binding.target_cname : '',
})
}}
>
<SelectTrigger id={`other-type-${index}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</div>
{binding.record_type === 'CNAME' ? (
<Input
id={`other-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) =>
handleOtherChange(index, {
...binding,
target_cname: event.target.value,
})
}
/>
) : (
<ServiceBindingIpInput
id={`other-ip-${index}`}
value={binding.target_ips}
pool={pool}
onChange={(targetIps) =>
handleOtherChange(index, {
...binding,
target_ips: targetIps,
target_ip_weights: Object.fromEntries(
targetIps.map((ip) => [
ip,
binding.target_ip_weights[ip] ?? 1,
]),
),
target_ip_priorities: Object.fromEntries(
targetIps.map((ip) => [
ip,
binding.target_ip_priorities[ip] ?? 1,
]),
),
})
}
/>
)}
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
</FramePanel>
) : null}
</Frame>
)
}
@@ -0,0 +1,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)
})
})
+201 -79
View File
@@ -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 { Area, AreaChart, XAxis } from 'recharts'
import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts'
import { EmptyState } from '@/components/empty-state'
import { Badge } from '@/components/reui/badge'
@@ -22,12 +22,13 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import type { HealthCheckProvider } from '@cfdm/shared'
/**
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
* Preview: https://reui.io/preview/base/chart-17
* Frame: https://reui.io/docs/components/base/frame
* Chart: shadcn Chart + Recharts AreaChart
* Chart: shadcn Chart + Recharts ComposedChart
*/
export interface UptimeProbe {
@@ -36,6 +37,7 @@ export interface UptimeProbe {
ok: boolean
latency_ms: number | null
checked_at: string
provider?: HealthCheckProvider
}
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 },
]
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 = {
latency: {
label: 'Задержка',
color: 'var(--chart-1)',
local: {
label: 'Local',
color: 'var(--info)',
},
cloudflare: {
label: 'Cloudflare',
color: 'var(--warning)',
},
globalping: {
label: 'Globalping',
color: 'var(--success)',
},
} satisfies ChartConfig
interface ChartPoint {
export interface AlignedChartPoint {
period: string
latency: number
ok: boolean
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[] {
return [...items]
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
.map((item) => ({
period: formatDate(item.checked_at),
latency: item.latency_ms ?? 0,
ok: item.ok && item.status !== 'down',
at: item.checked_at,
status: item.status,
}))
function isProviderKey(value: string | undefined): value is UptimeProviderKey {
return value === 'local' || value === 'cloudflare' || value === 'globalping'
}
function uptimePercent(points: ChartPoint[]): number | null {
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),
at: item.checked_at,
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: AlignedChartPoint[]): number | null {
if (points.length === 0) return null
const okCount = points.filter((point) => point.ok).length
return (okCount / points.length) * 100
}
function deltaPercent(points: ChartPoint[]): number | null {
function deltaPercent(points: AlignedChartPoint[]): number | null {
if (points.length < 4) return null
const mid = Math.floor(points.length / 2)
const prev = uptimePercent(points.slice(0, mid))
@@ -113,7 +182,7 @@ function UptimeDelta({ delta }: { delta: number }) {
}
export function probeUptimePercent(items: UptimeProbe[]): number | null {
return uptimePercent(toSeries(items))
return uptimePercent(toAlignedSeries(items).points)
}
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)}%`
}
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 {
items: UptimeProbe[]
isLoading?: boolean
@@ -151,30 +226,45 @@ export function UptimeChart({
}: UptimeChartProps) {
const gradientId = useId().replace(/:/g, '')
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 days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
useEffect(() => {
setTooltipPortal(document.body)
}, [])
function handlePeriodChange(next: UptimePeriodKey) {
onPeriodChange?.(next)
if (periodProp == null) setInternalPeriod(next)
setHovered(null)
}
const points = useMemo(
() => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
const { points, keys } = useMemo(
() => toAlignedSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
[items, days, skipPeriodFilter],
)
const uptime = uptimePercent(points)
const delta = deltaPercent(points)
const lastOk = points.at(-1)?.ok ?? true
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 = (
<FramePanel className="flex flex-col gap-6">
<FramePanel className="flex flex-col gap-6 overflow-visible">
{hideHeader ? null : (
<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">
@@ -242,48 +332,78 @@ export function UptimeChart({
</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">
<ChartContainer
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 }}
>
<AreaChart
<ComposedChart
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>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-latency)"
stopColor={`var(--color-${areaKey})`}
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-latency)"
stopColor={`var(--color-${areaKey})`}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<XAxis dataKey="period" hide />
<XAxis dataKey="at" hide />
<YAxis hide domain={['auto', 'auto']} />
<ChartTooltip
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
filterNull={false}
shared
isAnimationActive={false}
allowEscapeViewBox={{ x: true, y: true }}
portal={tooltipPortal ?? undefined}
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
content={
<ChartTooltipContent
formatter={(value, _name, item) => {
const point = item.payload as ChartPoint | undefined
const ping = Number(value)
labelFormatter={(_label, payload) => {
const at = (payload?.[0]?.payload as AlignedChartPoint | undefined)?.at
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 (
<div className="flex flex-1 items-center justify-between gap-4">
<span className="text-muted-foreground">
{point?.ok === false ? 'Down' : 'Пинг'}
{ok === false ? `${label} · Down` : `Пинг · ${label}`}
</span>
<span className="text-foreground font-mono font-medium tabular-nums">
{Number.isFinite(ping) ? `${ping} мс` : '—'}
{formatPing(value, ok)}
</span>
</div>
)
@@ -291,42 +411,44 @@ export function UptimeChart({
/>
}
/>
<Area
dataKey="latency"
name="latency"
type="natural"
fill={`url(#${gradientId})`}
stroke="var(--color-latency)"
strokeWidth={2}
isAnimationActive={false}
dot={(dotProps) => {
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={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
/>
</AreaChart>
{single ? (
<Area
dataKey={areaKey}
name={areaKey}
type="monotone"
fill={`url(#${gradientId})`}
stroke={`var(--color-${areaKey})`}
strokeWidth={2}
connectNulls={false}
isAnimationActive={false}
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
activeDot={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
/>
) : (
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>
</div>
</div>
+56 -426
View File
@@ -1,15 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { Trash2Icon } from 'lucide-react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import { ServiceAddressBlock } from '@/components/reui-kit/service-address-block'
import {
HealthCheckConfigFields,
type LbAndHealthConfig,
type LbMode,
type HealthCheckType,
type HealthProvider,
type HealthAggregate,
} from '@/components/health-check-config-fields'
import type {
CreateServiceWithConfigInput,
@@ -18,9 +13,16 @@ import type {
ServiceView,
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { parseHealthProviders } from '@cfdm/shared'
import { bindingToFqdn, parseFqdn } from '@/lib/parse-fqdn'
import { Badge } from '@/components/reui/badge'
import {
DEFAULT_BINDING_HEALTH,
emptyAddressBlock,
hydrateAddressBlock,
toBindingDrafts,
toDomainsPayload,
type AddressBlockState,
type BindingHealthConfig,
type ServiceBindingDraft,
} from '@/lib/service-address'
import { toast } from 'sonner'
import {
Sheet,
@@ -30,14 +32,8 @@ import {
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { Button } from '@cfdm/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Item,
ItemContent,
ItemGroup,
} from '@cfdm/ui/components/item'
import { LoadingButton } from '@/components/loading-button'
import {
Select,
@@ -47,44 +43,7 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
interface BindingHealthConfig {
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',
}
export type { ServiceBindingDraft }
interface ServiceEditSheetProps {
mode: 'create' | 'edit'
@@ -101,104 +60,19 @@ interface ServiceEditSheetProps {
onDelete?: (id: number) => void
}
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 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 {
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
return {
fqdn,
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...defaultHealth },
target_ip_weights: {},
target_ip_priorities: {},
}
}
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]),
),
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,
}
}
@@ -219,9 +93,11 @@ export function ServiceEditSheet({
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [serviceGroupId, setServiceGroupId] = useState('none')
const [ips, setIps] = useState<string[]>([])
const [commonFqdn, setCommonFqdn] = useState('')
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const [address, setAddress] = useState<AddressBlockState>(() => emptyAddressBlock())
const [health, setHealth] = useState<BindingHealthConfig>(() => ({
...DEFAULT_BINDING_HEALTH,
}))
const [lbMode, setLbMode] = useState<LbAndHealthConfig['lb_mode']>('round_robin')
const [lbWeight, setLbWeight] = useState(1)
const [lbPriority, setLbPriority] = useState(1)
@@ -243,10 +119,10 @@ export function ServiceEditSheet({
setServiceGroupId(
service.service_group_id != null ? String(service.service_group_id) : 'none',
)
setIps(service.ips ?? [])
const drafts = toBindingDrafts(service)
setBindings(drafts)
setCommonFqdn(drafts[0]?.fqdn ?? '')
setAddress(hydrateAddressBlock(drafts, service.ips ?? []))
setHealth(drafts[0]?.health ?? { ...DEFAULT_BINDING_HEALTH })
setLbMode(drafts[0]?.lb_mode ?? service.lb_mode ?? 'round_robin')
setLbWeight(service.lb_weight ?? 1)
setLbPriority(service.lb_priority ?? 1)
return
@@ -257,9 +133,9 @@ export function ServiceEditSheet({
setServiceGroupId(
defaultGroupId != null ? String(defaultGroupId) : 'none',
)
setIps([])
setCommonFqdn('')
setBindings([])
setAddress(emptyAddressBlock())
setHealth({ ...DEFAULT_BINDING_HEALTH })
setLbMode('round_robin')
setLbWeight(1)
setLbPriority(1)
}
@@ -270,136 +146,27 @@ export function ServiceEditSheet({
[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) {
const health = healthFromConfig(next)
setBindings((current) => {
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,
)
})
setLbMode(next.lb_mode)
setHealth(healthFromConfig(next))
}
const primaryHealthValue: LbAndHealthConfig = {
lb_mode: bindings[0]?.lb_mode ?? 'round_robin',
...(bindings[0]?.health ?? defaultHealth),
lb_mode: lbMode,
...health,
}
function resolveServiceGroupId(): number | null {
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() {
const syncedBindings = syncCommonDomain(bindings)
const domains = buildDomainsPayload(syncedBindings)
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
const ips = address.nodes.map((node) => node.ip)
const domains = toDomainsPayload(address, {
lb_mode: lbMode,
health,
})
const normalizedFqdns = domains.map((item) => item.fqdn.trim().toLowerCase())
const hasDuplicateFqdn =
new Set(normalizedFqdns).size !== normalizedFqdns.length
if (hasDuplicateFqdn) {
@@ -443,6 +210,7 @@ export function ServiceEditSheet({
const canSubmit = isCreate
? name.trim().length > 0 && slug.trim().length > 0
: Boolean(service)
const addressResetKey = `${mode}-${service?.id ?? 'new'}-${open ? 'open' : 'closed'}`
return (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -450,8 +218,8 @@ export function ServiceEditSheet({
<SheetHeader className="shrink-0 border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Общий домен и IP задаются у сервиса. Дополнительные FQDN ниже, зона
определяется автоматически.
Общий домен и пул IP в одном блоке. У каждого адреса можно указать
свой доп. FQDN.
</SheetDescription>
</SheetHeader>
@@ -498,33 +266,16 @@ export function ServiceEditSheet({
</SelectContent>
</Select>
</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>
</section>
<ServiceAddressBlock
key={addressResetKey}
value={address}
onChange={setAddress}
zoneHints={zoneHints}
/>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Health check</h3>
<HealthCheckConfigFields
@@ -533,127 +284,6 @@ export function ServiceEditSheet({
onChange={handlePrimaryHealthChange}
/>
</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>
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
+42
View File
@@ -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' },
])
})
})
+86
View File
@@ -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: '/' }]
}
+183
View File
@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_BINDING_HEALTH,
addAddressNode,
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.commonFqdn).toBe('rutg.rkns.top')
expect(state.nodes).toEqual([
{ ip: '93.115.203.183', extraFqdn: 'msk.rutg.rkns.top' },
{ ip: '185.244.181.61', extraFqdn: '' },
])
expect(state.otherBindings).toEqual([])
})
it('не схлопывает CNAME и A на несколько IP', () => {
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.nodes.every((node) => node.extraFqdn === '')).toBe(true)
expect(state.otherBindings.map((item) => item.fqdn)).toEqual([
'both.rkns.top',
'alias.rkns.top',
])
})
it('кладёт extra A с IP вне пула в otherBindings', () => {
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.otherBindings).toHaveLength(1)
expect(state.otherBindings[0]?.fqdn).toBe('edge.example.com')
})
})
describe('toDomainsPayload', () => {
it('собирает primary на весь пул и 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 сохраняет extra FQDN', () => {
const drafts = [
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
]
const first = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
const rebound = toAddressBindings(first, primaryMeta)
const second = hydrateAddressBlock(rebound, rebound[0]?.target_ips ?? [])
expect(second.commonFqdn).toBe(first.commonFqdn)
expect(second.nodes).toEqual(first.nodes)
expect(second.otherBindings).toEqual([])
})
})
describe('removeAddressNode', () => {
it('удаляет extra FQDN узла и IP из other A-bindings', () => {
const state = hydrateAddressBlock(
[
aRecord('gw.example.com', ['10.0.0.1', '10.0.0.2']),
aRecord('msk.example.com', ['10.0.0.1']),
aRecord('pair.example.com', ['10.0.0.1', '10.0.0.2']),
],
['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.otherBindings).toHaveLength(1)
expect(next.otherBindings[0]?.target_ips).toEqual(['10.0.0.2'])
})
})
describe('addAddressNode', () => {
it('не добавляет дубликат IP', () => {
const withIp = addAddressNode(
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdn: '' }] },
'1.1.1.1',
)
expect(withIp.nodes).toHaveLength(1)
})
})
describe('CNAME / otherBindings', () => {
it('сохраняет CNAME в otherBindings при круге hydrate → payload', () => {
const cname: ServiceBindingDraft = {
...emptyBindingDraft('alias.rkns.top'),
record_type: 'CNAME',
target_cname: 'rutg.rkns.top',
}
const drafts = [
aRecord('rutg.rkns.top', ['1.1.1.1']),
aRecord('msk.rkns.top', ['1.1.1.1']),
cname,
]
const state = hydrateAddressBlock(drafts, ['1.1.1.1'])
expect(state.nodes[0]?.extraFqdn).toBe('msk.rkns.top')
expect(state.otherBindings).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',
}),
)
})
})
+340
View File
@@ -0,0 +1,340 @@
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 {
commonFqdn: string
nodes: AddressNode[]
otherBindings: 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 {
commonFqdn: '',
nodes: [],
otherBindings: [],
target_ip_weights: {},
target_ip_priorities: {},
}
}
export function withPoolIps(
draft: ServiceBindingDraft,
pool: string[],
): ServiceBindingDraft {
if (draft.record_type !== 'A' || draft.target_ips.length > 0 || pool.length === 0) {
return draft
}
return {
...draft,
target_ips: pool,
target_ip_weights: Object.fromEntries(pool.map((ip) => [ip, draft.target_ip_weights[ip] ?? 1])),
target_ip_priorities: Object.fromEntries(
pool.map((ip) => [ip, draft.target_ip_priorities[ip] ?? 1]),
),
}
}
function uniqueIps(...lists: string[][]): string[] {
const seen = new Set<string>()
const out: string[] = []
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
}
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 canCollapseToNode(
extra: ServiceBindingDraft,
pool: Set<string>,
claimed: Set<string>,
): string | null {
if (extra.record_type !== 'A') return null
if (extra.target_ips.length !== 1) return null
const ip = extra.target_ips[0]?.trim() ?? ''
if (!ip || !pool.has(ip) || claimed.has(ip)) return null
if (!extra.fqdn.trim()) return null
return ip
}
export function hydrateAddressBlock(
drafts: ServiceBindingDraft[],
pool: string[] = [],
): AddressBlockState {
const primary = drafts[0]
const ips = uniqueIps(pool, primary?.target_ips ?? [])
const poolSet = new Set(ips)
const claimed = new Set<string>()
const extraByIp = new Map<string, string>()
const otherBindings: ServiceBindingDraft[] = []
for (const extra of drafts.slice(1)) {
const ip = canCollapseToNode(extra, poolSet, claimed)
if (ip) {
claimed.add(ip)
extraByIp.set(ip, extra.fqdn)
continue
}
otherBindings.push(extra)
}
return {
commonFqdn: primary?.fqdn ?? '',
nodes: ips.map((ip) => ({
ip,
extraFqdn: extraByIp.get(ip) ?? '',
})),
otherBindings,
target_ip_weights: { ...(primary?.target_ip_weights ?? {}) },
target_ip_priorities: { ...(primary?.target_ip_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),
otherBindings: pruneIpFromBindings(state.otherBindings, 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 },
}
}
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[] = []
const hasPrimary = Boolean(state.commonFqdn.trim()) || ips.length > 0
if (hasPrimary) {
drafts.push({
fqdn: state.commonFqdn,
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.otherBindings)
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))
}