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:
@@ -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