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

This commit is contained in:
Denozordec
2026-06-22 13:52:33 +07:00
parent 4a70cf5854
commit 6d1d8ca4f3
99 changed files with 6692 additions and 1483 deletions
+2 -2
View File
@@ -97,7 +97,7 @@ function CertificatesPage() {
<PageShell>
<PageHeader
title="Сертификаты"
description="Мониторинг SSL-сертификатов"
description="Мониторинг SSL: только хосты с активными сервисами или режимом «Обязательно»"
actions={
<Button
onClick={() => checkMutation.mutate()}
@@ -142,7 +142,7 @@ function CertificatesPage() {
</Card>
<DataTableCard
title="Сертификаты"
description="Все отслеживаемые хосты"
description="Хосты с активными сервисами или ручным мониторингом"
isEmpty={!filteredCerts.length}
emptyTitle={
isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены'
@@ -1,21 +1,29 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { GlobeIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
domainDetailQueryOptions,
domainKeys,
domainServiceBindingsQueryOptions,
subdomainKeys,
serviceGroupsQueryOptions,
servicesQueryOptions,
subdomainsListQueryOptions,
} from '@/queries'
import { api } from '@/lib/api-client'
import { formatDate } from '@/lib/format'
import { useDomainPage } from '@/hooks/use-domain-page'
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { EmptyState } from '@/components/empty-state'
import { StatusBadge } from '@/components/status-badge'
import { QueryState } from '@/components/query-state'
import { DomainHeader } from '@/components/domain-header'
import { DomainActionsBar } from '@/components/domain-actions-bar'
import { DomainBindingsCard } from '@/components/domain-bindings-card'
import { DataTableCard } from '@/components/data-table-card'
import { SubdomainsTable } from '@/components/subdomains-table'
import {
SubdomainEditSheet,
type SubdomainEditValues,
} from '@/components/subdomain-edit-sheet'
import { StatusBadge } from '@/components/status-badge'
import { formatDate } from '@/lib/format'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import {
@@ -25,14 +33,7 @@ import {
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemSeparator,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Spinner } from '@cfdm/ui/components/spinner'
export const Route = createFileRoute('/_auth/domains/$domainId/')({
@@ -42,32 +43,121 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({
await Promise.all([
queryClient.ensureQueryData(subdomainsListQueryOptions(id)),
queryClient.ensureQueryData(domainServiceBindingsQueryOptions(id)),
queryClient.ensureQueryData(servicesQueryOptions()),
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
])
return { breadcrumb: domain.zone_name }
},
component: DomainOverviewPage,
})
function DomainPageSkeleton() {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-10 w-1/3" />
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-40 w-full" />
</div>
<Skeleton className="h-64 w-full" />
</div>
)
}
function DomainOverviewPage() {
const { domainId } = Route.useParams()
const id = Number(domainId)
const queryClient = useQueryClient()
const { data: domain } = useQuery(domainDetailQueryOptions(id))
const { data: subdomains } = useQuery(subdomainsListQueryOptions(id))
const { data: bindings } = useQuery(domainServiceBindingsQueryOptions(id))
const syncMutation = useMutation({
mutationFn: () => api.post(`/api/v1/domains/${id}/sync`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: subdomainKeys.list(id) })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
queryClient.invalidateQueries({ queryKey: ['service-bindings'] })
toast.success('Синхронизация завершена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Ошибка синхронизации')
},
})
const {
domain,
bindings,
services,
serviceGroupById,
subdomainRows,
isLoading,
isError,
error,
refetch,
syncMutation,
createSubdomainMutation,
updateSubdomainMutation,
updateDomainCertMonitoringMutation,
deleteSubdomainMutation,
linkServiceMutation,
} = useDomainPage(id)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create')
const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(null)
function openCreateSheet() {
setSheetMode('create')
setEditTarget(null)
setSheetOpen(true)
}
function openEditSheet(row: SubdomainTableRow) {
setSheetMode('edit')
setEditTarget(row)
setSheetOpen(true)
}
function resolveServiceId(row: SubdomainTableRow): string {
if (row.serviceLinks.length === 0) return 'none'
return String(row.serviceLinks[0].serviceId)
}
async function handleSheetSubmit(values: SubdomainEditValues) {
if (sheetMode === 'create') {
await createSubdomainMutation.mutateAsync(values.name)
setSheetOpen(false)
return
}
if (!editTarget) return
const nameChanged = values.name !== editTarget.subdomain.name
const certMonitoringChanged =
values.certMonitoring !== editTarget.subdomain.cert_monitoring
const currentServiceId = resolveServiceId(editTarget)
const serviceChanged = values.serviceId !== currentServiceId
const targetServiceId =
values.serviceId === 'none' ? null : Number(values.serviceId)
if (nameChanged || certMonitoringChanged) {
await updateSubdomainMutation.mutateAsync({
id: editTarget.subdomain.id,
...(nameChanged ? { name: values.name } : {}),
...(certMonitoringChanged
? { cert_monitoring: values.certMonitoring }
: {}),
})
}
const bindingsNeedSync =
serviceChanged || (nameChanged && editTarget.bindingIds.length > 0)
if (bindingsNeedSync) {
await linkServiceMutation.mutateAsync({
subdomainName: values.name,
serviceId: targetServiceId,
bindingIds: editTarget.bindingIds,
})
} else if (serviceChanged && targetServiceId != null) {
await linkServiceMutation.mutateAsync({
subdomainName: values.name,
serviceId: targetServiceId,
bindingIds: [],
})
}
setSheetOpen(false)
}
const isSheetSaving =
createSubdomainMutation.isPending ||
updateSubdomainMutation.isPending ||
linkServiceMutation.isPending
return (
<PageShell>
@@ -88,6 +178,7 @@ function DomainOverviewPage() {
</Button>
<Button
variant="outline"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
@@ -101,87 +192,124 @@ function DomainOverviewPage() {
</>
}
/>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Сводка</CardTitle>
<CardDescription>Основные параметры зоны</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Статус</span>
{domain ? <StatusBadge status={domain.status} /> : '—'}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Группа</span>
{domain?.group_id ? (
<Button
variant="link"
className="h-auto p-0"
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
Открыть группу
</Button>
) : (
<Badge variant="outline">Без группы</Badge>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Последняя синхронизация</span>
<span className="font-medium">{formatDate(domain?.last_synced_at)}</span>
</div>
</CardContent>
</Card>
<DomainBindingsCard bindings={bindings ?? []} />
</div>
<Card>
<CardHeader>
<CardTitle>Поддомены</CardTitle>
<CardDescription>Обнаруженные поддомены в зоне</CardDescription>
</CardHeader>
<CardContent>
{subdomains?.length ? (
<ItemGroup className="gap-0">
{subdomains.map((s, index) => (
<div key={s.id}>
<Item variant="outline">
<ItemContent>
<ItemTitle className="font-mono font-normal">{s.fqdn}</ItemTitle>
</ItemContent>
<ItemActions>
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={<DomainPageSkeleton />}
>
{domain && (
<>
<DomainHeader
domain={domain}
onCertMonitoringChange={(value) =>
updateDomainCertMonitoringMutation.mutate(value)
}
isCertMonitoringSaving={updateDomainCertMonitoringMutation.isPending}
/>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Сводка</CardTitle>
<CardDescription>Основные параметры зоны</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Статус</span>
<StatusBadge status={domain.status} />
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Группа</span>
{domain.group_id ? (
<Button
variant="outline"
size="sm"
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId }}
search={{ host: s.fqdn }}
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
DNS
Открыть группу
</Button>
</ItemActions>
</Item>
{index < subdomains.length - 1 && <ItemSeparator />}
) : (
<Badge variant="outline">Без группы</Badge>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">
Последняя синхронизация
</span>
<span className="font-medium">
{formatDate(domain.last_synced_at)}
</span>
</div>
</CardContent>
</Card>
<DomainBindingsCard bindings={bindings} />
</div>
<DataTableCard
title="Поддомены"
description="Управление поддоменами и привязками сервисов"
isEmpty={subdomainRows.length === 0}
emptyTitle="Поддомены не найдены"
emptyDescription="Создайте поддомен вручную или синхронизируйте зону с Cloudflare"
emptyIcon={GlobeIcon}
emptyAction={
<div className="flex flex-wrap justify-center gap-2">
<Button onClick={openCreateSheet}>Создать поддомен</Button>
<Button
variant="outline"
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
>
Синхронизировать
</Button>
</div>
))}
</ItemGroup>
) : (
<EmptyState
icon={GlobeIcon}
title="Поддомены не найдены"
description="Нажмите «Синхронизировать» — поддомены извлекаются из DNS-записей Cloudflare"
}
toolbar={<DomainActionsBar onCreateSubdomain={openCreateSheet} />}
>
<SubdomainsTable
domainId={domainId}
rows={subdomainRows}
isDeleting={deleteSubdomainMutation.isPending}
isToggling={updateSubdomainMutation.isPending}
onEdit={openEditSheet}
onDelete={(row) =>
deleteSubdomainMutation.mutate(row.subdomain.id)
}
onToggleEnabled={(row) =>
updateSubdomainMutation.mutate({
id: row.subdomain.id,
enabled: !row.subdomain.enabled,
})
}
/>
</DataTableCard>
<SubdomainEditSheet
mode={sheetMode}
subdomain={editTarget?.subdomain ?? null}
zoneName={domain.zone_name}
services={services}
serviceGroupById={serviceGroupById}
currentServiceId={
editTarget ? resolveServiceId(editTarget) : 'none'
}
open={sheetOpen}
isSaving={isSheetSaving}
onOpenChange={setSheetOpen}
onSubmit={handleSheetSubmit}
/>
)}
</CardContent>
</Card>
</>
)}
</QueryState>
</PageShell>
)
}
+24 -18
View File
@@ -6,17 +6,16 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { api } from '@/lib/api-client'
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas'
import { buildIpsByDomainId } from '@/lib/domain-ips'
import {
domainKeys,
domainsListQueryOptions,
groupsQueryOptions,
serviceBindingsQueryOptions,
serviceBindingKeys,
} from '@/queries'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { DataTableCard } from '@/components/data-table-card'
import { DomainsDataTable } from '@/components/domains-data-table'
import { DomainsDataTable, type DomainTableRow } from '@/components/domains-data-table'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
@@ -46,7 +45,6 @@ export const Route = createFileRoute('/_auth/domains/')({
Promise.all([
queryClient.ensureQueryData(domainsListQueryOptions()),
queryClient.ensureQueryData(groupsQueryOptions()),
queryClient.ensureQueryData(serviceBindingsQueryOptions()),
]),
component: DomainsPage,
})
@@ -60,12 +58,6 @@ function DomainsPage() {
const queryClient = useQueryClient()
const { data: domains } = useQuery(domainsListQueryOptions(listGroupId))
const { data: groups } = useQuery(groupsQueryOptions())
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
const ipsByDomainId = useMemo(
() => buildIpsByDomainId(bindings ?? []),
[bindings],
)
const groupItems = useMemo(
() => [
@@ -94,6 +86,22 @@ function DomainsPage() {
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
toast.success('Зона удалена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить зону')
},
})
function handleDeleteDomain(domain: DomainTableRow) {
deleteMutation.mutate(domain.id)
}
const handleCreate = form.handleSubmit((values) => {
createMutation.mutate({
zone_name: values.zone_name.trim(),
@@ -123,22 +131,18 @@ function DomainsPage() {
}, [domains, filterGroupId])
const tableData = useMemo(
() =>
filteredDomains.map((domain) => ({
...domain,
ips: ipsByDomainId.get(domain.id) ?? [],
})),
[filteredDomains, ipsByDomainId],
() => filteredDomains as DomainTableRow[],
[filteredDomains],
)
return (
<PageShell>
<PageHeader
title="Домены"
description={`${tableData.length} зон · ${tableData.filter((d) => d.ips.length > 0).length} с IP · ${tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов`}
description={`${tableData.length} зон · ${tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов`}
actions={
<>
<Button variant="outline" render={<Link to="/groups" />}>
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
Канбан групп
</Button>
<Button onClick={() => setSheetOpen(true)}>Импортировать домен</Button>
@@ -155,6 +159,8 @@ function DomainsPage() {
groupFilterItems={groupItems}
groupFilterValue={filterGroupId || 'all'}
onGroupFilterChange={handleFilterGroupChange}
onDelete={handleDeleteDomain}
isDeleting={deleteMutation.isPending}
/>
</DataTableCard>
+129 -252
View File
@@ -1,8 +1,6 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { FolderTreeIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -10,46 +8,21 @@ import {
domainsListQueryOptions,
groupKeys,
groupsQueryOptions,
serviceBindingKeys,
serviceBindingsQueryOptions,
} from '@/queries'
import { api } from '@/lib/api-client'
import { createGroupSchema, type CreateGroupInput, type DomainListItem } from '@/lib/schemas'
import type { CreateGroupInput, Group } from '@/lib/schemas'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { TableToolbar } from '@/components/table-toolbar'
import { KanbanBoard } from '@/components/kanban-board'
import { DomainGroupCard } from '@/components/domain-group-card'
import { DataTableCard } from '@/components/data-table-card'
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
import { GroupsBoard } from '@/components/groups-board/groups-board'
import { GroupsBoardSkeleton } from '@/components/groups-board/groups-board-skeleton'
import { useGroupsBoard } from '@/hooks/use-groups-board'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Field,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
import { Spinner } from '@cfdm/ui/components/spinner'
export const Route = createFileRoute('/_auth/groups')({
loader: ({ context: { queryClient } }) =>
@@ -61,28 +34,30 @@ export const Route = createFileRoute('/_auth/groups')({
component: GroupsPage,
})
const UNGROUPED_COLUMN_ID = 'ungrouped'
function groupColumnId(groupId: number) {
return `group-${groupId}`
}
function parseGroupColumnId(columnId: string): number | null {
if (columnId === UNGROUPED_COLUMN_ID) return null
const match = columnId.match(/^group-(\d+)$/)
return match ? Number(match[1]) : null
}
function GroupsPage() {
const [catalogSearch, setCatalogSearch] = useState('')
const [createSheetOpen, setCreateSheetOpen] = useState(false)
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
const [deletingGroup, setDeletingGroup] = useState<Group | null>(null)
const queryClient = useQueryClient()
const { data: groups } = useQuery(groupsQueryOptions())
const {
data: groups,
isLoading,
isError,
error,
refetch,
} = useQuery(groupsQueryOptions())
const { data: domains } = useQuery(domainsListQueryOptions())
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
const form = useForm<CreateGroupInput>({
resolver: zodResolver(createGroupSchema),
defaultValues: { name: '', slug: '' },
const {
board,
activeDomain,
handleDragStart,
handleDragEnd,
} = useGroupsBoard({
groups,
domains,
})
const serviceLabelsByDomain = useMemo(() => {
@@ -97,36 +72,24 @@ function GroupsPage() {
return map
}, [bindings])
const columns = useMemo(() => {
const groupColumns =
groups?.map((group) => ({
id: groupColumnId(group.id),
title: group.name,
description: group.slug,
href: `/groups/${group.id}`,
items:
domains?.filter((d) => d.group_id === group.id) ?? [],
})) ?? []
const ungrouped: DomainListItem[] =
domains?.filter((d) => d.group_id === null) ?? []
return [
...groupColumns,
{
id: UNGROUPED_COLUMN_ID,
title: 'Без группы',
description: 'Домены без назначенной группы',
items: ungrouped,
},
]
const isEmpty = useMemo(() => {
if (!groups || !domains) return true
const hasDomains = domains.length > 0
if (hasDomains) return false
return groups.length === 0
}, [groups, domains])
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: groupKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
}
const createMutation = useMutation({
mutationFn: (body: CreateGroupInput) => api.post('/api/v1/groups', body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: groupKeys.all })
form.reset()
invalidateAll()
setCreateSheetOpen(false)
toast.success('Группа создана')
},
onError: (err) => {
@@ -134,11 +97,24 @@ function GroupsPage() {
},
})
const updateMutation = useMutation({
mutationFn: ({ id, body }: { id: number; body: CreateGroupInput }) =>
api.patch(`/api/v1/groups/${id}`, body),
onSuccess: () => {
invalidateAll()
setEditingGroup(null)
toast.success('Группа сохранена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить группу')
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/groups/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: groupKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
invalidateAll()
setDeletingGroup(null)
toast.success('Группа удалена')
},
onError: (err) => {
@@ -146,184 +122,85 @@ function GroupsPage() {
},
})
const moveDomainMutation = useMutation({
mutationFn: ({ domainId, groupId }: { domainId: number; groupId: number | null }) =>
api.patch(`/api/v1/domains/${domainId}`, { group_id: groupId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
toast.success('Группа домена обновлена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переместить домен')
},
})
function handleMove(itemId: string, _fromColumnId: string, toColumnId: string) {
const groupId = parseGroupColumnId(toColumnId)
if (toColumnId !== UNGROUPED_COLUMN_ID && groupId === null) return
moveDomainMutation.mutate({ domainId: Number(itemId), groupId })
}
const handleSubmit = form.handleSubmit((values) => {
createMutation.mutate(values)
})
const filteredGroups = useMemo(() => {
const list = groups ?? []
const query = catalogSearch.trim().toLowerCase()
if (!query) return list
return list.filter(
(g) =>
g.name.toLowerCase().includes(query) ||
g.slug.toLowerCase().includes(query),
)
}, [groups, catalogSearch])
const isCatalogFilteredEmpty =
(groups?.length ?? 0) > 0 && filteredGroups.length === 0
return (
<PageShell>
<PageHeader
title="Группы доменов"
description="Канбан-доска доменов по группам и справочник групп"
description="Перетащите домен в группу или создайте новую группу для организации зон"
actions={
<Button onClick={() => setCreateSheetOpen(true)}>
Добавить группу
</Button>
}
/>
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
<TabsList>
<TabsTrigger value="kanban">Канбан</TabsTrigger>
<TabsTrigger value="catalog">Справочник</TabsTrigger>
</TabsList>
<TabsContent value="kanban">
<Card>
<CardHeader>
<CardTitle>Доска групп</CardTitle>
<CardDescription>
Перетащите домен в колонку группы. Нажмите на название колонки, чтобы открыть список доменов.
</CardDescription>
</CardHeader>
<CardContent>
<KanbanBoard
columns={columns}
getItemId={(domain) => String(domain.id)}
renderCard={(domain) => (
<DomainGroupCard
domain={domain}
serviceLabels={serviceLabelsByDomain.get(domain.id)}
/>
)}
onMove={handleMove}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="catalog" className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Создать группу</CardTitle>
<CardDescription>Добавить новую группу доменов</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
<Field className="flex-1">
<FieldLabel htmlFor="name">Название</FieldLabel>
<Input
id="name"
placeholder="Название"
{...form.register('name')}
aria-invalid={!!form.formState.errors.name}
/>
</Field>
<Field className="flex-1">
<FieldLabel htmlFor="slug">Slug</FieldLabel>
<Input
id="slug"
placeholder="slug"
{...form.register('slug')}
aria-invalid={!!form.formState.errors.slug}
/>
</Field>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
{createMutation.isPending ? 'Создание…' : 'Создать'}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
<DataTableCard
title="Справочник групп"
description="Все группы доменов"
isEmpty={!filteredGroups.length}
emptyTitle={
isCatalogFilteredEmpty ? 'Ничего не найдено' : 'Группы не найдены'
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={<GroupsBoardSkeleton />}
>
{isEmpty ? (
<EmptyState
icon={FolderTreeIcon}
title="Группы не найдены"
description="Создайте группу и назначьте домены при импорте или перетаскиванием на доске."
action={
<Button onClick={() => setCreateSheetOpen(true)}>
Добавить группу
</Button>
}
emptyDescription={
isCatalogFilteredEmpty
? 'Измените поисковый запрос'
: 'Создайте первую группу в форме выше'
}
emptyIcon={FolderTreeIcon}
toolbar={
<TableToolbar
value={catalogSearch}
onChange={setCatalogSearch}
placeholder="Поиск по названию или slug…"
/>
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Slug</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredGroups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">{group.name}</TableCell>
<TableCell className="text-muted-foreground">{group.slug}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(group.id) }}
/>
}
>
Открыть
</Button>
<ConfirmDialog
trigger={
<Button
variant="destructive"
size="sm"
disabled={deleteMutation.isPending}
>
Удалить
</Button>
}
title="Удалить группу?"
description={`Группа «${group.name}» будет удалена. Домены останутся без группы.`}
onConfirm={() => deleteMutation.mutate(group.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DataTableCard>
</TabsContent>
</Tabs>
/>
) : (
<GroupsBoard
board={board}
activeDomain={activeDomain}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup}
serviceLabelsByDomain={serviceLabelsByDomain}
/>
)}
</QueryState>
<DomainGroupEditSheet
mode="create"
group={null}
open={createSheetOpen}
isSaving={createMutation.isPending}
onOpenChange={setCreateSheetOpen}
onCreate={(body) => createMutation.mutate(body)}
/>
<DomainGroupEditSheet
mode="edit"
group={editingGroup}
open={editingGroup !== null}
isSaving={updateMutation.isPending}
onOpenChange={(open) => {
if (!open) setEditingGroup(null)
}}
onSave={(id, body) => updateMutation.mutate({ id, body })}
/>
<ConfirmDialog
open={deletingGroup !== null}
onOpenChange={(open) => {
if (!open) setDeletingGroup(null)
}}
title="Удалить группу?"
description={
deletingGroup
? `Группа «${deletingGroup.name}» будет удалена. Домены останутся без группы.`
: ''
}
onConfirm={() => {
if (deletingGroup) deleteMutation.mutate(deletingGroup.id)
}}
disabled={deleteMutation.isPending}
/>
</PageShell>
)
}
@@ -61,7 +61,7 @@ function GroupDetailPage() {
variant="outline"
render={<Link to="/groups" />}
>
На канбан
На группам
</Button>
}
/>
+182 -72
View File
@@ -24,19 +24,15 @@ import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
import { ServiceGroupCard } from '@/components/service-group-card'
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
import { ServiceRow } from '@/components/service-row'
import { ServicesBoard } from '@/components/services-board/services-board'
import { ServicesBoardSkeleton } from '@/components/services-board/services-board-skeleton'
import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar'
import { useServicesBoard } from '@/hooks/use-services-board'
import { useServicesSelection } from '@/hooks/use-services-selection'
import { Button } from '@cfdm/ui/components/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import { ItemGroup } from '@cfdm/ui/components/item'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export const Route = createFileRoute('/_auth/services')({
validateSearch: (search: Record<string, unknown>) => ({
@@ -91,22 +87,23 @@ function setGroupEnabled(
}
}
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
if (!domainId) return true
return service.domains?.some((d) => d.domain_id === domainId) ?? false
}
function ServicesPage() {
const { domainId } = Route.useSearch()
const [createSheetOpen, setCreateSheetOpen] = useState(false)
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
const [defaultGroupId, setDefaultGroupId] = useState<number | null>(null)
const [editingGroup, setEditingGroup] = useState<ServiceGroupView | null>(null)
const [editingService, setEditingService] = useState<ServiceView | null>(null)
const [deletingService, setDeletingService] = useState<ServiceView | null>(null)
const [deletingGroup, setDeletingGroup] = useState<ServiceGroupView | null>(null)
const [savingId, setSavingId] = useState<number | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null)
const [bulkToggling, setBulkToggling] = useState(false)
const queryClient = useQueryClient()
const selection = useServicesSelection()
const {
data,
isLoading,
@@ -116,10 +113,40 @@ function ServicesPage() {
} = useQuery(serviceGroupsQueryOptions())
const { data: domains } = useQuery(domainsListQueryOptions())
const dragDisabled = domainId != null
const {
board,
activeService,
handleDragStart,
handleDragEnd,
} = useServicesBoard({
data,
domainId,
dragDisabled,
})
const filteredDomain = domainId
? domains?.find((d) => d.id === domainId)
: undefined
const groups = useMemo(() => data?.groups ?? [], [data?.groups])
const isEmpty = useMemo(() => {
if (!data) return true
if (domainId) {
return (
board.columns.length === 0 ||
board.columns.every((column) => column.items.length === 0)
)
}
const hasServices =
data.groups.some((group) => group.services.length > 0) ||
data.ungrouped.length > 0
if (hasServices) return false
return data.groups.length === 0
}, [data, domainId, board.columns])
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
@@ -199,6 +226,7 @@ function ServicesPage() {
onSuccess: () => {
invalidateAll()
setEditingService(null)
setDeletingService(null)
toast.success('Сервис удалён')
},
onError: (err) => {
@@ -244,6 +272,21 @@ function ServicesPage() {
},
})
const deleteGroupMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
onSuccess: () => {
invalidateAll()
setDeletingGroup(null)
toast.success('Группа удалена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить группу')
},
onSettled: () => {
setDeletingGroupId(null)
},
})
const toggleGroupMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
api.patch<ServiceGroupsResponse>(`/api/v1/service-groups/${id}/toggle`, {
@@ -305,24 +348,62 @@ function ServicesPage() {
toggleGroupMutation.mutate({ id: groupId, enabled })
}
const groups = useMemo(() => {
const list = data?.groups ?? []
if (!domainId) return list
return list
.map((group) => ({
...group,
services: group.services.filter((s) => serviceMatchesDomain(s, domainId)),
}))
.filter((group) => group.services.length > 0)
}, [data?.groups, domainId])
function handleOpenCreateService(groupId: number | null = null) {
setDefaultGroupId(groupId)
setCreateSheetOpen(true)
}
const ungrouped = useMemo(() => {
const list = data?.ungrouped ?? []
if (!domainId) return list
return list.filter((s) => serviceMatchesDomain(s, domainId))
}, [data?.ungrouped, domainId])
function handleDeleteGroup(id: number) {
setDeletingGroupId(id)
deleteGroupMutation.mutate(id)
}
const isEmpty = groups.length === 0 && ungrouped.length === 0
async function handleBulkToggle(enabled: boolean) {
const ids = Array.from(selection.selectedIds)
if (ids.length === 0) return
setBulkToggling(true)
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (previous) {
let next = previous
for (const id of ids) {
next = setServiceEnabled(next, id, enabled)
}
queryClient.setQueryData(serviceGroupKeys.all, next)
}
const results = await Promise.allSettled(
ids.map((id) =>
api.patch<ServiceView>(`/api/v1/services/${id}/toggle`, { enabled }),
),
)
const succeeded = results.filter((r) => r.status === 'fulfilled').length
const failed = results.length - succeeded
if (failed > 0) {
if (previous) {
queryClient.setQueryData(serviceGroupKeys.all, previous)
}
toast.error(`Не удалось переключить ${failed} из ${results.length} сервисов`)
} else {
toast.success(
enabled
? `Включено сервисов: ${succeeded}`
: `Выключено сервисов: ${succeeded}`,
)
selection.clear()
}
setBulkToggling(false)
invalidateAll()
}
const boardHint = dragDisabled
? 'Перетаскивание отключено при фильтре по домену'
: 'Перетащите сервис между группами или измените порядок в списке'
return (
<PageShell>
@@ -330,30 +411,33 @@ function ServicesPage() {
title="Сервисы"
description={
filteredDomain
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
: 'Сервисы и группы сервисов — FQDN синхронизируются в Cloudflare при включении'
? `Сервисы с привязками к домену ${filteredDomain.zone_name}. ${boardHint}`
: `Сервисы и группы — FQDN синхронизируются в Cloudflare при включении. ${boardHint}`
}
actions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</Button>
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
<Button onClick={() => handleOpenCreateService()}>Добавить сервис</Button>
</div>
}
/>
<ServicesBulkToolbar
count={selection.count}
isPending={bulkToggling}
onEnable={() => handleBulkToggle(true)}
onDisable={() => handleBulkToggle(false)}
onClear={selection.clear}
/>
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={
<div className="flex flex-col gap-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
}
skeleton={<ServicesBoardSkeleton />}
>
{isEmpty ? (
<EmptyState
@@ -369,46 +453,34 @@ function ServicesPage() {
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</Button>
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
<Button onClick={() => handleOpenCreateService()}>Добавить сервис</Button>
</div>
}
/>
) : (
<div className="flex flex-col gap-4">
{groups.map((group) => (
<ServiceGroupCard
key={group.id}
group={group}
<ServicesBoard
board={board}
activeService={activeService}
dragDisabled={dragDisabled}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onGroupToggle={handleGroupToggle}
onServiceToggle={handleServiceToggle}
onEditService={setEditingService}
onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup}
onAddService={handleOpenCreateService}
onEditService={setEditingService}
onDeleteService={setDeletingService}
togglingGroupId={togglingGroupId}
togglingServiceId={togglingServiceId}
/>
))}
{ungrouped.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Без группы</CardTitle>
</CardHeader>
<CardContent>
<ItemGroup>
{ungrouped.map((service) => (
<ServiceRow
key={service.id}
service={service}
onToggle={handleServiceToggle}
onEdit={setEditingService}
isToggling={togglingServiceId === service.id}
/>
))}
</ItemGroup>
</CardContent>
</Card>
) : null}
</div>
showCheckbox={!dragDisabled}
isSelected={selection.isSelected}
onSelectedChange={selection.setSelected}
isAllSelected={selection.isAllSelected}
isSomeSelected={selection.isSomeSelected}
onSelectAllInGroup={selection.selectAll}
onDeselectAllInGroup={selection.deselectAll}
/>
)}
</QueryState>
@@ -434,7 +506,11 @@ function ServicesPage() {
open={createSheetOpen}
knownDomains={domains ?? []}
isSaving={createServiceMutation.isPending}
onOpenChange={setCreateSheetOpen}
defaultGroupId={defaultGroupId}
onOpenChange={(open) => {
setCreateSheetOpen(open)
if (!open) setDefaultGroupId(null)
}}
onCreate={handleCreate}
/>
@@ -457,6 +533,40 @@ function ServicesPage() {
}}
onSave={(id, body) => updateGroupMutation.mutate({ id, body })}
/>
<ConfirmDialog
open={deletingService !== null}
onOpenChange={(open) => {
if (!open) setDeletingService(null)
}}
title="Удалить сервис?"
description={
deletingService
? `Сервис «${deletingService.name}» и его привязки будут удалены.`
: ''
}
onConfirm={() => {
if (deletingService) handleDelete(deletingService.id)
}}
disabled={deleteServiceMutation.isPending}
/>
<ConfirmDialog
open={deletingGroup !== null}
onOpenChange={(open) => {
if (!open) setDeletingGroup(null)
}}
title="Удалить группу?"
description={
deletingGroup
? `Группа «${deletingGroup.name}» будет удалена. Сервисы останутся без группы.`
: ''
}
onConfirm={() => {
if (deletingGroup) handleDeleteGroup(deletingGroup.id)
}}
disabled={deleteGroupMutation.isPending || deletingGroupId === deletingGroup?.id}
/>
</PageShell>
)
}