feat(services): сделать multi-FQDN привязки first-class в UI
Build and Push CFDM Docker Image / build-and-push (push) Successful in 2m9s
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 7s

Исправить prune при domains: [], показать все FQDN в каталоге/kanban, улучшить sheet привязок и панель на домене; убрать мёртвый код.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-30 01:29:08 +07:00
co-authored by Cursor
parent 859ad23006
commit 3e4cacab42
18 changed files with 469 additions and 511 deletions
+85 -84
View File
@@ -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 &&
@@ -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"]);
});
});
-1
View File
@@ -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',
-47
View File
@@ -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<typeof Avatar>
export type AppAvatarImageProps = ComponentProps<typeof AvatarImage>
export type AppAvatarFallbackProps = ComponentProps<typeof AvatarFallback>
export type AppAvatarBadgeProps = ComponentProps<typeof AvatarBadge>
export type AppAvatarGroupProps = ComponentProps<typeof AvatarGroup>
export type AppAvatarGroupCountProps = ComponentProps<typeof AvatarGroupCount>
export function AppAvatar({ className, ...props }: AppAvatarProps) {
return <Avatar className={cn(className)} {...props} />
}
export function AppAvatarImage({ className, ...props }: AppAvatarImageProps) {
return <AvatarImage className={cn(className)} {...props} />
}
export function AppAvatarFallback({
className,
...props
}: AppAvatarFallbackProps) {
return <AvatarFallback className={cn(className)} {...props} />
}
export function AppAvatarBadge({ className, ...props }: AppAvatarBadgeProps) {
return <AvatarBadge className={cn(className)} {...props} />
}
export function AppAvatarGroup({ className, ...props }: AppAvatarGroupProps) {
return <AvatarGroup className={cn(className)} {...props} />
}
export function AppAvatarGroupCount({
className,
...props
}: AppAvatarGroupCountProps) {
return <AvatarGroupCount className={cn(className)} {...props} />
}
@@ -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<typeof Separator>
export function AppSeparator({ className, ...props }: AppSeparatorProps) {
return <Separator className={cn(className)} {...props} />
}
@@ -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: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск…',
icon: <SearchIcon className="size-4" />,
},
],
[],
)
}
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<ColumnDef<ServiceCatalogRow>[]>(
() => [
{
id: 'name',
accessorKey: 'name',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Название" />
),
cell: ({ row }) => (
<button
type="button"
className="text-foreground max-w-64 truncate text-left font-medium hover:underline"
onClick={() => onEdit(row.original.service)}
>
{row.original.name}
</button>
),
},
{
id: 'group',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Группа" />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.original.groupName ?? 'Без группы'}
</span>
),
},
{
id: 'enabled',
accessorKey: 'enabled',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Switch
checked={row.original.enabled}
disabled={togglingId === row.original.id}
onCheckedChange={(checked) =>
onToggle(row.original.id, Boolean(checked))
}
aria-label={
row.original.enabled ? 'Выключить сервис' : 'Включить сервис'
}
/>
<StatusBadge
status={row.original.enabled ? 'active' : 'disabled'}
label={row.original.enabled ? 'Вкл' : 'Выкл'}
/>
</div>
),
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
aria-label="Действия"
/>
}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original.service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDelete(row.original.service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
],
[onEdit, onDelete, onToggle, togglingId],
)
return { columns }
export function serviceFilterFieldValue(
item: ServiceCatalogRow,
field: string,
): string {
if (field === 'name') return item.name.toLowerCase()
return ''
}
@@ -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<number>()
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 (
<div key={hostname}>
<Item size="sm" variant="muted" className="border-0 px-0">
<ItemContent className="gap-1">
<ItemTitle className="font-mono">{hostname}</ItemTitle>
<div className="text-muted-foreground text-sm">
{uniqueServices(groupBindings).join(', ')}
<div className="flex flex-wrap items-center gap-1.5">
{services.map((service) => (
<Link
key={service.serviceId}
to="/services"
search={{ serviceId: service.serviceId }}
className="inline-flex"
>
<Badge variant="outline" size="xs">
{service.serviceName}
</Badge>
</Link>
))}
</div>
<HostnameIpsHealth
bindings={groupBindings}
@@ -134,7 +159,12 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
variant="link"
className="h-auto p-0"
nativeButton={false}
render={<Link to="/services" search={{ domainId: bindings[0]?.domain_id }} />}
render={
<Link
to="/services"
search={{ domainId: bindings[0]?.domain_id }}
/>
}
>
К сервисам
</Button>
@@ -142,3 +172,4 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
</Frame>
)
}
@@ -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 (
<Item
variant="outline"
@@ -80,13 +78,7 @@ export function ServiceKanbanCard({
</ItemHeader>
<ItemContent className="min-w-0 gap-2">
{fqdn && fqdn !== '—' ? (
<span className="text-muted-foreground truncate font-mono text-xs">
{fqdn}
</span>
) : (
<span className="text-muted-foreground text-xs">FQDN не задан</span>
)}
<ServiceFqdnList service={service} />
</ItemContent>
<ItemFooter className="min-w-0 justify-between gap-2">
@@ -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 (
<Frame
ref={setNodeRef}
dense
spacing="sm"
style={style}
className={cn(
'cursor-grab active:cursor-grabbing',
isDragging && 'opacity-60 shadow-lg',
)}
{...listeners}
{...attributes}
>
<FrameHeader className="flex-row items-start justify-between gap-2">
<FrameTitle>{binding.zone_name}</FrameTitle>
{binding.group_name ? (
<Badge variant="secondary">{binding.group_name}</Badge>
) : (
<Badge variant="outline">Без группы</Badge>
)}
</FrameHeader>
<FramePanel className="flex flex-col gap-3">
<FieldGroup className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={`hostname-${binding.id}`}>Hostname</FieldLabel>
<Input
id={`hostname-${binding.id}`}
value={hostname}
placeholder="@"
className="font-mono tabular-nums"
onPointerDown={(e) => e.stopPropagation()}
onChange={(e) => setHostname(e.target.value)}
onBlur={() => {
if (hostname !== binding.hostname) {
onHostnameChange(binding.id, hostname)
}
}}
/>
</Field>
<Field>
<FieldLabel htmlFor={`ip-${binding.id}`}>IPv4</FieldLabel>
<Input
id={`ip-${binding.id}`}
value={ip}
placeholder="192.168.1.1"
className="font-mono tabular-nums"
onPointerDown={(e) => e.stopPropagation()}
onChange={(e) => setIp(e.target.value)}
onBlur={() => {
if (ip !== (binding.target_ip ?? '')) {
onIpChange(binding.id, ip)
}
}}
/>
</Field>
</FieldGroup>
<div className="flex flex-wrap items-center gap-2">
<Badge>{binding.service_name}</Badge>
{binding.sync_status && <StatusBadge status={binding.sync_status} />}
</div>
</FramePanel>
<FrameFooter>
<Button
variant="outline"
size="sm"
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(binding.domain_id) }}
/>
}
>
Домен
</Button>
</FrameFooter>
</Frame>
)
}
+48 -21
View File
@@ -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({
<SheetHeader className="shrink-0 border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Настройте параметры сервиса и привязки FQDN IP или CNAME. Зона определяется из FQDN
автоматически.
Настройте параметры сервиса и привязки FQDN IP или CNAME. Один
сервис может иметь несколько FQDN в разных зонах; зона определяется
из FQDN автоматически.
</SheetDescription>
</SheetHeader>
@@ -502,7 +512,7 @@ export function ServiceEditSheet({
<TabsContent value="bindings" className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
FQDN IP или CNAME для DNS-записей Cloudflare
Несколько FQDN в разных зонах IP или CNAME для DNS Cloudflare
</p>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
@@ -514,7 +524,7 @@ export function ServiceEditSheet({
<EmptyState
icon={Link2Icon}
title="Нет привязок"
description="Необязательно. Пример: newdom.ivx.su — зона ivx.su определится автоматически."
description="Необязательно. Можно добавить несколько FQDN: api.ivx.su и www.other.su — зоны определятся автоматически."
centered={false}
action={
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
@@ -533,22 +543,25 @@ export function ServiceEditSheet({
binding.record_type === 'A' &&
binding.target_ips.length > 1 &&
binding.lb_mode !== 'round_robin'
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
return (
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
<ItemContent className="w-full flex flex-col gap-3">
<div className="flex items-end gap-2">
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
<TaggedInput
id={`binding-fqdn-${index}`}
value={binding.fqdn ? [binding.fqdn] : []}
onChange={(tags) => handleFqdnChange(index, tags)}
placeholder={
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
}
maxItems={1}
/>
</Field>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="text-sm font-medium">
Привязка {index + 1}
</span>
{parsedZone ? (
<Badge variant="outline" size="xs" className="font-mono">
{parsedZone.zoneName}
</Badge>
) : binding.fqdn.trim() ? (
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
) : null}
</div>
<Button
type="button"
variant="ghost"
@@ -560,6 +573,20 @@ export function ServiceEditSheet({
<Trash2Icon />
</Button>
</div>
<Field className="min-w-0">
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
<Input
id={`binding-fqdn-${index}`}
className="font-mono"
value={binding.fqdn}
onChange={(event) =>
handleFqdnChange(index, event.target.value)
}
placeholder={
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
}
/>
</Field>
<Field>
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
<Select
@@ -0,0 +1,67 @@
import { Badge } from '@/components/reui/badge'
import { TruncatedText } from '@/components/truncated-text'
import { serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
interface ServiceFqdnListProps {
service: ServiceView
className?: string
emptyLabel?: string
}
export function ServiceFqdnList({
service,
className,
emptyLabel = 'FQDN не задан',
}: ServiceFqdnListProps) {
const fqdns = serviceDisplayFqdns(service)
if (fqdns.length === 0) {
return (
<span className={cn('text-muted-foreground text-xs', className)}>
{emptyLabel}
</span>
)
}
const [first, ...rest] = fqdns
const extraCount = rest.length
return (
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
<TruncatedText className="text-muted-foreground min-w-0 font-mono text-xs">
{first}
</TruncatedText>
{extraCount > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Badge
variant="outline"
size="xs"
className="shrink-0 tabular-nums"
/>
}
>
+{extraCount}
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<ul className="flex flex-col gap-0.5 font-mono text-xs">
{fqdns.map((fqdn) => (
<li key={fqdn}>{fqdn}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
)
}
@@ -10,8 +10,8 @@ import {
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { serviceDisplayFqdn } from '@/lib/service-utils'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
@@ -126,9 +126,7 @@ export function createServicesGroupedColumns({
<span className="truncate text-sm font-medium">
{original.service.name}
</span>
<span className="text-muted-foreground truncate font-mono text-xs">
{serviceDisplayFqdn(original.service)}
</span>
<ServiceFqdnList service={original.service} emptyLabel="—" />
</div>
)
},
@@ -1,14 +1,22 @@
import { Link } from '@tanstack/react-router'
import { useEffect, useMemo } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { CertMonitoring } from '@cfdm/shared'
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatServiceGroupLabel } from '@/lib/service-utils'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { Badge } from '@/components/reui/badge'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
@@ -34,6 +42,7 @@ interface SubdomainEditSheetProps {
services: ServiceView[]
serviceGroupById: Map<number, string | null>
currentServiceId: string
serviceLinks?: SubdomainServiceLink[]
open: boolean
isSaving: boolean
onOpenChange: (open: boolean) => void
@@ -47,6 +56,7 @@ export function SubdomainEditSheet({
services,
serviceGroupById,
currentServiceId,
serviceLinks = [],
open,
isSaving,
onOpenChange,
@@ -104,6 +114,7 @@ export function SubdomainEditSheet({
const certMonitoring = form.watch('certMonitoring')
const certHint =
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
function handleSubmit(values: SubdomainEditValues) {
onSubmit({
@@ -155,6 +166,34 @@ export function SubdomainEditSheet({
</FormFieldSimple>
{mode === 'edit' ? (
<>
{hasMultipleServices ? (
<Alert variant="warning">
<AlertTitle>Несколько сервисов на hostname</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<p>
Здесь редактируется основной сервис. Остальные привязки
управляются в карточке сервиса.
</p>
<div className="flex flex-wrap items-center gap-1.5">
{serviceLinks.map((link) => (
<Link
key={link.serviceId}
to="/services"
search={{ serviceId: link.serviceId }}
className="inline-flex"
>
<Badge variant="outline" size="xs">
{formatServiceGroupLabel(
link.groupName,
link.serviceName,
)}
</Badge>
</Link>
))}
</div>
</AlertDescription>
</Alert>
) : null}
<FormFieldSimple label="Сервис" htmlFor="subdomain_service">
<Controller
control={form.control}
+6 -5
View File
@@ -24,12 +24,13 @@ export function buildServiceGroupNameById(
return map
}
export function serviceDisplayFqdns(service: ServiceView): string[] {
return (service.domains ?? []).map((binding) => bindingToFqdn(binding))
}
export function serviceDisplayFqdn(service: ServiceView): string {
const first = service.domains?.[0]
if (first) {
return bindingToFqdn(first)
}
return '—'
const fqdns = serviceDisplayFqdns(service)
return fqdns[0] ?? '—'
}
export function aggregateServiceSyncStatus(service: ServiceView): string | null {
@@ -38,7 +38,7 @@ import {
SubdomainEditSheet,
type SubdomainEditValues,
} from '@/components/subdomain-edit-sheet'
import { DomainBindingsCard } from '@/components/domain-bindings-card'
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
import { StatusBadge } from '@/components/status-badge'
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
@@ -326,7 +326,6 @@ function DomainOverviewPage() {
<CountedLineTabs
tabs={[
{ id: 'overview', label: 'Обзор' },
{ id: 'dns', label: 'DNS' },
{ id: 'availability', label: 'Доступность' },
{
id: 'subdomains',
@@ -381,30 +380,6 @@ function DomainOverviewPage() {
</DetailPanel.Section>
</TabsContent>
<TabsContent value="dns" className="flex flex-col gap-4">
<DetailPanel.Section
title="DNS-записи"
description="Управление записями зоны в Cloudflare"
>
<p className="text-muted-foreground text-sm">
Полный редактор DNS вынесен на отдельную страницу.
</p>
<Button
variant="outline"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId }}
search={{ host: undefined }}
/>
}
>
Открыть DNS
</Button>
</DetailPanel.Section>
</TabsContent>
<TabsContent value="availability" className="flex flex-col gap-4">
<DomainAvailabilityPanel domainId={id} />
</TabsContent>
@@ -444,7 +419,7 @@ function DomainOverviewPage() {
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4">
<DomainBindingsCard bindings={bindings} />
<DomainBindingsPanel bindings={bindings} />
</TabsContent>
</CountedLineTabs>
@@ -457,6 +432,7 @@ function DomainOverviewPage() {
currentServiceId={
editTarget ? resolveServiceId(editTarget) : 'none'
}
serviceLinks={editTarget?.serviceLinks ?? []}
open={sheetOpen}
isSaving={isSheetSaving}
onOpenChange={setSheetOpen}
+37 -5
View File
@@ -1,6 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ServerIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -46,10 +46,13 @@ import { Button } from '@cfdm/ui/components/button'
export const Route = createFileRoute('/_auth/services')({
validateSearch: (
search: Record<string, unknown>,
): { domainId?: number; view?: 'board' } => ({
): { domainId?: number; serviceId?: number; view?: 'board' } => ({
...(search.domainId != null && search.domainId !== ''
? { domainId: Number(search.domainId) }
: {}),
...(search.serviceId != null && search.serviceId !== ''
? { serviceId: Number(search.serviceId) }
: {}),
...(search.view === 'board' ? { view: 'board' as const } : {}),
}),
loader: ({ context: { queryClient } }) =>
@@ -133,7 +136,7 @@ function flattenServices(
}
function ServicesPage() {
const { domainId, view: viewParam } = Route.useSearch()
const { domainId, serviceId, view: viewParam } = Route.useSearch()
const view = viewParam === 'board' ? 'board' : 'catalog'
const navigate = useNavigate({ from: Route.fullPath })
const [createSheetOpen, setCreateSheetOpen] = useState(false)
@@ -183,6 +186,30 @@ function ServicesPage() {
[data, domainId],
)
useEffect(() => {
if (serviceId == null || !data || editingService) return
const fromGroups = data.groups
.flatMap((group) => group.services)
.find((service) => service.id === serviceId)
const fromUngrouped = data.ungrouped.find(
(service) => service.id === serviceId,
)
const target = fromGroups ?? fromUngrouped
if (!target) return
setEditingService(target)
}, [serviceId, data, editingService])
function clearServiceSearch() {
if (serviceId == null) return
navigate({
search: (prev) => {
const next = { ...prev }
delete next.serviceId
return next
},
})
}
function setView(next: 'catalog' | 'board') {
navigate({
search: (prev) => ({
@@ -236,7 +263,7 @@ function ServicesPage() {
if (!hasConfig) return created
return api.patch<ServiceView>(`/api/v1/services/${created.id}`, {
ips: body.ips,
...(body.domains.length > 0 ? { domains: body.domains } : {}),
domains: body.domains,
service_group_id: body.service_group_id ?? null,
})
},
@@ -256,6 +283,7 @@ function ServicesPage() {
onSuccess: () => {
invalidateAll()
setEditingService(null)
clearServiceSearch()
toast.success('Сервис сохранён')
},
onError: (err) => {
@@ -272,6 +300,7 @@ function ServicesPage() {
invalidateAll()
setEditingService(null)
setDeletingService(null)
clearServiceSearch()
toast.success('Сервис удалён')
},
onError: (err) => {
@@ -460,7 +489,10 @@ function ServicesPage() {
isSaving={editingService !== null && savingId === editingService.id}
isDeleting={editingService !== null && deletingId === editingService.id}
onOpenChange={(open) => {
if (!open) setEditingService(null)
if (!open) {
setEditingService(null)
clearServiceSearch()
}
}}
onSave={handleSave}
onDelete={handleDelete}
+3
View File
@@ -32,6 +32,9 @@ health-check работают на двух уровнях:
- **Общий домен группы** — A-записи формируются из IP сервисов группы; режим LB
и параметры health-check настраиваются в карточке группы.
- **Несколько FQDN на сервис** — через `service_bindings` один сервис может быть
привязан к нескольким hostname в разных зонах (уникальность
`(domain_id, service_id, hostname)`).
- **Привязка сервиса с multi-A** — режим LB и health-check настраиваются в карточке
сервиса для каждой привязки с несколькими IP; для IP задаются вес/приоритет.
+50 -37
View File
@@ -4,6 +4,7 @@ import {
primaryKey,
sqliteTable,
text,
unique,
} from "drizzle-orm/sqlite-core";
export const groups = sqliteTable("groups", {
@@ -130,43 +131,55 @@ export const dnsRecords = sqliteTable("dns_records", {
.default(sql`datetime('now')`),
});
export const serviceBindings = sqliteTable("service_bindings", {
id: integer("id").primaryKey({ autoIncrement: true }),
domain_id: integer("domain_id")
.notNull()
.references(() => domains.id, { onDelete: "cascade" }),
service_id: integer("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull().default("@"),
cname_target: text("cname_target"),
dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, {
onDelete: "set null",
}),
lb_mode: text("lb_mode").notNull().default("round_robin"),
health_check_enabled: integer("health_check_enabled", { mode: "boolean" })
.notNull()
.default(false),
health_check_type: text("health_check_type").notNull().default("tcp"),
health_check_port: integer("health_check_port"),
health_check_path: text("health_check_path"),
health_check_expected_status: integer("health_check_expected_status"),
health_check_interval_sec: integer("health_check_interval_sec")
.notNull()
.default(30),
health_check_timeout_ms: integer("health_check_timeout_ms")
.notNull()
.default(3000),
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" })
.notNull()
.default(false),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
updated_at: text("updated_at")
.notNull()
.default(sql`datetime('now')`),
});
export const serviceBindings = sqliteTable(
"service_bindings",
{
id: integer("id").primaryKey({ autoIncrement: true }),
domain_id: integer("domain_id")
.notNull()
.references(() => domains.id, { onDelete: "cascade" }),
service_id: integer("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull().default("@"),
cname_target: text("cname_target"),
dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, {
onDelete: "set null",
}),
lb_mode: text("lb_mode").notNull().default("round_robin"),
health_check_enabled: integer("health_check_enabled", { mode: "boolean" })
.notNull()
.default(false),
health_check_type: text("health_check_type").notNull().default("tcp"),
health_check_port: integer("health_check_port"),
health_check_path: text("health_check_path"),
health_check_expected_status: integer("health_check_expected_status"),
health_check_interval_sec: integer("health_check_interval_sec")
.notNull()
.default(30),
health_check_timeout_ms: integer("health_check_timeout_ms")
.notNull()
.default(3000),
health_check_verify_tls: integer("health_check_verify_tls", {
mode: "boolean",
})
.notNull()
.default(false),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
updated_at: text("updated_at")
.notNull()
.default(sql`datetime('now')`),
},
(table) => [
unique("service_bindings_domain_service_hostname").on(
table.domain_id,
table.service_id,
table.hostname,
),
],
);
export const serviceIps = sqliteTable("service_ips", {
id: integer("id").primaryKey({ autoIncrement: true }),