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) => ({ 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(null) const [editingGroup, setEditingGroup] = useState(null) const [editingService, setEditingService] = useState(null) const [deletingService, setDeletingService] = useState(null) const [deletingGroup, setDeletingGroup] = useState(null) const [savingId, setSavingId] = useState(null) const [deletingId, setDeletingId] = useState(null) const [deletingGroupId, setDeletingGroupId] = useState(null) const [togglingServiceId, setTogglingServiceId] = useState(null) const [togglingGroupId, setTogglingGroupId] = useState(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('/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(`/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(`/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(`/api/v1/services/${id}/toggle`, { enabled }), onMutate: async ({ id, enabled }) => { await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all }) const previous = queryClient.getQueryData( 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(`/api/v1/service-groups/${id}/toggle`, { enabled, }), onMutate: async ({ id, enabled }) => { await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all }) const previous = queryClient.getQueryData( 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( 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(`/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 ( setCreateGroupSheetOpen(true)}> Добавить группу handleOpenCreateService()}>Добавить сервис } /> handleBulkToggle(true)} onDisable={() => handleBulkToggle(false)} onClear={selection.clear} /> } > {isEmpty ? ( setCreateGroupSheetOpen(true)}> Добавить группу handleOpenCreateService()}>Добавить сервис } /> ) : ( )} { if (!open) setEditingService(null) }} onSave={handleSave} onDelete={handleDelete} /> { setCreateSheetOpen(open) if (!open) setDefaultGroupId(null) }} onCreate={handleCreate} /> createGroupMutation.mutate(body)} /> { if (!open) setEditingGroup(null) }} onSave={(id, body) => updateGroupMutation.mutate({ id, body })} /> { if (!open) setDeletingService(null) }} title="Удалить сервис?" description={ deletingService ? `Сервис «${deletingService.name}» и его привязки будут удалены.` : '' } onConfirm={() => { if (deletingService) handleDelete(deletingService.id) }} disabled={deleteServiceMutation.isPending} /> { if (!open) setDeletingGroup(null) }} title="Удалить группу?" description={ deletingGroup ? `Группа «${deletingGroup.name}» будет удалена. Сервисы останутся без группы.` : '' } onConfirm={() => { if (deletingGroup) handleDeleteGroup(deletingGroup.id) }} disabled={deleteGroupMutation.isPending || deletingGroupId === deletingGroup?.id} /> ) }