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
573 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|