feat:Update pnpm-lock.yaml to link shared package; modify API routes to utilize new schemas for domain and service management; enhance DNS record handling with CNAME support; refactor service and subdomain routes for improved functionality; implement confirm dialog for domain deletion in the frontend; clean up unused components and improve UI consistency.
Build, Test, and Push CFDM Docker Image / test (push) Failing after 51s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

This commit is contained in:
Denozordec
2026-06-22 13:52:33 +07:00
parent 4a70cf5854
commit 6d1d8ca4f3
99 changed files with 6692 additions and 1483 deletions
@@ -0,0 +1,96 @@
import {
groupColumnId,
UNGROUPED_COLUMN_ID,
} from '@/components/groups-board/column-ids'
import type { BoardColumn, BoardState } from '@/components/groups-board/types'
import type { DomainListItem, Group } from '@/lib/schemas'
export function mapGroupsDomainsToBoard(
groups: Group[],
domains: DomainListItem[],
): BoardState {
const columns: BoardColumn[] = groups.map((group) => ({
id: groupColumnId(group.id),
groupId: group.id,
title: group.name,
slug: group.slug,
items: domains.filter((domain) => domain.group_id === group.id),
group,
}))
const ungrouped = domains.filter((domain) => domain.group_id === null)
columns.push({
id: UNGROUPED_COLUMN_ID,
groupId: null,
title: 'Без группы',
slug: null,
items: ungrouped,
})
return { columns }
}
export function findColumnId(
columns: BoardColumn[],
itemId: string,
): string | undefined {
if (columns.some((column) => column.id === itemId)) return itemId
return columns.find((column) =>
column.items.some((domain) => String(domain.id) === itemId),
)?.id
}
export function moveDomainBetweenColumns(
board: BoardState,
domainId: number,
fromColumnId: string,
toColumnId: string,
): BoardState {
if (fromColumnId === toColumnId) return board
const fromColumn = board.columns.find((column) => column.id === fromColumnId)
const domain = fromColumn?.items.find((item) => item.id === domainId)
if (!domain || !fromColumn) return board
const targetColumn = board.columns.find((column) => column.id === toColumnId)
if (!targetColumn) return board
const updatedDomain: DomainListItem = {
...domain,
group_id: targetColumn.groupId,
group_name: targetColumn.group?.name ?? null,
}
const columns = board.columns.map((column) => {
if (column.id === fromColumnId) {
return {
...column,
items: column.items.filter((item) => item.id !== domainId),
}
}
if (column.id === toColumnId) {
return {
...column,
items: [...column.items, updatedDomain],
}
}
return column
})
return { columns }
}
export function boardToDomainsList(
board: BoardState,
previous: DomainListItem[],
): DomainListItem[] {
const byId = new Map(previous.map((domain) => [domain.id, domain]))
for (const column of board.columns) {
for (const domain of column.items) {
byId.set(domain.id, domain)
}
}
return Array.from(byId.values())
}
@@ -0,0 +1,5 @@
export {
UNGROUPED_COLUMN_ID,
groupColumnId,
parseGroupColumnId,
} from '@/components/board/column-ids'
@@ -0,0 +1,100 @@
import { FolderTreeIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import type { BoardColumn } from '@/components/groups-board/types'
import type { Group } from '@/lib/schemas'
import { AccordionTrigger } from '@cfdm/ui/components/accordion'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
interface DomainGroupHeaderProps {
column: BoardColumn
isOpen?: boolean
isDragging?: boolean
dragDisabled?: boolean
onEditGroup?: (group: Group) => void
onDeleteGroup?: (group: Group) => void
}
export function DomainGroupHeader({
column,
isOpen = true,
isDragging = false,
dragDisabled = false,
onEditGroup,
onDeleteGroup,
}: DomainGroupHeaderProps) {
return (
<div className="flex items-center gap-1 px-1">
<AccordionTrigger className="min-h-10 flex-1 items-center gap-2 rounded-md py-2 hover:bg-muted/40 hover:no-underline">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{column.groupId !== null ? (
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-background/80 text-muted-foreground">
<FolderTreeIcon />
</span>
) : null}
<span className="font-medium">{column.title}</span>
<Badge variant="secondary">{column.items.length}</Badge>
{column.slug ? (
<Badge variant="outline" className="font-mono">
{column.slug}
</Badge>
) : null}
{!isOpen && isDragging && !dragDisabled ? (
<Badge variant="default" className="font-normal">
Отпустите для переноса
</Badge>
) : null}
</div>
</AccordionTrigger>
{column.groupId !== null && column.group ? (
<div
className="flex shrink-0 items-center gap-1 pr-1"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия для группы ${column.title}`}
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onEditGroup ? (
<DropdownMenuItem onClick={() => onEditGroup(column.group!)}>
<PencilIcon data-icon="inline-start" />
Редактировать
</DropdownMenuItem>
) : null}
{onDeleteGroup ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteGroup(column.group!)}
>
<Trash2Icon data-icon="inline-start" />
Удалить группу
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,105 @@
import { useEffect } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { DomainGroupHeader } from '@/components/groups-board/domain-group-header'
import { DomainRow } from '@/components/groups-board/domain-row'
import type { BoardColumn } from '@/components/groups-board/types'
import type { Group } from '@/lib/schemas'
import {
AccordionContent,
AccordionItem,
} from '@cfdm/ui/components/accordion'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@cfdm/ui/components/empty'
import { ItemGroup, ItemSeparator } from '@cfdm/ui/components/item'
import { cn } from '@cfdm/ui/lib/utils'
interface DomainGroupItemProps {
column: BoardColumn
isOpen?: boolean
isDragging?: boolean
onExpandColumn?: (columnId: string) => void
onEditGroup?: (group: Group) => void
onDeleteGroup?: (group: Group) => void
dragDisabled?: boolean
serviceLabelsByDomain?: Map<number, string[]>
}
export function DomainGroupItem({
column,
isOpen = true,
isDragging = false,
onExpandColumn,
onEditGroup,
onDeleteGroup,
dragDisabled = false,
serviceLabelsByDomain,
}: DomainGroupItemProps) {
const { setNodeRef, isOver } = useDroppable({
id: column.id,
disabled: dragDisabled,
})
useEffect(() => {
if (isOver && isDragging && !dragDisabled) {
onExpandColumn?.(column.id)
}
}, [isOver, isDragging, dragDisabled, column.id, onExpandColumn])
return (
<AccordionItem
value={column.id}
className={cn(
'not-last:border-b-0 overflow-hidden rounded-lg border border-border transition-colors',
!isOpen && 'bg-muted/20',
isOpen && 'bg-muted/30',
isOver && !dragDisabled && 'ring-2 ring-primary/30',
)}
>
<DomainGroupHeader
column={column}
isOpen={isOpen}
isDragging={isDragging}
dragDisabled={dragDisabled}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
/>
<AccordionContent className="px-1 pb-2">
<div ref={setNodeRef}>
{column.items.length > 0 ? (
<ItemGroup className="gap-0 py-1">
{column.items.map((domain, index) => (
<div key={domain.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null}
<DomainRow
domain={domain}
serviceLabels={serviceLabelsByDomain?.get(domain.id)}
dragDisabled={dragDisabled}
/>
</div>
))}
</ItemGroup>
) : (
<Empty
className={cn(
'border border-dashed py-2',
isOver && !dragDisabled && 'border-primary bg-primary/5',
)}
>
<EmptyHeader>
<EmptyTitle className="text-sm">Нет доменов в группе</EmptyTitle>
<EmptyDescription>
Перетащите домен сюда
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -0,0 +1,177 @@
import { useDraggable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { Link } from '@tanstack/react-router'
import {
ExternalLinkIcon,
GripVerticalIcon,
MoreHorizontalIcon,
ServerIcon,
} from 'lucide-react'
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 { Card, CardContent } from '@cfdm/ui/components/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { cn } from '@cfdm/ui/lib/utils'
export interface DomainRowProps {
domain: DomainListItem
serviceLabels?: string[]
dragDisabled?: boolean
overlay?: boolean
}
function DomainServiceList({
labels,
serviceCount,
}: {
labels: string[]
serviceCount: number
}) {
if (labels.length === 0) {
return (
<span className="text-sm text-muted-foreground">
{serviceCount > 0 ? `${serviceCount} сервис(ов)` : 'Нет сервисов'}
</span>
)
}
if (labels.length === 1) {
return (
<Badge variant="secondary" className="max-w-full truncate font-normal">
{labels[0]}
</Badge>
)
}
return (
<Card size="sm" className="w-full shadow-none">
<CardContent className="flex flex-col gap-1 py-0">
{labels.map((label) => (
<div
key={label}
className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"
>
<ServerIcon className="size-3 shrink-0" />
<span className="truncate">{label}</span>
</div>
))}
</CardContent>
</Card>
)
}
export function DomainRow({
domain,
serviceLabels = [],
dragDisabled = false,
overlay = false,
}: DomainRowProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: String(domain.id),
disabled: dragDisabled || overlay,
})
const style = transform
? { transform: CSS.Translate.toString(transform) }
: undefined
const hasServiceList = serviceLabels.length > 1
return (
<div
ref={overlay ? undefined : setNodeRef}
style={overlay ? undefined : style}
className={cn(
'flex gap-3 rounded-md px-3 transition-colors hover:bg-muted/50',
hasServiceList ? 'items-start py-2' : 'h-10 items-center',
(isDragging || overlay) && 'opacity-90 shadow-md',
isDragging && !overlay && 'z-10',
)}
>
{!dragDisabled && !overlay ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
'touch-none shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing',
hasServiceList && 'mt-0.5',
)}
aria-label={`Перетащить ${domain.zone_name}`}
{...listeners}
{...attributes}
>
<GripVerticalIcon />
</Button>
) : null}
<div
className={cn(
'flex min-w-0 shrink-0',
hasServiceList ? 'w-28 pt-0.5' : 'items-center',
)}
>
<span className="truncate text-sm font-medium">{domain.zone_name}</span>
</div>
<div className="min-w-0 flex-1">
<DomainServiceList
labels={serviceLabels}
serviceCount={domain.service_count}
/>
</div>
<div
className={cn(
'flex shrink-0 items-center gap-2',
hasServiceList && 'self-center',
)}
>
<StatusBadge status={domain.status} />
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия для ${domain.zone_name}`}
onPointerDown={(event) => event.stopPropagation()}
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
/>
}
>
<ExternalLinkIcon data-icon="inline-start" />
Обзор
</DropdownMenuItem>
<DropdownMenuItem
render={
<Link to="/services" search={{ domainId: domain.id }} />
}
>
<ServerIcon data-icon="inline-start" />
Сервисы
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
@@ -0,0 +1,19 @@
import { Skeleton } from '@cfdm/ui/components/skeleton'
export function GroupsBoardSkeleton() {
return (
<div className="grid w-full grid-cols-1 gap-2 lg:grid-cols-2">
{Array.from({ length: 4 }).map((_, groupIndex) => (
<div
key={groupIndex}
className="flex flex-col gap-2 rounded-lg border border-border px-2 py-2"
>
<Skeleton className="h-10 w-full" />
{Array.from({ length: 3 }).map((__, rowIndex) => (
<Skeleton key={rowIndex} className="h-10 w-full" />
))}
</div>
))}
</div>
)
}
@@ -0,0 +1,95 @@
import { useEffect, useMemo, useState } from 'react'
import { DragContextProvider } from '@/components/board/drag-context-provider'
import { DomainGroupItem } from '@/components/groups-board/domain-group-item'
import { DomainRow } from '@/components/groups-board/domain-row'
import type { BoardState } from '@/components/groups-board/types'
import type { DomainListItem, Group } from '@/lib/schemas'
import { Accordion } from '@cfdm/ui/components/accordion'
interface GroupsBoardProps {
board: BoardState
activeDomain?: DomainListItem
dragDisabled?: boolean
onDragStart: Parameters<typeof DragContextProvider>[0]['onDragStart']
onDragEnd: Parameters<typeof DragContextProvider>[0]['onDragEnd']
onEditGroup?: (group: Group) => void
onDeleteGroup?: (group: Group) => void
serviceLabelsByDomain?: Map<number, string[]>
}
export function GroupsBoard({
board,
activeDomain,
dragDisabled = false,
onDragStart,
onDragEnd,
onEditGroup,
onDeleteGroup,
serviceLabelsByDomain,
}: GroupsBoardProps) {
const columnIds = useMemo(
() => board.columns.map((column) => column.id),
[board.columns],
)
const columnIdsKey = columnIds.join(',')
const [openColumns, setOpenColumns] = useState<string[]>([])
useEffect(() => {
setOpenColumns((prev) => {
const preserved = prev.filter((id) => columnIds.includes(id))
const added = columnIds.filter((id) => !preserved.includes(id))
if (preserved.length === 0 && added.length > 0) {
return columnIds
}
return [...preserved, ...added]
})
}, [columnIdsKey, columnIds])
const isDragging = activeDomain != null
function handleExpandColumn(columnId: string) {
setOpenColumns((prev) =>
prev.includes(columnId) ? prev : [...prev, columnId],
)
}
return (
<DragContextProvider
disabled={dragDisabled}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
overlay={
activeDomain ? (
<DomainRow
domain={activeDomain}
serviceLabels={serviceLabelsByDomain?.get(activeDomain.id)}
dragDisabled
overlay
/>
) : null
}
>
<Accordion
multiple
value={openColumns}
onValueChange={setOpenColumns}
className="grid w-full grid-cols-1 gap-2 lg:grid-cols-2"
>
{board.columns.map((column) => (
<DomainGroupItem
key={column.id}
column={column}
isOpen={openColumns.includes(column.id)}
isDragging={isDragging}
onExpandColumn={handleExpandColumn}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
dragDisabled={dragDisabled}
serviceLabelsByDomain={serviceLabelsByDomain}
/>
))}
</Accordion>
</DragContextProvider>
)
}
@@ -0,0 +1,14 @@
import type { DomainListItem, Group } from '@/lib/schemas'
export interface BoardColumn {
id: string
groupId: number | null
title: string
slug: string | null
items: DomainListItem[]
group?: Group
}
export interface BoardState {
columns: BoardColumn[]
}