From 1457389ae7d5f1535e30eeab14d61a193367de2a Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 20 Aug 2026 13:33:02 +0700 Subject: [PATCH] =?UTF-8?q?feat(services):=20=D0=BE=D0=B1=D1=8A=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D0=B8=D1=82=D1=8C=20=D0=B0=D0=B4=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D0=B0=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D0=BE=D0=B4=D0=B8=D0=BD=20=D0=B1=D0=BB=D0=BE=D0=BA=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Общий FQDN, пул IP и доп. домен на строке IP — в одном Frame, без смены API. Co-authored-by: Cursor --- apps/web/src/components/reui-kit/index.ts | 1 + .../reui-kit/service-address-block.tsx | 437 ++++++++++++++++ .../web/src/components/service-edit-sheet.tsx | 482 ++---------------- apps/web/src/lib/service-address.test.ts | 183 +++++++ apps/web/src/lib/service-address.ts | 340 ++++++++++++ 5 files changed, 1017 insertions(+), 426 deletions(-) create mode 100644 apps/web/src/components/reui-kit/service-address-block.tsx create mode 100644 apps/web/src/lib/service-address.test.ts create mode 100644 apps/web/src/lib/service-address.ts diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index 8914a91..a0cd3af 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -28,3 +28,4 @@ export { type HealthProvider, type HealthAggregate, } from './health-source-tiles' +export { ServiceAddressBlock } from './service-address-block' diff --git a/apps/web/src/components/reui-kit/service-address-block.tsx b/apps/web/src/components/reui-kit/service-address-block.tsx new file mode 100644 index 0000000..6cb746e --- /dev/null +++ b/apps/web/src/components/reui-kit/service-address-block.tsx @@ -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) { + 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 ( + + + + Адреса + + Общий FQDN на весь пул · у каждого IP свой доп. домен + + + + Общий домен (FQDN) + + handleCommonFqdn(event.target.value)} + /> + {parsedCommon ? ( + + + {parsedCommon.zoneName} + + + ) : value.commonFqdn.trim() ? ( + + + зона не найдена + + + ) : null} + + + + + + + IP-адреса + + {value.nodes.length === 0 ? ( + + ) : ( + + {value.nodes.map((node) => { + const parsedExtra = parseFqdn(node.extraFqdn, zoneHints) + return ( + + + + + +
+ {node.ip} + + + +
+ + + Доп. FQDN + + + + handleNodeFqdn(node.ip, event.target.value) + } + /> + {parsedExtra ? ( + + + {parsedExtra.zoneName} + + + ) : node.extraFqdn.trim() ? ( + + + зона не найдена + + + ) : null} + + +
+
+ ) + })} +
+ )} + + { + setPendingIp(event.target.value) + setIpInvalid(false) + }} + onKeyDown={handlePendingKeyDown} + onBlur={() => tryAddIp(pendingIp)} + /> + + tryAddIp(pendingIp)}> + Добавить + + + + {showOthers ? null : ( +
+ +
+ )} +
+ + {showOthers ? ( + + +
+ Другие FQDN + CNAME и A не 1:1 с IP пула +
+ +
+ {value.otherBindings.length === 0 ? ( +

Нет дополнительных FQDN

+ ) : ( + + {value.otherBindings.map((binding, index) => { + const parsedZone = parseFqdn(binding.fqdn, zoneHints) + return ( + + +
+ {parsedZone ? ( + + {parsedZone.zoneName} + + ) : binding.fqdn.trim() ? ( + + зона не найдена + + ) : ( + FQDN + )} + +
+
+ + handleOtherChange(index, { + ...binding, + fqdn: event.target.value, + }) + } + placeholder={ + zoneHints[0] ? `api.${zoneHints[0]}` : 'api.ivx.su' + } + /> + +
+ {binding.record_type === 'CNAME' ? ( + + handleOtherChange(index, { + ...binding, + target_cname: event.target.value, + }) + } + /> + ) : ( + + 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, + ]), + ), + }) + } + /> + )} +
+
+ ) + })} +
+ )} +
+ ) : null} + + ) +} diff --git a/apps/web/src/components/service-edit-sheet.tsx b/apps/web/src/components/service-edit-sheet.tsx index 53deaed..79ffcdf 100644 --- a/apps/web/src/components/service-edit-sheet.tsx +++ b/apps/web/src/components/service-edit-sheet.tsx @@ -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 - target_ip_priorities: Record -} - -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([]) - const [commonFqdn, setCommonFqdn] = useState('') - const [bindings, setBindings] = useState([]) + const [address, setAddress] = useState(() => emptyAddressBlock()) + const [health, setHealth] = useState(() => ({ + ...DEFAULT_BINDING_HEALTH, + })) + const [lbMode, setLbMode] = useState('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 ( @@ -450,8 +218,8 @@ export function ServiceEditSheet({ {isCreate ? 'Новый сервис' : 'Редактирование сервиса'} - Общий домен и IP задаются у сервиса. Дополнительные FQDN — ниже, зона - определяется автоматически. + Общий домен и пул IP — в одном блоке. У каждого адреса можно указать + свой доп. FQDN. @@ -498,33 +266,16 @@ export function ServiceEditSheet({ - - - Общий домен (FQDN) - - handleCommonFqdnChange(e.target.value)} - /> - - - IP-адреса сервиса - - + +

Health check

- -
-
-

Доп. FQDN

- -
- {extraBindings.length === 0 ? ( -

- Нет дополнительных FQDN -

- ) : ( - - {extraBindings.map((binding, extraIndex) => { - const index = extraIndex + 1 - const parsedZone = parseFqdn(binding.fqdn, zoneHints) - return ( - - -
- {parsedZone ? ( - - {parsedZone.zoneName} - - ) : binding.fqdn.trim() ? ( - - зона не найдена - - ) : ( - - FQDN - - )} - -
-
- - handleFqdnChange(index, event.target.value) - } - placeholder={ - zoneHints[0] - ? `api.${zoneHints[0]}` - : 'api.ivx.su' - } - /> - -
- {binding.record_type === 'CNAME' ? ( - - handleCnameChange(index, event.target.value) - } - /> - ) : ( - - handleIpsChange(index, targetIps) - } - /> - )} -
-
- ) - })} -
- )} -
diff --git a/apps/web/src/lib/service-address.test.ts b/apps/web/src/lib/service-address.test.ts new file mode 100644 index 0000000..5cbaf6f --- /dev/null +++ b/apps/web/src/lib/service-address.test.ts @@ -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 { + 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', + }), + ) + }) +}) diff --git a/apps/web/src/lib/service-address.ts b/apps/web/src/lib/service-address.ts new file mode 100644 index 0000000..36645e7 --- /dev/null +++ b/apps/web/src/lib/service-address.ts @@ -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 + target_ip_priorities: Record +} + +export interface AddressNode { + ip: string + extraFqdn: string +} + +export interface AddressBlockState { + commonFqdn: string + nodes: AddressNode[] + otherBindings: ServiceBindingDraft[] + target_ip_weights: Record + target_ip_priorities: Record +} + +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() + 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, key: string): Record { + 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, + claimed: Set, +): 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() + const extraByIp = new Map() + 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)) +}