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
+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>
)
}