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