diff --git a/apps/web/src/components/reui-kit/service-address-block.tsx b/apps/web/src/components/reui-kit/service-address-block.tsx index 6cb746e..efb1f8c 100644 --- a/apps/web/src/components/reui-kit/service-address-block.tsx +++ b/apps/web/src/components/reui-kit/service-address-block.tsx @@ -1,8 +1,7 @@ -import { useState, type KeyboardEvent } from 'react' -import { PlusIcon, ServerIcon, Trash2Icon } from 'lucide-react' +import { useState, type KeyboardEvent, type ReactNode } from 'react' +import { ServerIcon, Trash2Icon } from 'lucide-react' import { EmptyState } from '@/components/empty-state' -import { ServiceBindingIpInput } from '@/components/service-binding-ip-input' import { isValidIpv4 } from '@/components/tagged-input' import { Badge } from '@/components/reui/badge' import { @@ -16,15 +15,15 @@ import { IconTile } from '@/components/reui/icon-tile' import { parseFqdn } from '@/lib/parse-fqdn' import { addAddressNode, - emptyBindingDraft, + addCommonFqdn, + addressHasFqdn, removeAddressNode, - withPoolIps, + removeCommonFqdn, + updateCommonFqdn, type AddressBlockState, - type ServiceBindingDraft, } from '@/lib/service-address' import { Button } from '@cfdm/ui/components/button' import { Field, FieldLabel } from '@cfdm/ui/components/field' -import { Input } from '@cfdm/ui/components/input' import { InputGroup, InputGroupAddon, @@ -39,16 +38,36 @@ import { ItemMedia, ItemTitle, } from '@cfdm/ui/components/item' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@cfdm/ui/components/select' + +function ZoneAddon({ + fqdn, + zoneHints, + trailing, +}: { + fqdn: string + zoneHints: string[] + trailing?: ReactNode +}) { + const parsed = parseFqdn(fqdn, zoneHints) + if (!parsed && !fqdn.trim() && !trailing) return null + return ( + + {parsed ? ( + + {parsed.zoneName} + + ) : fqdn.trim() ? ( + + зона не найдена + + ) : null} + {trailing} + + ) +} /** - * Единый блок адресов сервиса: общий FQDN + пул IP с опциональным доп. доменом. + * Единый блок адресов сервиса: список общих FQDN на весь пул + IP с доп. доменом. * Preview: https://reui.io/preview/base/settings-3 * Preview: https://reui.io/preview/base/list-9 * Preview: https://reui.io/preview/base/form-7 @@ -67,17 +86,30 @@ export function ServiceAddressBlock({ }) { const [pendingIp, setPendingIp] = useState('') const [ipInvalid, setIpInvalid] = useState(false) - const [otherOpen, setOtherOpen] = useState(value.otherBindings.length > 0) + const [pendingFqdn, setPendingFqdn] = useState('') + const [fqdnInvalid, setFqdnInvalid] = useState(false) const pool = value.nodes.map((node) => node.ip) - const parsedCommon = parseFqdn(value.commonFqdn, zoneHints) - const showOthers = otherOpen || value.otherBindings.length > 0 - const pendingTrimmed = pendingIp.trim() - const pendingInvalid = - ipInvalid && pendingTrimmed.length > 0 && !isValidIpv4(pendingTrimmed) + const pendingIpTrimmed = pendingIp.trim() + const pendingFqdnTrimmed = pendingFqdn.trim() + const pendingIpInvalid = + ipInvalid && pendingIpTrimmed.length > 0 && !isValidIpv4(pendingIpTrimmed) + const pendingFqdnInvalid = + fqdnInvalid && pendingFqdnTrimmed.length > 0 - function handleCommonFqdn(next: string) { - onChange({ ...value, commonFqdn: next }) + function tryAddFqdn(raw: string) { + const trimmed = raw.trim() + if (!trimmed) { + setFqdnInvalid(false) + return + } + if (addressHasFqdn(value, trimmed)) { + setFqdnInvalid(true) + return + } + onChange(addCommonFqdn(value, trimmed)) + setPendingFqdn('') + setFqdnInvalid(false) } function tryAddIp(raw: string) { @@ -95,7 +127,14 @@ export function ServiceAddressBlock({ setIpInvalid(false) } - function handlePendingKeyDown(event: KeyboardEvent) { + function handleFqdnKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter') { + event.preventDefault() + tryAddFqdn(pendingFqdn) + } + } + + function handleIpKeyDown(event: KeyboardEvent) { if (event.key === 'Enter') { event.preventDefault() tryAddIp(pendingIp) @@ -111,64 +150,69 @@ export function ServiceAddressBlock({ }) } - function handleRemoveIp(ip: string) { - onChange(removeAddressNode(value, ip)) - } - - function handleAddOther() { - setOtherOpen(true) - onChange({ - ...value, - otherBindings: [...value.otherBindings, withPoolIps(emptyBindingDraft(), pool)], - }) - } - - function handleOtherChange(index: number, next: ServiceBindingDraft) { - onChange({ - ...value, - otherBindings: value.otherBindings.map((item, i) => (i === index ? next : item)), - }) - } - - function handleRemoveOther(index: number) { - const otherBindings = value.otherBindings.filter((_, i) => i !== index) - onChange({ ...value, otherBindings }) - if (otherBindings.length === 0) setOtherOpen(false) - } - return ( Адреса - Общий FQDN на весь пул · у каждого IP свой доп. домен + Общие FQDN — на весь пул · у IP свой доп. домен - Общий домен (FQDN) - - handleCommonFqdn(event.target.value)} - /> - {parsedCommon ? ( - - - {parsedCommon.zoneName} - - - ) : value.commonFqdn.trim() ? ( - - - зона не найдена - - - ) : null} - + Общие домены (FQDN) +
+ {value.commonFqdns.map((fqdn, index) => ( + + + onChange(updateCommonFqdn(value, index, event.target.value)) + } + /> + onChange(removeCommonFqdn(value, index))} + > + + + } + /> + + ))} + + { + setPendingFqdn(event.target.value) + setFqdnInvalid(false) + }} + onKeyDown={handleFqdnKeyDown} + onBlur={() => tryAddFqdn(pendingFqdn)} + /> + tryAddFqdn(pendingFqdn)}> + Добавить + + } + /> + +
@@ -187,7 +231,6 @@ export function ServiceAddressBlock({ ) : ( {value.nodes.map((node) => { - const parsedExtra = parseFqdn(node.extraFqdn, zoneHints) return ( handleRemoveIp(node.ip)} + onClick={() => onChange(removeAddressNode(value, node.ip))} > @@ -241,19 +284,7 @@ export function ServiceAddressBlock({ handleNodeFqdn(node.ip, event.target.value) } /> - {parsedExtra ? ( - - - {parsedExtra.zoneName} - - - ) : node.extraFqdn.trim() ? ( - - - зона не найдена - - - ) : null} + @@ -268,12 +299,12 @@ export function ServiceAddressBlock({ className="font-mono" value={pendingIp} placeholder="192.168.1.1" - aria-invalid={pendingInvalid || undefined} + aria-invalid={pendingIpInvalid || undefined} onChange={(event) => { setPendingIp(event.target.value) setIpInvalid(false) }} - onKeyDown={handlePendingKeyDown} + onKeyDown={handleIpKeyDown} onBlur={() => tryAddIp(pendingIp)} /> @@ -282,156 +313,7 @@ export function ServiceAddressBlock({ - {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 79ffcdf..9cef13f 100644 --- a/apps/web/src/components/service-edit-sheet.tsx +++ b/apps/web/src/components/service-edit-sheet.tsx @@ -218,8 +218,8 @@ export function ServiceEditSheet({ {isCreate ? 'Новый сервис' : 'Редактирование сервиса'} - Общий домен и пул IP — в одном блоке. У каждого адреса можно указать - свой доп. FQDN. + Общие FQDN на весь пул IP. У каждого адреса можно указать свой доп. + FQDN. diff --git a/apps/web/src/lib/service-address.test.ts b/apps/web/src/lib/service-address.test.ts index 5cbaf6f..0b972cd 100644 --- a/apps/web/src/lib/service-address.test.ts +++ b/apps/web/src/lib/service-address.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_BINDING_HEALTH, addAddressNode, + addCommonFqdn, emptyAddressBlock, emptyBindingDraft, hydrateAddressBlock, @@ -41,15 +42,15 @@ describe('hydrateAddressBlock', () => { const state = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61']) - expect(state.commonFqdn).toBe('rutg.rkns.top') + expect(state.commonFqdns).toEqual(['rutg.rkns.top']) expect(state.nodes).toEqual([ { ip: '93.115.203.183', extraFqdn: 'msk.rutg.rkns.top' }, { ip: '185.244.181.61', extraFqdn: '' }, ]) - expect(state.otherBindings).toEqual([]) + expect(state.preservedBindings).toEqual([]) }) - it('не схлопывает CNAME и A на несколько IP', () => { + it('кладёт A на весь пул в commonFqdns, CNAME — в preserved', () => { const cname: ServiceBindingDraft = { ...emptyBindingDraft('alias.rkns.top'), record_type: 'CNAME', @@ -63,14 +64,12 @@ describe('hydrateAddressBlock', () => { const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2']) + expect(state.commonFqdns).toEqual(['rutg.rkns.top', 'both.rkns.top']) expect(state.nodes.every((node) => node.extraFqdn === '')).toBe(true) - expect(state.otherBindings.map((item) => item.fqdn)).toEqual([ - 'both.rkns.top', - 'alias.rkns.top', - ]) + expect(state.preservedBindings.map((item) => item.fqdn)).toEqual(['alias.rkns.top']) }) - it('кладёт extra A с IP вне пула в otherBindings', () => { + it('кладёт extra A с IP вне пула в preservedBindings', () => { const drafts = [ aRecord('gw.example.com', ['10.0.0.1']), aRecord('edge.example.com', ['8.8.8.8']), @@ -79,13 +78,14 @@ describe('hydrateAddressBlock', () => { const state = hydrateAddressBlock(drafts, ['10.0.0.1']) expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdn: '' }]) - expect(state.otherBindings).toHaveLength(1) - expect(state.otherBindings[0]?.fqdn).toBe('edge.example.com') + expect(state.commonFqdns).toEqual(['gw.example.com']) + expect(state.preservedBindings).toHaveLength(1) + expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com') }) }) describe('toDomainsPayload', () => { - it('собирает primary на весь пул и extra binding на один IP', () => { + it('собирает каждый common на весь пул и extra binding на один IP', () => { const drafts = [ aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']), aRecord('msk.rutg.rkns.top', ['93.115.203.183']), @@ -107,28 +107,30 @@ describe('toDomainsPayload', () => { ]) }) - it('круг hydrate → payload → hydrate сохраняет extra FQDN', () => { + it('круг hydrate → payload → hydrate сохраняет два common и extra FQDN', () => { const drafts = [ - aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']), - aRecord('msk.rutg.rkns.top', ['93.115.203.183']), + aRecord('gt.rkns.top', ['93.115.203.183', '185.244.181.61']), + aRecord('msk.rkns.top', ['93.115.203.183', '185.244.181.61']), + aRecord('nsgt.rkns.top', ['93.115.203.183']), ] const first = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61']) + expect(first.commonFqdns).toEqual(['gt.rkns.top', 'msk.rkns.top']) const rebound = toAddressBindings(first, primaryMeta) const second = hydrateAddressBlock(rebound, rebound[0]?.target_ips ?? []) - expect(second.commonFqdn).toBe(first.commonFqdn) + expect(second.commonFqdns).toEqual(first.commonFqdns) expect(second.nodes).toEqual(first.nodes) - expect(second.otherBindings).toEqual([]) + expect(second.preservedBindings).toEqual([]) }) }) describe('removeAddressNode', () => { - it('удаляет extra FQDN узла и IP из other A-bindings', () => { + it('удаляет extra FQDN узла и IP из preserved A-bindings', () => { const state = hydrateAddressBlock( [ aRecord('gw.example.com', ['10.0.0.1', '10.0.0.2']), aRecord('msk.example.com', ['10.0.0.1']), - aRecord('pair.example.com', ['10.0.0.1', '10.0.0.2']), + aRecord('edge.example.com', ['9.9.9.9', '10.0.0.1']), ], ['10.0.0.1', '10.0.0.2'], ) @@ -136,12 +138,12 @@ describe('removeAddressNode', () => { const next = removeAddressNode(state, '10.0.0.1') expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdn: '' }]) - expect(next.otherBindings).toHaveLength(1) - expect(next.otherBindings[0]?.target_ips).toEqual(['10.0.0.2']) + expect(next.preservedBindings).toHaveLength(1) + expect(next.preservedBindings[0]?.target_ips).toEqual(['9.9.9.9']) }) }) -describe('addAddressNode', () => { +describe('addAddressNode / addCommonFqdn', () => { it('не добавляет дубликат IP', () => { const withIp = addAddressNode( { ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdn: '' }] }, @@ -149,23 +151,31 @@ describe('addAddressNode', () => { ) expect(withIp.nodes).toHaveLength(1) }) + + it('не добавляет дубликат common FQDN', () => { + const state = addCommonFqdn( + { ...emptyAddressBlock(), commonFqdns: ['gt.rkns.top'] }, + 'GT.rkns.top', + ) + expect(state.commonFqdns).toEqual(['gt.rkns.top']) + }) }) -describe('CNAME / otherBindings', () => { - it('сохраняет CNAME в otherBindings при круге hydrate → payload', () => { +describe('CNAME / preservedBindings', () => { + it('сохраняет CNAME в preserved при круге hydrate → payload', () => { const cname: ServiceBindingDraft = { ...emptyBindingDraft('alias.rkns.top'), record_type: 'CNAME', target_cname: 'rutg.rkns.top', } const drafts = [ - aRecord('rutg.rkns.top', ['1.1.1.1']), + aRecord('rutg.rkns.top', ['1.1.1.1', '2.2.2.2']), aRecord('msk.rkns.top', ['1.1.1.1']), cname, ] - const state = hydrateAddressBlock(drafts, ['1.1.1.1']) + const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2']) expect(state.nodes[0]?.extraFqdn).toBe('msk.rkns.top') - expect(state.otherBindings).toHaveLength(1) + expect(state.preservedBindings).toHaveLength(1) const payload = toDomainsPayload(state, primaryMeta) expect(payload.map((item) => item.fqdn)).toEqual([ diff --git a/apps/web/src/lib/service-address.ts b/apps/web/src/lib/service-address.ts index 36645e7..32e98f8 100644 --- a/apps/web/src/lib/service-address.ts +++ b/apps/web/src/lib/service-address.ts @@ -37,9 +37,9 @@ export interface AddressNode { } export interface AddressBlockState { - commonFqdn: string + commonFqdns: string[] nodes: AddressNode[] - otherBindings: ServiceBindingDraft[] + preservedBindings: ServiceBindingDraft[] target_ip_weights: Record target_ip_priorities: Record } @@ -78,31 +78,14 @@ export function emptyBindingDraft(fqdn = ''): ServiceBindingDraft { export function emptyAddressBlock(): AddressBlockState { return { - commonFqdn: '', + commonFqdns: [], nodes: [], - otherBindings: [], + preservedBindings: [], target_ip_weights: {}, target_ip_priorities: {}, } } -export function withPoolIps( - draft: ServiceBindingDraft, - pool: string[], -): ServiceBindingDraft { - if (draft.record_type !== 'A' || draft.target_ips.length > 0 || pool.length === 0) { - return draft - } - return { - ...draft, - target_ips: pool, - target_ip_weights: Object.fromEntries(pool.map((ip) => [ip, draft.target_ip_weights[ip] ?? 1])), - target_ip_priorities: Object.fromEntries( - pool.map((ip) => [ip, draft.target_ip_priorities[ip] ?? 1]), - ), - } -} - function uniqueIps(...lists: string[][]): string[] { const seen = new Set() const out: string[] = [] @@ -123,6 +106,13 @@ function omitKey(record: Record, key: string): Record ip.trim()).filter(Boolean)) + if (set.size !== left.length) return false + return right.every((ip) => set.has(ip.trim())) +} + export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] { return (service.domains ?? []).map((binding) => ({ fqdn: bindingToFqdn(binding), @@ -151,49 +141,62 @@ export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] { })) } -function canCollapseToNode( - extra: ServiceBindingDraft, - pool: Set, - 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 +function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean { + return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool) } export function hydrateAddressBlock( drafts: ServiceBindingDraft[], pool: string[] = [], ): AddressBlockState { - const primary = drafts[0] - const ips = uniqueIps(pool, primary?.target_ips ?? []) + const multiIpTargets = drafts + .filter((draft) => draft.record_type === 'A' && draft.target_ips.length > 1) + .map((draft) => draft.target_ips) + const allAIps = drafts + .filter((draft) => draft.record_type === 'A') + .map((draft) => draft.target_ips) + const ips = + pool.length > 0 + ? uniqueIps(pool) + : uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps)) const poolSet = new Set(ips) + const commonFqdns: string[] = [] const claimed = new Set() const extraByIp = new Map() - const otherBindings: ServiceBindingDraft[] = [] + const preservedBindings: ServiceBindingDraft[] = [] + let weights: Record = {} + let priorities: Record = {} - for (const extra of drafts.slice(1)) { - const ip = canCollapseToNode(extra, poolSet, claimed) - if (ip) { - claimed.add(ip) - extraByIp.set(ip, extra.fqdn) + for (const draft of drafts) { + const fqdn = draft.fqdn.trim() + if (isFullPoolA(draft, ips)) { + if (fqdn) commonFqdns.push(draft.fqdn) + if (Object.keys(weights).length === 0) { + weights = { ...draft.target_ip_weights } + priorities = { ...draft.target_ip_priorities } + } continue } - otherBindings.push(extra) + if (draft.record_type === 'A' && draft.target_ips.length === 1) { + const ip = draft.target_ips[0]?.trim() ?? '' + if (ip && poolSet.has(ip) && fqdn && !claimed.has(ip)) { + claimed.add(ip) + extraByIp.set(ip, draft.fqdn) + continue + } + } + preservedBindings.push(draft) } return { - commonFqdn: primary?.fqdn ?? '', + commonFqdns, nodes: ips.map((ip) => ({ ip, extraFqdn: extraByIp.get(ip) ?? '', })), - otherBindings, - target_ip_weights: { ...(primary?.target_ip_weights ?? {}) }, - target_ip_priorities: { ...(primary?.target_ip_priorities ?? {}) }, + preservedBindings, + target_ip_weights: weights, + target_ip_priorities: priorities, } } @@ -221,7 +224,7 @@ export function removeAddressNode(state: AddressBlockState, ip: string): Address return { ...state, nodes: state.nodes.filter((node) => node.ip !== ip), - otherBindings: pruneIpFromBindings(state.otherBindings, ip), + preservedBindings: pruneIpFromBindings(state.preservedBindings, ip), target_ip_weights: omitKey(state.target_ip_weights, ip), target_ip_priorities: omitKey(state.target_ip_priorities, ip), } @@ -240,6 +243,42 @@ export function addAddressNode(state: AddressBlockState, ip: string): AddressBlo } } +function fqdnKey(value: string): string { + return value.trim().toLowerCase() +} + +export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean { + const key = fqdnKey(fqdn) + if (!key) return false + if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return true + if (state.nodes.some((node) => fqdnKey(node.extraFqdn) === key)) return true + return false +} + +export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState { + const trimmed = fqdn.trim() + if (!trimmed || addressHasFqdn(state, trimmed)) return state + return { ...state, commonFqdns: [...state.commonFqdns, trimmed] } +} + +export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState { + return { + ...state, + commonFqdns: state.commonFqdns.filter((_, i) => i !== index), + } +} + +export function updateCommonFqdn( + state: AddressBlockState, + index: number, + fqdn: string, +): AddressBlockState { + return { + ...state, + commonFqdns: state.commonFqdns.map((item, i) => (i === index ? fqdn : item)), + } +} + export function toAddressBindings( state: AddressBlockState, primary: AddressPrimaryMeta, @@ -253,10 +292,11 @@ export function toAddressBindings( ) const drafts: ServiceBindingDraft[] = [] - const hasPrimary = Boolean(state.commonFqdn.trim()) || ips.length > 0 - if (hasPrimary) { + for (const raw of state.commonFqdns) { + const fqdn = raw.trim() + if (!fqdn || ips.length === 0) continue drafts.push({ - fqdn: state.commonFqdn, + fqdn, record_type: 'A', target_ips: ips, target_cname: '', @@ -282,7 +322,7 @@ export function toAddressBindings( }) } - drafts.push(...state.otherBindings) + drafts.push(...state.preservedBindings) return drafts }