From 3e4cacab422bd84112044d799cc8ac5dfd3266c8 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 30 Jul 2026 01:29:08 +0700 Subject: [PATCH] =?UTF-8?q?feat(services):=20=D1=81=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20multi-FQDN=20=D0=BF=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D0=BA=D0=B8=20first-class=20=D0=B2=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исправить prune при domains: [], показать все FQDN в каталоге/kanban, улучшить sheet привязок и панель на домене; убрать мёртвый код. Co-authored-by: Cursor --- .../src/services/service-config-service.ts | 169 +++++++++--------- apps/api/test/service-bindings-prune.test.ts | 81 +++++++++ apps/web/eslint.config.js | 1 - apps/web/src/components/app-avatar.tsx | 47 ----- apps/web/src/components/app-separator.tsx | 9 - .../components/columns/services-columns.tsx | 135 +------------- ...ngs-card.tsx => domain-bindings-panel.tsx} | 45 ++++- .../components/kanban/service-kanban-card.tsx | 12 +- .../src/components/service-binding-card.tsx | 127 ------------- .../web/src/components/service-edit-sheet.tsx | 69 ++++--- .../components/services/service-fqdn-list.tsx | 67 +++++++ .../services/services-grouped-columns.tsx | 6 +- .../src/components/subdomain-edit-sheet.tsx | 39 ++++ apps/web/src/lib/service-utils.ts | 11 +- .../routes/_auth/domains/$domainId/index.tsx | 30 +--- apps/web/src/routes/_auth/services.tsx | 42 ++++- docs/Home.md | 3 + packages/db/src/schema.ts | 87 +++++---- 18 files changed, 469 insertions(+), 511 deletions(-) create mode 100644 apps/api/test/service-bindings-prune.test.ts delete mode 100644 apps/web/src/components/app-avatar.tsx delete mode 100644 apps/web/src/components/app-separator.tsx rename apps/web/src/components/{domain-bindings-card.tsx => domain-bindings-panel.tsx} (73%) delete mode 100644 apps/web/src/components/service-binding-card.tsx create mode 100644 apps/web/src/components/services/service-fqdn-list.tsx diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index d1c8179..ce30e9d 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -1172,97 +1172,95 @@ export async function updateConfig( let removedBindingIds: number[] = []; if (req.domains) { - if (req.domains.length > 0) { - for (const input of req.domains) { - const fqdn = input.fqdn.trim(); - if (!fqdn) continue; - const targetCname = bindingTargetCname(input); - const targetIps = bindingTargetIps(input); - if (!targetCname) { - validateTargetIpsInPool(targetIps, ips); - } else if (targetIps.length > 0) { - throw AppError.validation( - `укажите либо IP, либо CNAME для ${fqdn}`, - ); - } - - const { zoneName, hostname } = parseFqdn(fqdn, knownZones); - const domainId = await resolveDomainId(db, cf, zoneName); - - const binding = - repos.findBinding(db, id, domainId, hostname) ?? - repos.insertBinding(db, domainId, id, hostname, null); - - keptBindingIds.push(binding.id); - - const targetIpWeights = input.target_ip_weights ?? {}; - const targetIpPriorities = input.target_ip_priorities ?? {}; - const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({ - ip, - weight: targetIpWeights[ip] ?? 1, - priority: targetIpPriorities[ip] ?? 1, - })); - repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries); - repos.setBindingCnameTarget(db, binding.id, targetCname); - - if ( - input.lb_mode !== undefined || - input.health_check_enabled !== undefined || - input.health_check_type !== undefined || - input.health_check_port !== undefined || - input.health_check_path !== undefined || - input.health_check_expected_status !== undefined || - input.health_check_interval_sec !== undefined || - input.health_check_timeout_ms !== undefined || - input.health_check_verify_tls !== undefined - ) { - repos.updateBindingLbConfig(db, binding.id, { - lb_mode: input.lb_mode, - health_check_enabled: input.health_check_enabled, - health_check_type: input.health_check_type, - health_check_port: input.health_check_port, - health_check_path: input.health_check_path, - health_check_expected_status: input.health_check_expected_status, - health_check_interval_sec: input.health_check_interval_sec, - health_check_timeout_ms: input.health_check_timeout_ms, - health_check_verify_tls: input.health_check_verify_tls, - }); - } - - if (pushDns) { - let effectiveIps = targetIps; - const refreshedBinding = repos.getBinding(db, binding.id); - if (refreshedBinding.health_check_enabled) { - const activeIps = computeActiveIps(db, "binding", binding.id); - if (activeIps.length > 0) { - effectiveIps = activeIps; - } - } - await syncBindingDns( - db, - cf, - binding.id, - domainId, - hostname, - effectiveIps, - targetCname, - ); - } + for (const input of req.domains) { + const fqdn = input.fqdn.trim(); + if (!fqdn) continue; + const targetCname = bindingTargetCname(input); + const targetIps = bindingTargetIps(input); + if (!targetCname) { + validateTargetIpsInPool(targetIps, ips); + } else if (targetIps.length > 0) { + throw AppError.validation( + `укажите либо IP, либо CNAME для ${fqdn}`, + ); } - const removed = repos.bindingsToRemove(db, id, keptBindingIds); - removedBindingIds = removed.map((binding) => binding.id); - for (const binding of removed) { - await cleanupBindingDns( + const { zoneName, hostname } = parseFqdn(fqdn, knownZones); + const domainId = await resolveDomainId(db, cf, zoneName); + + const binding = + repos.findBinding(db, id, domainId, hostname) ?? + repos.insertBinding(db, domainId, id, hostname, null); + + keptBindingIds.push(binding.id); + + const targetIpWeights = input.target_ip_weights ?? {}; + const targetIpPriorities = input.target_ip_priorities ?? {}; + const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({ + ip, + weight: targetIpWeights[ip] ?? 1, + priority: targetIpPriorities[ip] ?? 1, + })); + repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries); + repos.setBindingCnameTarget(db, binding.id, targetCname); + + if ( + input.lb_mode !== undefined || + input.health_check_enabled !== undefined || + input.health_check_type !== undefined || + input.health_check_port !== undefined || + input.health_check_path !== undefined || + input.health_check_expected_status !== undefined || + input.health_check_interval_sec !== undefined || + input.health_check_timeout_ms !== undefined || + input.health_check_verify_tls !== undefined + ) { + repos.updateBindingLbConfig(db, binding.id, { + lb_mode: input.lb_mode, + health_check_enabled: input.health_check_enabled, + health_check_type: input.health_check_type, + health_check_port: input.health_check_port, + health_check_path: input.health_check_path, + health_check_expected_status: input.health_check_expected_status, + health_check_interval_sec: input.health_check_interval_sec, + health_check_timeout_ms: input.health_check_timeout_ms, + health_check_verify_tls: input.health_check_verify_tls, + }); + } + + if (pushDns) { + let effectiveIps = targetIps; + const refreshedBinding = repos.getBinding(db, binding.id); + if (refreshedBinding.health_check_enabled) { + const activeIps = computeActiveIps(db, "binding", binding.id); + if (activeIps.length > 0) { + effectiveIps = activeIps; + } + } + await syncBindingDns( db, cf, binding.id, - binding.domain_id, - binding.hostname, + domainId, + hostname, + effectiveIps, + targetCname, ); } - repos.deleteBindingsExcept(db, id, keptBindingIds); } + + const removed = repos.bindingsToRemove(db, id, keptBindingIds); + removedBindingIds = removed.map((binding) => binding.id); + for (const binding of removed) { + await cleanupBindingDns( + db, + cf, + binding.id, + binding.domain_id, + binding.hostname, + ); + } + repos.deleteBindingsExcept(db, id, keptBindingIds); } else if (ipsUpdated) { const bindings = repos.listBindingsByService(db, id); for (const binding of bindings) { @@ -1278,8 +1276,11 @@ export async function updateConfig( } service = repos.getService(db, id); + const remainingBindings = repos.listBindingsByService(db, id); if (shouldPushDns(db, service)) { - await syncServiceBindingsToDns(db, cf, id); + if (remainingBindings.length > 0) { + await syncServiceBindingsToDns(db, cf, id); + } await syncGroupDomainForService(db, cf, id); } else if ( req.domains && diff --git a/apps/api/test/service-bindings-prune.test.ts b/apps/api/test/service-bindings-prune.test.ts new file mode 100644 index 0000000..1772e43 --- /dev/null +++ b/apps/api/test/service-bindings-prune.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryDb, repos, runMigrations } from "@cfdm/db"; +import type { CloudflareClient } from "../src/lib/cf-client.js"; +import { updateConfig } from "../src/services/service-config-service.js"; + +function mockCf(): CloudflareClient { + return { + listDnsRecords: async () => [], + createDnsRecord: async () => ({ + id: "cf-new", + type: "A", + name: "api.example.com", + content: "10.0.0.1", + ttl: 1, + proxied: false, + }), + updateDnsRecord: async () => ({ + id: "cf-upd", + type: "A", + name: "api.example.com", + content: "10.0.0.1", + ttl: 1, + proxied: false, + }), + deleteDnsRecord: async () => undefined, + verifyToken: async () => true, + listZones: async () => [], + } as unknown as CloudflareClient; +} + +function setupDb() { + const { db, sqlite } = createMemoryDb(); + runMigrations(sqlite); + return db; +} + +describe("service bindings prune", () => { + it("updateConfig with domains: [] removes all bindings", async () => { + const db = setupDb(); + const cf = mockCf(); + + const domain = repos.createDomain(db, null, "example.com", "cf-zone-example"); + const service = repos.createService(db, "Web", "web"); + repos.replaceServiceIps(db, service.id, ["10.0.0.1"]); + const binding = repos.insertBinding(db, domain.id, service.id, "api", null); + repos.replaceBindingIps(db, binding.id, ["10.0.0.1"]); + + expect(repos.listBindingsByService(db, service.id)).toHaveLength(1); + + const view = await updateConfig(db, cf, service.id, { + ips: ["10.0.0.1"], + domains: [], + }); + + expect(repos.listBindingsByService(db, service.id)).toHaveLength(0); + expect(view.domains).toEqual([]); + }); + + it("updateConfig keeps multiple FQDN bindings", async () => { + const db = setupDb(); + const cf = mockCf(); + + repos.createDomain(db, null, "a.example", "cf-zone-a"); + repos.createDomain(db, null, "b.example", "cf-zone-b"); + const service = repos.createService(db, "Edge", "edge"); + repos.replaceServiceIps(db, service.id, ["10.0.0.2"]); + + const view = await updateConfig(db, cf, service.id, { + ips: ["10.0.0.2"], + domains: [ + { fqdn: "api.a.example", target_ips: ["10.0.0.2"] }, + { fqdn: "www.b.example", target_ips: ["10.0.0.2"] }, + ], + }); + + expect(view.domains).toHaveLength(2); + const bindings = repos.listBindingsByService(db, service.id); + expect(bindings).toHaveLength(2); + expect(bindings.map((b) => b.hostname).sort()).toEqual(["api", "www"]); + }); +}); diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index ac2d20c..a3170a4 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -74,7 +74,6 @@ export default defineConfig([ '**/services-board/*-row.tsx', '**/groups-board/*-row.tsx', '**/layout/app-shell.tsx', - '**/service-binding-card.tsx', ], rules: { 'no-restricted-syntax': 'off', diff --git a/apps/web/src/components/app-avatar.tsx b/apps/web/src/components/app-avatar.tsx deleted file mode 100644 index b1cab25..0000000 --- a/apps/web/src/components/app-avatar.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { ComponentProps } from 'react' -import { - Avatar, - AvatarBadge, - AvatarFallback, - AvatarGroup, - AvatarGroupCount, - AvatarImage, -} from '@cfdm/ui/components/avatar' -import { cn } from '@cfdm/ui/lib/utils' - -export type AppAvatarProps = ComponentProps -export type AppAvatarImageProps = ComponentProps -export type AppAvatarFallbackProps = ComponentProps -export type AppAvatarBadgeProps = ComponentProps -export type AppAvatarGroupProps = ComponentProps -export type AppAvatarGroupCountProps = ComponentProps - -export function AppAvatar({ className, ...props }: AppAvatarProps) { - return -} - -export function AppAvatarImage({ className, ...props }: AppAvatarImageProps) { - return -} - -export function AppAvatarFallback({ - className, - ...props -}: AppAvatarFallbackProps) { - return -} - -export function AppAvatarBadge({ className, ...props }: AppAvatarBadgeProps) { - return -} - -export function AppAvatarGroup({ className, ...props }: AppAvatarGroupProps) { - return -} - -export function AppAvatarGroupCount({ - className, - ...props -}: AppAvatarGroupCountProps) { - return -} diff --git a/apps/web/src/components/app-separator.tsx b/apps/web/src/components/app-separator.tsx deleted file mode 100644 index af3c7cd..0000000 --- a/apps/web/src/components/app-separator.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { ComponentProps } from 'react' -import { Separator } from '@cfdm/ui/components/separator' -import { cn } from '@cfdm/ui/lib/utils' - -export type AppSeparatorProps = ComponentProps - -export function AppSeparator({ className, ...props }: AppSeparatorProps) { - return -} diff --git a/apps/web/src/components/columns/services-columns.tsx b/apps/web/src/components/columns/services-columns.tsx index a9f4db7..0fbe18c 100644 --- a/apps/web/src/components/columns/services-columns.tsx +++ b/apps/web/src/components/columns/services-columns.tsx @@ -1,19 +1,8 @@ import { useMemo } from 'react' -import type { ColumnDef } from '@tanstack/react-table' -import { MoreHorizontalIcon, SearchIcon } from 'lucide-react' +import { SearchIcon } from 'lucide-react' -import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { createFilter, type FilterFieldConfig } from '@/components/reui/filters' -import { StatusBadge } from '@/components/status-badge' import type { ServiceView } from '@/lib/schemas' -import { Button } from '@cfdm/ui/components/button' -import { Switch } from '@cfdm/ui/components/switch' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@cfdm/ui/components/dropdown-menu' export interface ServiceCatalogRow { id: number @@ -50,126 +39,18 @@ export function useServiceFilterFields() { { key: 'name', label: 'Название', - icon: , type: 'text', - className: 'w-52', - placeholder: 'Поиск…', + icon: , }, ], [], ) } -export function serviceFilterFieldValue(item: ServiceCatalogRow, field: string) { - switch (field) { - case 'name': - return `${item.name} ${item.slug} ${item.groupName ?? ''}`.toLowerCase() - default: - return '' - } -} - -export function useServiceColumns({ - onEdit, - onDelete, - onToggle, - togglingId, -}: { - onEdit: (service: ServiceView) => void - onDelete: (service: ServiceView) => void - onToggle: (serviceId: number, enabled: boolean) => void - togglingId: number | null -}) { - const columns = useMemo[]>( - () => [ - { - id: 'name', - accessorKey: 'name', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - ), - }, - { - id: 'group', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.groupName ?? 'Без группы'} - - ), - }, - { - id: 'enabled', - accessorKey: 'enabled', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
- - onToggle(row.original.id, Boolean(checked)) - } - aria-label={ - row.original.enabled ? 'Выключить сервис' : 'Включить сервис' - } - /> - -
- ), - }, - { - id: 'actions', - enableSorting: false, - header: () => Действия, - cell: ({ row }) => ( - - - } - > - - - - onEdit(row.original.service)}> - Изменить - - onDelete(row.original.service)} - > - Удалить - - - - ), - }, - ], - [onEdit, onDelete, onToggle, togglingId], - ) - - return { columns } +export function serviceFilterFieldValue( + item: ServiceCatalogRow, + field: string, +): string { + if (field === 'name') return item.name.toLowerCase() + return '' } diff --git a/apps/web/src/components/domain-bindings-card.tsx b/apps/web/src/components/domain-bindings-panel.tsx similarity index 73% rename from apps/web/src/components/domain-bindings-card.tsx rename to apps/web/src/components/domain-bindings-panel.tsx index c875b14..b05e830 100644 --- a/apps/web/src/components/domain-bindings-card.tsx +++ b/apps/web/src/components/domain-bindings-panel.tsx @@ -24,7 +24,7 @@ import { ItemTitle, } from '@cfdm/ui/components/item' -interface DomainBindingsCardProps { +interface DomainBindingsPanelProps { bindings: ServiceBinding[] } @@ -67,11 +67,24 @@ function HostnameIpsHealth({ ) } -function uniqueServices(bindings: ServiceBinding[]): string[] { - return [...new Set(bindings.map((b) => b.service_name))] +function uniqueServiceEntries( + bindings: ServiceBinding[], +): { serviceId: number; serviceName: string }[] { + const seen = new Set() + const entries: { serviceId: number; serviceName: string }[] = [] + for (const binding of bindings) { + if (seen.has(binding.service_id)) continue + seen.add(binding.service_id) + entries.push({ + serviceId: binding.service_id, + serviceName: binding.service_name, + }) + } + return entries } -export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { +/** Panel of service bindings grouped by hostname for a domain zone. */ +export function DomainBindingsPanel({ bindings }: DomainBindingsPanelProps) { const byHostname = groupBindingsByHostname(bindings) const entries = [...byHostname.entries()] @@ -103,13 +116,25 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { ), ), ] + const services = uniqueServiceEntries(groupBindings) return (
{hostname} -
- {uniqueServices(groupBindings).join(', ')} +
+ {services.map((service) => ( + + + {service.serviceName} + + + ))}
} + render={ + + } > К сервисам @@ -142,3 +172,4 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { ) } + diff --git a/apps/web/src/components/kanban/service-kanban-card.tsx b/apps/web/src/components/kanban/service-kanban-card.tsx index 7045d5b..0a7a3ff 100644 --- a/apps/web/src/components/kanban/service-kanban-card.tsx +++ b/apps/web/src/components/kanban/service-kanban-card.tsx @@ -3,7 +3,7 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react' import { Badge } from '@/components/reui/badge' import { HealthCheckBadge } from '@/components/health-check-badge' import { StatusBadge } from '@/components/status-badge' -import { serviceDisplayFqdn } from '@/lib/service-utils' +import { ServiceFqdnList } from '@/components/services/service-fqdn-list' import type { ServiceView } from '@/lib/schemas' import { Button } from '@cfdm/ui/components/button' import { @@ -44,8 +44,6 @@ export function ServiceKanbanCard({ onEdit, onDelete, }: ServiceKanbanCardProps) { - const fqdn = serviceDisplayFqdn(service) - return ( - {fqdn && fqdn !== '—' ? ( - - {fqdn} - - ) : ( - FQDN не задан - )} + diff --git a/apps/web/src/components/service-binding-card.tsx b/apps/web/src/components/service-binding-card.tsx deleted file mode 100644 index 67fceee..0000000 --- a/apps/web/src/components/service-binding-card.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useDraggable } from '@dnd-kit/core' -import { CSS } from '@dnd-kit/utilities' -import { useEffect, useState } from 'react' -import { Link } from '@tanstack/react-router' -import { StatusBadge } from '@/components/status-badge' -import { Badge } from '@/components/reui/badge' -import { - Frame, - FrameFooter, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' -import { Button } from '@cfdm/ui/components/button' -import { - Field, - FieldGroup, - FieldLabel, -} from '@cfdm/ui/components/field' -import { Input } from '@cfdm/ui/components/input' -import { cn } from '@cfdm/ui/lib/utils' -import type { ServiceBinding } from '@/lib/schemas' - -interface ServiceBindingCardProps { - binding: ServiceBinding - onIpChange: (id: number, targetIp: string) => void - onHostnameChange: (id: number, hostname: string) => void -} - -export function ServiceBindingCard({ - binding, - onIpChange, - onHostnameChange, -}: ServiceBindingCardProps) { - const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ - id: String(binding.id), - }) - const [ip, setIp] = useState(binding.target_ip ?? '') - const [hostname, setHostname] = useState(binding.hostname) - - useEffect(() => { - setIp(binding.target_ip ?? '') - setHostname(binding.hostname) - }, [binding.target_ip, binding.hostname]) - - const style = transform - ? { transform: CSS.Translate.toString(transform) } - : undefined - - return ( - - - {binding.zone_name} - {binding.group_name ? ( - {binding.group_name} - ) : ( - Без группы - )} - - - - - Hostname - e.stopPropagation()} - onChange={(e) => setHostname(e.target.value)} - onBlur={() => { - if (hostname !== binding.hostname) { - onHostnameChange(binding.id, hostname) - } - }} - /> - - - IPv4 - e.stopPropagation()} - onChange={(e) => setIp(e.target.value)} - onBlur={() => { - if (ip !== (binding.target_ip ?? '')) { - onIpChange(binding.id, ip) - } - }} - /> - - -
- {binding.service_name} - {binding.sync_status && } -
-
- - - - - ) -} diff --git a/apps/web/src/components/service-edit-sheet.tsx b/apps/web/src/components/service-edit-sheet.tsx index f66f89c..71f45ad 100644 --- a/apps/web/src/components/service-edit-sheet.tsx +++ b/apps/web/src/components/service-edit-sheet.tsx @@ -18,7 +18,9 @@ import type { ServiceView, UpdateServiceConfigInput, } from '@/lib/schemas' -import { bindingToFqdn } from '@/lib/parse-fqdn' +import { bindingToFqdn, parseFqdn } from '@/lib/parse-fqdn' +import { Badge } from '@/components/reui/badge' +import { toast } from 'sonner' import { Sheet, SheetContent, @@ -247,8 +249,7 @@ export function ServiceEditSheet({ setBindings((current) => current.filter((_, i) => i !== index)) } - function handleFqdnChange(index: number, tags: string[]) { - const fqdn = tags[0] ?? '' + function handleFqdnChange(index: number, fqdn: string) { setBindings((current) => current.map((item, i) => (i === index ? { ...item, fqdn } : item)), ) @@ -340,14 +341,22 @@ export function ServiceEditSheet({ function handleSubmit() { const domains = buildDomainsPayload(bindings) + const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase()) + const hasDuplicateFqdn = + new Set(normalizedFqdns).size !== normalizedFqdns.length + if (hasDuplicateFqdn) { + toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы') + setActiveTab('bindings') + return + } const groupId = resolveServiceGroupId() const lbFields = groupHasDomain ? { lb_weight: lbWeight, lb_priority: lbPriority } : {} const configPayload = { ips, + domains, ...lbFields, - ...(domains.length > 0 ? { domains } : {}), } if (mode === 'create') { onCreate?.({ @@ -385,8 +394,9 @@ export function ServiceEditSheet({ {isCreate ? 'Новый сервис' : 'Редактирование сервиса'} - Настройте параметры сервиса и привязки FQDN → IP или CNAME. Зона определяется из FQDN - автоматически. + Настройте параметры сервиса и привязки FQDN → IP или CNAME. Один + сервис может иметь несколько FQDN в разных зонах; зона определяется + из FQDN автоматически. @@ -502,7 +512,7 @@ export function ServiceEditSheet({

- FQDN → IP или CNAME для DNS-записей Cloudflare + Несколько FQDN в разных зонах → IP или CNAME для DNS Cloudflare

+ + FQDN + + handleFqdnChange(index, event.target.value) + } + placeholder={ + zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su' + } + /> + Тип записи