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: 'Конфликт',
+46
View File
@@ -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])
}
+32
View File
@@ -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
}
+42
View File
@@ -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>
+44 -2
View File
@@ -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)
},
})
+36 -5
View File
@@ -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>
+112 -89
View File
@@ -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>
)
}
+218 -62
View File
@@ -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>
)
}
+323 -54
View File
@@ -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>
)
}