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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user