'use client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowLeft, ArrowRightLeft, Cloud, Loader2, Plus, RefreshCw, Settings, Trash2, } from 'lucide-react'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { usePathname } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Switch } from '@/components/ui/switch'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; import { CloudflareService, cloudflareQueryKey, NodeService, type CloudflareGroupPayload, type CloudflareMember, } from '@/lib/services/openflare'; import { getErrorMessage } from '../../../websites/components/website-utils'; import { GroupDialog } from '../../components/group-dialog'; import { MemberAddDialog } from '../../components/member-add-dialog'; import { MemberMoveDialog } from '../../components/member-move-dialog'; function getGroupIdFromPathname(pathname: string | null): number { const match = pathname?.match(/^\/cloudflare\/groups\/([^/]+)$/); return Number(match?.[1]); } export function CloudflareGroupDetailPageClient() { const t = useTranslations('cloudflare'); const tCommon = useTranslations('common'); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); const pathname = usePathname(); const groupID = useMemo(() => getGroupIdFromPathname(pathname), [pathname]); const queryClient = useQueryClient(); const [editOpen, setEditOpen] = useState(false); const [addOpen, setAddOpen] = useState(false); const [selectedMemberIDs, setSelectedMemberIDs] = useState>( () => new Set(), ); const [moveDialogOpen, setMoveDialogOpen] = useState(false); const [movingMembers, setMovingMembers] = useState([]); const [batchRemoveOpen, setBatchRemoveOpen] = useState(false); const detailQuery = useQuery({ queryKey: [...cloudflareQueryKey, 'groups', groupID], queryFn: () => CloudflareService.getGroup(groupID), enabled: Number.isInteger(groupID) && groupID > 0, refetchInterval: 5000, }); const domainsQuery = useQuery({ queryKey: [...cloudflareQueryKey, 'domains', 'available'], queryFn: () => CloudflareService.listAvailableDomains(), }); const nodesQuery = useQuery({ queryKey: ['openflare', 'nodes'], queryFn: () => NodeService.listNodes(), }); const groupsQuery = useQuery({ queryKey: [...cloudflareQueryKey, 'groups'], queryFn: () => CloudflareService.listGroups(), }); const invalidate = async () => queryClient.invalidateQueries({ queryKey: cloudflareQueryKey }); const updateGroupMutation = useMutation({ mutationFn: (payload: CloudflareGroupPayload) => CloudflareService.updateGroup(groupID, payload), onSuccess: async () => { toast.success(t('groupUpdated')); setEditOpen(false); await invalidate(); }, onError: (error) => toast.error(getErrorMessage(error)), }); const addMutation = useMutation({ mutationFn: async ({ domainIDs, proxied, }: { domainIDs: number[]; proxied: boolean; }) => { const results = await Promise.allSettled( domainIDs.map((domainID) => CloudflareService.createMember(groupID, { zone_domain_id: domainID, proxied, }), ), ); const failed = results.filter((r) => r.status === 'rejected'); const succeeded = results.length - failed.length; return { succeeded, failed: failed.length, total: results.length }; }, onSuccess: async ({ succeeded, failed, total }) => { if (failed === 0) { toast.success( total === 1 ? t('memberAddedOne') : t('memberAddedMany', { count: succeeded }), ); } else if (succeeded === 0) { toast.error(t('memberAddFailed', { count: failed })); } else { toast.warning(t('memberPartial', { succeeded, failed })); } setAddOpen(false); await invalidate(); }, onError: (error) => toast.error(getErrorMessage(error)), }); const proxiedMutation = useMutation({ mutationFn: ({ memberID, proxied, }: { memberID: number; proxied: boolean; }) => CloudflareService.updateMember(groupID, memberID, proxied), onSuccess: async () => { toast.success(t('proxyUpdated')); await invalidate(); }, onError: (error) => toast.error(getErrorMessage(error)), }); const syncMutation = useMutation({ mutationFn: (memberID: number) => CloudflareService.syncMember(groupID, memberID), onSuccess: () => toast.success(t('memberSyncQueued')), onError: (error) => toast.error(getErrorMessage(error)), }); const removeMutation = useMutation({ mutationFn: (memberID: number) => CloudflareService.removeMember(groupID, memberID), onSuccess: async (_, memberID) => { toast.success(t('memberDeleted')); setSelectedMemberIDs((prev) => { const next = new Set(prev); next.delete(memberID); return next; }); await invalidate(); }, onError: (error) => toast.error(getErrorMessage(error)), }); const moveMutation = useMutation({ mutationFn: async ({ targetGroupId, membersToMove, }: { targetGroupId: number; membersToMove: CloudflareMember[]; }) => { if (membersToMove.length === 1) { return CloudflareService.moveMember( groupID, membersToMove[0].id, targetGroupId, ); } return CloudflareService.batchMoveMembers( groupID, membersToMove.map((m) => m.id), targetGroupId, ); }, onSuccess: async (_, variables) => { if (variables.membersToMove.length === 1) { toast.success(t('memberMoved')); } else { toast.success( t('batchMoved', { count: variables.membersToMove.length }), ); } setMoveDialogOpen(false); setSelectedMemberIDs((prev) => { const next = new Set(prev); for (const m of variables.membersToMove) { next.delete(m.id); } return next; }); await invalidate(); }, onError: (error) => toast.error(getErrorMessage(error)), }); const batchRemoveMutation = useMutation({ mutationFn: (memberIds: number[]) => CloudflareService.batchRemoveMembers(groupID, memberIds), onSuccess: async (_, memberIds) => { toast.success(t('batchRemoved', { count: memberIds.length })); setBatchRemoveOpen(false); setSelectedMemberIDs(new Set()); await invalidate(); }, onError: (error) => toast.error(getErrorMessage(error)), }); const handleSingleMove = (member: CloudflareMember) => { setMovingMembers([member]); setMoveDialogOpen(true); }; const handleBatchMove = (selectedList: CloudflareMember[]) => { if (selectedList.length === 0) return; setMovingMembers(selectedList); setMoveDialogOpen(true); }; if (!mounted || detailQuery.isLoading) return (
); if (detailQuery.isError || !detailQuery.data) return (
void detailQuery.refetch()} />
); const { group, members } = detailQuery.data; const selectedMembers = members.filter((m) => selectedMemberIDs.has(m.id)); const allSelected = members.length > 0 && selectedMembers.length === members.length; const isIndeterminate = selectedMembers.length > 0 && selectedMembers.length < members.length; return (

{group.name}

{t('currentPoint')} {t('activeNode', { name: group.active_node.name, ip: group.active_node.ip, })} {group.enabled ? t('syncEnabled') : t('syncDisabled')} {t('primaryNode', { name: group.primary_node.name })} {t('backupNode', { name: group.backup_node?.name ?? t('backupUnset'), })}
{t('members')} {t('membersDesc')}
{selectedMembers.length > 0 && (
{t('selectedCount', { count: selectedMembers.length })}
)}
{members.length === 0 ? (

{t('noMembers')}

) : ( { if (checked) { setSelectedMemberIDs( new Set(members.map((m) => m.id)), ); } else { setSelectedMemberIDs(new Set()); } }} aria-label='Select all' /> {t('columns.domain')} {t('columns.desiredIp')} {t('columns.status')} {t('columns.proxied')} {t('columns.actions')} {members.map((member) => ( { setSelectedMemberIDs((prev) => { const next = new Set(prev); if (checked) { next.add(member.id); } else { next.delete(member.id); } return next; }); }} aria-label={`Select ${member.domain}`} />
{member.domain}
{member.last_error ? (

{member.last_error}

) : null}
{member.desired_ip || t('pendingSync')} {member.sync_status} proxiedMutation.mutate({ memberID: member.id, proxied, }) } />
))}
)}
updateGroupMutation.mutate(payload)} /> addMutation.mutate({ domainIDs, proxied }) } /> moveMutation.mutate({ targetGroupId, membersToMove: movingMembers, }) } /> {t('batchRemoveDialog.title')} {t('batchRemoveDialog.desc', { count: selectedMembers.length })} {tCommon('cancel')} batchRemoveMutation.mutate(selectedMembers.map((m) => m.id)) } > {batchRemoveMutation.isPending ? t('batchRemoveDialog.removing') : t('batchRemoveDialog.confirm')}
); }