mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 08:36:37 +08:00
fix
This commit is contained in:
@@ -143,30 +143,40 @@ dev:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
# Merge local feat into canary, push canary, then restore the previous branch.
|
# Merge the current local branch into canary, push canary, then restore it.
|
||||||
# Does not leave the working tree on canary when started from another branch.
|
# Dirty worktrees are auto-stashed and restored after the operation.
|
||||||
canary:
|
canary:
|
||||||
@set -e; \
|
@set -e; \
|
||||||
if ! git rev-parse --git-dir >/dev/null 2>&1; then \
|
if ! git rev-parse --git-dir >/dev/null 2>&1; then \
|
||||||
echo "Error: not a git repository"; exit 1; \
|
echo "Error: not a git repository"; exit 1; \
|
||||||
fi; \
|
fi; \
|
||||||
if [ -n "$$(git status --porcelain)" ]; then \
|
|
||||||
echo "Error: working tree is dirty; commit or stash first"; exit 1; \
|
|
||||||
fi; \
|
|
||||||
orig=$$(git rev-parse --abbrev-ref HEAD); \
|
orig=$$(git rev-parse --abbrev-ref HEAD); \
|
||||||
if [ "$$orig" = "HEAD" ]; then \
|
if [ "$$orig" = "HEAD" ]; then \
|
||||||
echo "Error: detached HEAD; checkout a branch first"; exit 1; \
|
echo "Error: detached HEAD; checkout a branch first"; exit 1; \
|
||||||
fi; \
|
fi; \
|
||||||
|
if [ "$$orig" = "canary" ]; then \
|
||||||
|
echo "Error: already on canary; checkout a source branch first"; exit 1; \
|
||||||
|
fi; \
|
||||||
|
stashed=0; \
|
||||||
|
if [ -n "$$(git status --porcelain)" ]; then \
|
||||||
|
echo "Working tree is dirty; stashing local changes..."; \
|
||||||
|
git stash push -u -m "make canary auto-stash from $$orig"; \
|
||||||
|
stashed=1; \
|
||||||
|
fi; \
|
||||||
cleanup() { \
|
cleanup() { \
|
||||||
cur=$$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true); \
|
cur=$$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true); \
|
||||||
if [ "$$cur" != "$$orig" ]; then \
|
if [ "$$cur" != "$$orig" ]; then \
|
||||||
echo "Restoring branch $$orig..."; \
|
echo "Restoring branch $$orig..."; \
|
||||||
git checkout -q "$$orig"; \
|
git checkout -q "$$orig"; \
|
||||||
fi; \
|
fi; \
|
||||||
|
if [ "$$stashed" = "1" ]; then \
|
||||||
|
echo "Restoring stashed local changes..."; \
|
||||||
|
git stash pop; \
|
||||||
|
fi; \
|
||||||
}; \
|
}; \
|
||||||
trap cleanup EXIT; \
|
trap cleanup EXIT; \
|
||||||
echo "Merging feat -> canary (started from $$orig)..."; \
|
echo "Merging $$orig -> canary..."; \
|
||||||
git checkout canary; \
|
git checkout canary; \
|
||||||
git merge --no-edit feat; \
|
git merge --no-edit "$$orig"; \
|
||||||
git push -u origin canary; \
|
git push -u origin canary; \
|
||||||
echo "Done: feat merged into canary and pushed."
|
echo "Done: $$orig merged into canary and pushed."
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Cloud,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Settings,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
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,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
import { getErrorMessage } from '../../../websites/components/website-utils';
|
||||||
|
import { GroupDialog } from '../../components/group-dialog';
|
||||||
|
import { MemberAddDialog } from '../../components/member-add-dialog';
|
||||||
|
|
||||||
|
export function CloudflareGroupDetailPageClient() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const groupID = Number(params.id);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [addOpen, setAddOpen] = 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 invalidate = async () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
|
||||||
|
|
||||||
|
const updateGroupMutation = useMutation({
|
||||||
|
mutationFn: (payload: CloudflareGroupPayload) =>
|
||||||
|
CloudflareService.updateGroup(groupID, payload),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('分组配置已更新');
|
||||||
|
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
|
||||||
|
? '域名已加入并排队同步'
|
||||||
|
: `已添加 ${succeeded} 个域名并排队同步`,
|
||||||
|
);
|
||||||
|
} else if (succeeded === 0) {
|
||||||
|
toast.error(`添加失败:${failed} 个域名未能加入`);
|
||||||
|
} else {
|
||||||
|
toast.warning(`部分成功:${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('橙云设置已更新并排队同步');
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
const syncMutation = useMutation({
|
||||||
|
mutationFn: (memberID: number) =>
|
||||||
|
CloudflareService.syncMember(groupID, memberID),
|
||||||
|
onSuccess: () => toast.success('成员同步任务已入队'),
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: (memberID: number) =>
|
||||||
|
CloudflareService.removeMember(groupID, memberID),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('成员及远端 A 记录已删除');
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detailQuery.isLoading)
|
||||||
|
return (
|
||||||
|
<div className='w-full py-6 px-1'>
|
||||||
|
<LoadingStateWithBorder icon={Cloud} description='加载分组详情中...' />
|
||||||
|
</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;
|
||||||
|
|
||||||
|
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' />
|
||||||
|
返回列表
|
||||||
|
</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' />
|
||||||
|
)}
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings data-icon='inline-start' />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button size='sm' onClick={() => setAddOpen(true)}>
|
||||||
|
<Plus data-icon='inline-start' />
|
||||||
|
添加域名
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className='border-dashed shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='text-base'>当前指向</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
生效节点 {group.active_node.name} · {group.active_node.ip}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='flex flex-wrap gap-2'>
|
||||||
|
<Badge variant={group.enabled ? 'default' : 'secondary'}>
|
||||||
|
{group.enabled ? '同步已启用' : '同步已停用'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant='outline'>主节点 {group.primary_node.name}</Badge>
|
||||||
|
<Badge variant='outline'>
|
||||||
|
备用节点 {group.backup_node?.name ?? '未设置'}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className='border-dashed shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='text-base'>域名成员</CardTitle>
|
||||||
|
<CardDescription>成员级橙云是同步时的唯一依据。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<p className='py-8 text-center text-sm text-muted-foreground'>
|
||||||
|
暂无域名成员。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>域名</TableHead>
|
||||||
|
<TableHead>期望 IP</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>橙云</TableHead>
|
||||||
|
<TableHead className='text-right'>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{members.map((member) => (
|
||||||
|
<TableRow key={member.id}>
|
||||||
|
<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 || '待同步'}</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={() => syncMutation.mutate(member.id)}
|
||||||
|
>
|
||||||
|
<RefreshCw data-icon='inline-start' />
|
||||||
|
同步
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => removeMutation.mutate(member.id)}
|
||||||
|
>
|
||||||
|
<Trash2 data-icon='inline-start' />
|
||||||
|
移出
|
||||||
|
</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 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,335 +1,28 @@
|
|||||||
'use client';
|
import { Suspense } from 'react';
|
||||||
|
import { Cloud } from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Cloud,
|
|
||||||
Loader2,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Settings,
|
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
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 { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||||
import {
|
|
||||||
CloudflareService,
|
|
||||||
cloudflareQueryKey,
|
|
||||||
NodeService,
|
|
||||||
type CloudflareGroupPayload,
|
|
||||||
} from '@/lib/services/openflare';
|
|
||||||
import { getErrorMessage } from '../../../websites/components/website-utils';
|
|
||||||
import { GroupDialog } from '../../components/group-dialog';
|
|
||||||
import { MemberAddDialog } from '../../components/member-add-dialog';
|
|
||||||
|
|
||||||
export default function CloudflareGroupDetailPage() {
|
import { CloudflareGroupDetailPageClient } from './page-client';
|
||||||
const params = useParams<{ id: string }>();
|
|
||||||
const groupID = Number(params.id);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
|
||||||
const [addOpen, setAddOpen] = 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 invalidate = async () =>
|
|
||||||
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
|
|
||||||
|
|
||||||
const updateGroupMutation = useMutation({
|
/** Placeholder for static export; real ids resolve client-side via useParams. */
|
||||||
mutationFn: (payload: CloudflareGroupPayload) =>
|
export async function generateStaticParams() {
|
||||||
CloudflareService.updateGroup(groupID, payload),
|
return [{ id: '1' }];
|
||||||
onSuccess: async () => {
|
}
|
||||||
toast.success('分组配置已更新');
|
|
||||||
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
|
|
||||||
? '域名已加入并排队同步'
|
|
||||||
: `已添加 ${succeeded} 个域名并排队同步`,
|
|
||||||
);
|
|
||||||
} else if (succeeded === 0) {
|
|
||||||
toast.error(`添加失败:${failed} 个域名未能加入`);
|
|
||||||
} else {
|
|
||||||
toast.warning(`部分成功:${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('橙云设置已更新并排队同步');
|
|
||||||
await invalidate();
|
|
||||||
},
|
|
||||||
onError: (error) => toast.error(getErrorMessage(error)),
|
|
||||||
});
|
|
||||||
const syncMutation = useMutation({
|
|
||||||
mutationFn: (memberID: number) =>
|
|
||||||
CloudflareService.syncMember(groupID, memberID),
|
|
||||||
onSuccess: () => toast.success('成员同步任务已入队'),
|
|
||||||
onError: (error) => toast.error(getErrorMessage(error)),
|
|
||||||
});
|
|
||||||
const removeMutation = useMutation({
|
|
||||||
mutationFn: (memberID: number) =>
|
|
||||||
CloudflareService.removeMember(groupID, memberID),
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success('成员及远端 A 记录已删除');
|
|
||||||
await invalidate();
|
|
||||||
},
|
|
||||||
onError: (error) => toast.error(getErrorMessage(error)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (detailQuery.isLoading)
|
|
||||||
return (
|
|
||||||
<div className='w-full py-6 px-1'>
|
|
||||||
<LoadingStateWithBorder icon={Cloud} description='加载分组详情中...' />
|
|
||||||
</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;
|
|
||||||
|
|
||||||
|
export default async function CloudflareGroupDetailPage() {
|
||||||
return (
|
return (
|
||||||
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
<Suspense
|
||||||
<div className='flex flex-col gap-4'>
|
fallback={
|
||||||
<Button variant='ghost' size='sm' className='self-start' asChild>
|
<div className='w-full py-6 px-1'>
|
||||||
<Link href='/cloudflare'>
|
<LoadingStateWithBorder
|
||||||
<ArrowLeft data-icon='inline-start' />
|
icon={Cloud}
|
||||||
返回列表
|
description='加载分组详情中...'
|
||||||
</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' />
|
|
||||||
)}
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
onClick={() => setEditOpen(true)}
|
|
||||||
>
|
|
||||||
<Settings data-icon='inline-start' />
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button size='sm' onClick={() => setAddOpen(true)}>
|
|
||||||
<Plus data-icon='inline-start' />
|
|
||||||
添加域名
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
<Card className='border-dashed shadow-none'>
|
<CloudflareGroupDetailPageClient />
|
||||||
<CardHeader>
|
</Suspense>
|
||||||
<CardTitle className='text-base'>当前指向</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
生效节点 {group.active_node.name} · {group.active_node.ip}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className='flex flex-wrap gap-2'>
|
|
||||||
<Badge variant={group.enabled ? 'default' : 'secondary'}>
|
|
||||||
{group.enabled ? '同步已启用' : '同步已停用'}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant='outline'>主节点 {group.primary_node.name}</Badge>
|
|
||||||
<Badge variant='outline'>
|
|
||||||
备用节点 {group.backup_node?.name ?? '未设置'}
|
|
||||||
</Badge>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className='border-dashed shadow-none'>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className='text-base'>域名成员</CardTitle>
|
|
||||||
<CardDescription>成员级橙云是同步时的唯一依据。</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{members.length === 0 ? (
|
|
||||||
<p className='py-8 text-center text-sm text-muted-foreground'>
|
|
||||||
暂无域名成员。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>域名</TableHead>
|
|
||||||
<TableHead>期望 IP</TableHead>
|
|
||||||
<TableHead>状态</TableHead>
|
|
||||||
<TableHead>橙云</TableHead>
|
|
||||||
<TableHead className='text-right'>操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{members.map((member) => (
|
|
||||||
<TableRow key={member.id}>
|
|
||||||
<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 || '待同步'}</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={() => syncMutation.mutate(member.id)}
|
|
||||||
>
|
|
||||||
<RefreshCw data-icon='inline-start' />
|
|
||||||
同步
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='destructive'
|
|
||||||
size='sm'
|
|
||||||
onClick={() => removeMutation.mutate(member.id)}
|
|
||||||
>
|
|
||||||
<Trash2 data-icon='inline-start' />
|
|
||||||
移出
|
|
||||||
</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 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,8 +90,8 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20.19.24",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.6",
|
"eslint-config-next": "15.5.6",
|
||||||
|
|||||||
Generated
+2
-2
@@ -232,10 +232,10 @@ importers:
|
|||||||
specifier: ^14.6.1
|
specifier: ^14.6.1
|
||||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20
|
specifier: ^20.19.24
|
||||||
version: 20.19.24
|
version: 20.19.24
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19
|
specifier: ^19.2.2
|
||||||
version: 19.2.2
|
version: 19.2.2
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19
|
specifier: ^19
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import CloudflareGroupDetailPage from '@/app/(main)/cloudflare/groups/[id]/page';
|
import { CloudflareGroupDetailPageClient } from '@/app/(main)/cloudflare/groups/[id]/page-client';
|
||||||
import { CloudflareService, NodeService } from '@/lib/services/openflare';
|
import { CloudflareService, NodeService } from '@/lib/services/openflare';
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({
|
vi.mock('next/navigation', () => ({
|
||||||
@@ -40,7 +40,7 @@ function renderPage() {
|
|||||||
});
|
});
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<CloudflareGroupDetailPage />
|
<CloudflareGroupDetailPageClient />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe('Cloudflare sync tasks panel', () => {
|
|||||||
|
|
||||||
it('queries only sync_member and sync_group and hides other tasks', async () => {
|
it('queries only sync_member and sync_group and hides other tasks', async () => {
|
||||||
vi.mocked(AdminTaskService.listTaskExecutions).mockImplementation(
|
vi.mocked(AdminTaskService.listTaskExecutions).mockImplementation(
|
||||||
async (request) => {
|
async (request = {}) => {
|
||||||
if (request.task_type === 'cloudflare:sync_member') {
|
if (request.task_type === 'cloudflare:sync_member') {
|
||||||
return {
|
return {
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
Reference in New Issue
Block a user