Update pnpm-lock.yaml to include new dependencies for @dnd-kit packages; enhance frontend documentation with MCP patterns and shadcn guidelines; refactor route handling for groups and services; implement new DataTableCard and KanbanBoard components for better domain and service management; add service binding functionality and improve DNS record management with new schemas and queries.
Build, Test, and Push CFDM Docker Image / test (push) Failing after 16h45m4s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / test (push) Failing after 16h45m4s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
This commit is contained in:
@@ -10,12 +10,18 @@ interface DataTableCardProps {
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
isEmpty?: boolean
|
||||
}
|
||||
|
||||
export function DataTableCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
isEmpty,
|
||||
}: DataTableCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -23,7 +29,16 @@ export function DataTableCard({
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">{children}</CardContent>
|
||||
<CardContent className="p-0">
|
||||
{isEmpty && emptyTitle ? (
|
||||
<div className="flex flex-col gap-1 p-6 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">{emptyTitle}</p>
|
||||
{emptyDescription && <p>{emptyDescription}</p>}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping'
|
||||
import type { DnsRecord } from '@/lib/schemas'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface DnsRecordsTableProps {
|
||||
records: DnsRecord[]
|
||||
onDelete: (recordId: number) => void
|
||||
isDeleting?: boolean
|
||||
}
|
||||
|
||||
function DnsRecordCells({
|
||||
record,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}: {
|
||||
record: DnsRecord
|
||||
onDelete: (recordId: number) => void
|
||||
isDeleting?: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TableCell className="max-w-xs truncate font-mono text-sm">{record.content}</TableCell>
|
||||
<TableCell className="tabular-nums">{record.ttl}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={record.sync_status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDelete(record.id)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</TableCell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SingleRecordRow({
|
||||
record,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
isFirstGroup,
|
||||
}: {
|
||||
record: DnsRecord
|
||||
onDelete: (recordId: number) => void
|
||||
isDeleting?: boolean
|
||||
isFirstGroup: boolean
|
||||
}) {
|
||||
return (
|
||||
<TableRow className={cn(!isFirstGroup && 'border-t-4 border-muted')}>
|
||||
<TableCell>{record.record_type}</TableCell>
|
||||
<TableCell className="font-medium">{record.name}</TableCell>
|
||||
<DnsRecordCells record={record} onDelete={onDelete} isDeleting={isDeleting} />
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function MultiValueGroupRows({
|
||||
group,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
isFirstGroup,
|
||||
}: {
|
||||
group: DnsRecordGroup
|
||||
onDelete: (recordId: number) => void
|
||||
isDeleting?: boolean
|
||||
isFirstGroup: boolean
|
||||
}) {
|
||||
const [first, ...rest] = group.records
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className={cn(
|
||||
'bg-muted/25',
|
||||
!isFirstGroup && 'border-t-4 border-muted',
|
||||
)}
|
||||
>
|
||||
<TableCell rowSpan={group.records.length} className="align-top">
|
||||
<Badge variant="outline">{group.recordType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell rowSpan={group.records.length} className="align-top font-medium">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{group.name}</span>
|
||||
<Badge variant="secondary" className="w-fit">
|
||||
{group.records.length} адресов
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<DnsRecordCells record={first} onDelete={onDelete} isDeleting={isDeleting} />
|
||||
</TableRow>
|
||||
{rest.map((record) => (
|
||||
<TableRow key={record.id} className="bg-muted/25">
|
||||
<DnsRecordCells record={record} onDelete={onDelete} isDeleting={isDeleting} />
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTableProps) {
|
||||
const groups = groupDnsRecords(records)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">Тип</TableHead>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Значение</TableHead>
|
||||
<TableHead className="w-16">TTL</TableHead>
|
||||
<TableHead>Синхронизация</TableHead>
|
||||
<TableHead className="w-28" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group, index) =>
|
||||
group.isMultiValue ? (
|
||||
<MultiValueGroupRows
|
||||
key={group.key}
|
||||
group={group}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
isFirstGroup={index === 0}
|
||||
/>
|
||||
) : (
|
||||
<SingleRecordRow
|
||||
key={group.records[0].id}
|
||||
record={group.records[0]}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
isFirstGroup={index === 0}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { groupBindingsByHostname } from '@/lib/domain-ips'
|
||||
import type { ServiceBinding } from '@/lib/schemas'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
|
||||
interface DomainBindingsCardProps {
|
||||
bindings: ServiceBinding[]
|
||||
}
|
||||
|
||||
function uniqueIps(bindings: ServiceBinding[]): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
bindings.map((b) => b.target_ip).filter((ip): ip is string => Boolean(ip)),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
||||
const byHostname = groupBindingsByHostname(bindings)
|
||||
const entries = [...byHostname.entries()]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Привязки сервисов</CardTitle>
|
||||
<CardDescription>IP-адреса, назначенные сервисам в этой зоне</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entries.length === 0 ? (
|
||||
<Empty className="border border-dashed p-4">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Нет привязок</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Создайте привязку на странице сервисов — IP появится здесь после синхронизации DNS
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
<ItemGroup className="gap-0">
|
||||
{entries.map(([hostname, hostnameBindings], index) => {
|
||||
const ips = uniqueIps(hostnameBindings)
|
||||
const services = [...new Set(hostnameBindings.map((b) => b.service_name))]
|
||||
|
||||
return (
|
||||
<div key={hostname}>
|
||||
<Item variant="outline">
|
||||
<ItemContent className="gap-2">
|
||||
<ItemTitle className="font-mono">{hostname}</ItemTitle>
|
||||
<ItemDescription>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ips.length > 0 ? (
|
||||
ips.map((ip) => (
|
||||
<Badge key={ip} variant="secondary" className="font-mono">
|
||||
{ip}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">IP не задан</span>
|
||||
)}
|
||||
</div>
|
||||
</ItemDescription>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{services.map((name) => (
|
||||
<Badge key={name}>{name}</Badge>
|
||||
))}
|
||||
{[
|
||||
...new Set(
|
||||
hostnameBindings
|
||||
.map((b) => b.sync_status)
|
||||
.filter((s): s is string => Boolean(s)),
|
||||
),
|
||||
].map((status) => (
|
||||
<StatusBadge key={status} status={status} />
|
||||
))}
|
||||
</div>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={<Link to="/services" />}
|
||||
>
|
||||
Сервисы
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
{index < entries.length - 1 && <ItemSeparator />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</CardContent>
|
||||
{entries.length > 0 && (
|
||||
<CardFooter>
|
||||
<Button variant="link" className="h-auto p-0" render={<Link to="/services" />}>
|
||||
Управление привязками
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GlobeIcon } from 'lucide-react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
|
||||
interface DomainGroupCardProps {
|
||||
domain: DomainListItem
|
||||
serviceLabels?: string[]
|
||||
}
|
||||
|
||||
export function DomainGroupCard({ domain, serviceLabels = [] }: DomainGroupCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: String(domain.id),
|
||||
})
|
||||
|
||||
const style = transform
|
||||
? { transform: CSS.Translate.toString(transform) }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
size="sm"
|
||||
style={style}
|
||||
className={cn(
|
||||
'cursor-grab bg-card active:cursor-grabbing',
|
||||
isDragging && 'opacity-60 shadow-lg',
|
||||
)}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<GlobeIcon />
|
||||
{domain.zone_name}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<StatusBadge status={domain.status} />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
{(serviceLabels.length > 0 || domain.service_count > 0) && (
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{serviceLabels.length > 0
|
||||
? serviceLabels.map((label) => (
|
||||
<Badge key={label} variant="outline">
|
||||
{label}
|
||||
</Badge>
|
||||
))
|
||||
: (
|
||||
<Badge variant="outline">
|
||||
{domain.service_count} сервис(ов)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(domain.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
|
||||
interface DomainIpBadgesProps {
|
||||
ips: string[]
|
||||
}
|
||||
|
||||
function IpBadge({ ip }: { ip: string }) {
|
||||
return (
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{ip}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function DomainIpBadges({ ips }: DomainIpBadgesProps) {
|
||||
if (ips.length === 0) {
|
||||
return <span className="text-muted-foreground">—</span>
|
||||
}
|
||||
|
||||
if (ips.length === 1) {
|
||||
return <IpBadge ip={ips[0]} />
|
||||
}
|
||||
|
||||
const [first, ...rest] = ips
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<IpBadge ip={first} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Badge variant="outline" className="cursor-default font-mono tabular-nums" />
|
||||
}
|
||||
>
|
||||
+{rest.length}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs p-2">
|
||||
<ItemGroup className="gap-0">
|
||||
{ips.map((ip, index) => (
|
||||
<div key={ip}>
|
||||
<Item size="xs" variant="muted" className="border-0 px-2 py-1">
|
||||
<ItemContent>
|
||||
<ItemTitle className="font-mono font-normal">{ip}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
{index < ips.length - 1 && <ItemSeparator className="my-0" />}
|
||||
</div>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
import {
|
||||
ArrowUpDownIcon,
|
||||
GlobeIcon,
|
||||
MoreHorizontalIcon,
|
||||
SearchIcon,
|
||||
} from 'lucide-react'
|
||||
import { DomainIpBadges } from '@/components/domain-ip-badges'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@cfdm/ui/components/input-group'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
|
||||
export interface DomainTableRow extends DomainListItem {
|
||||
ips: string[]
|
||||
}
|
||||
|
||||
interface GroupFilterItem {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface DomainsDataTableProps {
|
||||
data: DomainTableRow[]
|
||||
groupFilterItems: GroupFilterItem[]
|
||||
groupFilterValue: string
|
||||
onGroupFilterChange: (value: string | null) => void
|
||||
}
|
||||
|
||||
export function DomainsDataTable({
|
||||
data,
|
||||
groupFilterItems,
|
||||
groupFilterValue,
|
||||
onGroupFilterChange,
|
||||
}: DomainsDataTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
|
||||
const columns = useMemo<ColumnDef<DomainTableRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'zone_name',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
Зона
|
||||
<ArrowUpDownIcon />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-medium"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{row.original.zone_name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
accessorFn: (row) => row.group_name ?? 'Без группы',
|
||||
header: 'Группа',
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original
|
||||
if (domain.group_id && domain.group_name) {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
render={
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: String(domain.group_id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{domain.group_name}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return <Badge variant="outline">Без группы</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ips',
|
||||
accessorFn: (row) => row.ips.join(' '),
|
||||
header: 'IP-адреса',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <DomainIpBadges ips={row.original.ips} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'service_count',
|
||||
header: 'Сервисы',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 tabular-nums"
|
||||
render={<Link to="/services" />}
|
||||
>
|
||||
{row.original.service_count}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_synced_at',
|
||||
header: 'Синхронизация',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{row.original.last_synced_at ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon" className="size-8" />
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">Действия</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: { sorting, columnFilters },
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: {
|
||||
pagination: { pageSize: 10 },
|
||||
},
|
||||
})
|
||||
|
||||
const filterGroupItems = useMemo(
|
||||
() => [{ label: 'Все группы', value: 'all' }, ...groupFilterItems],
|
||||
[groupFilterItems],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center">
|
||||
<InputGroup className="max-w-sm">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Поиск по зоне…"
|
||||
value={(table.getColumn('zone_name')?.getFilterValue() as string) ?? ''}
|
||||
onChange={(event) =>
|
||||
table.getColumn('zone_name')?.setFilterValue(event.target.value)
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Select
|
||||
items={filterGroupItems}
|
||||
value={groupFilterValue || 'all'}
|
||||
onValueChange={onGroupFilterChange}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-56">
|
||||
<SelectValue placeholder="Все группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterGroupItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground sm:ml-auto">
|
||||
<GlobeIcon className="size-4" />
|
||||
<span className="tabular-nums">
|
||||
{table.getFilteredRowModel().rows.length} зон
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden px-4 pb-4">
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={header.id === 'actions' ? 'w-12 text-right' : undefined}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cell.column.id === 'actions' ? 'text-right' : undefined}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-48 p-0">
|
||||
<Empty className="border-0">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Домены не найдены</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Импортируйте зону из Cloudflare или измените фильтры
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{table.getPageCount() > 1 && (
|
||||
<div className="flex items-center justify-end gap-2 px-4 pb-4">
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
Стр. {table.getState().pagination.pageIndex + 1} из {table.getPageCount()}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Вперёд
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { KanbanColumn } from '@/components/kanban-column'
|
||||
import { ScrollArea, ScrollBar } from '@cfdm/ui/components/scroll-area'
|
||||
|
||||
export interface KanbanColumnDef<T> {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
href?: string
|
||||
items: T[]
|
||||
}
|
||||
|
||||
interface KanbanBoardProps<T> {
|
||||
columns: KanbanColumnDef<T>[]
|
||||
getItemId: (item: T) => string
|
||||
renderCard: (item: T) => ReactNode
|
||||
renderOverlay?: (item: T) => ReactNode
|
||||
onMove: (itemId: string, fromColumnId: string, toColumnId: string) => void
|
||||
}
|
||||
|
||||
function findColumnForItem<T>(
|
||||
columns: KanbanColumnDef<T>[],
|
||||
itemId: string,
|
||||
getItemId: (item: T) => string,
|
||||
): string | undefined {
|
||||
return columns.find((col) => col.items.some((item) => getItemId(item) === itemId))?.id
|
||||
}
|
||||
|
||||
export function KanbanBoard<T>({
|
||||
columns,
|
||||
getItemId,
|
||||
renderCard,
|
||||
renderOverlay,
|
||||
onMove,
|
||||
}: KanbanBoardProps<T>) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
)
|
||||
|
||||
const activeItem = activeId
|
||||
? columns.flatMap((c) => c.items).find((item) => getItemId(item) === activeId)
|
||||
: undefined
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveId(String(event.active.id))
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
setActiveId(null)
|
||||
if (!over) return
|
||||
|
||||
const itemId = String(active.id)
|
||||
const fromColumnId = findColumnForItem(columns, itemId, getItemId)
|
||||
const toColumnId = String(over.id)
|
||||
if (!fromColumnId || fromColumnId === toColumnId) return
|
||||
onMove(itemId, fromColumnId, toColumnId)
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex w-max gap-4 pb-4">
|
||||
{columns.map((column) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
description={column.description}
|
||||
href={column.href}
|
||||
count={column.items.length}
|
||||
>
|
||||
{column.items.map((item) => (
|
||||
<div key={getItemId(item)}>{renderCard(item)}</div>
|
||||
))}
|
||||
</KanbanColumn>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeItem && (renderOverlay ? renderOverlay(activeItem) : renderCard(activeItem))}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
href?: string
|
||||
count: number
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function KanbanColumn({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
count,
|
||||
children,
|
||||
}: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id })
|
||||
const isEmpty = count === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex w-80 shrink-0 flex-col gap-3 rounded-xl bg-muted/50 p-4 ring-1 ring-foreground/10 transition-shadow',
|
||||
isOver && 'ring-2 ring-primary',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{href ? (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start p-0 text-base font-medium"
|
||||
render={
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: href.replace('/groups/', '') }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-base font-medium">{title}</span>
|
||||
)}
|
||||
{description && (
|
||||
<span className="text-sm text-muted-foreground">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary">{count}</Badge>
|
||||
</div>
|
||||
<div className="flex min-h-32 flex-col gap-2">
|
||||
{children}
|
||||
{isEmpty && (
|
||||
<Empty className="min-h-28 border">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle className="text-xs">Пусто</EmptyTitle>
|
||||
<EmptyDescription className="text-xs">
|
||||
Перетащите домен сюда
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,13 @@ function getBreadcrumbs(pathname: string) {
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/groups\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Группы', href: '/groups' },
|
||||
{ label: 'Группа', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
|
||||
const domainId = pathname.split('/')[2]
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
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 '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
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 (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
size="sm"
|
||||
style={style}
|
||||
className={cn(
|
||||
'cursor-grab bg-card active:cursor-grabbing',
|
||||
isDragging && 'opacity-60 shadow-lg',
|
||||
)}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{binding.zone_name}</CardTitle>
|
||||
<CardAction>
|
||||
{binding.group_name ? (
|
||||
<Badge variant="secondary">{binding.group_name}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Без группы</Badge>
|
||||
)}
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup className="flex flex-col gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`hostname-${binding.id}`}>Hostname</FieldLabel>
|
||||
<Input
|
||||
id={`hostname-${binding.id}`}
|
||||
value={hostname}
|
||||
placeholder="@"
|
||||
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"
|
||||
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>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(binding.domain_id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Домен
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { cn } from '@cfdm/ui/lib/utils'
|
||||
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
|
||||
|
||||
const statusVariants: Record<string, BadgeVariant> = {
|
||||
active: 'default',
|
||||
synced: 'default',
|
||||
ok: 'default',
|
||||
pending_push: 'secondary',
|
||||
@@ -16,6 +17,7 @@ const statusVariants: Record<string, BadgeVariant> = {
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
active: 'Активен',
|
||||
synced: 'Синхронизировано',
|
||||
pending_push: 'Ожидает отправки',
|
||||
conflict: 'Конфликт',
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { DnsRecord } from '@/lib/schemas'
|
||||
|
||||
export interface DnsRecordGroup {
|
||||
key: string
|
||||
recordType: string
|
||||
name: string
|
||||
records: DnsRecord[]
|
||||
isMultiValue: boolean
|
||||
}
|
||||
|
||||
const MULTI_VALUE_TYPES = new Set(['A', 'AAAA'])
|
||||
|
||||
function groupKey(record: DnsRecord): string {
|
||||
return `${record.record_type}:${record.name}`
|
||||
}
|
||||
|
||||
export function groupDnsRecords(records: DnsRecord[]): DnsRecordGroup[] {
|
||||
const map = new Map<string, DnsRecord[]>()
|
||||
|
||||
for (const record of records) {
|
||||
const key = groupKey(record)
|
||||
const list = map.get(key) ?? []
|
||||
list.push(record)
|
||||
map.set(key, list)
|
||||
}
|
||||
|
||||
return [...map.entries()].map(([key, groupRecords]) => {
|
||||
const [recordType, ...nameParts] = key.split(':')
|
||||
const name = nameParts.join(':')
|
||||
const isMultiValue =
|
||||
MULTI_VALUE_TYPES.has(recordType) && groupRecords.length > 1
|
||||
|
||||
return {
|
||||
key,
|
||||
recordType,
|
||||
name,
|
||||
records: groupRecords,
|
||||
isMultiValue,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function allSameValue<T>(values: T[]): boolean {
|
||||
if (values.length <= 1) return true
|
||||
return values.every((v) => v === values[0])
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ServiceBinding } from '@/lib/schemas'
|
||||
|
||||
export function getDomainIps(bindings: ServiceBinding[], domainId: number): string[] {
|
||||
const ips = bindings
|
||||
.filter((b) => b.domain_id === domainId && b.target_ip)
|
||||
.map((b) => b.target_ip as string)
|
||||
return [...new Set(ips)]
|
||||
}
|
||||
|
||||
export function groupBindingsByHostname(
|
||||
bindings: ServiceBinding[],
|
||||
): Map<string, ServiceBinding[]> {
|
||||
const map = new Map<string, ServiceBinding[]>()
|
||||
for (const binding of bindings) {
|
||||
const list = map.get(binding.hostname) ?? []
|
||||
list.push(binding)
|
||||
map.set(binding.hostname, list)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function buildIpsByDomainId(bindings: ServiceBinding[]): Map<number, string[]> {
|
||||
const map = new Map<number, string[]>()
|
||||
for (const binding of bindings) {
|
||||
if (!binding.target_ip) continue
|
||||
const existing = map.get(binding.domain_id) ?? []
|
||||
if (!existing.includes(binding.target_ip)) {
|
||||
map.set(binding.domain_id, [...existing, binding.target_ip])
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -8,6 +8,10 @@ export const groupSchema = z.object({
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const groupWithStatsSchema = groupSchema.extend({
|
||||
domain_count: z.number(),
|
||||
})
|
||||
|
||||
export const serviceSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
@@ -27,6 +31,28 @@ export const domainSchema = z.object({
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const domainListItemSchema = domainSchema.extend({
|
||||
group_name: z.string().nullable(),
|
||||
service_count: z.number(),
|
||||
})
|
||||
|
||||
export const serviceBindingSchema = z.object({
|
||||
id: z.number(),
|
||||
domain_id: z.number(),
|
||||
service_id: z.number(),
|
||||
hostname: z.string(),
|
||||
dns_record_id: z.number().nullable(),
|
||||
zone_name: z.string(),
|
||||
group_id: z.number().nullable(),
|
||||
group_name: z.string().nullable(),
|
||||
service_name: z.string(),
|
||||
service_slug: z.string(),
|
||||
target_ip: z.string().nullable(),
|
||||
sync_status: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const dnsRecordSchema = z.object({
|
||||
id: z.number(),
|
||||
domain_id: z.number(),
|
||||
@@ -58,8 +84,11 @@ export const certificateSchema = z.object({
|
||||
})
|
||||
|
||||
export type Group = z.infer<typeof groupSchema>
|
||||
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
||||
export type Service = z.infer<typeof serviceSchema>
|
||||
export type Domain = z.infer<typeof domainSchema>
|
||||
export type DomainListItem = z.infer<typeof domainListItemSchema>
|
||||
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
|
||||
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
||||
export type Certificate = z.infer<typeof certificateSchema>
|
||||
|
||||
@@ -73,6 +102,18 @@ export const createServiceSchema = z.object({
|
||||
slug: z.string().min(1, 'Укажите slug'),
|
||||
})
|
||||
|
||||
export const createServiceBindingSchema = z.object({
|
||||
domain_id: z.string().min(1, 'Выберите домен'),
|
||||
service_id: z.string().min(1, 'Выберите сервис'),
|
||||
hostname: z.string().optional(),
|
||||
target_ip: z.string().optional(),
|
||||
})
|
||||
|
||||
export const updateDomainGroupSchema = z.object({
|
||||
group_id: z.number().nullable(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
|
||||
export const createDomainSchema = z.object({
|
||||
zone_name: z.string().min(1, 'Укажите имя зоны'),
|
||||
group_id: z.string(),
|
||||
@@ -93,6 +134,7 @@ export const createDnsRecordSchema = z.object({
|
||||
|
||||
export type CreateGroupInput = z.infer<typeof createGroupSchema>
|
||||
export type CreateServiceInput = z.infer<typeof createServiceSchema>
|
||||
export type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchema>
|
||||
export type CreateDomainInput = z.infer<typeof createDomainSchema>
|
||||
export type LoginInput = z.infer<typeof loginSchema>
|
||||
export type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { certificateSchema, dnsRecordSchema, domainSchema, groupSchema, serviceSchema } from '@/lib/schemas'
|
||||
import {
|
||||
certificateSchema,
|
||||
dnsRecordSchema,
|
||||
domainListItemSchema,
|
||||
domainSchema,
|
||||
groupSchema,
|
||||
groupWithStatsSchema,
|
||||
serviceBindingSchema,
|
||||
serviceSchema,
|
||||
} from '@/lib/schemas'
|
||||
import { subdomainSchema } from '@/lib/schemas-ext'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const groupKeys = {
|
||||
all: ['groups'] as const,
|
||||
detail: (id: number) => [...groupKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export const groupsQueryOptions = () =>
|
||||
@@ -17,6 +27,15 @@ export const groupsQueryOptions = () =>
|
||||
},
|
||||
})
|
||||
|
||||
export const groupDetailQueryOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: groupKeys.detail(id),
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown>(`/api/v1/groups/${id}`)
|
||||
return groupWithStatsSchema.parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const serviceKeys = {
|
||||
all: ['services'] as const,
|
||||
}
|
||||
@@ -30,6 +49,29 @@ export const servicesQueryOptions = () =>
|
||||
},
|
||||
})
|
||||
|
||||
export const serviceBindingKeys = {
|
||||
all: ['service-bindings'] as const,
|
||||
byDomain: (domainId: number) => [...serviceBindingKeys.all, 'domain', domainId] as const,
|
||||
}
|
||||
|
||||
export const serviceBindingsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: serviceBindingKeys.all,
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown[]>('/api/v1/service-bindings')
|
||||
return z.array(serviceBindingSchema).parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const domainServiceBindingsQueryOptions = (domainId: number) =>
|
||||
queryOptions({
|
||||
queryKey: serviceBindingKeys.byDomain(domainId),
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/service-bindings`)
|
||||
return z.array(serviceBindingSchema).parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const domainKeys = {
|
||||
all: ['domains'] as const,
|
||||
list: (groupId?: number) => [...domainKeys.all, 'list', groupId] as const,
|
||||
@@ -42,7 +84,7 @@ export const domainsListQueryOptions = (groupId?: number) =>
|
||||
queryFn: async () => {
|
||||
const qs = groupId ? `?group_id=${groupId}` : ''
|
||||
const data = await api.get<unknown[]>(`/api/v1/domains${qs}`)
|
||||
return z.array(domainSchema).parse(data)
|
||||
return z.array(domainListItemSchema).parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Route as AuthServicesRouteImport } from './routes/_auth/services'
|
||||
import { Route as AuthGroupsRouteImport } from './routes/_auth/groups'
|
||||
import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates'
|
||||
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
||||
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
|
||||
import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index'
|
||||
import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns'
|
||||
|
||||
@@ -53,6 +54,11 @@ const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({
|
||||
path: '/domains/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({
|
||||
id: '/$groupId',
|
||||
path: '/$groupId',
|
||||
getParentRoute: () => AuthGroupsRoute,
|
||||
} as any)
|
||||
const AuthDomainsDomainIdIndexRoute =
|
||||
AuthDomainsDomainIdIndexRouteImport.update({
|
||||
id: '/domains/$domainId/',
|
||||
@@ -69,8 +75,9 @@ export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthIndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/certificates': typeof AuthCertificatesRoute
|
||||
'/groups': typeof AuthGroupsRoute
|
||||
'/groups': typeof AuthGroupsRouteWithChildren
|
||||
'/services': typeof AuthServicesRoute
|
||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||
'/domains/': typeof AuthDomainsIndexRoute
|
||||
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
||||
'/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
|
||||
@@ -78,9 +85,10 @@ export interface FileRoutesByFullPath {
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/certificates': typeof AuthCertificatesRoute
|
||||
'/groups': typeof AuthGroupsRoute
|
||||
'/groups': typeof AuthGroupsRouteWithChildren
|
||||
'/services': typeof AuthServicesRoute
|
||||
'/': typeof AuthIndexRoute
|
||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||
'/domains': typeof AuthDomainsIndexRoute
|
||||
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
||||
'/domains/$domainId': typeof AuthDomainsDomainIdIndexRoute
|
||||
@@ -90,9 +98,10 @@ export interface FileRoutesById {
|
||||
'/_auth': typeof AuthRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/_auth/certificates': typeof AuthCertificatesRoute
|
||||
'/_auth/groups': typeof AuthGroupsRoute
|
||||
'/_auth/groups': typeof AuthGroupsRouteWithChildren
|
||||
'/_auth/services': typeof AuthServicesRoute
|
||||
'/_auth/': typeof AuthIndexRoute
|
||||
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
||||
'/_auth/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
||||
'/_auth/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
|
||||
@@ -105,6 +114,7 @@ export interface FileRouteTypes {
|
||||
| '/certificates'
|
||||
| '/groups'
|
||||
| '/services'
|
||||
| '/groups/$groupId'
|
||||
| '/domains/'
|
||||
| '/domains/$domainId/dns'
|
||||
| '/domains/$domainId/'
|
||||
@@ -115,6 +125,7 @@ export interface FileRouteTypes {
|
||||
| '/groups'
|
||||
| '/services'
|
||||
| '/'
|
||||
| '/groups/$groupId'
|
||||
| '/domains'
|
||||
| '/domains/$domainId/dns'
|
||||
| '/domains/$domainId'
|
||||
@@ -126,6 +137,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth/groups'
|
||||
| '/_auth/services'
|
||||
| '/_auth/'
|
||||
| '/_auth/groups/$groupId'
|
||||
| '/_auth/domains/'
|
||||
| '/_auth/domains/$domainId/dns'
|
||||
| '/_auth/domains/$domainId/'
|
||||
@@ -187,6 +199,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthDomainsIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/groups/$groupId': {
|
||||
id: '/_auth/groups/$groupId'
|
||||
path: '/$groupId'
|
||||
fullPath: '/groups/$groupId'
|
||||
preLoaderRoute: typeof AuthGroupsGroupIdRouteImport
|
||||
parentRoute: typeof AuthGroupsRoute
|
||||
}
|
||||
'/_auth/domains/$domainId/': {
|
||||
id: '/_auth/domains/$domainId/'
|
||||
path: '/domains/$domainId'
|
||||
@@ -204,9 +223,21 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthGroupsRouteChildren {
|
||||
AuthGroupsGroupIdRoute: typeof AuthGroupsGroupIdRoute
|
||||
}
|
||||
|
||||
const AuthGroupsRouteChildren: AuthGroupsRouteChildren = {
|
||||
AuthGroupsGroupIdRoute: AuthGroupsGroupIdRoute,
|
||||
}
|
||||
|
||||
const AuthGroupsRouteWithChildren = AuthGroupsRoute._addFileChildren(
|
||||
AuthGroupsRouteChildren,
|
||||
)
|
||||
|
||||
interface AuthRouteChildren {
|
||||
AuthCertificatesRoute: typeof AuthCertificatesRoute
|
||||
AuthGroupsRoute: typeof AuthGroupsRoute
|
||||
AuthGroupsRoute: typeof AuthGroupsRouteWithChildren
|
||||
AuthServicesRoute: typeof AuthServicesRoute
|
||||
AuthIndexRoute: typeof AuthIndexRoute
|
||||
AuthDomainsIndexRoute: typeof AuthDomainsIndexRoute
|
||||
@@ -216,7 +247,7 @@ interface AuthRouteChildren {
|
||||
|
||||
const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthCertificatesRoute: AuthCertificatesRoute,
|
||||
AuthGroupsRoute: AuthGroupsRoute,
|
||||
AuthGroupsRoute: AuthGroupsRouteWithChildren,
|
||||
AuthServicesRoute: AuthServicesRoute,
|
||||
AuthIndexRoute: AuthIndexRoute,
|
||||
AuthDomainsIndexRoute: AuthDomainsIndexRoute,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from 'sonner'
|
||||
@@ -7,17 +8,10 @@ import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys }
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { DnsRecordsTable } from '@/components/dns-records-table'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
@@ -30,19 +24,21 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
const DNS_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX'] as const
|
||||
|
||||
const dnsTypeItems = DNS_TYPES.map((type) => ({ label: type, value: type }))
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/dns')({
|
||||
loader: ({ context: { queryClient }, params }) => {
|
||||
const id = Number(params.domainId)
|
||||
@@ -55,6 +51,7 @@ export const Route = createFileRoute('/_auth/domains/$domainId/dns')({
|
||||
})
|
||||
|
||||
function DnsPage() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const { domainId } = Route.useParams()
|
||||
const id = Number(domainId)
|
||||
const queryClient = useQueryClient()
|
||||
@@ -97,6 +94,7 @@ function DnsPage() {
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
})
|
||||
setSheetOpen(false)
|
||||
toast.success('DNS-запись создана')
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -119,46 +117,78 @@ function DnsPage() {
|
||||
createMutation.mutate(values)
|
||||
})
|
||||
|
||||
const hasRecords = useMemo(() => (records?.length ?? 0) > 0, [records])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title={`${domain?.zone_name ?? ''} — DNS`}
|
||||
description="Управление DNS-записями зоны"
|
||||
back={{ to: '/domains', label: '← К списку доменов' }}
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
>
|
||||
{syncMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать с Cloudflare'}
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
Новая запись
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
>
|
||||
{syncMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать с Cloudflare'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Новая запись</CardTitle>
|
||||
<CardDescription>Добавить DNS-запись в зону</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreate}>
|
||||
<FieldGroup className="grid gap-4 md:grid-cols-6">
|
||||
|
||||
<DataTableCard
|
||||
title="DNS-записи"
|
||||
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
|
||||
emptyTitle="DNS-записи не найдены"
|
||||
emptyDescription="Создайте запись или синхронизируйте зону с Cloudflare"
|
||||
isEmpty={!hasRecords}
|
||||
>
|
||||
{hasRecords && (
|
||||
<div className="p-4">
|
||||
<DnsRecordsTable
|
||||
records={records ?? []}
|
||||
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DataTableCard>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Новая DNS-запись</SheetTitle>
|
||||
<SheetDescription>
|
||||
Добавить запись в зону {domain?.zone_name ?? ''}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleCreate} className="flex flex-col gap-4 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="record_type">Тип</FieldLabel>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="record_type"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<Select
|
||||
items={dnsTypeItems}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger id="record_type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DNS_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
{dnsTypeItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -168,11 +198,11 @@ function DnsPage() {
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Имя</FieldLabel>
|
||||
<Input id="name" {...form.register('name')} />
|
||||
<Input id="name" placeholder="@" {...form.register('name')} />
|
||||
</Field>
|
||||
<Field className="md:col-span-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="content">Значение</FieldLabel>
|
||||
<Input id="content" {...form.register('content')} />
|
||||
<Input id="content" placeholder="192.168.1.1" {...form.register('content')} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ttl">TTL</FieldLabel>
|
||||
@@ -182,7 +212,7 @@ function DnsPage() {
|
||||
{...form.register('ttl', { valueAsNumber: true })}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex flex-col justify-end gap-2">
|
||||
<Field className="flex flex-row items-center justify-between gap-4">
|
||||
<FieldLabel htmlFor="proxied">Прокси Cloudflare</FieldLabel>
|
||||
<Controller
|
||||
control={form.control}
|
||||
@@ -196,55 +226,18 @@ function DnsPage() {
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex items-end">
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending} className="w-full">
|
||||
{createMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataTableCard title="DNS-записи" description="Записи в зоне">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Значение</TableHead>
|
||||
<TableHead>TTL</TableHead>
|
||||
<TableHead>Синхронизация</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records?.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.record_type}</TableCell>
|
||||
<TableCell>{r.name}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{r.content}</TableCell>
|
||||
<TableCell>{r.ttl}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={r.sync_status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteMutation.mutate(r.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { domainDetailQueryOptions, domainKeys, subdomainKeys, subdomainsListQueryOptions } from '@/queries'
|
||||
import {
|
||||
domainDetailQueryOptions,
|
||||
domainKeys,
|
||||
domainServiceBindingsQueryOptions,
|
||||
subdomainKeys,
|
||||
subdomainsListQueryOptions,
|
||||
} from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DomainBindingsCard } from '@/components/domain-bindings-card'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Card,
|
||||
@@ -18,6 +27,13 @@ import {
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
@@ -26,6 +42,7 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
return Promise.all([
|
||||
queryClient.ensureQueryData(domainDetailQueryOptions(id)),
|
||||
queryClient.ensureQueryData(subdomainsListQueryOptions(id)),
|
||||
queryClient.ensureQueryData(domainServiceBindingsQueryOptions(id)),
|
||||
])
|
||||
},
|
||||
component: DomainOverviewPage,
|
||||
@@ -37,12 +54,14 @@ function DomainOverviewPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: domain } = useQuery(domainDetailQueryOptions(id))
|
||||
const { data: subdomains } = useQuery(subdomainsListQueryOptions(id))
|
||||
const { data: bindings } = useQuery(domainServiceBindingsQueryOptions(id))
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => api.post(`/api/v1/domains/${id}/sync`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: subdomainKeys.list(id) })
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: ['service-bindings'] })
|
||||
toast.success('Синхронизация завершена')
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -82,6 +101,44 @@ function DomainOverviewPage() {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Сводка</CardTitle>
|
||||
<CardDescription>Основные параметры зоны</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Статус</span>
|
||||
{domain ? <StatusBadge status={domain.status} /> : '—'}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Группа</span>
|
||||
{domain?.group_id ? (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
render={
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: String(domain.group_id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Открыть группу
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">Без группы</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Последняя синхронизация</span>
|
||||
<span className="font-medium">{domain?.last_synced_at ?? '—'}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DomainBindingsCard bindings={bindings ?? []} />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Поддомены</CardTitle>
|
||||
@@ -89,13 +146,18 @@ function DomainOverviewPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{subdomains?.length ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{subdomains.map((s) => (
|
||||
<li key={s.id} className="text-sm">
|
||||
{s.fqdn}
|
||||
</li>
|
||||
<ItemGroup className="gap-0">
|
||||
{subdomains.map((s, index) => (
|
||||
<div key={s.id}>
|
||||
<Item variant="outline">
|
||||
<ItemContent>
|
||||
<ItemTitle className="font-mono font-normal">{s.fqdn}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
{index < subdomains.length - 1 && <ItemSeparator />}
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</ItemGroup>
|
||||
) : (
|
||||
<Empty className="border border-dashed p-4">
|
||||
<EmptyHeader>
|
||||
|
||||
@@ -6,15 +6,20 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from 'sonner'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas'
|
||||
import { domainKeys, domainsListQueryOptions, groupsQueryOptions } from '@/queries'
|
||||
import { buildIpsByDomainId } from '@/lib/domain-ips'
|
||||
import {
|
||||
domainKeys,
|
||||
domainsListQueryOptions,
|
||||
groupsQueryOptions,
|
||||
serviceBindingsQueryOptions,
|
||||
} from '@/queries'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { DomainsDataTable } from '@/components/domains-data-table'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
@@ -32,13 +37,13 @@ import {
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/')({
|
||||
@@ -46,16 +51,26 @@ export const Route = createFileRoute('/_auth/domains/')({
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(domainsListQueryOptions()),
|
||||
queryClient.ensureQueryData(groupsQueryOptions()),
|
||||
queryClient.ensureQueryData(serviceBindingsQueryOptions()),
|
||||
]),
|
||||
component: DomainsPage,
|
||||
})
|
||||
|
||||
function DomainsPage() {
|
||||
const [groupId, setGroupId] = useState('')
|
||||
const filterGroupId = groupId ? Number(groupId) : undefined
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [importGroupId, setImportGroupId] = useState('')
|
||||
const [filterGroupId, setFilterGroupId] = useState('')
|
||||
const listGroupId =
|
||||
filterGroupId && filterGroupId !== 'none' ? Number(filterGroupId) : undefined
|
||||
const queryClient = useQueryClient()
|
||||
const { data: domains } = useQuery(domainsListQueryOptions(filterGroupId))
|
||||
const { data: domains } = useQuery(domainsListQueryOptions(listGroupId))
|
||||
const { data: groups } = useQuery(groupsQueryOptions())
|
||||
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
|
||||
|
||||
const ipsByDomainId = useMemo(
|
||||
() => buildIpsByDomainId(bindings ?? []),
|
||||
[bindings],
|
||||
)
|
||||
|
||||
const groupItems = useMemo(
|
||||
() => [
|
||||
@@ -75,7 +90,8 @@ function DomainsPage() {
|
||||
api.post('/api/v1/domains', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
form.reset({ zone_name: '', group_id: groupId })
|
||||
form.reset({ zone_name: '', group_id: importGroupId })
|
||||
setSheetOpen(false)
|
||||
toast.success('Домен импортирован')
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -86,31 +102,89 @@ function DomainsPage() {
|
||||
const handleCreate = form.handleSubmit((values) => {
|
||||
createMutation.mutate({
|
||||
zone_name: values.zone_name.trim(),
|
||||
group_id: groupId ? Number(groupId) : undefined,
|
||||
group_id: importGroupId ? Number(importGroupId) : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
const handleGroupChange = (value: string | null) => {
|
||||
const handleImportGroupChange = (value: string | null) => {
|
||||
const next = value === 'none' || !value ? '' : value
|
||||
setGroupId(next)
|
||||
setImportGroupId(next)
|
||||
form.setValue('group_id', next)
|
||||
}
|
||||
|
||||
const handleFilterGroupChange = (value: string | null) => {
|
||||
if (!value || value === 'all') {
|
||||
setFilterGroupId('')
|
||||
return
|
||||
}
|
||||
setFilterGroupId(value === 'none' ? 'none' : value)
|
||||
}
|
||||
|
||||
const filteredDomains = useMemo(() => {
|
||||
if (filterGroupId === 'none') {
|
||||
return domains?.filter((d) => d.group_id === null) ?? []
|
||||
}
|
||||
return domains ?? []
|
||||
}, [domains, filterGroupId])
|
||||
|
||||
const tableData = useMemo(
|
||||
() =>
|
||||
filteredDomains.map((domain) => ({
|
||||
...domain,
|
||||
ips: ipsByDomainId.get(domain.id) ?? [],
|
||||
})),
|
||||
[filteredDomains, ipsByDomainId],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title="Домены"
|
||||
description="Импорт и управление зонами Cloudflare"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" render={<Link to="/groups" />}>
|
||||
Канбан групп
|
||||
</Button>
|
||||
<Button onClick={() => setSheetOpen(true)}>Импортировать домен</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Добавить домен</CardTitle>
|
||||
<CardDescription>Импортировать зону из Cloudflare</CardDescription>
|
||||
|
||||
<Card className="border-dashed">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Быстрый обзор</CardTitle>
|
||||
<CardDescription>
|
||||
{tableData.length} зон ·{' '}
|
||||
{tableData.filter((d) => d.ips.length > 0).length} с IP-адресами ·{' '}
|
||||
{tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreate}>
|
||||
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
|
||||
<Field className="max-w-xs flex-1">
|
||||
</Card>
|
||||
|
||||
<DataTableCard
|
||||
title="Список доменов"
|
||||
description="Импортированные зоны Cloudflare"
|
||||
>
|
||||
<DomainsDataTable
|
||||
data={tableData}
|
||||
groupFilterItems={groupItems}
|
||||
groupFilterValue={filterGroupId || 'all'}
|
||||
onGroupFilterChange={handleFilterGroupChange}
|
||||
/>
|
||||
</DataTableCard>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Импорт домена</SheetTitle>
|
||||
<SheetDescription>
|
||||
Добавить зону из аккаунта Cloudflare в менеджер
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleCreate} className="flex flex-col gap-4 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="zone_name">Имя зоны</FieldLabel>
|
||||
<Input
|
||||
id="zone_name"
|
||||
@@ -119,14 +193,14 @@ function DomainsPage() {
|
||||
aria-invalid={!!form.formState.errors.zone_name}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="w-48">
|
||||
<FieldLabel htmlFor="group_id">Группа</FieldLabel>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="import_group_id">Группа</FieldLabel>
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={groupId || 'none'}
|
||||
onValueChange={handleGroupChange}
|
||||
value={importGroupId || 'none'}
|
||||
onValueChange={handleImportGroupChange}
|
||||
>
|
||||
<SelectTrigger id="group_id" className="w-full">
|
||||
<SelectTrigger id="import_group_id" className="w-full">
|
||||
<SelectValue placeholder="Без группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -138,67 +212,16 @@ function DomainsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending} className="w-full">
|
||||
{createMutation.isPending && <Spinner data-icon="inline-start" />}
|
||||
{createMutation.isPending ? 'Импорт…' : 'Импортировать'}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataTableCard title="Список доменов" description="Импортированные зоны">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Зона</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Последняя синхронизация</TableHead>
|
||||
<TableHead>Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{domains?.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="font-medium">{d.zone_name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={d.status} />
|
||||
</TableCell>
|
||||
<TableCell>{d.last_synced_at ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(d.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId: String(d.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from 'sonner'
|
||||
import { groupsQueryOptions, groupKeys, domainKeys } from '@/queries'
|
||||
import {
|
||||
domainKeys,
|
||||
domainsListQueryOptions,
|
||||
groupKeys,
|
||||
groupsQueryOptions,
|
||||
serviceBindingsQueryOptions,
|
||||
} from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createGroupSchema, type CreateGroupInput } from '@/lib/schemas'
|
||||
import { createGroupSchema, type CreateGroupInput, type DomainListItem } from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { ResourceList } from '@/components/resource-list'
|
||||
import { KanbanBoard } from '@/components/kanban-board'
|
||||
import { DomainGroupCard } from '@/components/domain-group-card'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
@@ -22,22 +31,92 @@ import {
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@cfdm/ui/components/tabs'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export const Route = createFileRoute('/_auth/groups')({
|
||||
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(groupsQueryOptions()),
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(groupsQueryOptions()),
|
||||
queryClient.ensureQueryData(domainsListQueryOptions()),
|
||||
queryClient.ensureQueryData(serviceBindingsQueryOptions()),
|
||||
]),
|
||||
component: GroupsPage,
|
||||
})
|
||||
|
||||
const UNGROUPED_COLUMN_ID = 'ungrouped'
|
||||
|
||||
function groupColumnId(groupId: number) {
|
||||
return `group-${groupId}`
|
||||
}
|
||||
|
||||
function parseGroupColumnId(columnId: string): number | null {
|
||||
if (columnId === UNGROUPED_COLUMN_ID) return null
|
||||
const match = columnId.match(/^group-(\d+)$/)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function GroupsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: groups } = useQuery(groupsQueryOptions())
|
||||
const { data: domains } = useQuery(domainsListQueryOptions())
|
||||
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
|
||||
|
||||
const form = useForm<CreateGroupInput>({
|
||||
resolver: zodResolver(createGroupSchema),
|
||||
defaultValues: { name: '', slug: '' },
|
||||
})
|
||||
|
||||
const serviceLabelsByDomain = useMemo(() => {
|
||||
const map = new Map<number, string[]>()
|
||||
for (const binding of bindings ?? []) {
|
||||
const list = map.get(binding.domain_id) ?? []
|
||||
if (!list.includes(binding.service_name)) {
|
||||
list.push(binding.service_name)
|
||||
}
|
||||
map.set(binding.domain_id, list)
|
||||
}
|
||||
return map
|
||||
}, [bindings])
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const groupColumns =
|
||||
groups?.map((group) => ({
|
||||
id: groupColumnId(group.id),
|
||||
title: group.name,
|
||||
description: group.slug,
|
||||
href: `/groups/${group.id}`,
|
||||
items:
|
||||
domains?.filter((d) => d.group_id === group.id) ?? [],
|
||||
})) ?? []
|
||||
|
||||
const ungrouped: DomainListItem[] =
|
||||
domains?.filter((d) => d.group_id === null) ?? []
|
||||
|
||||
return [
|
||||
...groupColumns,
|
||||
{
|
||||
id: UNGROUPED_COLUMN_ID,
|
||||
title: 'Без группы',
|
||||
description: 'Домены без назначенной группы',
|
||||
items: ungrouped,
|
||||
},
|
||||
]
|
||||
}, [groups, domains])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: CreateGroupInput) => api.post('/api/v1/groups', body),
|
||||
onSuccess: () => {
|
||||
@@ -62,73 +141,150 @@ function GroupsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const moveDomainMutation = useMutation({
|
||||
mutationFn: ({ domainId, groupId }: { domainId: number; groupId: number | null }) =>
|
||||
api.patch(`/api/v1/domains/${domainId}`, { group_id: groupId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
toast.success('Группа домена обновлена')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось переместить домен')
|
||||
},
|
||||
})
|
||||
|
||||
function handleMove(itemId: string, _fromColumnId: string, toColumnId: string) {
|
||||
const groupId = parseGroupColumnId(toColumnId)
|
||||
if (toColumnId !== UNGROUPED_COLUMN_ID && groupId === null) return
|
||||
moveDomainMutation.mutate({ domainId: Number(itemId), groupId })
|
||||
}
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
createMutation.mutate(values)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title="Группы"
|
||||
description="Группировка доменов для удобного управления"
|
||||
description="Канбан-доска доменов по группам и справочник групп"
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Создать группу</CardTitle>
|
||||
<CardDescription>Добавить новую группу доменов</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="name">Название</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Название"
|
||||
{...form.register('name')}
|
||||
aria-invalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="slug"
|
||||
placeholder="slug"
|
||||
{...form.register('slug')}
|
||||
aria-invalid={!!form.formState.errors.slug}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="kanban">Канбан</TabsTrigger>
|
||||
<TabsTrigger value="catalog">Справочник</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="kanban">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Доска групп</CardTitle>
|
||||
<CardDescription>
|
||||
Перетащите домен в колонку группы. Нажмите на название колонки, чтобы открыть список доменов.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<KanbanBoard
|
||||
columns={columns}
|
||||
getItemId={(domain) => String(domain.id)}
|
||||
renderCard={(domain) => (
|
||||
<DomainGroupCard
|
||||
domain={domain}
|
||||
serviceLabels={serviceLabelsByDomain.get(domain.id)}
|
||||
/>
|
||||
)}
|
||||
{createMutation.isPending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ResourceList
|
||||
items={
|
||||
groups?.map((g) => ({
|
||||
id: g.id,
|
||||
primary: g.name,
|
||||
secondary: `(${g.slug})`,
|
||||
})) ?? []
|
||||
}
|
||||
emptyTitle="Группы не найдены"
|
||||
emptyDescription="Создайте первую группу в форме выше"
|
||||
renderActions={(item) => (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteMutation.mutate(item.id as number)}
|
||||
disabled={deleteMutation.isPending}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="catalog" className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Создать группу</CardTitle>
|
||||
<CardDescription>Добавить новую группу доменов</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="name">Название</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Название"
|
||||
{...form.register('name')}
|
||||
aria-invalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="slug"
|
||||
placeholder="slug"
|
||||
{...form.register('slug')}
|
||||
aria-invalid={!!form.formState.errors.slug}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataTableCard
|
||||
title="Справочник групп"
|
||||
description="Все группы доменов"
|
||||
isEmpty={!groups?.length}
|
||||
emptyTitle="Группы не найдены"
|
||||
emptyDescription="Создайте первую группу в форме выше"
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups?.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">{group.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{group.slug}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: String(group.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteMutation.mutate(group.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
domainsListQueryOptions,
|
||||
groupDetailQueryOptions,
|
||||
} from '@/queries'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
|
||||
export const Route = createFileRoute('/_auth/groups/$groupId')({
|
||||
loader: ({ context: { queryClient }, params }) => {
|
||||
const id = Number(params.groupId)
|
||||
return Promise.all([
|
||||
queryClient.ensureQueryData(groupDetailQueryOptions(id)),
|
||||
queryClient.ensureQueryData(domainsListQueryOptions(id)),
|
||||
])
|
||||
},
|
||||
component: GroupDetailPage,
|
||||
})
|
||||
|
||||
function GroupDetailPage() {
|
||||
const { groupId } = Route.useParams()
|
||||
const id = Number(groupId)
|
||||
const { data: group } = useQuery(groupDetailQueryOptions(id))
|
||||
const { data: domains } = useQuery(domainsListQueryOptions(id))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title={group?.name ?? 'Группа'}
|
||||
description={
|
||||
group
|
||||
? `${group.domain_count} домен(ов) · slug: ${group.slug}`
|
||||
: 'Домены в группе'
|
||||
}
|
||||
back={{ to: '/groups', label: '← К группам' }}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
render={<Link to="/groups" />}
|
||||
>
|
||||
На канбан
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DataTableCard
|
||||
title="Домены группы"
|
||||
description="Список доменов, назначенных этой группе"
|
||||
emptyTitle="В группе нет доменов"
|
||||
emptyDescription="Перетащите домены на канбане групп или назначьте группу при импорте"
|
||||
isEmpty={!domains?.length}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Зона</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Сервисы</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{domains?.map((domain) => (
|
||||
<TableRow key={domain.id}>
|
||||
<TableCell className="font-medium">{domain.zone_name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={domain.status} />
|
||||
</TableCell>
|
||||
<TableCell>{domain.service_count}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(domain.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,29 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from 'sonner'
|
||||
import { servicesQueryOptions, serviceKeys } from '@/queries'
|
||||
import {
|
||||
domainKeys,
|
||||
domainsListQueryOptions,
|
||||
serviceBindingKeys,
|
||||
serviceBindingsQueryOptions,
|
||||
serviceKeys,
|
||||
servicesQueryOptions,
|
||||
} from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createServiceSchema, type CreateServiceInput } from '@/lib/schemas'
|
||||
import {
|
||||
createServiceBindingSchema,
|
||||
createServiceSchema,
|
||||
type CreateServiceBindingInput,
|
||||
type CreateServiceInput,
|
||||
type ServiceBinding,
|
||||
} from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { ResourceList } from '@/components/resource-list'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { KanbanBoard } from '@/components/kanban-board'
|
||||
import { ServiceBindingCard } from '@/components/service-binding-card'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
@@ -17,32 +33,99 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@cfdm/ui/components/tabs'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services')({
|
||||
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(servicesQueryOptions()),
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(servicesQueryOptions()),
|
||||
queryClient.ensureQueryData(serviceBindingsQueryOptions()),
|
||||
queryClient.ensureQueryData(domainsListQueryOptions()),
|
||||
]),
|
||||
component: ServicesPage,
|
||||
})
|
||||
|
||||
function serviceColumnId(serviceId: number) {
|
||||
return `service-${serviceId}`
|
||||
}
|
||||
|
||||
function parseServiceColumnId(columnId: string): number | null {
|
||||
const match = columnId.match(/^service-(\d+)$/)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function ServicesPage() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: services } = useQuery(servicesQueryOptions())
|
||||
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
|
||||
const { data: domains } = useQuery(domainsListQueryOptions())
|
||||
|
||||
const form = useForm<CreateServiceInput>({
|
||||
const catalogForm = useForm<CreateServiceInput>({
|
||||
resolver: zodResolver(createServiceSchema),
|
||||
defaultValues: { name: '', slug: '' },
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
const bindingForm = useForm<CreateServiceBindingInput>({
|
||||
resolver: zodResolver(createServiceBindingSchema),
|
||||
defaultValues: {
|
||||
domain_id: '',
|
||||
service_id: '',
|
||||
hostname: '@',
|
||||
target_ip: '',
|
||||
},
|
||||
})
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return (
|
||||
services?.map((service) => ({
|
||||
id: serviceColumnId(service.id),
|
||||
title: service.name,
|
||||
description: service.slug,
|
||||
items: bindings?.filter((b) => b.service_id === service.id) ?? [],
|
||||
})) ?? []
|
||||
)
|
||||
}, [services, bindings])
|
||||
|
||||
const createServiceMutation = useMutation({
|
||||
mutationFn: (body: CreateServiceInput) => api.post('/api/v1/services', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
|
||||
form.reset()
|
||||
catalogForm.reset()
|
||||
toast.success('Сервис создан')
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -50,63 +133,249 @@ function ServicesPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
createMutation.mutate(values)
|
||||
const createBindingMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
domain_id: number
|
||||
service_id: number
|
||||
hostname?: string
|
||||
target_ip?: string
|
||||
}) => api.post('/api/v1/service-bindings', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
bindingForm.reset({ domain_id: '', service_id: '', hostname: '@', target_ip: '' })
|
||||
setSheetOpen(false)
|
||||
toast.success('Привязка создана')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось создать привязку')
|
||||
},
|
||||
})
|
||||
|
||||
const updateBindingMutation = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
}: {
|
||||
id: number
|
||||
body: { service_id?: number; hostname?: string; target_ip?: string }
|
||||
}) => api.patch(`/api/v1/service-bindings/${id}`, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось обновить привязку')
|
||||
},
|
||||
})
|
||||
|
||||
function handleMove(itemId: string, _fromColumnId: string, toColumnId: string) {
|
||||
const serviceId = parseServiceColumnId(toColumnId)
|
||||
if (serviceId === null) return
|
||||
updateBindingMutation.mutate({
|
||||
id: Number(itemId),
|
||||
body: { service_id: serviceId },
|
||||
})
|
||||
}
|
||||
|
||||
function handleIpChange(id: number, targetIp: string) {
|
||||
updateBindingMutation.mutate({ id, body: { target_ip: targetIp } })
|
||||
}
|
||||
|
||||
function handleHostnameChange(id: number, hostname: string) {
|
||||
updateBindingMutation.mutate({ id, body: { hostname } })
|
||||
}
|
||||
|
||||
const renderBindingCard = (binding: ServiceBinding) => (
|
||||
<ServiceBindingCard
|
||||
binding={binding}
|
||||
onIpChange={handleIpChange}
|
||||
onHostnameChange={handleHostnameChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const handleCatalogSubmit = catalogForm.handleSubmit((values) => {
|
||||
createServiceMutation.mutate(values)
|
||||
})
|
||||
|
||||
const handleBindingSubmit = bindingForm.handleSubmit((values) => {
|
||||
createBindingMutation.mutate({
|
||||
domain_id: Number(values.domain_id),
|
||||
service_id: Number(values.service_id),
|
||||
hostname: values.hostname || '@',
|
||||
target_ip: values.target_ip || undefined,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title="Сервисы"
|
||||
description="Справочник сервисов для привязки к доменам"
|
||||
description="Канбан привязок доменов к сервисам с настройкой IP через DNS"
|
||||
actions={
|
||||
<Button onClick={() => setSheetOpen(true)}>Добавить привязку</Button>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Создать сервис</CardTitle>
|
||||
<CardDescription>Добавить новый сервис в справочник</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="name">Название</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Название"
|
||||
{...form.register('name')}
|
||||
aria-invalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="slug"
|
||||
placeholder="slug"
|
||||
{...form.register('slug')}
|
||||
aria-invalid={!!form.formState.errors.slug}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="kanban">Канбан</TabsTrigger>
|
||||
<TabsTrigger value="catalog">Справочник</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="kanban">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Доска сервисов</CardTitle>
|
||||
<CardDescription>
|
||||
Перетащите привязку между колонками или отредактируйте IP прямо на карточке
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<KanbanBoard
|
||||
columns={columns}
|
||||
getItemId={(binding) => String(binding.id)}
|
||||
renderCard={renderBindingCard}
|
||||
renderOverlay={renderBindingCard}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="catalog" className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Создать сервис</CardTitle>
|
||||
<CardDescription>Добавить новый тип сервиса в справочник</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCatalogSubmit}>
|
||||
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="svc-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="svc-name"
|
||||
placeholder="Название"
|
||||
{...catalogForm.register('name')}
|
||||
aria-invalid={!!catalogForm.formState.errors.name}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="svc-slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="svc-slug"
|
||||
placeholder="slug"
|
||||
{...catalogForm.register('slug')}
|
||||
aria-invalid={!!catalogForm.formState.errors.slug}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={createServiceMutation.isPending}>
|
||||
{createServiceMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{createServiceMutation.isPending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataTableCard
|
||||
title="Справочник сервисов"
|
||||
description="Типы сервисов для привязки к доменам"
|
||||
isEmpty={!services?.length}
|
||||
emptyTitle="Сервисы не найдены"
|
||||
emptyDescription="Создайте первый сервис в форме выше"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services?.map((service) => (
|
||||
<TableRow key={service.id}>
|
||||
<TableCell className="font-medium">{service.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{service.slug}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Новая привязка</SheetTitle>
|
||||
<SheetDescription>
|
||||
Свяжите домен с сервисом и укажите IP для A-записи
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleBindingSubmit} className="flex flex-col gap-4 px-4">
|
||||
<Field>
|
||||
<FieldLabel>Домен</FieldLabel>
|
||||
<Select
|
||||
value={bindingForm.watch('domain_id')}
|
||||
onValueChange={(value) => bindingForm.setValue('domain_id', value ?? '')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите домен" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{domains?.map((domain) => (
|
||||
<SelectItem key={domain.id} value={String(domain.id)}>
|
||||
{domain.zone_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Сервис</FieldLabel>
|
||||
<Select
|
||||
value={bindingForm.watch('service_id')}
|
||||
onValueChange={(value) => bindingForm.setValue('service_id', value ?? '')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите сервис" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{services?.map((service) => (
|
||||
<SelectItem key={service.id} value={String(service.id)}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="binding-hostname">Hostname</FieldLabel>
|
||||
<Input
|
||||
id="binding-hostname"
|
||||
placeholder="@"
|
||||
{...bindingForm.register('hostname')}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="binding-ip">IPv4</FieldLabel>
|
||||
<Input
|
||||
id="binding-ip"
|
||||
placeholder="192.168.1.1"
|
||||
{...bindingForm.register('target_ip')}
|
||||
/>
|
||||
</Field>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={createBindingMutation.isPending}>
|
||||
{createBindingMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Создание…' : 'Создать'}
|
||||
Создать
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ResourceList
|
||||
items={
|
||||
services?.map((s) => ({
|
||||
id: s.id,
|
||||
primary: s.name,
|
||||
secondary: `(${s.slug})`,
|
||||
})) ?? []
|
||||
}
|
||||
emptyTitle="Сервисы не найдены"
|
||||
emptyDescription="Создайте первый сервис в форме выше"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user