Files
cloudflare-domain-manager/apps/web/src/routes/_auth/services.tsx
T
Denozordec 2147782ba6
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m55s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m14s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 7s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
refactor: Replace legacy UI components with new App components across various files for improved consistency and maintainability
2026-06-25 16:54:21 +07:00

573 lines
19 KiB
TypeScript

import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { ServerIcon } from 'lucide-react'
import { toast } from 'sonner'
import {
domainKeys,
domainsListQueryOptions,
serviceBindingKeys,
serviceGroupKeys,
serviceGroupsQueryOptions,
serviceKeys,
} from '@/queries'
import { api } from '@/lib/api-client'
import type {
CreateServiceGroupInput,
CreateServiceWithConfigInput,
ServiceGroupView,
ServiceGroupsResponse,
ServiceView,
UpdateServiceConfigInput,
} 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 { ServiceEditSheet } from '@/components/service-edit-sheet'
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
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 { AppButton } from '@/components/app-button'
export const Route = createFileRoute('/_auth/services')({
validateSearch: (search: Record<string, unknown>) => ({
domainId:
search.domainId != null && search.domainId !== ''
? Number(search.domainId)
: undefined,
}),
loader: ({ context: { queryClient } }) =>
Promise.all([
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
queryClient.ensureQueryData(domainsListQueryOptions()),
]),
component: ServicesPage,
})
function setServiceEnabled(
data: ServiceGroupsResponse,
serviceId: number,
enabled: boolean,
): ServiceGroupsResponse {
return {
groups: data.groups.map((group) => ({
...group,
services: group.services.map((service) =>
service.id === serviceId ? { ...service, enabled } : service,
),
})),
ungrouped: data.ungrouped.map((service) =>
service.id === serviceId ? { ...service, enabled } : service,
),
}
}
function setGroupEnabled(
data: ServiceGroupsResponse,
groupId: number,
enabled: boolean,
): ServiceGroupsResponse {
return {
...data,
groups: data.groups.map((group) => {
if (group.id !== groupId) return group
return {
...group,
enabled,
services: enabled
? group.services
: group.services.map((service) => ({ ...service, enabled: 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,
isError,
error,
refetch,
} = 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 })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
}
const createGroupMutation = useMutation({
mutationFn: (body: CreateServiceGroupInput) =>
api.post('/api/v1/service-groups', body),
onSuccess: () => {
invalidateAll()
setCreateGroupSheetOpen(false)
toast.success('Группа создана')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось создать группу')
},
})
const updateGroupMutation = useMutation({
mutationFn: ({ id, body }: { id: number; body: CreateServiceGroupInput }) =>
api.patch(`/api/v1/service-groups/${id}`, body),
onSuccess: () => {
invalidateAll()
setEditingGroup(null)
toast.success('Группа сохранена, DNS синхронизируется')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить группу')
},
})
const createServiceMutation = useMutation({
mutationFn: async (body: CreateServiceWithConfigInput) => {
const created = await api.post<ServiceView>('/api/v1/services', {
name: body.name,
slug: body.slug,
service_group_id: body.service_group_id ?? null,
})
const hasConfig = body.ips.length > 0 || body.domains.length > 0
if (!hasConfig) return created
return api.patch<ServiceView>(`/api/v1/services/${created.id}`, {
ips: body.ips,
...(body.domains.length > 0 ? { domains: body.domains } : {}),
service_group_id: body.service_group_id ?? null,
})
},
onSuccess: () => {
invalidateAll()
setCreateSheetOpen(false)
toast.success('Сервис создан')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось создать сервис')
},
})
const updateServiceMutation = useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdateServiceConfigInput }) =>
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
onSuccess: () => {
invalidateAll()
setEditingService(null)
toast.success('Сервис сохранён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
},
onSettled: () => {
setSavingId(null)
},
})
const deleteServiceMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/services/${id}`),
onSuccess: () => {
invalidateAll()
setEditingService(null)
setDeletingService(null)
toast.success('Сервис удалён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
},
onSettled: () => {
setDeletingId(null)
},
})
const toggleServiceMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
api.patch<ServiceView>(`/api/v1/services/${id}/toggle`, { enabled }),
onMutate: async ({ id, enabled }) => {
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (previous) {
queryClient.setQueryData(
serviceGroupKeys.all,
setServiceEnabled(previous, id, enabled),
)
}
return { previous }
},
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
}
toast.error(err instanceof Error ? err.message : 'Не удалось переключить сервис')
},
onSuccess: (_data, { enabled }) => {
toast.success(
enabled
? 'Сервис включён, DNS синхронизируется с Cloudflare'
: 'Сервис выключен, DNS-записи удалены из Cloudflare',
)
},
onSettled: () => {
setTogglingServiceId(null)
invalidateAll()
},
})
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`, {
enabled,
}),
onMutate: async ({ id, enabled }) => {
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (previous) {
queryClient.setQueryData(
serviceGroupKeys.all,
setGroupEnabled(previous, id, enabled),
)
}
return { previous }
},
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
}
toast.error(err instanceof Error ? err.message : 'Не удалось переключить группу')
},
onSuccess: (_data, { enabled }) => {
toast.success(
enabled
? 'Группа включена, DNS включённых сервисов синхронизируется'
: 'Группа выключена, DNS-записи сервисов удалены',
)
},
onSettled: () => {
setTogglingGroupId(null)
invalidateAll()
},
})
function handleSave(id: number, body: UpdateServiceConfigInput) {
setSavingId(id)
updateServiceMutation.mutate({ id, body })
}
function handleDelete(id: number) {
setDeletingId(id)
deleteServiceMutation.mutate(id)
}
function handleCreate(body: CreateServiceWithConfigInput) {
createServiceMutation.mutate(body)
}
function handleServiceToggle(serviceId: number, enabled: boolean) {
setTogglingServiceId(serviceId)
toggleServiceMutation.mutate({ id: serviceId, enabled })
}
function handleGroupToggle(groupId: number, enabled: boolean) {
setTogglingGroupId(groupId)
toggleGroupMutation.mutate({ id: groupId, enabled })
}
function handleOpenCreateService(groupId: number | null = null) {
setDefaultGroupId(groupId)
setCreateSheetOpen(true)
}
function handleDeleteGroup(id: number) {
setDeletingGroupId(id)
deleteGroupMutation.mutate(id)
}
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>
<PageHeader
title="Сервисы"
description={
filteredDomain
? `Сервисы с привязками к домену ${filteredDomain.zone_name}. ${boardHint}`
: `Сервисы и группы — FQDN синхронизируются в Cloudflare при включении. ${boardHint}`
}
actions={
<div className="flex flex-wrap gap-2">
<AppButton variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</AppButton>
<AppButton onClick={() => handleOpenCreateService()}>Добавить сервис</AppButton>
</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={<ServicesBoardSkeleton />}
>
{isEmpty ? (
<EmptyState
icon={ServerIcon}
title="Сервисы не найдены"
description={
filteredDomain
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
: 'Создайте группу и сервис — например VPN Panel или Home Assistant.'
}
action={
<div className="flex flex-wrap justify-center gap-2">
<AppButton variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</AppButton>
<AppButton onClick={() => handleOpenCreateService()}>Добавить сервис</AppButton>
</div>
}
/>
) : (
<ServicesBoard
board={board}
activeService={activeService}
dragDisabled={dragDisabled}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onGroupToggle={handleGroupToggle}
onServiceToggle={handleServiceToggle}
onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup}
onAddService={handleOpenCreateService}
onEditService={setEditingService}
onDeleteService={setDeletingService}
togglingGroupId={togglingGroupId}
togglingServiceId={togglingServiceId}
showCheckbox={!dragDisabled}
isSelected={selection.isSelected}
onSelectedChange={selection.setSelected}
isAllSelected={selection.isAllSelected}
isSomeSelected={selection.isSomeSelected}
onSelectAllInGroup={selection.selectAll}
onDeselectAllInGroup={selection.deselectAll}
/>
)}
</QueryState>
<ServiceEditSheet
mode="edit"
service={editingService}
groups={groups}
open={editingService !== null}
knownDomains={domains ?? []}
isSaving={editingService !== null && savingId === editingService.id}
isDeleting={editingService !== null && deletingId === editingService.id}
onOpenChange={(open) => {
if (!open) setEditingService(null)
}}
onSave={handleSave}
onDelete={handleDelete}
/>
<ServiceEditSheet
mode="create"
service={null}
groups={groups}
open={createSheetOpen}
knownDomains={domains ?? []}
isSaving={createServiceMutation.isPending}
defaultGroupId={defaultGroupId}
onOpenChange={(open) => {
setCreateSheetOpen(open)
if (!open) setDefaultGroupId(null)
}}
onCreate={handleCreate}
/>
<ServiceGroupEditSheet
mode="create"
group={null}
open={createGroupSheetOpen}
isSaving={createGroupMutation.isPending}
onOpenChange={setCreateGroupSheetOpen}
onCreate={(body) => createGroupMutation.mutate(body)}
/>
<ServiceGroupEditSheet
mode="edit"
group={editingGroup}
open={editingGroup !== null}
isSaving={updateGroupMutation.isPending}
onOpenChange={(open) => {
if (!open) setEditingGroup(null)
}}
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>
)
}