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 <[email protected]>
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
@@ -1172,7 +1172,6 @@ export async function updateConfig(
let removedBindingIds: number[] = []; let removedBindingIds: number[] = [];
if (req.domains) { if (req.domains) {
if (req.domains.length > 0) {
for (const input of req.domains) { for (const input of req.domains) {
const fqdn = input.fqdn.trim(); const fqdn = input.fqdn.trim();
if (!fqdn) continue; if (!fqdn) continue;
@@ -1262,7 +1261,6 @@ export async function updateConfig(
); );
} }
repos.deleteBindingsExcept(db, id, keptBindingIds); repos.deleteBindingsExcept(db, id, keptBindingIds);
}
} else if (ipsUpdated) { } else if (ipsUpdated) {
const bindings = repos.listBindingsByService(db, id); const bindings = repos.listBindingsByService(db, id);
for (const binding of bindings) { for (const binding of bindings) {
@@ -1278,8 +1276,11 @@ export async function updateConfig(
} }
service = repos.getService(db, id); service = repos.getService(db, id);
const remainingBindings = repos.listBindingsByService(db, id);
if (shouldPushDns(db, service)) { if (shouldPushDns(db, service)) {
if (remainingBindings.length > 0) {
await syncServiceBindingsToDns(db, cf, id); await syncServiceBindingsToDns(db, cf, id);
}
await syncGroupDomainForService(db, cf, id); await syncGroupDomainForService(db, cf, id);
} else if ( } else if (
req.domains && 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', '**/services-board/*-row.tsx',
'**/groups-board/*-row.tsx', '**/groups-board/*-row.tsx',
'**/layout/app-shell.tsx', '**/layout/app-shell.tsx',
'**/service-binding-card.tsx',
], ],
rules: { rules: {
'no-restricted-syntax': 'off', '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 { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table' import { SearchIcon } from 'lucide-react'
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters' import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
import { StatusBadge } from '@/components/status-badge'
import type { ServiceView } from '@/lib/schemas' 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 { export interface ServiceCatalogRow {
id: number id: number
@@ -50,126 +39,18 @@ export function useServiceFilterFields() {
{ {
key: 'name', key: 'name',
label: 'Название', label: 'Название',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text', type: 'text',
className: 'w-52', icon: <SearchIcon className="size-4" />,
placeholder: 'Поиск…',
}, },
], ],
[], [],
) )
} }
export function serviceFilterFieldValue(item: ServiceCatalogRow, field: string) { export function serviceFilterFieldValue(
switch (field) { item: ServiceCatalogRow,
case 'name': field: string,
return `${item.name} ${item.slug} ${item.groupName ?? ''}`.toLowerCase() ): string {
default: if (field === 'name') return item.name.toLowerCase()
return '' 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 }
} }
@@ -24,7 +24,7 @@ import {
ItemTitle, ItemTitle,
} from '@cfdm/ui/components/item' } from '@cfdm/ui/components/item'
interface DomainBindingsCardProps { interface DomainBindingsPanelProps {
bindings: ServiceBinding[] bindings: ServiceBinding[]
} }
@@ -67,11 +67,24 @@ function HostnameIpsHealth({
) )
} }
function uniqueServices(bindings: ServiceBinding[]): string[] { function uniqueServiceEntries(
return [...new Set(bindings.map((b) => b.service_name))] 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 byHostname = groupBindingsByHostname(bindings)
const entries = [...byHostname.entries()] const entries = [...byHostname.entries()]
@@ -103,13 +116,25 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
), ),
), ),
] ]
const services = uniqueServiceEntries(groupBindings)
return ( return (
<div key={hostname}> <div key={hostname}>
<Item size="sm" variant="muted" className="border-0 px-0"> <Item size="sm" variant="muted" className="border-0 px-0">
<ItemContent className="gap-1"> <ItemContent className="gap-1">
<ItemTitle className="font-mono">{hostname}</ItemTitle> <ItemTitle className="font-mono">{hostname}</ItemTitle>
<div className="text-muted-foreground text-sm"> <div className="flex flex-wrap items-center gap-1.5">
{uniqueServices(groupBindings).join(', ')} {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> </div>
<HostnameIpsHealth <HostnameIpsHealth
bindings={groupBindings} bindings={groupBindings}
@@ -134,7 +159,12 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
variant="link" variant="link"
className="h-auto p-0" className="h-auto p-0"
nativeButton={false} nativeButton={false}
render={<Link to="/services" search={{ domainId: bindings[0]?.domain_id }} />} render={
<Link
to="/services"
search={{ domainId: bindings[0]?.domain_id }}
/>
}
> >
К сервисам К сервисам
</Button> </Button>
@@ -142,3 +172,4 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
</Frame> </Frame>
) )
} }
@@ -3,7 +3,7 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { HealthCheckBadge } from '@/components/health-check-badge' import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-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 type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { import {
@@ -44,8 +44,6 @@ export function ServiceKanbanCard({
onEdit, onEdit,
onDelete, onDelete,
}: ServiceKanbanCardProps) { }: ServiceKanbanCardProps) {
const fqdn = serviceDisplayFqdn(service)
return ( return (
<Item <Item
variant="outline" variant="outline"
@@ -80,13 +78,7 @@ export function ServiceKanbanCard({
</ItemHeader> </ItemHeader>
<ItemContent className="min-w-0 gap-2"> <ItemContent className="min-w-0 gap-2">
{fqdn && fqdn !== '—' ? ( <ServiceFqdnList service={service} />
<span className="text-muted-foreground truncate font-mono text-xs">
{fqdn}
</span>
) : (
<span className="text-muted-foreground text-xs">FQDN не задан</span>
)}
</ItemContent> </ItemContent>
<ItemFooter className="min-w-0 justify-between gap-2"> <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, ServiceView,
UpdateServiceConfigInput, UpdateServiceConfigInput,
} from '@/lib/schemas' } 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 { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -247,8 +249,7 @@ export function ServiceEditSheet({
setBindings((current) => current.filter((_, i) => i !== index)) setBindings((current) => current.filter((_, i) => i !== index))
} }
function handleFqdnChange(index: number, tags: string[]) { function handleFqdnChange(index: number, fqdn: string) {
const fqdn = tags[0] ?? ''
setBindings((current) => setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, fqdn } : item)), current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
) )
@@ -340,14 +341,22 @@ export function ServiceEditSheet({
function handleSubmit() { function handleSubmit() {
const domains = buildDomainsPayload(bindings) 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 groupId = resolveServiceGroupId()
const lbFields = groupHasDomain const lbFields = groupHasDomain
? { lb_weight: lbWeight, lb_priority: lbPriority } ? { lb_weight: lbWeight, lb_priority: lbPriority }
: {} : {}
const configPayload = { const configPayload = {
ips, ips,
domains,
...lbFields, ...lbFields,
...(domains.length > 0 ? { domains } : {}),
} }
if (mode === 'create') { if (mode === 'create') {
onCreate?.({ onCreate?.({
@@ -385,8 +394,9 @@ export function ServiceEditSheet({
<SheetHeader className="shrink-0 border-b pb-4"> <SheetHeader className="shrink-0 border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle> <SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription> <SheetDescription>
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Зона определяется из FQDN Настройте параметры сервиса и привязки FQDN → IP или CNAME. Один
автоматически. сервис может иметь несколько FQDN в разных зонах; зона определяется
из FQDN автоматически.
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
@@ -502,7 +512,7 @@ export function ServiceEditSheet({
<TabsContent value="bindings" className="flex flex-col gap-4"> <TabsContent value="bindings" className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
FQDN → IP или CNAME для DNS-записей Cloudflare Несколько FQDN в разных зонах → IP или CNAME для DNS Cloudflare
</p> </p>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}> <Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" /> <PlusIcon data-icon="inline-start" />
@@ -514,7 +524,7 @@ export function ServiceEditSheet({
<EmptyState <EmptyState
icon={Link2Icon} icon={Link2Icon}
title="Нет привязок" title="Нет привязок"
description="Необязательно. Пример: newdom.ivx.su — зона ivx.su определится автоматически." description="Необязательно. Можно добавить несколько FQDN: api.ivx.su и www.other.su — зоны определятся автоматически."
centered={false} centered={false}
action={ action={
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}> <Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
@@ -533,22 +543,25 @@ export function ServiceEditSheet({
binding.record_type === 'A' && binding.record_type === 'A' &&
binding.target_ips.length > 1 && binding.target_ips.length > 1 &&
binding.lb_mode !== 'round_robin' binding.lb_mode !== 'round_robin'
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
return ( return (
<Item key={`binding-${index}`} variant="outline" className="items-stretch"> <Item key={`binding-${index}`} variant="outline" className="items-stretch">
<ItemContent className="w-full flex flex-col gap-3"> <ItemContent className="w-full flex flex-col gap-3">
<div className="flex items-end gap-2"> <div className="flex items-center justify-between gap-2">
<Field className="min-w-0 flex-1"> <div className="flex min-w-0 items-center gap-2">
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel> <span className="text-sm font-medium">
<TaggedInput Привязка {index + 1}
id={`binding-fqdn-${index}`} </span>
value={binding.fqdn ? [binding.fqdn] : []} {parsedZone ? (
onChange={(tags) => handleFqdnChange(index, tags)} <Badge variant="outline" size="xs" className="font-mono">
placeholder={ {parsedZone.zoneName}
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su' </Badge>
} ) : binding.fqdn.trim() ? (
maxItems={1} <Badge variant="warning-light" size="xs">
/> зона не найдена
</Field> </Badge>
) : null}
</div>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -560,6 +573,20 @@ export function ServiceEditSheet({
<Trash2Icon /> <Trash2Icon />
</Button> </Button>
</div> </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> <Field>
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel> <FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
<Select <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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { HealthCheckBadge } from '@/components/health-check-badge' import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { serviceDisplayFqdn } from '@/lib/service-utils'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas' import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { import {
DropdownMenu, DropdownMenu,
@@ -126,9 +126,7 @@ export function createServicesGroupedColumns({
<span className="truncate text-sm font-medium"> <span className="truncate text-sm font-medium">
{original.service.name} {original.service.name}
</span> </span>
<span className="text-muted-foreground truncate font-mono text-xs"> <ServiceFqdnList service={original.service} emptyLabel="—" />
{serviceDisplayFqdn(original.service)}
</span>
</div> </div>
) )
}, },
@@ -1,14 +1,22 @@
import { Link } from '@tanstack/react-router'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { useForm, Controller } from 'react-hook-form' import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import type { CertMonitoring } from '@cfdm/shared' import type { CertMonitoring } from '@cfdm/shared'
import type { ServiceView, SubdomainRecord } from '@/lib/schemas' import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
import { certMonitoringOptions } from '@/lib/cert-monitoring' import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatServiceGroupLabel } from '@/lib/service-utils' import { formatServiceGroupLabel } from '@/lib/service-utils'
import { FormSheet } from '@/components/form-sheet' import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field' import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button' 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 { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input' import { Input } from '@cfdm/ui/components/input'
import { import {
@@ -34,6 +42,7 @@ interface SubdomainEditSheetProps {
services: ServiceView[] services: ServiceView[]
serviceGroupById: Map<number, string | null> serviceGroupById: Map<number, string | null>
currentServiceId: string currentServiceId: string
serviceLinks?: SubdomainServiceLink[]
open: boolean open: boolean
isSaving: boolean isSaving: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
@@ -47,6 +56,7 @@ export function SubdomainEditSheet({
services, services,
serviceGroupById, serviceGroupById,
currentServiceId, currentServiceId,
serviceLinks = [],
open, open,
isSaving, isSaving,
onOpenChange, onOpenChange,
@@ -104,6 +114,7 @@ export function SubdomainEditSheet({
const certMonitoring = form.watch('certMonitoring') const certMonitoring = form.watch('certMonitoring')
const certHint = const certHint =
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
function handleSubmit(values: SubdomainEditValues) { function handleSubmit(values: SubdomainEditValues) {
onSubmit({ onSubmit({
@@ -155,6 +166,34 @@ export function SubdomainEditSheet({
</FormFieldSimple> </FormFieldSimple>
{mode === 'edit' ? ( {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"> <FormFieldSimple label="Сервис" htmlFor="subdomain_service">
<Controller <Controller
control={form.control} control={form.control}
+6 -5
View File
@@ -24,12 +24,13 @@ export function buildServiceGroupNameById(
return map return map
} }
export function serviceDisplayFqdns(service: ServiceView): string[] {
return (service.domains ?? []).map((binding) => bindingToFqdn(binding))
}
export function serviceDisplayFqdn(service: ServiceView): string { export function serviceDisplayFqdn(service: ServiceView): string {
const first = service.domains?.[0] const fqdns = serviceDisplayFqdns(service)
if (first) { return fqdns[0] ?? '—'
return bindingToFqdn(first)
}
return '—'
} }
export function aggregateServiceSyncStatus(service: ServiceView): string | null { export function aggregateServiceSyncStatus(service: ServiceView): string | null {
@@ -38,7 +38,7 @@ import {
SubdomainEditSheet, SubdomainEditSheet,
type SubdomainEditValues, type SubdomainEditValues,
} from '@/components/subdomain-edit-sheet' } 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 { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring' import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
@@ -326,7 +326,6 @@ function DomainOverviewPage() {
<CountedLineTabs <CountedLineTabs
tabs={[ tabs={[
{ id: 'overview', label: 'Обзор' }, { id: 'overview', label: 'Обзор' },
{ id: 'dns', label: 'DNS' },
{ id: 'availability', label: 'Доступность' }, { id: 'availability', label: 'Доступность' },
{ {
id: 'subdomains', id: 'subdomains',
@@ -381,30 +380,6 @@ function DomainOverviewPage() {
</DetailPanel.Section> </DetailPanel.Section>
</TabsContent> </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"> <TabsContent value="availability" className="flex flex-col gap-4">
<DomainAvailabilityPanel domainId={id} /> <DomainAvailabilityPanel domainId={id} />
</TabsContent> </TabsContent>
@@ -444,7 +419,7 @@ function DomainOverviewPage() {
</TabsContent> </TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4"> <TabsContent value="bindings" className="flex flex-col gap-4">
<DomainBindingsCard bindings={bindings} /> <DomainBindingsPanel bindings={bindings} />
</TabsContent> </TabsContent>
</CountedLineTabs> </CountedLineTabs>
@@ -457,6 +432,7 @@ function DomainOverviewPage() {
currentServiceId={ currentServiceId={
editTarget ? resolveServiceId(editTarget) : 'none' editTarget ? resolveServiceId(editTarget) : 'none'
} }
serviceLinks={editTarget?.serviceLinks ?? []}
open={sheetOpen} open={sheetOpen}
isSaving={isSheetSaving} isSaving={isSheetSaving}
onOpenChange={setSheetOpen} onOpenChange={setSheetOpen}
+37 -5
View File
@@ -1,6 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' 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 { ServerIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
@@ -46,10 +46,13 @@ import { Button } from '@cfdm/ui/components/button'
export const Route = createFileRoute('/_auth/services')({ export const Route = createFileRoute('/_auth/services')({
validateSearch: ( validateSearch: (
search: Record<string, unknown>, search: Record<string, unknown>,
): { domainId?: number; view?: 'board' } => ({ ): { domainId?: number; serviceId?: number; view?: 'board' } => ({
...(search.domainId != null && search.domainId !== '' ...(search.domainId != null && search.domainId !== ''
? { domainId: Number(search.domainId) } ? { domainId: Number(search.domainId) }
: {}), : {}),
...(search.serviceId != null && search.serviceId !== ''
? { serviceId: Number(search.serviceId) }
: {}),
...(search.view === 'board' ? { view: 'board' as const } : {}), ...(search.view === 'board' ? { view: 'board' as const } : {}),
}), }),
loader: ({ context: { queryClient } }) => loader: ({ context: { queryClient } }) =>
@@ -133,7 +136,7 @@ function flattenServices(
} }
function ServicesPage() { function ServicesPage() {
const { domainId, view: viewParam } = Route.useSearch() const { domainId, serviceId, view: viewParam } = Route.useSearch()
const view = viewParam === 'board' ? 'board' : 'catalog' const view = viewParam === 'board' ? 'board' : 'catalog'
const navigate = useNavigate({ from: Route.fullPath }) const navigate = useNavigate({ from: Route.fullPath })
const [createSheetOpen, setCreateSheetOpen] = useState(false) const [createSheetOpen, setCreateSheetOpen] = useState(false)
@@ -183,6 +186,30 @@ function ServicesPage() {
[data, domainId], [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') { function setView(next: 'catalog' | 'board') {
navigate({ navigate({
search: (prev) => ({ search: (prev) => ({
@@ -236,7 +263,7 @@ function ServicesPage() {
if (!hasConfig) return created if (!hasConfig) return created
return api.patch<ServiceView>(`/api/v1/services/${created.id}`, { return api.patch<ServiceView>(`/api/v1/services/${created.id}`, {
ips: body.ips, ips: body.ips,
...(body.domains.length > 0 ? { domains: body.domains } : {}), domains: body.domains,
service_group_id: body.service_group_id ?? null, service_group_id: body.service_group_id ?? null,
}) })
}, },
@@ -256,6 +283,7 @@ function ServicesPage() {
onSuccess: () => { onSuccess: () => {
invalidateAll() invalidateAll()
setEditingService(null) setEditingService(null)
clearServiceSearch()
toast.success('Сервис сохранён') toast.success('Сервис сохранён')
}, },
onError: (err) => { onError: (err) => {
@@ -272,6 +300,7 @@ function ServicesPage() {
invalidateAll() invalidateAll()
setEditingService(null) setEditingService(null)
setDeletingService(null) setDeletingService(null)
clearServiceSearch()
toast.success('Сервис удалён') toast.success('Сервис удалён')
}, },
onError: (err) => { onError: (err) => {
@@ -460,7 +489,10 @@ function ServicesPage() {
isSaving={editingService !== null && savingId === editingService.id} isSaving={editingService !== null && savingId === editingService.id}
isDeleting={editingService !== null && deletingId === editingService.id} isDeleting={editingService !== null && deletingId === editingService.id}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setEditingService(null) if (!open) {
setEditingService(null)
clearServiceSearch()
}
}} }}
onSave={handleSave} onSave={handleSave}
onDelete={handleDelete} onDelete={handleDelete}
+3
View File
@@ -32,6 +32,9 @@ health-check работают на двух уровнях:
- **Общий домен группы** — A-записи формируются из IP сервисов группы; режим LB - **Общий домен группы** — A-записи формируются из IP сервисов группы; режим LB
и параметры health-check настраиваются в карточке группы. и параметры health-check настраиваются в карточке группы.
- **Несколько FQDN на сервис** — через `service_bindings` один сервис может быть
привязан к нескольким hostname в разных зонах (уникальность
`(domain_id, service_id, hostname)`).
- **Привязка сервиса с multi-A** — режим LB и health-check настраиваются в карточке - **Привязка сервиса с multi-A** — режим LB и health-check настраиваются в карточке
сервиса для каждой привязки с несколькими IP; для IP задаются вес/приоритет. сервиса для каждой привязки с несколькими IP; для IP задаются вес/приоритет.
+16 -3
View File
@@ -4,6 +4,7 @@ import {
primaryKey, primaryKey,
sqliteTable, sqliteTable,
text, text,
unique,
} from "drizzle-orm/sqlite-core"; } from "drizzle-orm/sqlite-core";
export const groups = sqliteTable("groups", { export const groups = sqliteTable("groups", {
@@ -130,7 +131,9 @@ export const dnsRecords = sqliteTable("dns_records", {
.default(sql`datetime('now')`), .default(sql`datetime('now')`),
}); });
export const serviceBindings = sqliteTable("service_bindings", { export const serviceBindings = sqliteTable(
"service_bindings",
{
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
domain_id: integer("domain_id") domain_id: integer("domain_id")
.notNull() .notNull()
@@ -157,7 +160,9 @@ export const serviceBindings = sqliteTable("service_bindings", {
health_check_timeout_ms: integer("health_check_timeout_ms") health_check_timeout_ms: integer("health_check_timeout_ms")
.notNull() .notNull()
.default(3000), .default(3000),
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" }) health_check_verify_tls: integer("health_check_verify_tls", {
mode: "boolean",
})
.notNull() .notNull()
.default(false), .default(false),
created_at: text("created_at") created_at: text("created_at")
@@ -166,7 +171,15 @@ export const serviceBindings = sqliteTable("service_bindings", {
updated_at: text("updated_at") updated_at: text("updated_at")
.notNull() .notNull()
.default(sql`datetime('now')`), .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", { export const serviceIps = sqliteTable("service_ips", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),