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: 'Конфликт',
|
||||
|
||||
Reference in New Issue
Block a user