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

This commit is contained in:
Denozordec
2026-06-15 16:41:10 +07:00
parent b1467575c5
commit 5f7b3ac388
50 changed files with 3364 additions and 367 deletions
+16 -1
View File
@@ -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>
)
}
+96
View File
@@ -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>
)
}
+81
View File
@@ -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>
)
}
+2
View File
@@ -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: 'Конфликт',