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
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 <[email protected]>
This commit is contained in:
@@ -28,3 +28,4 @@ export {
|
|||||||
type HealthProvider,
|
type HealthProvider,
|
||||||
type HealthAggregate,
|
type HealthAggregate,
|
||||||
} from './health-source-tiles'
|
} from './health-source-tiles'
|
||||||
|
export { ServiceAddressBlock } from './service-address-block'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 — ниже, зона
|
Общий домен и пул 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,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',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user