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