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,11 @@
export const UNGROUPED_COLUMN_ID = 'ungrouped'
export function groupColumnId(groupId: number) {
return `group-${groupId}`
}
export 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
}
@@ -0,0 +1,64 @@
import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
pointerWithin,
rectIntersection,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core'
import type { ReactNode } from 'react'
interface DragContextProviderProps {
children: ReactNode
overlay?: ReactNode
onDragStart: (event: DragStartEvent) => void
onDragEnd: (event: DragEndEvent) => void
disabled?: boolean
}
const collisionDetection: CollisionDetection = (args) => {
const pointerCollisions = pointerWithin(args)
if (pointerCollisions.length > 0) {
return pointerCollisions
}
const intersectionCollisions = rectIntersection(args)
if (intersectionCollisions.length > 0) {
return intersectionCollisions
}
return closestCenter(args)
}
export function DragContextProvider({
children,
overlay,
onDragStart,
onDragEnd,
disabled = false,
}: DragContextProviderProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
)
if (disabled) {
return <>{children}</>
}
return (
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{children}
<DragOverlay dropAnimation={null}>{overlay}</DragOverlay>
</DndContext>
)
}
+9 -3
View File
@@ -12,7 +12,9 @@ import {
} from '@cfdm/ui/components/alert-dialog'
interface ConfirmDialogProps {
trigger: ReactElement
trigger?: ReactElement
open?: boolean
onOpenChange?: (open: boolean) => void
title: string
description: string
confirmLabel?: string
@@ -23,6 +25,8 @@ interface ConfirmDialogProps {
export function ConfirmDialog({
trigger,
open,
onOpenChange,
title,
description,
confirmLabel = 'Удалить',
@@ -31,8 +35,10 @@ export function ConfirmDialog({
disabled,
}: ConfirmDialogProps) {
return (
<AlertDialog>
<AlertDialogTrigger disabled={disabled} render={trigger} />
<AlertDialog open={open} onOpenChange={onOpenChange}>
{trigger ? (
<AlertDialogTrigger disabled={disabled} render={trigger} />
) : null}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
@@ -0,0 +1,17 @@
import { PlusIcon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
interface DomainActionsBarProps {
onCreateSubdomain: () => void
}
export function DomainActionsBar({ onCreateSubdomain }: DomainActionsBarProps) {
return (
<div className="flex flex-wrap items-center gap-2">
<Button onClick={onCreateSubdomain}>
<PlusIcon data-icon="inline-start" />
Создать поддомен
</Button>
</div>
)
}
@@ -22,7 +22,6 @@ import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemSeparator,
ItemTitle,
@@ -32,12 +31,8 @@ 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)),
),
]
function uniqueServices(bindings: ServiceBinding[]): string[] {
return [...new Set(bindings.map((b) => b.service_name))]
}
export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
@@ -48,7 +43,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
<Card>
<CardHeader>
<CardTitle>Привязки сервисов</CardTitle>
<CardDescription>IP-адреса, назначенные сервисам в этой зоне</CardDescription>
<CardDescription>Сервисы, назначенные hostname в этой зоне</CardDescription>
</CardHeader>
<CardContent>
{entries.length === 0 ? (
@@ -56,34 +51,20 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
<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))]
const services = uniqueServices(hostnameBindings)
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>
@@ -103,6 +84,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/services" search={{ domainId: undefined }} />}
>
Сервисы
@@ -118,7 +100,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
</CardContent>
{entries.length > 0 && (
<CardFooter>
<Button variant="link" className="h-auto p-0" render={<Link to="/services" search={{ domainId: undefined }} />}>
<Button variant="link" className="h-auto p-0" nativeButton={false} render={<Link to="/services" search={{ domainId: undefined }} />}>
Управление привязками
</Button>
</CardFooter>
@@ -1,87 +0,0 @@
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,111 @@
import { useEffect, useState } from 'react'
import type { CreateGroupInput, Group } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
Field,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { Spinner } from '@cfdm/ui/components/spinner'
interface DomainGroupEditSheetProps {
mode: 'create' | 'edit'
group: Group | null
open: boolean
isSaving: boolean
onOpenChange: (open: boolean) => void
onCreate?: (body: CreateGroupInput) => void
onSave?: (id: number, body: CreateGroupInput) => void
}
export function DomainGroupEditSheet({
mode,
group,
open,
isSaving,
onOpenChange,
onCreate,
onSave,
}: DomainGroupEditSheetProps) {
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
useEffect(() => {
if (!open) return
if (mode === 'edit' && group) {
setName(group.name)
setSlug(group.slug)
} else {
setName('')
setSlug('')
}
}, [open, mode, group])
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const trimmedName = name.trim()
const trimmedSlug = slug.trim()
if (!trimmedName || !trimmedSlug) return
const body: CreateGroupInput = {
name: trimmedName,
slug: trimmedSlug,
}
if (mode === 'create') {
onCreate?.(body)
} else if (group) {
onSave?.(group.id, body)
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>
{mode === 'create' ? 'Новая группа доменов' : 'Редактировать группу'}
</SheetTitle>
<SheetDescription>
Группы используются для организации доменов на доске.
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="domain-group-name">Название</FieldLabel>
<Input
id="domain-group-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Production"
/>
</Field>
<Field>
<FieldLabel htmlFor="domain-group-slug">Slug</FieldLabel>
<Input
id="domain-group-slug"
value={slug}
onChange={(event) => setSlug(event.target.value)}
placeholder="production"
/>
</Field>
</FieldGroup>
<SheetFooter>
<Button type="submit" disabled={isSaving} className="w-full">
{isSaving && <Spinner data-icon="inline-start" />}
{isSaving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
+69
View File
@@ -0,0 +1,69 @@
import type { Domain } from '@/lib/schemas'
import type { CertMonitoring } from '@cfdm/shared'
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
import { StatusBadge } from '@/components/status-badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
interface DomainHeaderProps {
domain: Domain
onCertMonitoringChange?: (value: CertMonitoring) => void
isCertMonitoringSaving?: boolean
}
export function DomainHeader({
domain,
onCertMonitoringChange,
isCertMonitoringSaving,
}: DomainHeaderProps) {
const certMonitoringItems = certMonitoringOptions.map((option) => ({
label: option.label,
value: option.value,
}))
return (
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-semibold tracking-tight">{domain.zone_name}</h2>
<p className="text-sm text-muted-foreground">
Домен: <span className="font-mono text-foreground">{domain.zone_name}</span>
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Статус зоны:</span>
<StatusBadge status={domain.status} />
</div>
{onCertMonitoringChange && (
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-2">
<span className="text-sm text-muted-foreground">
Мониторинг SSL (apex):
</span>
<Select
items={certMonitoringItems}
value={domain.cert_monitoring}
onValueChange={(value) =>
onCertMonitoringChange((value ?? 'auto') as CertMonitoring)
}
disabled={isCertMonitoringSaving}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue>
{certMonitoringLabel(domain.cert_monitoring)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)
}
+37 -12
View File
@@ -17,7 +17,7 @@ import {
MoreHorizontalIcon,
SearchIcon,
} from 'lucide-react'
import { DomainIpBadges } from '@/components/domain-ip-badges'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import type { DomainListItem } from '@/lib/schemas'
import { Badge } from '@cfdm/ui/components/badge'
@@ -55,9 +55,7 @@ import {
TableRow,
} from '@cfdm/ui/components/table'
export interface DomainTableRow extends DomainListItem {
ips: string[]
}
export type DomainTableRow = DomainListItem
interface GroupFilterItem {
label: string
@@ -69,6 +67,8 @@ interface DomainsDataTableProps {
groupFilterItems: GroupFilterItem[]
groupFilterValue: string
onGroupFilterChange: (value: string | null) => void
onDelete: (domain: DomainTableRow) => void
isDeleting?: boolean
}
export function DomainsDataTable({
@@ -76,9 +76,12 @@ export function DomainsDataTable({
groupFilterItems,
groupFilterValue,
onGroupFilterChange,
onDelete,
isDeleting = false,
}: DomainsDataTableProps) {
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [deleteTarget, setDeleteTarget] = useState<DomainTableRow | null>(null)
const columns = useMemo<ColumnDef<DomainTableRow>[]>(
() => [
@@ -98,6 +101,7 @@ export function DomainsDataTable({
<Button
variant="link"
className="h-auto p-0 font-medium"
nativeButton={false}
render={
<Link
to="/domains/$domainId"
@@ -120,6 +124,7 @@ export function DomainsDataTable({
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link
to="/groups/$groupId"
@@ -134,13 +139,6 @@ export function DomainsDataTable({
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: 'Сервисы',
@@ -148,6 +146,7 @@ export function DomainsDataTable({
<Button
variant="link"
className="h-auto p-0 tabular-nums"
nativeButton={false}
render={
<Link
to="/services"
@@ -210,13 +209,20 @@ export function DomainsDataTable({
>
DNS
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={isDeleting}
onClick={() => setDeleteTarget(row.original)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[],
[isDeleting],
)
const table = useReactTable({
@@ -353,6 +359,25 @@ export function DomainsDataTable({
</Button>
</div>
)}
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
title="Удалить зону?"
description={
deleteTarget
? `Зона «${deleteTarget.zone_name}» будет удалена из менеджера вместе с DNS-записями и привязками. Зона в Cloudflare не затрагивается.`
: ''
}
onConfirm={() => {
if (!deleteTarget) return
onDelete(deleteTarget)
setDeleteTarget(null)
}}
disabled={isDeleting}
/>
</div>
)
}
@@ -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[]
}
-96
View File
@@ -1,96 +0,0 @@
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
@@ -1,81 +0,0 @@
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>
)
}
-103
View File
@@ -1,103 +0,0 @@
import { PencilIcon } from 'lucide-react'
import { StatusBadge } from '@/components/status-badge'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import type { ServiceView } 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 {
Item,
ItemContent,
ItemGroup,
ItemTitle,
} from '@cfdm/ui/components/item'
interface ServiceCardProps {
service: ServiceView
onEdit: (service: ServiceView) => void
}
function aggregateSyncStatus(service: ServiceView) {
const statuses = (service.domains ?? [])
.map((domain) => domain.sync_status)
.filter((status): status is string => Boolean(status))
if (statuses.length === 0) return null
if (statuses.includes('error')) return 'error'
if (statuses.includes('pending_push')) return 'pending_push'
if (statuses.every((status) => status === 'synced')) return 'synced'
return statuses[0]
}
export function ServiceCard({ service, onEdit }: ServiceCardProps) {
const ips = service.ips ?? []
const domains = service.domains ?? []
const syncStatus = aggregateSyncStatus(service)
return (
<Card>
<CardHeader>
<CardTitle>{service.name}</CardTitle>
<CardDescription>
<Badge variant="outline">{service.slug}</Badge>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">IP-адреса</p>
{ips.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="flex flex-wrap gap-1.5">
{ips.map((ip) => (
<Badge key={ip} variant="secondary">
{ip}
</Badge>
))}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Домены</p>
{domains.length === 0 ? (
<p className="text-sm text-muted-foreground">Не привязаны</p>
) : (
<ItemGroup>
{domains.map((binding) => (
<Item key={binding.binding_id} variant="outline" size="sm">
<ItemContent>
<ItemTitle className="flex flex-wrap items-center gap-2">
<span>{bindingToFqdn(binding)}</span>
{binding.target_ips.map((ip) => (
<Badge key={ip} variant="secondary">
{ip}
</Badge>
))}
{binding.sync_status ? (
<StatusBadge status={binding.sync_status} />
) : null}
</ItemTitle>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
</CardContent>
<CardFooter className="flex flex-wrap items-center justify-between gap-2">
<div>{syncStatus ? <StatusBadge status={syncStatus} /> : null}</div>
<Button type="button" variant="outline" size="sm" onClick={() => onEdit(service)}>
<PencilIcon data-icon="inline-start" />
Редактировать
</Button>
</CardFooter>
</Card>
)
}
+170 -62
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { EmptyState } from '@/components/empty-state'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import type {
@@ -24,6 +25,7 @@ import {
ItemContent,
ItemGroup,
} from '@cfdm/ui/components/item'
import { Separator } from '@cfdm/ui/components/separator'
import {
Sheet,
SheetContent,
@@ -49,7 +51,9 @@ import {
export interface ServiceBindingDraft {
fqdn: string
record_type: 'A' | 'CNAME'
target_ips: string[]
target_cname: string
}
interface ServiceEditSheetProps {
@@ -60,6 +64,7 @@ interface ServiceEditSheetProps {
knownDomains: DomainListItem[]
isSaving: boolean
isDeleting?: boolean
defaultGroupId?: number | null
onOpenChange: (open: boolean) => void
onCreate?: (body: CreateServiceWithConfigInput) => void
onSave?: (id: number, body: UpdateServiceConfigInput) => void
@@ -69,17 +74,30 @@ interface ServiceEditSheetProps {
function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
return (service.domains ?? []).map((binding) => ({
fqdn: bindingToFqdn(binding),
record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'),
target_ips: binding.target_ips ?? [],
target_cname: binding.target_cname ?? '',
}))
}
function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
return bindings
.filter((binding) => binding.fqdn.trim() && binding.target_ips.length > 0)
.map((binding) => ({
fqdn: binding.fqdn.trim(),
target_ips: binding.target_ips,
}))
.filter((binding) => {
if (!binding.fqdn.trim()) return false
if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
return binding.target_ips.length > 0
})
.map((binding) =>
binding.record_type === 'CNAME'
? {
fqdn: binding.fqdn.trim(),
target_cname: binding.target_cname.trim(),
}
: {
fqdn: binding.fqdn.trim(),
target_ips: binding.target_ips,
},
)
}
export function ServiceEditSheet({
@@ -90,6 +108,7 @@ export function ServiceEditSheet({
knownDomains,
isSaving,
isDeleting = false,
defaultGroupId = null,
onOpenChange,
onCreate,
onSave,
@@ -124,11 +143,13 @@ export function ServiceEditSheet({
if (mode === 'create') {
setName('')
setSlug('')
setServiceGroupId('none')
setServiceGroupId(
defaultGroupId != null ? String(defaultGroupId) : 'none',
)
setIps([])
setBindings([])
}
}, [open, mode, service])
}, [open, mode, service, defaultGroupId])
const zoneHints = useMemo(
() => knownDomains.map((domain) => domain.zone_name),
@@ -136,7 +157,10 @@ export function ServiceEditSheet({
)
function handleAddBinding() {
setBindings((current) => [...current, { fqdn: '', target_ips: [] }])
setBindings((current) => [
...current,
{ fqdn: '', record_type: 'A', target_ips: [], target_cname: '' },
])
}
function handleRemoveBinding(index: number) {
@@ -150,6 +174,27 @@ export function ServiceEditSheet({
)
}
function handleRecordTypeChange(index: number, recordType: 'A' | 'CNAME') {
setBindings((current) =>
current.map((item, i) =>
i === index
? {
...item,
record_type: recordType,
target_ips: recordType === 'A' ? item.target_ips : [],
target_cname: recordType === 'CNAME' ? item.target_cname : '',
}
: item,
),
)
}
function handleCnameChange(index: number, value: string) {
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, target_cname: value } : item)),
)
}
function handleIpsChange(index: number, targetIps: string[]) {
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, target_ips: targetIps } : item)),
@@ -198,20 +243,34 @@ export function ServiceEditSheet({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto sm:max-w-lg">
<SheetHeader>
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-xl">
<SheetHeader className="border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Настройте IP-пул и привязки FQDN IP. Зона определяется из FQDN автоматически.
Настройте параметры сервиса и привязки FQDN IP или CNAME. Зона определяется из FQDN
автоматически.
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 px-4">
<Tabs defaultValue="general">
<TabsList className="w-full">
<div className="flex flex-1 flex-col gap-4 px-4 py-4">
<Tabs
defaultValue="general"
orientation="horizontal"
className="flex w-full flex-col gap-4"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="general">Основное</TabsTrigger>
<TabsTrigger value="bindings">Привязки</TabsTrigger>
<TabsTrigger value="bindings">
Привязки
{bindings.length > 0 ? (
<span className="text-muted-foreground tabular-nums">
({bindings.length})
</span>
) : null}
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="flex flex-col gap-4 pt-4">
<TabsContent value="general" className="flex flex-col gap-4">
<FieldGroup className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
@@ -262,35 +321,81 @@ export function ServiceEditSheet({
</Field>
</FieldGroup>
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<FieldLabel>Привязки доменов</FieldLabel>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</div>
{bindings.length === 0 ? (
<p className="text-sm text-muted-foreground">
Необязательно. Введите FQDN, например newdom.ivx.su зона ivx.su определится
автоматически.
</p>
) : (
<ItemGroup>
{bindings.map((binding, index) => (
<Item key={`binding-${index}`} variant="outline">
<ItemContent className="flex flex-col gap-3">
<TabsContent value="bindings" className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
FQDN IP или CNAME для DNS-записей Cloudflare
</p>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</div>
{bindings.length === 0 ? (
<EmptyState
icon={Link2Icon}
title="Нет привязок"
description="Необязательно. Пример: newdom.ivx.su — зона ivx.su определится автоматически."
action={
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить привязку
</Button>
}
/>
) : (
<ItemGroup className="gap-2">
{bindings.map((binding, index) => (
<Item key={`binding-${index}`} variant="outline">
<ItemContent className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
<TaggedInput
id={`binding-fqdn-${index}`}
value={binding.fqdn ? [binding.fqdn] : []}
onChange={(tags) => handleFqdnChange(index, tags)}
placeholder={
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
}
maxItems={1}
/>
</Field>
<Field>
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(value) =>
handleRecordTypeChange(index, (value ?? 'A') as 'A' | 'CNAME')
}
>
<SelectTrigger id={`binding-type-${index}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</Field>
{binding.record_type === 'CNAME' ? (
<Field>
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
<TaggedInput
id={`binding-fqdn-${index}`}
value={binding.fqdn ? [binding.fqdn] : []}
onChange={(tags) => handleFqdnChange(index, tags)}
placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'}
maxItems={1}
<FieldLabel htmlFor={`binding-cname-${index}`}>
CNAME-цель
</FieldLabel>
<Input
id={`binding-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) => handleCnameChange(index, event.target.value)}
/>
</Field>
) : (
<Field>
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
<ServiceBindingIpInput
@@ -300,27 +405,30 @@ export function ServiceEditSheet({
onChange={(targetIps) => handleIpsChange(index, targetIps)}
/>
</Field>
</ItemContent>
<ItemActions>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
)}
</div>
)}
</ItemContent>
<ItemActions>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
)}
</TabsContent>
</Tabs>
</div>
<SheetFooter className="flex flex-row flex-wrap gap-2">
<Separator />
<SheetFooter className="flex flex-row flex-wrap gap-2 border-t-0 pt-4">
{!isCreate ? (
<ConfirmDialog
trigger={
@@ -341,7 +449,7 @@ export function ServiceEditSheet({
) : null}
<Button
type="button"
className={isCreate ? 'ml-auto' : 'ml-auto'}
className="ml-auto"
disabled={!canSubmit || isSaving || isDeleting}
onClick={handleSubmit}
>
@@ -1,116 +0,0 @@
import { ChevronDownIcon, PencilIcon } from 'lucide-react'
import { useState } from 'react'
import { ServiceGroupIcon } from '@/components/service-group-icon'
import { ServiceRow } from '@/components/service-row'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import { ItemGroup } from '@cfdm/ui/components/item'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Switch } from '@cfdm/ui/components/switch'
import { cn } from '@cfdm/ui/lib/utils'
interface ServiceGroupCardProps {
group: ServiceGroupView
onGroupToggle: (groupId: number, enabled: boolean) => void
onServiceToggle: (serviceId: number, enabled: boolean) => void
onEditService: (service: ServiceView) => void
onEditGroup: (group: ServiceGroupView) => void
togglingGroupId?: number | null
togglingServiceId?: number | null
}
export function ServiceGroupCard({
group,
onGroupToggle,
onServiceToggle,
onEditService,
onEditGroup,
togglingGroupId = null,
togglingServiceId = null,
}: ServiceGroupCardProps) {
const [open, setOpen] = useState(true)
const isGroupToggling = togglingGroupId === group.id
return (
<Card>
<Collapsible open={open} onOpenChange={setOpen}>
<CardHeader>
<div className="flex items-center gap-2">
<CollapsibleTrigger
className={cn(
'flex flex-1 items-center gap-2 text-left',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<ChevronDownIcon
className={cn(
'size-4 shrink-0 transition-transform',
open && 'rotate-180',
)}
/>
<ServiceGroupIcon type={group.type} />
<CardTitle className="flex-1">{group.name}</CardTitle>
</CollapsibleTrigger>
{group.domain ? (
<Badge variant="outline">{group.domain}</Badge>
) : null}
</div>
<CardAction className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => onEditGroup(group)}
aria-label={`Редактировать группу ${group.name}`}
>
<PencilIcon />
</Button>
{isGroupToggling ? (
<Spinner className="size-4" />
) : (
<Switch
checked={group.enabled}
disabled={isGroupToggling}
onCheckedChange={(checked) => onGroupToggle(group.id, checked)}
aria-label={`${group.enabled ? 'Выключить' : 'Включить'} группу ${group.name}`}
/>
)}
</CardAction>
</CardHeader>
<CollapsibleContent>
<CardContent>
{group.services.length === 0 ? (
<p className="text-sm text-muted-foreground">Нет сервисов в группе</p>
) : (
<ItemGroup>
{group.services.map((service) => (
<ServiceRow
key={service.id}
service={service}
onToggle={onServiceToggle}
onEdit={onEditService}
isToggling={togglingServiceId === service.id}
disabled={!group.enabled}
/>
))}
</ItemGroup>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
)
}
@@ -92,7 +92,7 @@ export function ServiceGroupEditSheet({
{mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
</SheetTitle>
<SheetDescription>
Домен группы любой FQDN (gr.ivx.su, domain.new.ivx.su). Публикуется в Cloudflare отдельно от привязок сервисов.
Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов.
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
@@ -127,7 +127,7 @@ export function ServiceGroupEditSheet({
</Select>
</Field>
<Field>
<FieldLabel htmlFor="group-domain">Домен группы (FQDN)</FieldLabel>
<FieldLabel htmlFor="group-domain">Домен группы (FQDN, необязательно)</FieldLabel>
<Input
id="group-domain"
value={domain}
-77
View File
@@ -1,77 +0,0 @@
import { PencilIcon } from 'lucide-react'
import { StatusBadge } from '@/components/status-badge'
import {
aggregateServiceSyncStatus,
serviceDisplayFqdn,
} from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Switch } from '@cfdm/ui/components/switch'
interface ServiceRowProps {
service: ServiceView
onToggle: (serviceId: number, enabled: boolean) => void
onEdit: (service: ServiceView) => void
isToggling?: boolean
disabled?: boolean
}
export function ServiceRow({
service,
onToggle,
onEdit,
isToggling = false,
disabled = false,
}: ServiceRowProps) {
const syncStatus = aggregateServiceSyncStatus(service)
const fqdn = serviceDisplayFqdn(service)
return (
<Item variant="outline" size="sm">
<ItemContent>
<ItemTitle>{service.name}</ItemTitle>
<ItemDescription>{fqdn}</ItemDescription>
</ItemContent>
<ItemActions className="gap-1">
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
{isToggling ? (
<Spinner className="size-4" />
) : (
<Switch
checked={service.enabled}
disabled={disabled || isToggling}
onCheckedChange={(checked) => onToggle(service.id, checked)}
aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`}
/>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Редактировать ${service.name}`}
onClick={() => onEdit(service)}
>
<PencilIcon />
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="hidden md:inline-flex"
onClick={() => onEdit(service)}
>
<PencilIcon data-icon="inline-start" />
Редактировать
</Button>
</ItemActions>
</Item>
)
}
@@ -0,0 +1,152 @@
import { arrayMove } from '@dnd-kit/sortable'
import type { ServiceGroupsResponse, ServiceView } from '@/lib/schemas'
import {
groupColumnId,
UNGROUPED_COLUMN_ID,
} from '@/components/services-board/column-ids'
import type { BoardColumn, BoardState } from '@/components/services-board/types'
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
if (!domainId) return true
return service.domains?.some((d) => d.domain_id === domainId) ?? false
}
export function mapResponseToBoard(
data: ServiceGroupsResponse,
domainId?: number,
): BoardState {
const groups = domainId
? data.groups
.map((group) => ({
...group,
services: group.services.filter((s) => serviceMatchesDomain(s, domainId)),
}))
.filter((group) => group.services.length > 0)
: data.groups
const ungrouped = domainId
? data.ungrouped.filter((s) => serviceMatchesDomain(s, domainId))
: data.ungrouped
const columns: BoardColumn[] = groups.map((group) => ({
id: groupColumnId(group.id),
groupId: group.id,
title: group.name,
domain: group.domain,
enabled: group.enabled,
type: group.type,
items: group.services,
group,
}))
if (!domainId || ungrouped.length > 0) {
columns.push({
id: UNGROUPED_COLUMN_ID,
groupId: null,
title: 'Без группы',
domain: null,
enabled: true,
type: 'custom',
items: ungrouped,
})
}
return { columns }
}
export function findColumnId(columns: BoardColumn[], id: string): string | undefined {
if (columns.some((column) => column.id === id)) return id
return columns.find((column) =>
column.items.some((service) => String(service.id) === id),
)?.id
}
export function findColumn(columns: BoardColumn[], id: string): BoardColumn | undefined {
const columnId = findColumnId(columns, id)
return columnId ? columns.find((column) => column.id === columnId) : undefined
}
export function reorderInColumn(
board: BoardState,
columnId: string,
activeId: string,
overId: string,
): BoardState {
const columnIndex = board.columns.findIndex((column) => column.id === columnId)
if (columnIndex === -1) return board
const column = board.columns[columnIndex]!
const oldIndex = column.items.findIndex((service) => String(service.id) === activeId)
const newIndex = column.items.findIndex((service) => String(service.id) === overId)
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return board
const items = arrayMove(column.items, oldIndex, newIndex)
const columns = [...board.columns]
columns[columnIndex] = { ...column, items }
return { columns }
}
export function moveServiceBetweenColumns(
board: BoardState,
serviceId: number,
fromColumnId: string,
toColumnId: string,
overId: string,
): BoardState {
const fromIndex = board.columns.findIndex((column) => column.id === fromColumnId)
const toIndex = board.columns.findIndex((column) => column.id === toColumnId)
if (fromIndex === -1 || toIndex === -1) return board
const fromColumn = board.columns[fromIndex]!
const toColumn = board.columns[toIndex]!
const fromItems = [...fromColumn.items]
const serviceIndex = fromItems.findIndex((service) => service.id === serviceId)
if (serviceIndex === -1) return board
const [service] = fromItems.splice(serviceIndex, 1)
if (!service) return board
const targetGroupId = toColumn.groupId
const movedService: ServiceView = {
...service,
service_group_id: targetGroupId,
}
let insertIndex = toColumn.items.length
if (overId !== toColumnId) {
const overIndex = toColumn.items.findIndex(
(item) => String(item.id) === overId,
)
if (overIndex !== -1) insertIndex = overIndex
}
const targetItems = [...toColumn.items]
targetItems.splice(insertIndex, 0, movedService)
const columns = [...board.columns]
columns[fromIndex] = {
...fromColumn,
items: fromItems,
}
columns[toIndex] = { ...toColumn, items: targetItems }
return { columns }
}
export function boardToQueryData(
board: BoardState,
previous: ServiceGroupsResponse,
): ServiceGroupsResponse {
const groups = previous.groups.map((group) => {
const column = board.columns.find((col) => col.groupId === group.id)
return column ? { ...group, services: column.items } : group
})
const ungroupedColumn = board.columns.find(
(column) => column.id === UNGROUPED_COLUMN_ID,
)
return {
groups,
ungrouped: ungroupedColumn?.items ?? previous.ungrouped,
}
}
@@ -0,0 +1,5 @@
export {
UNGROUPED_COLUMN_ID,
groupColumnId,
parseGroupColumnId,
} from '@/components/board/column-ids'
@@ -0,0 +1,152 @@
import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { ServiceGroupIcon } from '@/components/service-group-icon'
import type { BoardColumn } from '@/components/services-board/types'
import type { ServiceGroupView } 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 { Checkbox } from '@cfdm/ui/components/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Switch } from '@cfdm/ui/components/switch'
import { cn } from '@cfdm/ui/lib/utils'
interface ServiceGroupHeaderProps {
column: BoardColumn
isOpen?: boolean
isDragging?: boolean
dragDisabled?: boolean
showCheckbox?: boolean
groupAllSelected?: boolean
groupSomeSelected?: boolean
serviceIds: number[]
isGroupToggling?: boolean
onSelectAllInGroup?: (ids: number[]) => void
onDeselectAllInGroup?: (ids: number[]) => void
onGroupToggle?: (groupId: number, enabled: boolean) => void
onEditGroup?: (group: ServiceGroupView) => void
onDeleteGroup?: (group: ServiceGroupView) => void
}
export function ServiceGroupHeader({
column,
isOpen = true,
isDragging = false,
dragDisabled = false,
showCheckbox = false,
groupAllSelected = false,
groupSomeSelected = false,
serviceIds,
isGroupToggling = false,
onSelectAllInGroup,
onDeselectAllInGroup,
onGroupToggle,
onEditGroup,
onDeleteGroup,
}: ServiceGroupHeaderProps) {
return (
<div className="flex items-center gap-1 px-1">
{showCheckbox && !dragDisabled && column.items.length > 0 ? (
<Checkbox
checked={groupAllSelected}
onCheckedChange={() => {
if (groupAllSelected || groupSomeSelected) {
onDeselectAllInGroup?.(serviceIds)
} else {
onSelectAllInGroup?.(serviceIds)
}
}}
onPointerDown={(event) => event.stopPropagation()}
aria-label={`Выбрать все сервисы в группе ${column.title}`}
className={cn(
'ml-1',
groupSomeSelected && !groupAllSelected && 'opacity-60',
)}
/>
) : null}
<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">
<ServiceGroupIcon type={column.type} />
</span>
) : null}
<span className="font-medium">{column.title}</span>
<Badge variant="secondary">{column.items.length}</Badge>
{column.domain ? (
<Badge variant="outline">{column.domain}</Badge>
) : null}
{!isOpen && isDragging && !dragDisabled ? (
<Badge variant="default" className="font-normal">
Отпустите для переноса
</Badge>
) : null}
</div>
</AccordionTrigger>
{column.groupId !== null ? (
<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 && column.group ? (
<DropdownMenuItem onClick={() => onEditGroup(column.group!)}>
<PencilIcon data-icon="inline-start" />
Редактировать
</DropdownMenuItem>
) : null}
{onDeleteGroup && column.group ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteGroup(column.group!)}
>
<Trash2Icon data-icon="inline-start" />
Удалить группу
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{column.domain && onGroupToggle ? (
isGroupToggling ? (
<Spinner className="size-4" />
) : (
<Switch
checked={column.enabled}
disabled={isGroupToggling}
onCheckedChange={(checked) =>
onGroupToggle(column.groupId!, checked)
}
aria-label={`${column.enabled ? 'Выключить' : 'Включить'} группу ${column.title}`}
/>
)
) : null}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,193 @@
import { useEffect } from 'react'
import { useDroppable } from '@dnd-kit/core'
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { PlusIcon } from 'lucide-react'
import { ServiceGroupHeader } from '@/components/services-board/service-group-header'
import { ServiceRow } from '@/components/services-board/service-row'
import type { BoardColumn } from '@/components/services-board/types'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import {
AccordionContent,
AccordionItem,
} from '@cfdm/ui/components/accordion'
import { Button } from '@cfdm/ui/components/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@cfdm/ui/components/empty'
import { ItemGroup, ItemSeparator } from '@cfdm/ui/components/item'
import { cn } from '@cfdm/ui/lib/utils'
interface ServiceGroupItemProps {
column: BoardColumn
isOpen?: boolean
isDragging?: boolean
onExpandColumn?: (columnId: string) => void
onGroupToggle?: (groupId: number, enabled: boolean) => void
onEditGroup?: (group: ServiceGroupView) => void
onDeleteGroup?: (group: ServiceGroupView) => void
onAddService?: (groupId: number | null) => void
onServiceToggle: (serviceId: number, enabled: boolean) => void
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
togglingGroupId?: number | null
togglingServiceId?: number | null
dragDisabled?: boolean
showCheckbox?: boolean
isSelected?: (id: number) => boolean
onSelectedChange?: (id: number, selected: boolean) => void
isAllSelected?: (ids: number[]) => boolean
isSomeSelected?: (ids: number[]) => boolean
onSelectAllInGroup?: (ids: number[]) => void
onDeselectAllInGroup?: (ids: number[]) => void
}
export function ServiceGroupItem({
column,
isOpen = true,
isDragging = false,
onExpandColumn,
onGroupToggle,
onEditGroup,
onDeleteGroup,
onAddService,
onServiceToggle,
onEditService,
onDeleteService,
togglingGroupId = null,
togglingServiceId = null,
dragDisabled = false,
showCheckbox = false,
isSelected,
onSelectedChange,
isAllSelected,
isSomeSelected,
onSelectAllInGroup,
onDeselectAllInGroup,
}: ServiceGroupItemProps) {
const { setNodeRef, isOver } = useDroppable({
id: column.id,
disabled: dragDisabled,
})
const isGroupToggling = column.groupId !== null && togglingGroupId === column.groupId
const serviceDisabled = Boolean(column.domain) && !column.enabled
const serviceIds = column.items.map((s) => s.id)
const groupAllSelected = isAllSelected?.(serviceIds) ?? false
const groupSomeSelected = isSomeSelected?.(serviceIds) ?? false
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',
)}
>
<ServiceGroupHeader
column={column}
isOpen={isOpen}
isDragging={isDragging}
dragDisabled={dragDisabled}
showCheckbox={showCheckbox}
groupAllSelected={groupAllSelected}
groupSomeSelected={groupSomeSelected}
serviceIds={serviceIds}
isGroupToggling={isGroupToggling}
onSelectAllInGroup={onSelectAllInGroup}
onDeselectAllInGroup={onDeselectAllInGroup}
onGroupToggle={onGroupToggle}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
/>
<AccordionContent className="px-1 pb-2">
<div ref={setNodeRef}>
<SortableContext
items={column.items.map((service) => String(service.id))}
strategy={verticalListSortingStrategy}
>
{column.items.length > 0 ? (
<ItemGroup className="gap-0 py-1">
{column.items.map((service, index) => (
<div key={service.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null}
<ServiceRow
service={service}
onToggle={onServiceToggle}
onEdit={onEditService}
onDelete={onDeleteService}
isToggling={togglingServiceId === service.id}
disabled={serviceDisabled}
dragDisabled={dragDisabled}
showCheckbox={showCheckbox}
selected={isSelected?.(service.id) ?? false}
onSelectedChange={(selected) =>
onSelectedChange?.(service.id, selected)
}
/>
</div>
))}
</ItemGroup>
) : (
<Empty
className={cn(
'border border-dashed py-2',
isOver && !dragDisabled && 'border-primary bg-primary/5',
)}
>
<EmptyHeader>
<EmptyTitle className="text-sm">Нет сервисов в группе</EmptyTitle>
<EmptyDescription>
{dragDisabled
? 'Сервисы не найдены'
: 'Перетащите сервис сюда или добавьте новый'}
</EmptyDescription>
</EmptyHeader>
{onAddService ? (
<EmptyContent>
<Button
type="button"
variant="link"
size="sm"
onClick={() => onAddService(column.groupId)}
>
<PlusIcon data-icon="inline-start" />
Добавить сервис
</Button>
</EmptyContent>
) : null}
</Empty>
)}
</SortableContext>
{column.items.length > 0 && onAddService ? (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-1 w-full justify-start text-muted-foreground"
onClick={() => onAddService(column.groupId)}
>
<PlusIcon data-icon="inline-start" />
Добавить сервис
</Button>
) : null}
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -0,0 +1,199 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVerticalIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { StatusBadge } from '@/components/status-badge'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import {
aggregateServiceSyncStatus,
serviceDisplayFqdn,
} from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
export interface ServiceRowProps {
service: ServiceView
onToggle: (serviceId: number, enabled: boolean) => void
onEdit: (service: ServiceView) => void
onDelete: (service: ServiceView) => void
isToggling?: boolean
disabled?: boolean
dragDisabled?: boolean
overlay?: boolean
selected?: boolean
onSelectedChange?: (selected: boolean) => void
showCheckbox?: boolean
}
export function ServiceRow({
service,
onToggle,
onEdit,
onDelete,
isToggling = false,
disabled = false,
dragDisabled = false,
overlay = false,
selected = false,
onSelectedChange,
showCheckbox = false,
}: ServiceRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: String(service.id),
disabled: dragDisabled || overlay,
})
const syncStatus = aggregateServiceSyncStatus(service)
const fqdn = serviceDisplayFqdn(service)
const allFqdns = (service.domains ?? []).map((d) => bindingToFqdn(d))
const style = transform
? {
transform: CSS.Transform.toString(transform),
transition,
}
: undefined
const fqdnContent =
allFqdns.length > 1 ? (
<Tooltip>
<TooltipTrigger
render={
<span className="cursor-default truncate font-mono">{fqdn}</span>
}
/>
<TooltipContent>
<div className="flex flex-col gap-0.5">
{allFqdns.map((name) => (
<span key={name}>{name}</span>
))}
</div>
</TooltipContent>
</Tooltip>
) : (
<span className="truncate font-mono">{fqdn}</span>
)
return (
<div
ref={overlay ? undefined : setNodeRef}
style={overlay ? undefined : style}
className={cn(
'flex h-10 items-center gap-3 rounded-md px-3 transition-colors',
service.enabled ? 'hover:bg-muted/50' : 'text-muted-foreground hover:bg-muted/40',
selected && 'bg-accent/60',
disabled && 'opacity-70',
(isDragging || overlay) && 'opacity-90 shadow-md',
isDragging && !overlay && 'z-10',
)}
>
{showCheckbox && !dragDisabled && !overlay ? (
<Checkbox
checked={selected}
onCheckedChange={(checked) => onSelectedChange?.(checked === true)}
onPointerDown={(event) => event.stopPropagation()}
aria-label={`Выбрать ${service.name}`}
/>
) : null}
{!dragDisabled && !overlay ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing"
aria-label={`Перетащить ${service.name}`}
{...listeners}
{...attributes}
>
<GripVerticalIcon />
</Button>
) : null}
<div className="flex min-w-0 shrink-0 items-center gap-2">
<span
className={cn(
'truncate text-sm font-medium',
service.enabled ? 'text-foreground' : 'text-muted-foreground',
)}
>
{service.name}
</span>
<Badge variant="secondary" className="shrink-0 font-mono tabular-nums">
{service.slug}
</Badge>
</div>
<div className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{fqdnContent}
</div>
<div className="flex shrink-0 items-center gap-2">
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
{isToggling ? (
<Spinner className="size-4" />
) : (
<Switch
checked={service.enabled}
disabled={disabled || isToggling}
onPointerDown={(event) => event.stopPropagation()}
onCheckedChange={(checked) => onToggle(service.id, checked)}
aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`}
/>
)}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия для ${service.name}`}
onPointerDown={(event) => event.stopPropagation()}
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(service)}>
<PencilIcon data-icon="inline-start" />
Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDelete(service)}
>
<Trash2Icon data-icon="inline-start" />
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
@@ -0,0 +1,19 @@
import { Skeleton } from '@cfdm/ui/components/skeleton'
export function ServicesBoardSkeleton() {
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,136 @@
import { useEffect, useMemo, useState } from 'react'
import { DragContextProvider } from '@/components/board/drag-context-provider'
import { ServiceGroupItem } from '@/components/services-board/service-group-item'
import { ServiceRow } from '@/components/services-board/service-row'
import type { BoardState } from '@/components/services-board/types'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import { Accordion } from '@cfdm/ui/components/accordion'
interface ServicesBoardProps {
board: BoardState
activeService?: ServiceView
dragDisabled?: boolean
onDragStart: Parameters<typeof DragContextProvider>[0]['onDragStart']
onDragEnd: Parameters<typeof DragContextProvider>[0]['onDragEnd']
onGroupToggle: (groupId: number, enabled: boolean) => void
onServiceToggle: (serviceId: number, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup?: (group: ServiceGroupView) => void
onAddService?: (groupId: number | null) => void
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
togglingGroupId?: number | null
togglingServiceId?: number | null
showCheckbox?: boolean
isSelected?: (id: number) => boolean
onSelectedChange?: (id: number, selected: boolean) => void
isAllSelected?: (ids: number[]) => boolean
isSomeSelected?: (ids: number[]) => boolean
onSelectAllInGroup?: (ids: number[]) => void
onDeselectAllInGroup?: (ids: number[]) => void
}
export function ServicesBoard({
board,
activeService,
dragDisabled = false,
onDragStart,
onDragEnd,
onGroupToggle,
onServiceToggle,
onEditGroup,
onDeleteGroup,
onAddService,
onEditService,
onDeleteService,
togglingGroupId = null,
togglingServiceId = null,
showCheckbox = false,
isSelected,
onSelectedChange,
isAllSelected,
isSomeSelected,
onSelectAllInGroup,
onDeselectAllInGroup,
}: ServicesBoardProps) {
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 = activeService != null
function handleExpandColumn(columnId: string) {
setOpenColumns((prev) =>
prev.includes(columnId) ? prev : [...prev, columnId],
)
}
return (
<DragContextProvider
disabled={dragDisabled}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
overlay={
activeService ? (
<ServiceRow
service={activeService}
onToggle={onServiceToggle}
onEdit={onEditService}
onDelete={onDeleteService}
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) => (
<ServiceGroupItem
key={column.id}
column={column}
isOpen={openColumns.includes(column.id)}
isDragging={isDragging}
onExpandColumn={handleExpandColumn}
onGroupToggle={onGroupToggle}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
onAddService={onAddService}
onServiceToggle={onServiceToggle}
onEditService={onEditService}
onDeleteService={onDeleteService}
togglingGroupId={togglingGroupId}
togglingServiceId={togglingServiceId}
dragDisabled={dragDisabled}
showCheckbox={showCheckbox}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
isAllSelected={isAllSelected}
isSomeSelected={isSomeSelected}
onSelectAllInGroup={onSelectAllInGroup}
onDeselectAllInGroup={onDeselectAllInGroup}
/>
))}
</Accordion>
</DragContextProvider>
)
}
@@ -0,0 +1,54 @@
import { Button } from '@cfdm/ui/components/button'
interface ServicesBulkToolbarProps {
count: number
isPending?: boolean
onEnable: () => void
onDisable: () => void
onClear: () => void
}
export function ServicesBulkToolbar({
count,
isPending = false,
onEnable,
onDisable,
onClear,
}: ServicesBulkToolbarProps) {
if (count === 0) return null
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2">
<span className="text-sm text-muted-foreground">
Выбрано: {count}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={onEnable}
>
Включить
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={onDisable}
>
Выключить
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending}
onClick={onClear}
>
Снять выделение
</Button>
</div>
)
}
@@ -0,0 +1,16 @@
import type { ServiceGroup, ServiceGroupView, ServiceView } from '@/lib/schemas'
export interface BoardColumn {
id: string
groupId: number | null
title: string
domain: string | null
enabled: boolean
type: ServiceGroup['type']
items: ServiceView[]
group?: ServiceGroupView
}
export interface BoardState {
columns: BoardColumn[]
}
+3 -3
View File
@@ -5,9 +5,9 @@ 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',
active: 'success',
synced: 'success',
ok: 'success',
pending_push: 'secondary',
warning: 'secondary',
conflict: 'destructive',
@@ -0,0 +1,201 @@
import { useEffect, useMemo, useState } from 'react'
import type { CertMonitoring } from '@cfdm/shared'
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatServiceGroupLabel } from '@/lib/service-utils'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Field,
FieldDescription,
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 { Spinner } from '@cfdm/ui/components/spinner'
export interface SubdomainEditValues {
name: string
serviceId: string
certMonitoring: CertMonitoring
}
interface SubdomainEditSheetProps {
mode: 'create' | 'edit'
subdomain: SubdomainRecord | null
zoneName: string
services: ServiceView[]
serviceGroupById: Map<number, string | null>
currentServiceId: string
open: boolean
isSaving: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: SubdomainEditValues) => void
}
export function SubdomainEditSheet({
mode,
subdomain,
zoneName,
services,
serviceGroupById,
currentServiceId,
open,
isSaving,
onOpenChange,
onSubmit,
}: SubdomainEditSheetProps) {
const [name, setName] = useState('')
const [serviceId, setServiceId] = useState('none')
const [certMonitoring, setCertMonitoring] = useState<CertMonitoring>('auto')
const serviceItems = useMemo(
() => [
{ label: 'Без сервиса', value: 'none' },
...services.map((service) => ({
label: formatServiceGroupLabel(
serviceGroupById.get(service.id),
service.name,
),
value: String(service.id),
})),
],
[services, serviceGroupById],
)
const certMonitoringItems = useMemo(
() =>
certMonitoringOptions.map((option) => ({
label: option.label,
value: option.value,
})),
[],
)
useEffect(() => {
if (!open) return
if (mode === 'edit' && subdomain) {
setName(subdomain.name)
setServiceId(currentServiceId || 'none')
setCertMonitoring(subdomain.cert_monitoring)
return
}
setName('')
setServiceId('none')
setCertMonitoring('auto')
}, [open, mode, subdomain, currentServiceId])
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
onSubmit({ name: trimmed, serviceId, certMonitoring })
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>
{mode === 'create' ? 'Создать поддомен' : 'Редактировать поддомен'}
</SheetTitle>
<SheetDescription>
{mode === 'create'
? `Имя записи в зоне ${zoneName} (например, www или api)`
: `Изменение поддомена в зоне ${zoneName}`}
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="subdomain_name">Имя</FieldLabel>
<Input
id="subdomain_name"
placeholder="www"
value={name}
onChange={(event) => setName(event.target.value)}
className="font-mono"
/>
</Field>
{mode === 'edit' && (
<>
<Field>
<FieldLabel htmlFor="subdomain_service">Сервис</FieldLabel>
<Select
items={serviceItems}
value={serviceId}
onValueChange={(value) => setServiceId(value ?? 'none')}
>
<SelectTrigger id="subdomain_service" className="w-full">
<SelectValue placeholder="Без сервиса" />
</SelectTrigger>
<SelectContent>
{serviceItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="subdomain_cert_monitoring">
Мониторинг SSL
</FieldLabel>
<Select
items={certMonitoringItems}
value={certMonitoring}
onValueChange={(value) =>
setCertMonitoring((value ?? 'auto') as CertMonitoring)
}
>
<SelectTrigger id="subdomain_cert_monitoring" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription>
{
certMonitoringOptions.find((o) => o.value === certMonitoring)
?.description
}
</FieldDescription>
</Field>
</>
)}
</FieldGroup>
<SheetFooter>
<Button type="submit" disabled={isSaving || !name.trim()} className="w-full">
{isSaving && <Spinner data-icon="inline-start" />}
{isSaving
? 'Сохранение…'
: mode === 'create'
? 'Создать'
: 'Сохранить'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,142 @@
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon } from 'lucide-react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page'
import { formatDate } from '@/lib/format'
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'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
interface SubdomainsTableProps {
domainId: string
rows: SubdomainTableRow[]
isDeleting?: boolean
isToggling?: boolean
onEdit: (row: SubdomainTableRow) => void
onDelete: (row: SubdomainTableRow) => void
onToggleEnabled: (row: SubdomainTableRow) => void
}
export function SubdomainsTable({
domainId,
rows,
isDeleting = false,
isToggling = false,
onEdit,
onDelete,
onToggleEnabled,
}: SubdomainsTableProps) {
const [deleteTarget, setDeleteTarget] = useState<SubdomainTableRow | null>(null)
return (
<>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Поддомен</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Группа / Сервис</TableHead>
<TableHead>Создан</TableHead>
<TableHead className="w-12">
<span className="sr-only">Действия</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.subdomain.id}>
<TableCell className="font-mono">{row.subdomain.fqdn}</TableCell>
<TableCell>
<Badge variant={row.subdomain.enabled ? 'default' : 'outline'}>
{row.subdomain.enabled ? 'Активен' : 'Неактивен'}
</Badge>
</TableCell>
<TableCell>{formatSubdomainServiceLinks(row.serviceLinks)}</TableCell>
<TableCell>{formatDate(row.subdomain.created_at)}</TableCell>
<TableCell 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 onClick={() => onEdit(row)}>
Редактировать
</DropdownMenuItem>
<DropdownMenuItem
disabled={isToggling}
onClick={() => onToggleEnabled(row)}
>
{row.subdomain.enabled ? 'Деактивировать' : 'Активировать'}
</DropdownMenuItem>
<DropdownMenuItem
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId }}
search={{ host: row.subdomain.fqdn }}
/>
}
>
DNS-записи
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteTarget(row)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
title="Удалить поддомен?"
description={
deleteTarget
? `Поддомен ${deleteTarget.subdomain.fqdn} будет удалён из менеджера.`
: ''
}
confirmLabel="Удалить"
onConfirm={() => {
if (deleteTarget) {
onDelete(deleteTarget)
setDeleteTarget(null)
}
}}
disabled={isDeleting}
/>
</>
)
}