mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 01:36:37 +08:00
578 lines
19 KiB
TypeScript
578 lines
19 KiB
TypeScript
'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<Set<number>>(
|
|
() => new Set(),
|
|
);
|
|
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
|
|
const [movingMembers, setMovingMembers] = useState<CloudflareMember[]>([]);
|
|
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 (
|
|
<div className='w-full py-6 px-1'>
|
|
<LoadingStateWithBorder icon={Cloud} description={t('loadingDetail')} />
|
|
</div>
|
|
);
|
|
if (detailQuery.isError || !detailQuery.data)
|
|
return (
|
|
<div className='w-full py-6 px-1'>
|
|
<ErrorInline
|
|
message={getErrorMessage(detailQuery.error)}
|
|
onRetry={() => void detailQuery.refetch()}
|
|
/>
|
|
</div>
|
|
);
|
|
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 (
|
|
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
|
<div className='flex flex-col gap-4'>
|
|
<Button variant='ghost' size='sm' className='self-start' asChild>
|
|
<Link href='/cloudflare'>
|
|
<ArrowLeft data-icon='inline-start' />
|
|
{t('back')}
|
|
</Link>
|
|
</Button>
|
|
<div className='flex items-center justify-between gap-3'>
|
|
<div className='flex items-center gap-2'>
|
|
<Cloud className='size-5 text-primary' />
|
|
<h1 className='text-2xl font-semibold tracking-tight'>
|
|
{group.name}
|
|
</h1>
|
|
</div>
|
|
<div className='flex items-center gap-2'>
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => void detailQuery.refetch()}
|
|
disabled={detailQuery.isFetching}
|
|
>
|
|
{detailQuery.isFetching ? (
|
|
<Loader2 data-icon='inline-start' className='animate-spin' />
|
|
) : (
|
|
<RefreshCw data-icon='inline-start' />
|
|
)}
|
|
{t('refresh')}
|
|
</Button>
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => setEditOpen(true)}
|
|
>
|
|
<Settings data-icon='inline-start' />
|
|
{t('edit')}
|
|
</Button>
|
|
<Button size='sm' onClick={() => setAddOpen(true)}>
|
|
<Plus data-icon='inline-start' />
|
|
{t('addDomain')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Card className='border-dashed shadow-none'>
|
|
<CardHeader>
|
|
<CardTitle className='text-base'>{t('currentPoint')}</CardTitle>
|
|
<CardDescription>
|
|
{t('activeNode', {
|
|
name: group.active_node.name,
|
|
ip: group.active_node.ip,
|
|
})}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className='flex flex-wrap gap-2'>
|
|
<Badge variant={group.enabled ? 'default' : 'secondary'}>
|
|
{group.enabled ? t('syncEnabled') : t('syncDisabled')}
|
|
</Badge>
|
|
<Badge variant='outline'>
|
|
{t('primaryNode', { name: group.primary_node.name })}
|
|
</Badge>
|
|
<Badge variant='outline'>
|
|
{t('backupNode', {
|
|
name: group.backup_node?.name ?? t('backupUnset'),
|
|
})}
|
|
</Badge>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className='border-dashed shadow-none'>
|
|
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
|
<div>
|
|
<CardTitle className='text-base'>{t('members')}</CardTitle>
|
|
<CardDescription>{t('membersDesc')}</CardDescription>
|
|
</div>
|
|
{selectedMembers.length > 0 && (
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<Badge variant='secondary' className='text-xs'>
|
|
{t('selectedCount', { count: selectedMembers.length })}
|
|
</Badge>
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => handleBatchMove(selectedMembers)}
|
|
>
|
|
<ArrowRightLeft data-icon='inline-start' />
|
|
{t('batchMove')}
|
|
</Button>
|
|
<Button
|
|
variant='destructive'
|
|
size='sm'
|
|
onClick={() => setBatchRemoveOpen(true)}
|
|
>
|
|
<Trash2 data-icon='inline-start' />
|
|
{t('batchRemove')}
|
|
</Button>
|
|
<Button
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={() => setSelectedMemberIDs(new Set())}
|
|
>
|
|
{t('clearSelection')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
{members.length === 0 ? (
|
|
<p className='py-8 text-center text-sm text-muted-foreground'>
|
|
{t('noMembers')}
|
|
</p>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className='w-12'>
|
|
<Checkbox
|
|
checked={
|
|
allSelected
|
|
? true
|
|
: isIndeterminate
|
|
? 'indeterminate'
|
|
: false
|
|
}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setSelectedMemberIDs(
|
|
new Set(members.map((m) => m.id)),
|
|
);
|
|
} else {
|
|
setSelectedMemberIDs(new Set());
|
|
}
|
|
}}
|
|
aria-label='Select all'
|
|
/>
|
|
</TableHead>
|
|
<TableHead>{t('columns.domain')}</TableHead>
|
|
<TableHead>{t('columns.desiredIp')}</TableHead>
|
|
<TableHead>{t('columns.status')}</TableHead>
|
|
<TableHead>{t('columns.proxied')}</TableHead>
|
|
<TableHead className='text-right'>
|
|
{t('columns.actions')}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{members.map((member) => (
|
|
<TableRow
|
|
key={member.id}
|
|
data-state={
|
|
selectedMemberIDs.has(member.id) ? 'selected' : undefined
|
|
}
|
|
>
|
|
<TableCell className='w-12'>
|
|
<Checkbox
|
|
checked={selectedMemberIDs.has(member.id)}
|
|
onCheckedChange={(checked) => {
|
|
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}`}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className='font-medium'>{member.domain}</div>
|
|
{member.last_error ? (
|
|
<p className='max-w-md text-xs text-destructive'>
|
|
{member.last_error}
|
|
</p>
|
|
) : null}
|
|
</TableCell>
|
|
<TableCell>
|
|
{member.desired_ip || t('pendingSync')}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={
|
|
member.sync_status === 'ok'
|
|
? 'default'
|
|
: member.sync_status === 'error'
|
|
? 'destructive'
|
|
: 'secondary'
|
|
}
|
|
>
|
|
{member.sync_status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Switch
|
|
checked={member.proxied}
|
|
onCheckedChange={(proxied) =>
|
|
proxiedMutation.mutate({
|
|
memberID: member.id,
|
|
proxied,
|
|
})
|
|
}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className='flex justify-end gap-2'>
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => handleSingleMove(member)}
|
|
>
|
|
<ArrowRightLeft data-icon='inline-start' />
|
|
{t('move')}
|
|
</Button>
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => syncMutation.mutate(member.id)}
|
|
>
|
|
<RefreshCw data-icon='inline-start' />
|
|
{t('sync')}
|
|
</Button>
|
|
<Button
|
|
variant='destructive'
|
|
size='sm'
|
|
onClick={() => removeMutation.mutate(member.id)}
|
|
>
|
|
<Trash2 data-icon='inline-start' />
|
|
{t('remove')}
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<GroupDialog
|
|
open={editOpen}
|
|
onOpenChange={setEditOpen}
|
|
group={group}
|
|
nodes={nodesQuery.data ?? []}
|
|
pending={updateGroupMutation.isPending}
|
|
onSubmit={(payload) => updateGroupMutation.mutate(payload)}
|
|
/>
|
|
<MemberAddDialog
|
|
open={addOpen}
|
|
onOpenChange={setAddOpen}
|
|
domains={domainsQuery.data ?? []}
|
|
defaultProxied={group.default_proxied}
|
|
pending={addMutation.isPending}
|
|
onSubmit={(domainIDs, proxied) =>
|
|
addMutation.mutate({ domainIDs, proxied })
|
|
}
|
|
/>
|
|
<MemberMoveDialog
|
|
open={moveDialogOpen}
|
|
onOpenChange={setMoveDialogOpen}
|
|
members={movingMembers}
|
|
groups={groupsQuery.data ?? []}
|
|
currentGroupId={groupID}
|
|
pending={moveMutation.isPending}
|
|
onSubmit={(targetGroupId) =>
|
|
moveMutation.mutate({
|
|
targetGroupId,
|
|
membersToMove: movingMembers,
|
|
})
|
|
}
|
|
/>
|
|
<AlertDialog open={batchRemoveOpen} onOpenChange={setBatchRemoveOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t('batchRemoveDialog.title')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t('batchRemoveDialog.desc', { count: selectedMembers.length })}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
disabled={batchRemoveMutation.isPending}
|
|
onClick={() =>
|
|
batchRemoveMutation.mutate(selectedMembers.map((m) => m.id))
|
|
}
|
|
>
|
|
{batchRemoveMutation.isPending
|
|
? t('batchRemoveDialog.removing')
|
|
: t('batchRemoveDialog.confirm')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|