feat(cloudflare): add DNS pointing integration

This commit is contained in:
ryan
2026-08-04 13:30:21 +08:00
parent ea7658815a
commit 4eced2b721
22 changed files with 1834 additions and 376 deletions
+191 -52
View File
@@ -1,10 +1,31 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, Cloud, FolderCog, Settings } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
Cloud,
Plus,
RefreshCw,
Settings,
Trash2,
} from 'lucide-react';
import Link from 'next/link';
import { useState } from 'react';
import { toast } from 'sonner';
import { GroupDialog } from '@/app/(main)/cloudflare/components/group-dialog';
import { SyncTasksPanel } from '@/app/(main)/cloudflare/components/sync-tasks-panel';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
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 {
@@ -14,19 +35,68 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
CloudflareService,
cloudflareQueryKey,
NodeService,
type CloudflareGroup,
type CloudflareGroupPayload,
} from '@/lib/services/openflare';
import { getErrorMessage } from '../websites/components/website-utils';
export default function CloudflarePage() {
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CloudflareGroup | null>(
null,
);
const overviewQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'overview'],
queryFn: () => CloudflareService.getOverview(),
});
const groupsQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'groups'],
queryFn: () => CloudflareService.listGroups(),
});
const nodesQuery = useQuery({
queryKey: ['openflare', 'nodes'],
queryFn: () => NodeService.listNodes(),
});
const invalidate = async () =>
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
const createMutation = useMutation({
mutationFn: (payload: CloudflareGroupPayload) =>
CloudflareService.createGroup(payload),
onSuccess: async () => {
toast.success('指向分组已创建');
setCreateOpen(false);
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const syncMutation = useMutation({
mutationFn: (id: number) => CloudflareService.syncGroup(id),
onSuccess: async () => {
toast.success('分组同步任务已入队');
await queryClient.invalidateQueries({
queryKey: [...cloudflareQueryKey, 'sync-executions'],
});
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => CloudflareService.deleteGroup(id),
onSuccess: async () => {
toast.success('指向分组及远端记录已删除');
setDeleteTarget(null);
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const overview = overviewQuery.data;
@@ -44,11 +114,9 @@ export default function CloudflarePage() {
连接设置
</Link>
</Button>
<Button asChild size='sm'>
<Link href='/cloudflare/groups'>
<FolderCog data-icon='inline-start' />
指向分组
</Link>
<Button size='sm' onClick={() => setCreateOpen(true)}>
<Plus data-icon='inline-start' />
新增分组
</Button>
</div>
</div>
@@ -59,14 +127,10 @@ export default function CloudflarePage() {
description='加载 Cloudflare 总览中...'
/>
) : overviewQuery.isError ? (
<Card>
<CardContent className='pt-6'>
<ErrorInline
message={getErrorMessage(overviewQuery.error)}
onRetry={() => void overviewQuery.refetch()}
/>
</CardContent>
</Card>
<ErrorInline
message={getErrorMessage(overviewQuery.error)}
onRetry={() => void overviewQuery.refetch()}
/>
) : !overview?.connection.ready ? (
<Alert>
<AlertTriangle />
@@ -81,46 +145,121 @@ export default function CloudflarePage() {
</Button>
</AlertDescription>
</Alert>
) : (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-5'>
{[
['指向分组', overview.group_count],
['域名成员', overview.member_count],
['同步正常', overview.ok_count],
['等待同步', overview.pending_count],
['同步错误', overview.error_count],
].map(([label, value]) => (
<Card key={label}>
<CardHeader className='pb-2'>
<CardDescription>{label}</CardDescription>
<CardTitle className='text-2xl'>{value}</CardTitle>
</CardHeader>
</Card>
))}
</div>
)}
) : null}
<Card>
<CardHeader>
<CardTitle>同步边界</CardTitle>
<CardDescription>
OpenFlare 数据库是本模块的期望状态来源。
</CardDescription>
</CardHeader>
<CardContent className='flex flex-col gap-3 text-sm text-muted-foreground'>
<p>
同步会覆盖本模块接管的同名 A 记录;如果 Cloudflare 中存在多条同名
A,需先手动清理。
<div className='flex flex-col gap-4'>
<div>
<h2 className='text-lg font-semibold'>指向分组</h2>
<p className='text-sm text-muted-foreground'>
管理 Cloudflare A 记录对应的节点与域名成员。
</p>
<p>成员移出或分组删除时,默认同时删除本模块管理的远端 A 记录。</p>
<div>
<Badge variant='secondary'>一期限制</Badge>
<span className='ml-2'>
一期不提供自动故障切换,备用节点仅保存配置。
</span>
</div>
{groupsQuery.isLoading ? (
<LoadingStateWithBorder
icon={Cloud}
description='加载指向分组中...'
/>
) : groupsQuery.isError ? (
<ErrorInline
message={getErrorMessage(groupsQuery.error)}
onRetry={() => void groupsQuery.refetch()}
/>
) : (groupsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder
icon={Cloud}
title='暂无指向分组'
description='创建分组后,可将域名成员同步到指定的边缘节点。'
actionText='新增分组'
onAction={() => setCreateOpen(true)}
/>
) : (
<div className='grid gap-4 lg:grid-cols-2'>
{(groupsQuery.data ?? []).map((group) => (
<Card key={group.id} className='border-dashed shadow-none'>
<CardHeader>
<div className='flex items-start justify-between gap-3'>
<div>
<CardTitle className='text-base'>{group.name}</CardTitle>
<CardDescription>
{group.active_node.name} · {group.active_node.ip}
</CardDescription>
</div>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '已启用' : '已停用'}
</Badge>
</div>
</CardHeader>
<CardContent className='flex flex-col gap-4'>
<p className='text-sm text-muted-foreground'>
成员 {group.member_count} 个 · 新成员默认
{group.default_proxied ? '开启' : '关闭'}橙云
</p>
<div className='flex flex-wrap gap-2'>
<Button asChild size='sm'>
<Link href={`/cloudflare/groups/${group.id}`}>
<Settings data-icon='inline-start' />
管理
</Link>
</Button>
<Button
variant='outline'
size='sm'
onClick={() => syncMutation.mutate(group.id)}
>
<RefreshCw data-icon='inline-start' />
同步
</Button>
<Button
variant='destructive'
size='sm'
onClick={() => setDeleteTarget(group)}
>
<Trash2 data-icon='inline-start' />
删除
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
)}
</div>
<SyncTasksPanel />
<GroupDialog
open={createOpen}
onOpenChange={setCreateOpen}
nodes={nodesQuery.data ?? []}
pending={createMutation.isPending}
onSubmit={(payload) => createMutation.mutate(payload)}
/>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除指向分组</AlertDialogTitle>
<AlertDialogDescription>
将删除 {deleteTarget?.name} 的全部成员及本模块管理的远端 A
记录。此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}