feat(services): сделать multi-FQDN привязки first-class в UI
Исправить prune при domains: [], показать все FQDN в каталоге/kanban, улучшить sheet привязок и панель на домене; убрать мёртвый код. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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 ''
|
||||
}
|
||||
|
||||
+38
-7
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user