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}
/>
</>
)
}
+256
View File
@@ -0,0 +1,256 @@
import { useMemo } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { api } from '@/lib/api-client'
import {
createServiceBinding,
createSubdomain,
deleteServiceBinding,
deleteSubdomain,
domainDetailQueryOptions,
domainServiceBindingsQueryOptions,
invalidateDomainPage,
serviceGroupsQueryOptions,
servicesQueryOptions,
subdomainsListQueryOptions,
updateDomain,
updateSubdomain,
} from '@/queries'
import type { CertMonitoring } from '@cfdm/shared'
import type { ServiceBinding, SubdomainRecord } from '@/lib/schemas'
import {
buildServiceGroupNameById,
formatServiceGroupLabel,
} from '@/lib/service-utils'
export interface SubdomainServiceLink {
serviceId: number
groupName: string | null
serviceName: string
}
export interface SubdomainTableRow {
subdomain: SubdomainRecord
serviceLinks: SubdomainServiceLink[]
bindingIds: number[]
}
export function formatSubdomainServiceLinks(links: SubdomainServiceLink[]): string {
if (links.length === 0) return '—'
return links
.map((link) => formatServiceGroupLabel(link.groupName, link.serviceName))
.join(', ')
}
function mapSubdomainRows(
subdomains: SubdomainRecord[],
bindings: ServiceBinding[],
serviceGroupById: Map<number, string | null>,
): SubdomainTableRow[] {
const byHostname = new Map<string, ServiceBinding[]>()
for (const binding of bindings) {
const list = byHostname.get(binding.hostname) ?? []
list.push(binding)
byHostname.set(binding.hostname, list)
}
return subdomains.map((subdomain) => {
const hostnameBindings = byHostname.get(subdomain.name) ?? []
const seenServiceIds = new Set<number>()
const serviceLinks: SubdomainServiceLink[] = []
for (const binding of hostnameBindings) {
if (seenServiceIds.has(binding.service_id)) continue
seenServiceIds.add(binding.service_id)
serviceLinks.push({
serviceId: binding.service_id,
groupName: serviceGroupById.get(binding.service_id) ?? null,
serviceName: binding.service_name,
})
}
return {
subdomain,
serviceLinks,
bindingIds: hostnameBindings.map((b) => b.id),
}
})
}
export function useDomainPage(domainId: number) {
const queryClient = useQueryClient()
const domainQuery = useQuery(domainDetailQueryOptions(domainId))
const subdomainsQuery = useQuery(subdomainsListQueryOptions(domainId))
const bindingsQuery = useQuery(domainServiceBindingsQueryOptions(domainId))
const servicesQuery = useQuery(servicesQueryOptions())
const serviceGroupsQuery = useQuery(serviceGroupsQueryOptions())
const isLoading =
domainQuery.isLoading ||
subdomainsQuery.isLoading ||
bindingsQuery.isLoading ||
servicesQuery.isLoading ||
serviceGroupsQuery.isLoading
const isError =
domainQuery.isError ||
subdomainsQuery.isError ||
bindingsQuery.isError ||
servicesQuery.isError ||
serviceGroupsQuery.isError
const error =
domainQuery.error ??
subdomainsQuery.error ??
bindingsQuery.error ??
servicesQuery.error ??
serviceGroupsQuery.error
const serviceGroupById = useMemo(
() =>
serviceGroupsQuery.data
? buildServiceGroupNameById(serviceGroupsQuery.data)
: new Map<number, string | null>(),
[serviceGroupsQuery.data],
)
const subdomainRows = useMemo(
() =>
mapSubdomainRows(
subdomainsQuery.data ?? [],
bindingsQuery.data ?? [],
serviceGroupById,
),
[subdomainsQuery.data, bindingsQuery.data, serviceGroupById],
)
function invalidate() {
invalidateDomainPage(queryClient, domainId)
}
const syncMutation = useMutation({
mutationFn: () => api.post(`/api/v1/domains/${domainId}/sync`),
onSuccess: () => {
invalidate()
void queryClient.invalidateQueries({ queryKey: ['domains'] })
void queryClient.invalidateQueries({ queryKey: ['service-bindings'] })
toast.success('Синхронизация завершена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Ошибка синхронизации')
},
})
const createSubdomainMutation = useMutation({
mutationFn: (name: string) => createSubdomain(domainId, { name }),
onSuccess: () => {
invalidate()
toast.success('Поддомен создан')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось создать поддомен')
},
})
const updateSubdomainMutation = useMutation({
mutationFn: ({
id,
...body
}: {
id: number
name?: string
enabled?: boolean
cert_monitoring?: CertMonitoring
}) => updateSubdomain(id, body),
onSuccess: () => {
invalidate()
toast.success('Поддомен обновлён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось обновить поддомен')
},
})
const updateDomainCertMonitoringMutation = useMutation({
mutationFn: (certMonitoring: CertMonitoring) =>
updateDomain(domainId, { cert_monitoring: certMonitoring }),
onSuccess: () => {
invalidate()
void queryClient.invalidateQueries({ queryKey: ['domains'] })
toast.success('Режим мониторинга SSL обновлён')
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось обновить мониторинг SSL',
)
},
})
const deleteSubdomainMutation = useMutation({
mutationFn: (id: number) => deleteSubdomain(id),
onSuccess: () => {
invalidate()
toast.success('Поддомен удалён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить поддомен')
},
})
const linkServiceMutation = useMutation({
mutationFn: async ({
subdomainName,
serviceId,
bindingIds,
}: {
subdomainName: string
serviceId: number | null
bindingIds: number[]
}) => {
await Promise.all(bindingIds.map((id) => deleteServiceBinding(id)))
if (serviceId != null) {
await createServiceBinding({
domain_id: domainId,
service_id: serviceId,
hostname: subdomainName,
})
}
},
onSuccess: (_data, variables) => {
invalidate()
toast.success(
variables.serviceId != null ? 'Сервис привязан' : 'Сервис отвязан',
)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось привязать сервис')
},
})
function refetch() {
void domainQuery.refetch()
void subdomainsQuery.refetch()
void bindingsQuery.refetch()
void servicesQuery.refetch()
void serviceGroupsQuery.refetch()
}
return {
domain: domainQuery.data,
bindings: bindingsQuery.data ?? [],
services: servicesQuery.data ?? [],
serviceGroupById,
subdomainRows,
isLoading,
isError,
error: error instanceof Error ? error : null,
refetch,
syncMutation,
createSubdomainMutation,
updateSubdomainMutation,
updateDomainCertMonitoringMutation,
deleteSubdomainMutation,
linkServiceMutation,
}
}
+149
View File
@@ -0,0 +1,149 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useReducer, useState } from 'react'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { toast } from 'sonner'
import { parseGroupColumnId } from '@/components/board/column-ids'
import {
boardToDomainsList,
findColumnId,
mapGroupsDomainsToBoard,
moveDomainBetweenColumns,
} from '@/components/groups-board/board-state'
import type { BoardState } from '@/components/groups-board/types'
import { UNGROUPED_COLUMN_ID } from '@/components/groups-board/column-ids'
import { api } from '@/lib/api-client'
import type { DomainListItem, Group } from '@/lib/schemas'
import { domainKeys, groupKeys } from '@/queries'
type BoardAction =
| { type: 'set'; board: BoardState }
| { type: 'replace'; board: BoardState }
function boardReducer(state: BoardState, action: BoardAction): BoardState {
switch (action.type) {
case 'set':
case 'replace':
return action.board
default:
return state
}
}
interface UseGroupsBoardOptions {
groups: Group[] | undefined
domains: DomainListItem[] | undefined
dragDisabled?: boolean
}
export function useGroupsBoard({
groups,
domains,
dragDisabled = false,
}: UseGroupsBoardOptions) {
const queryClient = useQueryClient()
const [board, dispatch] = useReducer(boardReducer, { columns: [] })
const [activeId, setActiveId] = useState<string | null>(null)
useEffect(() => {
if (!groups || !domains) return
dispatch({
type: 'set',
board: mapGroupsDomainsToBoard(groups, domains),
})
}, [groups, domains])
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: groupKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
}
function syncDomainsCache(nextBoard: BoardState) {
const previous = queryClient.getQueryData<DomainListItem[]>(
domainKeys.list(),
)
if (!previous) return
queryClient.setQueryData(
domainKeys.list(),
boardToDomainsList(nextBoard, previous),
)
}
const moveDomainMutation = useMutation({
mutationFn: ({
domainId,
groupId,
}: {
domainId: number
groupId: number | null
}) => api.patch(`/api/v1/domains/${domainId}`, { group_id: groupId }),
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переместить домен')
invalidateAll()
},
})
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
setActiveId(null)
if (dragDisabled || !over) return
const domainId = Number(active.id)
const overId = String(over.id)
const fromColumnId = findColumnId(board.columns, String(domainId))
let toColumnId = findColumnId(board.columns, overId)
if (!toColumnId && board.columns.some((column) => column.id === overId)) {
toColumnId = overId
}
if (!fromColumnId || !toColumnId || fromColumnId === toColumnId) return
const targetGroupId = parseGroupColumnId(toColumnId)
if (toColumnId !== UNGROUPED_COLUMN_ID && targetGroupId === null) return
const previousBoard = board
const nextBoard = moveDomainBetweenColumns(
board,
domainId,
fromColumnId,
toColumnId,
)
if (nextBoard === board) return
dispatch({ type: 'replace', board: nextBoard })
syncDomainsCache(nextBoard)
try {
await moveDomainMutation.mutateAsync({
domainId,
groupId: targetGroupId,
})
toast.success('Домен перемещён')
invalidateAll()
} catch {
dispatch({ type: 'replace', board: previousBoard })
syncDomainsCache(previousBoard)
}
}
function handleDragStart(event: DragStartEvent) {
if (dragDisabled) return
setActiveId(String(event.active.id))
}
const activeDomain = activeId
? board.columns
.flatMap((column) => column.items)
.find((domain) => String(domain.id) === activeId)
: undefined
return {
board,
activeId,
activeDomain,
handleDragStart,
handleDragEnd,
dragDisabled,
}
}
+225
View File
@@ -0,0 +1,225 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useReducer, useState } from 'react'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { toast } from 'sonner'
import {
boardToQueryData,
findColumnId,
mapResponseToBoard,
moveServiceBetweenColumns,
reorderInColumn,
} from '@/components/services-board/board-state'
import { parseGroupColumnId } from '@/components/services-board/column-ids'
import type { BoardState } from '@/components/services-board/types'
import { api } from '@/lib/api-client'
import type { ServiceGroupsResponse } from '@/lib/schemas'
import {
domainKeys,
serviceBindingKeys,
serviceGroupKeys,
serviceKeys,
} from '@/queries'
type BoardAction =
| { type: 'set'; board: BoardState }
| { type: 'replace'; board: BoardState }
function boardReducer(state: BoardState, action: BoardAction): BoardState {
switch (action.type) {
case 'set':
case 'replace':
return action.board
default:
return state
}
}
interface UseServicesBoardOptions {
data: ServiceGroupsResponse | undefined
domainId?: number
dragDisabled?: boolean
}
export function useServicesBoard({
data,
domainId,
dragDisabled = false,
}: UseServicesBoardOptions) {
const queryClient = useQueryClient()
const [board, dispatch] = useReducer(boardReducer, { columns: [] })
const [activeId, setActiveId] = useState<string | null>(null)
useEffect(() => {
if (!data) return
dispatch({ type: 'set', board: mapResponseToBoard(data, domainId) })
}, [data, domainId])
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
}
function syncQueryCache(nextBoard: BoardState) {
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (!previous) return
queryClient.setQueryData(
serviceGroupKeys.all,
boardToQueryData(nextBoard, previous),
)
}
const reorderMutation = useMutation({
mutationFn: ({
groupId,
serviceIds,
}: {
groupId: number | null
serviceIds: number[]
}) =>
api.patch('/api/v1/services/reorder', {
group_id: groupId,
service_ids: serviceIds,
}),
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось изменить порядок')
invalidateAll()
},
})
const moveGroupMutation = useMutation({
mutationFn: ({
serviceId,
groupId,
}: {
serviceId: number
groupId: number | null
}) =>
api.patch(`/api/v1/services/${serviceId}`, {
service_group_id: groupId,
}),
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переместить сервис')
invalidateAll()
},
})
async function persistColumnOrder(columnId: string, serviceIds: number[]) {
if (serviceIds.length === 0) return
await reorderMutation.mutateAsync({
groupId: parseGroupColumnId(columnId),
serviceIds,
})
}
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
setActiveId(null)
if (dragDisabled || !over) return
const activeServiceId = String(active.id)
const overId = String(over.id)
if (overId === activeServiceId) return
const fromColumnId = findColumnId(board.columns, activeServiceId)
let toColumnId = findColumnId(board.columns, overId)
if (!toColumnId && board.columns.some((column) => column.id === overId)) {
toColumnId = overId
}
if (!fromColumnId || !toColumnId) return
const serviceId = Number(activeServiceId)
const previousBoard = board
if (fromColumnId === toColumnId) {
if (overId === toColumnId) return
const nextBoard = reorderInColumn(board, fromColumnId, activeServiceId, overId)
if (nextBoard === board) return
dispatch({ type: 'replace', board: nextBoard })
syncQueryCache(nextBoard)
const column = nextBoard.columns.find((col) => col.id === fromColumnId)
if (!column) return
try {
await persistColumnOrder(
fromColumnId,
column.items.map((item) => item.id),
)
toast.success('Порядок сервисов обновлён')
} catch {
dispatch({ type: 'replace', board: previousBoard })
syncQueryCache(previousBoard)
}
return
}
const nextBoard = moveServiceBetweenColumns(
board,
serviceId,
fromColumnId,
toColumnId,
overId,
)
if (nextBoard === board) return
dispatch({ type: 'replace', board: nextBoard })
syncQueryCache(nextBoard)
const targetGroupId = parseGroupColumnId(toColumnId)
const targetColumn = nextBoard.columns.find((col) => col.id === toColumnId)
const sourceColumn = nextBoard.columns.find((col) => col.id === fromColumnId)
try {
await moveGroupMutation.mutateAsync({
serviceId,
groupId: targetGroupId,
})
if (targetColumn) {
await persistColumnOrder(
toColumnId,
targetColumn.items.map((item) => item.id),
)
}
if (sourceColumn && sourceColumn.items.length > 0) {
await persistColumnOrder(
fromColumnId,
sourceColumn.items.map((item) => item.id),
)
}
toast.success('Сервис перемещён')
invalidateAll()
} catch {
dispatch({ type: 'replace', board: previousBoard })
syncQueryCache(previousBoard)
}
}
function handleDragStart(event: DragStartEvent) {
if (dragDisabled) return
setActiveId(String(event.active.id))
}
const activeService = activeId
? board.columns
.flatMap((column) => column.items)
.find((service) => String(service.id) === activeId)
: undefined
return {
board,
activeId,
activeService,
handleDragStart,
handleDragEnd,
dragDisabled,
}
}
@@ -0,0 +1,97 @@
import { useCallback, useMemo, useState } from 'react'
export function useServicesSelection() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set())
const toggle = useCallback((id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const setSelected = useCallback((id: number, selected: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (selected) {
next.add(id)
} else {
next.delete(id)
}
return next
})
}, [])
const selectAll = useCallback((ids: number[]) => {
setSelectedIds((prev) => {
const next = new Set(prev)
for (const id of ids) {
next.add(id)
}
return next
})
}, [])
const deselectAll = useCallback((ids: number[]) => {
setSelectedIds((prev) => {
const next = new Set(prev)
for (const id of ids) {
next.delete(id)
}
return next
})
}, [])
const clear = useCallback(() => {
setSelectedIds(new Set())
}, [])
const isSelected = useCallback(
(id: number) => selectedIds.has(id),
[selectedIds],
)
const isAllSelected = useCallback(
(ids: number[]) => ids.length > 0 && ids.every((id) => selectedIds.has(id)),
[selectedIds],
)
const isSomeSelected = useCallback(
(ids: number[]) => ids.some((id) => selectedIds.has(id)),
[selectedIds],
)
const count = selectedIds.size
return useMemo(
() => ({
selectedIds,
count,
toggle,
setSelected,
selectAll,
deselectAll,
clear,
isSelected,
isAllSelected,
isSomeSelected,
}),
[
selectedIds,
count,
toggle,
setSelected,
selectAll,
deselectAll,
clear,
isSelected,
isAllSelected,
isSomeSelected,
],
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { CertMonitoring } from '@cfdm/shared'
export const certMonitoringOptions: Array<{
value: CertMonitoring
label: string
description: string
}> = [
{
value: 'auto',
label: 'Авто',
description: 'Проверять, если хост обслуживается активным сервисом',
},
{
value: 'required',
label: 'Обязательно',
description: 'Всегда проверять SSL, даже без привязок',
},
{
value: 'skipped',
label: 'Не проверять',
description: 'Исключить из мониторинга сертификатов',
},
]
export function certMonitoringLabel(value: string): string {
return certMonitoringOptions.find((o) => o.value === value)?.label ?? value
}
+11 -6
View File
@@ -1,9 +1,14 @@
import type { ServiceBinding } from '@/lib/schemas'
function bindingIps(binding: ServiceBinding): string[] {
if (binding.target_ips.length > 0) return binding.target_ips
return binding.target_ip ? [binding.target_ip] : []
}
export function getDomainIps(bindings: ServiceBinding[], domainId: number): string[] {
const ips = bindings
.filter((b) => b.domain_id === domainId && b.target_ip)
.map((b) => b.target_ip as string)
.filter((b) => b.domain_id === domainId)
.flatMap(bindingIps)
return [...new Set(ips)]
}
@@ -22,11 +27,11 @@ export function groupBindingsByHostname(
export function buildIpsByDomainId(bindings: ServiceBinding[]): Map<number, string[]> {
const map = new Map<number, string[]>()
for (const binding of bindings) {
if (!binding.target_ip) continue
const ips = bindingIps(binding)
if (ips.length === 0) continue
const existing = map.get(binding.domain_id) ?? []
if (!existing.includes(binding.target_ip)) {
map.set(binding.domain_id, [...existing, binding.target_ip])
}
const merged = [...new Set([...existing, ...ips])]
map.set(binding.domain_id, merged)
}
return map
}
+8 -12
View File
@@ -1,12 +1,8 @@
import { z } from 'zod'
export const subdomainSchema = z.object({
id: z.number(),
domain_id: z.number(),
name: z.string(),
fqdn: z.string(),
created_at: z.string(),
updated_at: z.string(),
})
export type Subdomain = z.infer<typeof subdomainSchema>
export {
createSubdomainSchema,
subdomainSchema,
updateSubdomainSchema,
type CreateSubdomainInput,
type SubdomainRecord,
type UpdateSubdomainInput,
} from '@cfdm/shared'
+69 -20
View File
@@ -50,8 +50,10 @@ export const serviceDomainBindingSchema = z
zone_name: z.string(),
hostname: z.string(),
fqdn: z.string(),
record_type: z.enum(['A', 'CNAME']).default('A'),
target_ips: z.array(z.string()).optional(),
target_ip: z.string().nullable().optional(),
target_cname: z.string().nullable().optional(),
sync_status: z.string().nullable(),
})
.transform((binding) => ({
@@ -62,6 +64,10 @@ export const serviceDomainBindingSchema = z
: binding.target_ip
? [binding.target_ip]
: [],
target_cname: binding.target_cname?.trim() || null,
record_type: binding.target_cname?.trim()
? ('CNAME' as const)
: (binding.record_type ?? 'A'),
}))
export const serviceViewSchema = serviceSchema.extend({
@@ -86,6 +92,7 @@ export const domainSchema = z.object({
zone_name: z.string(),
cf_zone_id: z.string(),
status: z.string(),
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
last_synced_at: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
@@ -96,22 +103,33 @@ export const domainListItemSchema = domainSchema.extend({
service_count: z.number(),
})
export const serviceBindingSchema = z.object({
id: z.number(),
domain_id: z.number(),
service_id: z.number(),
hostname: z.string(),
dns_record_id: z.number().nullable(),
zone_name: z.string(),
group_id: z.number().nullable(),
group_name: z.string().nullable(),
service_name: z.string(),
service_slug: z.string(),
target_ip: z.string().nullable(),
sync_status: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
})
export const serviceBindingSchema = z
.object({
id: z.number(),
domain_id: z.number(),
service_id: z.number(),
hostname: z.string(),
dns_record_id: z.number().nullable(),
zone_name: z.string(),
group_id: z.number().nullable(),
group_name: z.string().nullable(),
service_name: z.string(),
service_slug: z.string(),
target_ip: z.string().nullable(),
target_ips: z.array(z.string()).optional(),
sync_status: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
})
.transform((binding) => ({
...binding,
target_ips:
binding.target_ips && binding.target_ips.length > 0
? binding.target_ips
: binding.target_ip
? [binding.target_ip]
: [],
}))
export const dnsRecordSchema = z.object({
id: z.number(),
@@ -169,10 +187,30 @@ const ipv4Schema = z
'╨Э╨╡╨║╨╛╤А╤А╨╡╨║╤В╨╜╤Л╨╣ IPv4',
)
const serviceDomainInputSchema = z.object({
fqdn: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ FQDN'),
target_ips: z.array(ipv4Schema).min(1, '╨Т╤Л╨▒╨╡╤А╨╕╤В╨╡ ╤Е╨╛╤В╤П ╨▒╤Л ╨╛╨┤╨╕╨╜ IP'),
})
const serviceDomainInputSchema = z
.object({
fqdn: z.string().min(1, 'Укажите FQDN'),
target_ips: z.array(ipv4Schema).optional(),
target_cname: z.string().min(1, 'Укажите CNAME-цель').optional(),
})
.superRefine((data, ctx) => {
const hasIps = (data.target_ips?.length ?? 0) > 0
const hasCname = Boolean(data.target_cname?.trim())
if (!hasIps && !hasCname) {
ctx.addIssue({
code: 'custom',
message: 'Укажите IP или CNAME-цель',
path: ['target_ips'],
})
}
if (hasIps && hasCname) {
ctx.addIssue({
code: 'custom',
message: 'Укажите либо IP, либо CNAME-цель',
path: ['target_cname'],
})
}
})
export const createServiceSchema = z.object({
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
@@ -248,3 +286,14 @@ export type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchem
export type CreateDomainInput = z.infer<typeof createDomainSchema>
export type LoginInput = z.infer<typeof loginSchema>
export type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>
export {
createSubdomainSchema,
subdomainSchema,
updateDomainSchema,
updateSubdomainSchema,
type CreateSubdomainInput,
type SubdomainRecord,
type UpdateDomainInput,
type UpdateSubdomainInput,
} from '@cfdm/shared'
+24 -1
View File
@@ -1,5 +1,28 @@
import { bindingToFqdn } from '@/lib/parse-fqdn'
import type { ServiceView } from '@/lib/schemas'
import type { ServiceGroupsResponse, ServiceView } from '@/lib/schemas'
export function formatServiceGroupLabel(
groupName: string | null | undefined,
serviceName: string,
): string {
const group = groupName?.trim() || 'Без группы'
return `${group} / ${serviceName}`
}
export function buildServiceGroupNameById(
data: ServiceGroupsResponse,
): Map<number, string | null> {
const map = new Map<number, string | null>()
for (const group of data.groups) {
for (const service of group.services) {
map.set(service.id, group.name)
}
}
for (const service of data.ungrouped) {
map.set(service.id, null)
}
return map
}
export function serviceDisplayFqdn(service: ServiceView): string {
const first = service.domains?.[0]
+49 -1
View File
@@ -10,8 +10,11 @@ import {
serviceBindingSchema,
serviceGroupsResponseSchema,
serviceViewSchema,
subdomainSchema,
type CreateSubdomainInput,
type UpdateDomainInput,
type UpdateSubdomainInput,
} from '@/lib/schemas'
import { subdomainSchema } from '@/lib/schemas-ext'
import { z } from 'zod'
export const groupKeys = {
@@ -160,3 +163,48 @@ export const subdomainsListQueryOptions = (domainId: number) =>
return z.array(subdomainSchema).parse(data)
},
})
export interface CreateServiceBindingBody {
domain_id: number
service_id: number
hostname?: string
target_ip?: string
}
export async function createSubdomain(domainId: number, body: CreateSubdomainInput) {
const data = await api.post<unknown>(`/api/v1/domains/${domainId}/subdomains`, body)
return subdomainSchema.parse(data)
}
export async function updateSubdomain(id: number, body: UpdateSubdomainInput) {
const data = await api.patch<unknown>(`/api/v1/subdomains/${id}`, body)
return subdomainSchema.parse(data)
}
export async function updateDomain(id: number, body: UpdateDomainInput) {
const data = await api.patch<unknown>(`/api/v1/domains/${id}`, body)
return domainSchema.parse(data)
}
export async function deleteSubdomain(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/subdomains/${id}`)
}
export async function createServiceBinding(body: CreateServiceBindingBody) {
const data = await api.post<unknown>('/api/v1/service-bindings', body)
return serviceBindingSchema.parse(data)
}
export async function deleteServiceBinding(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`)
}
export function invalidateDomainPage(
queryClient: import('@tanstack/react-query').QueryClient,
domainId: number,
) {
void queryClient.invalidateQueries({ queryKey: subdomainKeys.list(domainId) })
void queryClient.invalidateQueries({ queryKey: serviceBindingKeys.byDomain(domainId) })
void queryClient.invalidateQueries({ queryKey: domainKeys.detail(domainId) })
void queryClient.invalidateQueries({ queryKey: dnsKeys.list(domainId) })
}
+2 -2
View File
@@ -97,7 +97,7 @@ function CertificatesPage() {
<PageShell>
<PageHeader
title="Сертификаты"
description="Мониторинг SSL-сертификатов"
description="Мониторинг SSL: только хосты с активными сервисами или режимом «Обязательно»"
actions={
<Button
onClick={() => checkMutation.mutate()}
@@ -142,7 +142,7 @@ function CertificatesPage() {
</Card>
<DataTableCard
title="Сертификаты"
description="Все отслеживаемые хосты"
description="Хосты с активными сервисами или ручным мониторингом"
isEmpty={!filteredCerts.length}
emptyTitle={
isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены'
@@ -1,21 +1,29 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { GlobeIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
domainDetailQueryOptions,
domainKeys,
domainServiceBindingsQueryOptions,
subdomainKeys,
serviceGroupsQueryOptions,
servicesQueryOptions,
subdomainsListQueryOptions,
} from '@/queries'
import { api } from '@/lib/api-client'
import { formatDate } from '@/lib/format'
import { useDomainPage } from '@/hooks/use-domain-page'
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { EmptyState } from '@/components/empty-state'
import { StatusBadge } from '@/components/status-badge'
import { QueryState } from '@/components/query-state'
import { DomainHeader } from '@/components/domain-header'
import { DomainActionsBar } from '@/components/domain-actions-bar'
import { DomainBindingsCard } from '@/components/domain-bindings-card'
import { DataTableCard } from '@/components/data-table-card'
import { SubdomainsTable } from '@/components/subdomains-table'
import {
SubdomainEditSheet,
type SubdomainEditValues,
} from '@/components/subdomain-edit-sheet'
import { StatusBadge } from '@/components/status-badge'
import { formatDate } from '@/lib/format'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import {
@@ -25,14 +33,7 @@ import {
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemSeparator,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Spinner } from '@cfdm/ui/components/spinner'
export const Route = createFileRoute('/_auth/domains/$domainId/')({
@@ -42,32 +43,121 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({
await Promise.all([
queryClient.ensureQueryData(subdomainsListQueryOptions(id)),
queryClient.ensureQueryData(domainServiceBindingsQueryOptions(id)),
queryClient.ensureQueryData(servicesQueryOptions()),
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
])
return { breadcrumb: domain.zone_name }
},
component: DomainOverviewPage,
})
function DomainPageSkeleton() {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-10 w-1/3" />
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-40 w-full" />
</div>
<Skeleton className="h-64 w-full" />
</div>
)
}
function DomainOverviewPage() {
const { domainId } = Route.useParams()
const id = Number(domainId)
const queryClient = useQueryClient()
const { data: domain } = useQuery(domainDetailQueryOptions(id))
const { data: subdomains } = useQuery(subdomainsListQueryOptions(id))
const { data: bindings } = useQuery(domainServiceBindingsQueryOptions(id))
const syncMutation = useMutation({
mutationFn: () => api.post(`/api/v1/domains/${id}/sync`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: subdomainKeys.list(id) })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
queryClient.invalidateQueries({ queryKey: ['service-bindings'] })
toast.success('Синхронизация завершена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Ошибка синхронизации')
},
})
const {
domain,
bindings,
services,
serviceGroupById,
subdomainRows,
isLoading,
isError,
error,
refetch,
syncMutation,
createSubdomainMutation,
updateSubdomainMutation,
updateDomainCertMonitoringMutation,
deleteSubdomainMutation,
linkServiceMutation,
} = useDomainPage(id)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create')
const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(null)
function openCreateSheet() {
setSheetMode('create')
setEditTarget(null)
setSheetOpen(true)
}
function openEditSheet(row: SubdomainTableRow) {
setSheetMode('edit')
setEditTarget(row)
setSheetOpen(true)
}
function resolveServiceId(row: SubdomainTableRow): string {
if (row.serviceLinks.length === 0) return 'none'
return String(row.serviceLinks[0].serviceId)
}
async function handleSheetSubmit(values: SubdomainEditValues) {
if (sheetMode === 'create') {
await createSubdomainMutation.mutateAsync(values.name)
setSheetOpen(false)
return
}
if (!editTarget) return
const nameChanged = values.name !== editTarget.subdomain.name
const certMonitoringChanged =
values.certMonitoring !== editTarget.subdomain.cert_monitoring
const currentServiceId = resolveServiceId(editTarget)
const serviceChanged = values.serviceId !== currentServiceId
const targetServiceId =
values.serviceId === 'none' ? null : Number(values.serviceId)
if (nameChanged || certMonitoringChanged) {
await updateSubdomainMutation.mutateAsync({
id: editTarget.subdomain.id,
...(nameChanged ? { name: values.name } : {}),
...(certMonitoringChanged
? { cert_monitoring: values.certMonitoring }
: {}),
})
}
const bindingsNeedSync =
serviceChanged || (nameChanged && editTarget.bindingIds.length > 0)
if (bindingsNeedSync) {
await linkServiceMutation.mutateAsync({
subdomainName: values.name,
serviceId: targetServiceId,
bindingIds: editTarget.bindingIds,
})
} else if (serviceChanged && targetServiceId != null) {
await linkServiceMutation.mutateAsync({
subdomainName: values.name,
serviceId: targetServiceId,
bindingIds: [],
})
}
setSheetOpen(false)
}
const isSheetSaving =
createSubdomainMutation.isPending ||
updateSubdomainMutation.isPending ||
linkServiceMutation.isPending
return (
<PageShell>
@@ -88,6 +178,7 @@ function DomainOverviewPage() {
</Button>
<Button
variant="outline"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
@@ -101,87 +192,124 @@ function DomainOverviewPage() {
</>
}
/>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Сводка</CardTitle>
<CardDescription>Основные параметры зоны</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Статус</span>
{domain ? <StatusBadge status={domain.status} /> : '—'}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Группа</span>
{domain?.group_id ? (
<Button
variant="link"
className="h-auto p-0"
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
Открыть группу
</Button>
) : (
<Badge variant="outline">Без группы</Badge>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Последняя синхронизация</span>
<span className="font-medium">{formatDate(domain?.last_synced_at)}</span>
</div>
</CardContent>
</Card>
<DomainBindingsCard bindings={bindings ?? []} />
</div>
<Card>
<CardHeader>
<CardTitle>Поддомены</CardTitle>
<CardDescription>Обнаруженные поддомены в зоне</CardDescription>
</CardHeader>
<CardContent>
{subdomains?.length ? (
<ItemGroup className="gap-0">
{subdomains.map((s, index) => (
<div key={s.id}>
<Item variant="outline">
<ItemContent>
<ItemTitle className="font-mono font-normal">{s.fqdn}</ItemTitle>
</ItemContent>
<ItemActions>
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={<DomainPageSkeleton />}
>
{domain && (
<>
<DomainHeader
domain={domain}
onCertMonitoringChange={(value) =>
updateDomainCertMonitoringMutation.mutate(value)
}
isCertMonitoringSaving={updateDomainCertMonitoringMutation.isPending}
/>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Сводка</CardTitle>
<CardDescription>Основные параметры зоны</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Статус</span>
<StatusBadge status={domain.status} />
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Группа</span>
{domain.group_id ? (
<Button
variant="outline"
size="sm"
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId }}
search={{ host: s.fqdn }}
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
DNS
Открыть группу
</Button>
</ItemActions>
</Item>
{index < subdomains.length - 1 && <ItemSeparator />}
) : (
<Badge variant="outline">Без группы</Badge>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">
Последняя синхронизация
</span>
<span className="font-medium">
{formatDate(domain.last_synced_at)}
</span>
</div>
</CardContent>
</Card>
<DomainBindingsCard bindings={bindings} />
</div>
<DataTableCard
title="Поддомены"
description="Управление поддоменами и привязками сервисов"
isEmpty={subdomainRows.length === 0}
emptyTitle="Поддомены не найдены"
emptyDescription="Создайте поддомен вручную или синхронизируйте зону с Cloudflare"
emptyIcon={GlobeIcon}
emptyAction={
<div className="flex flex-wrap justify-center gap-2">
<Button onClick={openCreateSheet}>Создать поддомен</Button>
<Button
variant="outline"
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
>
Синхронизировать
</Button>
</div>
))}
</ItemGroup>
) : (
<EmptyState
icon={GlobeIcon}
title="Поддомены не найдены"
description="Нажмите «Синхронизировать» — поддомены извлекаются из DNS-записей Cloudflare"
}
toolbar={<DomainActionsBar onCreateSubdomain={openCreateSheet} />}
>
<SubdomainsTable
domainId={domainId}
rows={subdomainRows}
isDeleting={deleteSubdomainMutation.isPending}
isToggling={updateSubdomainMutation.isPending}
onEdit={openEditSheet}
onDelete={(row) =>
deleteSubdomainMutation.mutate(row.subdomain.id)
}
onToggleEnabled={(row) =>
updateSubdomainMutation.mutate({
id: row.subdomain.id,
enabled: !row.subdomain.enabled,
})
}
/>
</DataTableCard>
<SubdomainEditSheet
mode={sheetMode}
subdomain={editTarget?.subdomain ?? null}
zoneName={domain.zone_name}
services={services}
serviceGroupById={serviceGroupById}
currentServiceId={
editTarget ? resolveServiceId(editTarget) : 'none'
}
open={sheetOpen}
isSaving={isSheetSaving}
onOpenChange={setSheetOpen}
onSubmit={handleSheetSubmit}
/>
)}
</CardContent>
</Card>
</>
)}
</QueryState>
</PageShell>
)
}
+24 -18
View File
@@ -6,17 +6,16 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { api } from '@/lib/api-client'
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas'
import { buildIpsByDomainId } from '@/lib/domain-ips'
import {
domainKeys,
domainsListQueryOptions,
groupsQueryOptions,
serviceBindingsQueryOptions,
serviceBindingKeys,
} from '@/queries'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { DataTableCard } from '@/components/data-table-card'
import { DomainsDataTable } from '@/components/domains-data-table'
import { DomainsDataTable, type DomainTableRow } from '@/components/domains-data-table'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
@@ -46,7 +45,6 @@ export const Route = createFileRoute('/_auth/domains/')({
Promise.all([
queryClient.ensureQueryData(domainsListQueryOptions()),
queryClient.ensureQueryData(groupsQueryOptions()),
queryClient.ensureQueryData(serviceBindingsQueryOptions()),
]),
component: DomainsPage,
})
@@ -60,12 +58,6 @@ function DomainsPage() {
const queryClient = useQueryClient()
const { data: domains } = useQuery(domainsListQueryOptions(listGroupId))
const { data: groups } = useQuery(groupsQueryOptions())
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
const ipsByDomainId = useMemo(
() => buildIpsByDomainId(bindings ?? []),
[bindings],
)
const groupItems = useMemo(
() => [
@@ -94,6 +86,22 @@ function DomainsPage() {
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
toast.success('Зона удалена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить зону')
},
})
function handleDeleteDomain(domain: DomainTableRow) {
deleteMutation.mutate(domain.id)
}
const handleCreate = form.handleSubmit((values) => {
createMutation.mutate({
zone_name: values.zone_name.trim(),
@@ -123,22 +131,18 @@ function DomainsPage() {
}, [domains, filterGroupId])
const tableData = useMemo(
() =>
filteredDomains.map((domain) => ({
...domain,
ips: ipsByDomainId.get(domain.id) ?? [],
})),
[filteredDomains, ipsByDomainId],
() => filteredDomains as DomainTableRow[],
[filteredDomains],
)
return (
<PageShell>
<PageHeader
title="Домены"
description={`${tableData.length} зон · ${tableData.filter((d) => d.ips.length > 0).length} с IP · ${tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов`}
description={`${tableData.length} зон · ${tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов`}
actions={
<>
<Button variant="outline" render={<Link to="/groups" />}>
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
Канбан групп
</Button>
<Button onClick={() => setSheetOpen(true)}>Импортировать домен</Button>
@@ -155,6 +159,8 @@ function DomainsPage() {
groupFilterItems={groupItems}
groupFilterValue={filterGroupId || 'all'}
onGroupFilterChange={handleFilterGroupChange}
onDelete={handleDeleteDomain}
isDeleting={deleteMutation.isPending}
/>
</DataTableCard>
+129 -252
View File
@@ -1,8 +1,6 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { FolderTreeIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -10,46 +8,21 @@ import {
domainsListQueryOptions,
groupKeys,
groupsQueryOptions,
serviceBindingKeys,
serviceBindingsQueryOptions,
} from '@/queries'
import { api } from '@/lib/api-client'
import { createGroupSchema, type CreateGroupInput, type DomainListItem } from '@/lib/schemas'
import type { CreateGroupInput, Group } from '@/lib/schemas'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { TableToolbar } from '@/components/table-toolbar'
import { KanbanBoard } from '@/components/kanban-board'
import { DomainGroupCard } from '@/components/domain-group-card'
import { DataTableCard } from '@/components/data-table-card'
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
import { GroupsBoard } from '@/components/groups-board/groups-board'
import { GroupsBoardSkeleton } from '@/components/groups-board/groups-board-skeleton'
import { useGroupsBoard } from '@/hooks/use-groups-board'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Field,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
import { Spinner } from '@cfdm/ui/components/spinner'
export const Route = createFileRoute('/_auth/groups')({
loader: ({ context: { queryClient } }) =>
@@ -61,28 +34,30 @@ export const Route = createFileRoute('/_auth/groups')({
component: GroupsPage,
})
const UNGROUPED_COLUMN_ID = 'ungrouped'
function groupColumnId(groupId: number) {
return `group-${groupId}`
}
function parseGroupColumnId(columnId: string): number | null {
if (columnId === UNGROUPED_COLUMN_ID) return null
const match = columnId.match(/^group-(\d+)$/)
return match ? Number(match[1]) : null
}
function GroupsPage() {
const [catalogSearch, setCatalogSearch] = useState('')
const [createSheetOpen, setCreateSheetOpen] = useState(false)
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
const [deletingGroup, setDeletingGroup] = useState<Group | null>(null)
const queryClient = useQueryClient()
const { data: groups } = useQuery(groupsQueryOptions())
const {
data: groups,
isLoading,
isError,
error,
refetch,
} = useQuery(groupsQueryOptions())
const { data: domains } = useQuery(domainsListQueryOptions())
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
const form = useForm<CreateGroupInput>({
resolver: zodResolver(createGroupSchema),
defaultValues: { name: '', slug: '' },
const {
board,
activeDomain,
handleDragStart,
handleDragEnd,
} = useGroupsBoard({
groups,
domains,
})
const serviceLabelsByDomain = useMemo(() => {
@@ -97,36 +72,24 @@ function GroupsPage() {
return map
}, [bindings])
const columns = useMemo(() => {
const groupColumns =
groups?.map((group) => ({
id: groupColumnId(group.id),
title: group.name,
description: group.slug,
href: `/groups/${group.id}`,
items:
domains?.filter((d) => d.group_id === group.id) ?? [],
})) ?? []
const ungrouped: DomainListItem[] =
domains?.filter((d) => d.group_id === null) ?? []
return [
...groupColumns,
{
id: UNGROUPED_COLUMN_ID,
title: 'Без группы',
description: 'Домены без назначенной группы',
items: ungrouped,
},
]
const isEmpty = useMemo(() => {
if (!groups || !domains) return true
const hasDomains = domains.length > 0
if (hasDomains) return false
return groups.length === 0
}, [groups, domains])
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: groupKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
}
const createMutation = useMutation({
mutationFn: (body: CreateGroupInput) => api.post('/api/v1/groups', body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: groupKeys.all })
form.reset()
invalidateAll()
setCreateSheetOpen(false)
toast.success('Группа создана')
},
onError: (err) => {
@@ -134,11 +97,24 @@ function GroupsPage() {
},
})
const updateMutation = useMutation({
mutationFn: ({ id, body }: { id: number; body: CreateGroupInput }) =>
api.patch(`/api/v1/groups/${id}`, body),
onSuccess: () => {
invalidateAll()
setEditingGroup(null)
toast.success('Группа сохранена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить группу')
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/groups/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: groupKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
invalidateAll()
setDeletingGroup(null)
toast.success('Группа удалена')
},
onError: (err) => {
@@ -146,184 +122,85 @@ function GroupsPage() {
},
})
const moveDomainMutation = useMutation({
mutationFn: ({ domainId, groupId }: { domainId: number; groupId: number | null }) =>
api.patch(`/api/v1/domains/${domainId}`, { group_id: groupId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
toast.success('Группа домена обновлена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переместить домен')
},
})
function handleMove(itemId: string, _fromColumnId: string, toColumnId: string) {
const groupId = parseGroupColumnId(toColumnId)
if (toColumnId !== UNGROUPED_COLUMN_ID && groupId === null) return
moveDomainMutation.mutate({ domainId: Number(itemId), groupId })
}
const handleSubmit = form.handleSubmit((values) => {
createMutation.mutate(values)
})
const filteredGroups = useMemo(() => {
const list = groups ?? []
const query = catalogSearch.trim().toLowerCase()
if (!query) return list
return list.filter(
(g) =>
g.name.toLowerCase().includes(query) ||
g.slug.toLowerCase().includes(query),
)
}, [groups, catalogSearch])
const isCatalogFilteredEmpty =
(groups?.length ?? 0) > 0 && filteredGroups.length === 0
return (
<PageShell>
<PageHeader
title="Группы доменов"
description="Канбан-доска доменов по группам и справочник групп"
description="Перетащите домен в группу или создайте новую группу для организации зон"
actions={
<Button onClick={() => setCreateSheetOpen(true)}>
Добавить группу
</Button>
}
/>
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
<TabsList>
<TabsTrigger value="kanban">Канбан</TabsTrigger>
<TabsTrigger value="catalog">Справочник</TabsTrigger>
</TabsList>
<TabsContent value="kanban">
<Card>
<CardHeader>
<CardTitle>Доска групп</CardTitle>
<CardDescription>
Перетащите домен в колонку группы. Нажмите на название колонки, чтобы открыть список доменов.
</CardDescription>
</CardHeader>
<CardContent>
<KanbanBoard
columns={columns}
getItemId={(domain) => String(domain.id)}
renderCard={(domain) => (
<DomainGroupCard
domain={domain}
serviceLabels={serviceLabelsByDomain.get(domain.id)}
/>
)}
onMove={handleMove}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="catalog" className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Создать группу</CardTitle>
<CardDescription>Добавить новую группу доменов</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
<Field className="flex-1">
<FieldLabel htmlFor="name">Название</FieldLabel>
<Input
id="name"
placeholder="Название"
{...form.register('name')}
aria-invalid={!!form.formState.errors.name}
/>
</Field>
<Field className="flex-1">
<FieldLabel htmlFor="slug">Slug</FieldLabel>
<Input
id="slug"
placeholder="slug"
{...form.register('slug')}
aria-invalid={!!form.formState.errors.slug}
/>
</Field>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
{createMutation.isPending ? 'Создание…' : 'Создать'}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
<DataTableCard
title="Справочник групп"
description="Все группы доменов"
isEmpty={!filteredGroups.length}
emptyTitle={
isCatalogFilteredEmpty ? 'Ничего не найдено' : 'Группы не найдены'
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={<GroupsBoardSkeleton />}
>
{isEmpty ? (
<EmptyState
icon={FolderTreeIcon}
title="Группы не найдены"
description="Создайте группу и назначьте домены при импорте или перетаскиванием на доске."
action={
<Button onClick={() => setCreateSheetOpen(true)}>
Добавить группу
</Button>
}
emptyDescription={
isCatalogFilteredEmpty
? 'Измените поисковый запрос'
: 'Создайте первую группу в форме выше'
}
emptyIcon={FolderTreeIcon}
toolbar={
<TableToolbar
value={catalogSearch}
onChange={setCatalogSearch}
placeholder="Поиск по названию или slug…"
/>
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Slug</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredGroups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">{group.name}</TableCell>
<TableCell className="text-muted-foreground">{group.slug}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(group.id) }}
/>
}
>
Открыть
</Button>
<ConfirmDialog
trigger={
<Button
variant="destructive"
size="sm"
disabled={deleteMutation.isPending}
>
Удалить
</Button>
}
title="Удалить группу?"
description={`Группа «${group.name}» будет удалена. Домены останутся без группы.`}
onConfirm={() => deleteMutation.mutate(group.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DataTableCard>
</TabsContent>
</Tabs>
/>
) : (
<GroupsBoard
board={board}
activeDomain={activeDomain}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup}
serviceLabelsByDomain={serviceLabelsByDomain}
/>
)}
</QueryState>
<DomainGroupEditSheet
mode="create"
group={null}
open={createSheetOpen}
isSaving={createMutation.isPending}
onOpenChange={setCreateSheetOpen}
onCreate={(body) => createMutation.mutate(body)}
/>
<DomainGroupEditSheet
mode="edit"
group={editingGroup}
open={editingGroup !== null}
isSaving={updateMutation.isPending}
onOpenChange={(open) => {
if (!open) setEditingGroup(null)
}}
onSave={(id, body) => updateMutation.mutate({ id, body })}
/>
<ConfirmDialog
open={deletingGroup !== null}
onOpenChange={(open) => {
if (!open) setDeletingGroup(null)
}}
title="Удалить группу?"
description={
deletingGroup
? `Группа «${deletingGroup.name}» будет удалена. Домены останутся без группы.`
: ''
}
onConfirm={() => {
if (deletingGroup) deleteMutation.mutate(deletingGroup.id)
}}
disabled={deleteMutation.isPending}
/>
</PageShell>
)
}
@@ -61,7 +61,7 @@ function GroupDetailPage() {
variant="outline"
render={<Link to="/groups" />}
>
На канбан
На группам
</Button>
}
/>
+182 -72
View File
@@ -24,19 +24,15 @@ import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
import { ServiceGroupCard } from '@/components/service-group-card'
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
import { ServiceRow } from '@/components/service-row'
import { ServicesBoard } from '@/components/services-board/services-board'
import { ServicesBoardSkeleton } from '@/components/services-board/services-board-skeleton'
import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar'
import { useServicesBoard } from '@/hooks/use-services-board'
import { useServicesSelection } from '@/hooks/use-services-selection'
import { Button } from '@cfdm/ui/components/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import { ItemGroup } from '@cfdm/ui/components/item'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export const Route = createFileRoute('/_auth/services')({
validateSearch: (search: Record<string, unknown>) => ({
@@ -91,22 +87,23 @@ function setGroupEnabled(
}
}
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
if (!domainId) return true
return service.domains?.some((d) => d.domain_id === domainId) ?? false
}
function ServicesPage() {
const { domainId } = Route.useSearch()
const [createSheetOpen, setCreateSheetOpen] = useState(false)
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
const [defaultGroupId, setDefaultGroupId] = useState<number | null>(null)
const [editingGroup, setEditingGroup] = useState<ServiceGroupView | null>(null)
const [editingService, setEditingService] = useState<ServiceView | null>(null)
const [deletingService, setDeletingService] = useState<ServiceView | null>(null)
const [deletingGroup, setDeletingGroup] = useState<ServiceGroupView | null>(null)
const [savingId, setSavingId] = useState<number | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null)
const [bulkToggling, setBulkToggling] = useState(false)
const queryClient = useQueryClient()
const selection = useServicesSelection()
const {
data,
isLoading,
@@ -116,10 +113,40 @@ function ServicesPage() {
} = useQuery(serviceGroupsQueryOptions())
const { data: domains } = useQuery(domainsListQueryOptions())
const dragDisabled = domainId != null
const {
board,
activeService,
handleDragStart,
handleDragEnd,
} = useServicesBoard({
data,
domainId,
dragDisabled,
})
const filteredDomain = domainId
? domains?.find((d) => d.id === domainId)
: undefined
const groups = useMemo(() => data?.groups ?? [], [data?.groups])
const isEmpty = useMemo(() => {
if (!data) return true
if (domainId) {
return (
board.columns.length === 0 ||
board.columns.every((column) => column.items.length === 0)
)
}
const hasServices =
data.groups.some((group) => group.services.length > 0) ||
data.ungrouped.length > 0
if (hasServices) return false
return data.groups.length === 0
}, [data, domainId, board.columns])
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
@@ -199,6 +226,7 @@ function ServicesPage() {
onSuccess: () => {
invalidateAll()
setEditingService(null)
setDeletingService(null)
toast.success('Сервис удалён')
},
onError: (err) => {
@@ -244,6 +272,21 @@ function ServicesPage() {
},
})
const deleteGroupMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
onSuccess: () => {
invalidateAll()
setDeletingGroup(null)
toast.success('Группа удалена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить группу')
},
onSettled: () => {
setDeletingGroupId(null)
},
})
const toggleGroupMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
api.patch<ServiceGroupsResponse>(`/api/v1/service-groups/${id}/toggle`, {
@@ -305,24 +348,62 @@ function ServicesPage() {
toggleGroupMutation.mutate({ id: groupId, enabled })
}
const groups = useMemo(() => {
const list = data?.groups ?? []
if (!domainId) return list
return list
.map((group) => ({
...group,
services: group.services.filter((s) => serviceMatchesDomain(s, domainId)),
}))
.filter((group) => group.services.length > 0)
}, [data?.groups, domainId])
function handleOpenCreateService(groupId: number | null = null) {
setDefaultGroupId(groupId)
setCreateSheetOpen(true)
}
const ungrouped = useMemo(() => {
const list = data?.ungrouped ?? []
if (!domainId) return list
return list.filter((s) => serviceMatchesDomain(s, domainId))
}, [data?.ungrouped, domainId])
function handleDeleteGroup(id: number) {
setDeletingGroupId(id)
deleteGroupMutation.mutate(id)
}
const isEmpty = groups.length === 0 && ungrouped.length === 0
async function handleBulkToggle(enabled: boolean) {
const ids = Array.from(selection.selectedIds)
if (ids.length === 0) return
setBulkToggling(true)
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (previous) {
let next = previous
for (const id of ids) {
next = setServiceEnabled(next, id, enabled)
}
queryClient.setQueryData(serviceGroupKeys.all, next)
}
const results = await Promise.allSettled(
ids.map((id) =>
api.patch<ServiceView>(`/api/v1/services/${id}/toggle`, { enabled }),
),
)
const succeeded = results.filter((r) => r.status === 'fulfilled').length
const failed = results.length - succeeded
if (failed > 0) {
if (previous) {
queryClient.setQueryData(serviceGroupKeys.all, previous)
}
toast.error(`Не удалось переключить ${failed} из ${results.length} сервисов`)
} else {
toast.success(
enabled
? `Включено сервисов: ${succeeded}`
: `Выключено сервисов: ${succeeded}`,
)
selection.clear()
}
setBulkToggling(false)
invalidateAll()
}
const boardHint = dragDisabled
? 'Перетаскивание отключено при фильтре по домену'
: 'Перетащите сервис между группами или измените порядок в списке'
return (
<PageShell>
@@ -330,30 +411,33 @@ function ServicesPage() {
title="Сервисы"
description={
filteredDomain
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
: 'Сервисы и группы сервисов — FQDN синхронизируются в Cloudflare при включении'
? `Сервисы с привязками к домену ${filteredDomain.zone_name}. ${boardHint}`
: `Сервисы и группы — FQDN синхронизируются в Cloudflare при включении. ${boardHint}`
}
actions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</Button>
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
<Button onClick={() => handleOpenCreateService()}>Добавить сервис</Button>
</div>
}
/>
<ServicesBulkToolbar
count={selection.count}
isPending={bulkToggling}
onEnable={() => handleBulkToggle(true)}
onDisable={() => handleBulkToggle(false)}
onClear={selection.clear}
/>
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={
<div className="flex flex-col gap-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
}
skeleton={<ServicesBoardSkeleton />}
>
{isEmpty ? (
<EmptyState
@@ -369,46 +453,34 @@ function ServicesPage() {
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</Button>
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
<Button onClick={() => handleOpenCreateService()}>Добавить сервис</Button>
</div>
}
/>
) : (
<div className="flex flex-col gap-4">
{groups.map((group) => (
<ServiceGroupCard
key={group.id}
group={group}
<ServicesBoard
board={board}
activeService={activeService}
dragDisabled={dragDisabled}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onGroupToggle={handleGroupToggle}
onServiceToggle={handleServiceToggle}
onEditService={setEditingService}
onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup}
onAddService={handleOpenCreateService}
onEditService={setEditingService}
onDeleteService={setDeletingService}
togglingGroupId={togglingGroupId}
togglingServiceId={togglingServiceId}
/>
))}
{ungrouped.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Без группы</CardTitle>
</CardHeader>
<CardContent>
<ItemGroup>
{ungrouped.map((service) => (
<ServiceRow
key={service.id}
service={service}
onToggle={handleServiceToggle}
onEdit={setEditingService}
isToggling={togglingServiceId === service.id}
/>
))}
</ItemGroup>
</CardContent>
</Card>
) : null}
</div>
showCheckbox={!dragDisabled}
isSelected={selection.isSelected}
onSelectedChange={selection.setSelected}
isAllSelected={selection.isAllSelected}
isSomeSelected={selection.isSomeSelected}
onSelectAllInGroup={selection.selectAll}
onDeselectAllInGroup={selection.deselectAll}
/>
)}
</QueryState>
@@ -434,7 +506,11 @@ function ServicesPage() {
open={createSheetOpen}
knownDomains={domains ?? []}
isSaving={createServiceMutation.isPending}
onOpenChange={setCreateSheetOpen}
defaultGroupId={defaultGroupId}
onOpenChange={(open) => {
setCreateSheetOpen(open)
if (!open) setDefaultGroupId(null)
}}
onCreate={handleCreate}
/>
@@ -457,6 +533,40 @@ function ServicesPage() {
}}
onSave={(id, body) => updateGroupMutation.mutate({ id, body })}
/>
<ConfirmDialog
open={deletingService !== null}
onOpenChange={(open) => {
if (!open) setDeletingService(null)
}}
title="Удалить сервис?"
description={
deletingService
? `Сервис «${deletingService.name}» и его привязки будут удалены.`
: ''
}
onConfirm={() => {
if (deletingService) handleDelete(deletingService.id)
}}
disabled={deleteServiceMutation.isPending}
/>
<ConfirmDialog
open={deletingGroup !== null}
onOpenChange={(open) => {
if (!open) setDeletingGroup(null)
}}
title="Удалить группу?"
description={
deletingGroup
? `Группа «${deletingGroup.name}» будет удалена. Сервисы останутся без группы.`
: ''
}
onConfirm={() => {
if (deletingGroup) handleDeleteGroup(deletingGroup.id)
}}
disabled={deleteGroupMutation.isPending || deletingGroupId === deletingGroup?.id}
/>
</PageShell>
)
}