feat(cloudflare): add DNS pointing integration

Implement Cloudflare connection management, pointing groups and members, asynchronous A-record reconciliation, node IP triggers, admin APIs, management pages, migrations, tests, and documentation.
This commit is contained in:
ryan
2026-08-04 12:32:37 +08:00
parent 99f0f63b99
commit 3edcdb9e9f
47 changed files with 7019 additions and 49 deletions
@@ -0,0 +1,166 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import type {
CloudflareGroup,
CloudflareGroupPayload,
NodeItem,
} from '@/lib/services/openflare';
export function GroupDialog({
open,
onOpenChange,
group,
nodes,
pending,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
group?: CloudflareGroup | null;
nodes: NodeItem[];
pending: boolean;
onSubmit: (payload: CloudflareGroupPayload) => void;
}) {
const edgeNodes = useMemo(
() => nodes.filter((node) => node.node_type === 'edge_node'),
[nodes],
);
const [name, setName] = useState('');
const [primaryNodeID, setPrimaryNodeID] = useState('');
const [backupNodeID, setBackupNodeID] = useState('none');
const [defaultProxied, setDefaultProxied] = useState(true);
const [enabled, setEnabled] = useState(true);
useEffect(() => {
if (!open) return;
setName(group?.name ?? '');
setPrimaryNodeID(group ? String(group.primary_node.id) : '');
setBackupNodeID(group?.backup_node ? String(group.backup_node.id) : 'none');
setDefaultProxied(group?.default_proxied ?? true);
setEnabled(group?.enabled ?? true);
}, [group, open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{group ? '编辑指向分组' : '新增指向分组'}</DialogTitle>
<DialogDescription>
一期使用主节点作为生效节点;备用节点仅保存,不会自动切换。
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor='cf-group-name'>分组名称</FieldLabel>
<Input
id='cf-group-name'
value={name}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor='cf-primary-node'>主节点</FieldLabel>
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
<SelectTrigger id='cf-primary-node' className='w-full'>
<SelectValue placeholder='选择带 IPv4 的边缘节点' />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{edgeNodes.map((node) => (
<SelectItem
key={node.id}
value={String(node.id)}
disabled={!node.ip}
>
{node.name} · {node.ip || '未配置 IP'}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-backup-node'>备用节点</FieldLabel>
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
<SelectTrigger id='cf-backup-node' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='none'>不设置</SelectItem>
{edgeNodes
.filter((node) => String(node.id) !== primaryNodeID)
.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name} · {node.ip || '未配置 IP'}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-default-proxied'>
新成员默认开启橙云
</FieldLabel>
<Switch
id='cf-default-proxied'
checked={defaultProxied}
onCheckedChange={setDefaultProxied}
/>
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-enabled'>启用同步</FieldLabel>
<Switch
id='cf-enabled'
checked={enabled}
onCheckedChange={setEnabled}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
disabled={pending || !name.trim() || !primaryNodeID}
onClick={() =>
onSubmit({
name: name.trim(),
primary_node_id: Number(primaryNodeID),
backup_node_id:
backupNodeID === 'none' ? null : Number(backupNodeID),
default_proxied: defaultProxied,
enabled,
})
}
>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,100 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import type { CloudflareAvailableDomain } from '@/lib/services/openflare';
export function MemberAddDialog({
open,
onOpenChange,
domains,
defaultProxied,
pending,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
domains: CloudflareAvailableDomain[];
defaultProxied: boolean;
pending: boolean;
onSubmit: (zoneDomainID: number, proxied: boolean) => void;
}) {
const [domainID, setDomainID] = useState('');
const [proxied, setProxied] = useState(defaultProxied);
useEffect(() => {
if (!open) return;
setDomainID('');
setProxied(defaultProxied);
}, [defaultProxied, open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>添加域名成员</DialogTitle>
<DialogDescription>
加入后会创建或接管唯一同名 A 记录;多条同名 A 会拒绝同步。
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor='cf-domain'>Zone 域名</FieldLabel>
<Select value={domainID} onValueChange={setDomainID}>
<SelectTrigger id='cf-domain' className='w-full'>
<SelectValue placeholder='选择可用域名' />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{domains.map((domain) => (
<SelectItem key={domain.id} value={String(domain.id)}>
{domain.domain}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-member-proxied'>开启橙云代理</FieldLabel>
<Switch
id='cf-member-proxied'
checked={proxied}
onCheckedChange={setProxied}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
disabled={pending || !domainID}
onClick={() => onSubmit(Number(domainID), proxied)}
>
添加并同步
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,295 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Cloud, 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 {
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() {
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,
});
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: ({
domainID,
proxied,
}: {
domainID: number;
proxied: boolean;
}) =>
CloudflareService.createMember(groupID, {
zone_domain_id: domainID,
proxied,
}),
onSuccess: async () => {
toast.success('域名已加入并排队同步');
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 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 asChild variant='outline' size='sm'>
<Link href='/cloudflare/groups'>返回分组</Link>
</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>
<Card>
<CardHeader>
<CardTitle>当前指向</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>
<Alert>
<Cloud />
<AlertTitle>远端记录所有权</AlertTitle>
<AlertDescription>
移出成员会立即删除本模块缓存或唯一同名 A 记录;存在多条同名 A
时会拒绝操作并要求先手动清理。
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>域名成员</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={(domainID, proxied) =>
addMutation.mutate({ domainID, proxied })
}
/>
</div>
);
}
@@ -0,0 +1,201 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Cloud, Plus, RefreshCw, Settings, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { 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 { 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';
import { GroupDialog } from '../components/group-dialog';
export default function CloudflareGroupsPage() {
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CloudflareGroup | null>(
null,
);
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: () => toast.success('分组同步任务已入队'),
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)),
});
return (
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<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'>
Cloudflare 指向分组
</h1>
</div>
<div className='flex items-center gap-2'>
<Button asChild variant='outline' size='sm'>
<Link href='/cloudflare'>返回总览</Link>
</Button>
<Button size='sm' onClick={() => setCreateOpen(true)}>
<Plus data-icon='inline-start' />
新增分组
</Button>
</div>
</div>
{groupsQuery.isLoading ? (
<LoadingStateWithBorder icon={Cloud} description='加载指向分组中...' />
) : groupsQuery.isError ? (
<ErrorInline
message={getErrorMessage(groupsQuery.error)}
onRetry={() => void groupsQuery.refetch()}
/>
) : (groupsQuery.data ?? []).length === 0 ? (
<Card>
<CardContent className='py-12 text-center text-sm text-muted-foreground'>
暂无指向分组。
</CardContent>
</Card>
) : (
<div className='grid gap-4 lg:grid-cols-2'>
{(groupsQuery.data ?? []).map((group) => (
<Card key={group.id}>
<CardHeader>
<div className='flex items-start justify-between gap-3'>
<div>
<CardTitle>{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>
)}
<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>
);
}
+126
View File
@@ -0,0 +1,126 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, Cloud, FolderCog, Settings } from 'lucide-react';
import Link from 'next/link';
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 { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
CloudflareService,
cloudflareQueryKey,
} from '@/lib/services/openflare';
import { getErrorMessage } from '../websites/components/website-utils';
export default function CloudflarePage() {
const overviewQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'overview'],
queryFn: () => CloudflareService.getOverview(),
});
const overview = overviewQuery.data;
return (
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<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'>Cloudflare</h1>
</div>
<div className='flex items-center gap-2'>
<Button asChild variant='outline' size='sm'>
<Link href='/cloudflare/settings'>
<Settings data-icon='inline-start' />
连接设置
</Link>
</Button>
<Button asChild size='sm'>
<Link href='/cloudflare/groups'>
<FolderCog data-icon='inline-start' />
指向分组
</Link>
</Button>
</div>
</div>
{overviewQuery.isLoading ? (
<LoadingStateWithBorder
icon={Cloud}
description='加载 Cloudflare 总览中...'
/>
) : overviewQuery.isError ? (
<Card>
<CardContent className='pt-6'>
<ErrorInline
message={getErrorMessage(overviewQuery.error)}
onRetry={() => void overviewQuery.refetch()}
/>
</CardContent>
</Card>
) : !overview?.connection.ready ? (
<Alert>
<AlertTriangle />
<AlertTitle>Cloudflare 连接尚未就绪</AlertTitle>
<AlertDescription className='flex flex-col items-start gap-3'>
<span>
请先导入现有 Cloudflare DNS 账号,或录入独立 API Token
并完成连接测试。
</span>
<Button asChild size='sm'>
<Link href='/cloudflare/settings'>配置连接</Link>
</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>
)}
<Card>
<CardHeader>
<CardTitle>同步边界</CardTitle>
<CardDescription>
OpenFlare 数据库是本模块的期望状态来源。
</CardDescription>
</CardHeader>
<CardContent className='flex flex-col gap-3 text-sm text-muted-foreground'>
<p>
同步会覆盖本模块接管的同名 A 记录;如果 Cloudflare 中存在多条同名
A,需先手动清理。
</p>
<p>成员移出或分组删除时,默认同时删除本模块管理的远端 A 记录。</p>
<div>
<Badge variant='secondary'>一期限制</Badge>
<span className='ml-2'>
一期不提供自动故障切换,备用节点仅保存配置。
</span>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,257 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Cloud, Save, ShieldCheck, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ErrorInline } from '@/components/layout/error';
import {
CloudflareService,
cloudflareQueryKey,
DnsAccountService,
type CloudflareConnectionSource,
} from '@/lib/services/openflare';
import { getErrorMessage } from '../../websites/components/website-utils';
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'] as const;
export default function CloudflareSettingsPage() {
const queryClient = useQueryClient();
const [source, setSource] =
useState<CloudflareConnectionSource>('dns_account');
const [dnsAccountID, setDNSAccountID] = useState('');
const [apiToken, setAPIToken] = useState('');
const connectionQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'connection'],
queryFn: () => CloudflareService.getConnection(),
});
const accountsQuery = useQuery({
queryKey: dnsAccountsQueryKey,
queryFn: () => DnsAccountService.list(),
});
const cloudflareAccounts = useMemo(
() =>
(accountsQuery.data ?? []).filter(
(account) => account.type === 'cloudflare',
),
[accountsQuery.data],
);
useEffect(() => {
const connection = connectionQuery.data;
if (!connection?.configured) return;
if (connection.source) setSource(connection.source);
if (connection.dns_account_id)
setDNSAccountID(String(connection.dns_account_id));
}, [connectionQuery.data]);
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
};
const saveMutation = useMutation({
mutationFn: () =>
CloudflareService.saveConnection({
source,
dns_account_id: source === 'dns_account' ? Number(dnsAccountID) : 0,
api_token: source === 'standalone' ? apiToken : '',
}),
onSuccess: async () => {
toast.success('Cloudflare 连接配置已保存');
setAPIToken('');
await refresh();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const verifyMutation = useMutation({
mutationFn: () => CloudflareService.verifyConnection(),
onSuccess: async () => {
toast.success('Cloudflare 连接验证成功');
await refresh();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const clearMutation = useMutation({
mutationFn: () => CloudflareService.clearConnection(),
onSuccess: async () => {
toast.success('Cloudflare 连接已清除');
setDNSAccountID('');
setAPIToken('');
await refresh();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
return (
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<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'>
Cloudflare 连接设置
</h1>
</div>
<Button asChild variant='outline' size='sm'>
<Link href='/cloudflare'>返回总览</Link>
</Button>
</div>
{connectionQuery.isError ? (
<ErrorInline
message={getErrorMessage(connectionQuery.error)}
onRetry={() => void connectionQuery.refetch()}
/>
) : null}
<Card>
<CardHeader>
<CardTitle>凭据来源</CardTitle>
<CardDescription>
Token 建议授予 Zone:Read 与 DNS:Edit 权限;Token 不会在 API
或页面中回显。
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel htmlFor='cf-source'>连接来源</FieldLabel>
<Select
value={source}
onValueChange={(value) =>
setSource(value as CloudflareConnectionSource)
}
>
<SelectTrigger id='cf-source' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='dns_account'>
导入现有 DNS 账号
</SelectItem>
<SelectItem value='standalone'>独立 API Token</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
{source === 'dns_account' ? (
<Field>
<FieldLabel htmlFor='cf-dns-account'>
Cloudflare DNS 账号
</FieldLabel>
<Select value={dnsAccountID} onValueChange={setDNSAccountID}>
<SelectTrigger id='cf-dns-account' className='w-full'>
<SelectValue placeholder='选择 DNS 账号' />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{cloudflareAccounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>
仅显示类型为 cloudflare 的 DNS 账号。可前往{' '}
<Link href='/dns-accounts'>DNS 账号</Link> 新增。
</FieldDescription>
</Field>
) : (
<Field>
<FieldLabel htmlFor='cf-api-token'>API Token</FieldLabel>
<Input
id='cf-api-token'
type='password'
autoComplete='new-password'
value={apiToken}
onChange={(event) => setAPIToken(event.target.value)}
placeholder={
connectionQuery.data?.configured
? '留空不会回显现有 Token;保存将替换'
: '输入 Cloudflare API Token'
}
/>
</Field>
)}
<div className='flex flex-wrap items-center gap-2'>
<Button
onClick={() => saveMutation.mutate()}
disabled={
saveMutation.isPending ||
(source === 'dns_account' ? !dnsAccountID : !apiToken.trim())
}
>
<Save data-icon='inline-start' />
保存配置
</Button>
<Button
variant='outline'
onClick={() => verifyMutation.mutate()}
disabled={
!connectionQuery.data?.configured || verifyMutation.isPending
}
>
<ShieldCheck data-icon='inline-start' />
测试连接
</Button>
<Button
variant='destructive'
onClick={() => clearMutation.mutate()}
disabled={
!connectionQuery.data?.configured || clearMutation.isPending
}
>
<Trash2 data-icon='inline-start' />
清除连接
</Button>
</div>
</FieldGroup>
</CardContent>
</Card>
<Alert>
<ShieldCheck />
<AlertTitle>当前状态</AlertTitle>
<AlertDescription>
{connectionQuery.data?.ready
? 'Token 已验证,Cloudflare 指向同步可以执行。'
: '保存配置后仍需测试连接;未验证状态下同步任务会被拒绝。'}
</AlertDescription>
</Alert>
</div>
);
}
+6
View File
@@ -54,6 +54,11 @@ export const openflareWebsiteNavGroup: OpenFlareNavGroup = {
{ title: '域名列表', url: '/websites', childUrls: ['/websites/detail'] },
{ title: 'TLS证书', url: '/certificates' },
{ title: 'DNS账号', url: '/dns-accounts' },
{
title: 'Cloudflare',
url: '/cloudflare',
childUrls: ['/cloudflare/settings', '/cloudflare/groups'],
},
{ title: '源站地址', url: '/origins', childUrls: ['/origins/detail'] },
],
};
@@ -109,6 +114,7 @@ export const openflareWebsiteSubNav = [
{ title: '网站列表', url: '/websites' },
{ title: '证书', url: '/certificates' },
{ title: 'DNS 账号', url: '/dns-accounts' },
{ title: 'Cloudflare', url: '/cloudflare' },
] as const;
const nonConsoleRoutePrefixes = [
@@ -0,0 +1,105 @@
import { OpenFlareBaseService } from './base.service';
import type {
CloudflareAvailableDomain,
CloudflareConnection,
CloudflareConnectionPayload,
CloudflareGroup,
CloudflareGroupDetail,
CloudflareGroupPayload,
CloudflareMember,
CloudflareMemberCreatePayload,
CloudflareOverview,
CloudflareSyncReceipt,
} from './types';
export const cloudflareQueryKey = ['openflare', 'cloudflare'] as const;
export class CloudflareService extends OpenFlareBaseService {
protected static override readonly basePath = '/api/v1/d/cloudflare';
static getConnection(): Promise<CloudflareConnection> {
return this.get<CloudflareConnection>('/connection');
}
static saveConnection(
payload: CloudflareConnectionPayload,
): Promise<CloudflareConnection> {
return this.put<CloudflareConnection>('/connection', payload);
}
static verifyConnection(): Promise<CloudflareConnection> {
return this.post<CloudflareConnection>('/connection/verify');
}
static clearConnection(): Promise<void> {
return this.post<void>('/connection/clear');
}
static getOverview(): Promise<CloudflareOverview> {
return this.get<CloudflareOverview>('/overview');
}
static listGroups(): Promise<CloudflareGroup[]> {
return this.get<CloudflareGroup[]>('/groups');
}
static getGroup(id: number): Promise<CloudflareGroupDetail> {
return this.get<CloudflareGroupDetail>(`/groups/${id}`);
}
static createGroup(
payload: CloudflareGroupPayload,
): Promise<CloudflareGroup> {
return this.post<CloudflareGroup>('/groups', payload);
}
static updateGroup(
id: number,
payload: CloudflareGroupPayload,
): Promise<CloudflareGroup> {
return this.post<CloudflareGroup>(`/groups/${id}/update`, payload);
}
static deleteGroup(id: number): Promise<void> {
return this.post<void>(`/groups/${id}/delete`);
}
static syncGroup(id: number): Promise<CloudflareSyncReceipt> {
return this.post<CloudflareSyncReceipt>(`/groups/${id}/sync`);
}
static listAvailableDomains(): Promise<CloudflareAvailableDomain[]> {
return this.get<CloudflareAvailableDomain[]>('/domains/available');
}
static createMember(
groupId: number,
payload: CloudflareMemberCreatePayload,
): Promise<CloudflareMember> {
return this.post<CloudflareMember>(`/groups/${groupId}/members`, payload);
}
static updateMember(
groupId: number,
memberId: number,
proxied: boolean,
): Promise<CloudflareMember> {
return this.post<CloudflareMember>(
`/groups/${groupId}/members/${memberId}/update`,
{ proxied },
);
}
static removeMember(groupId: number, memberId: number): Promise<void> {
return this.post<void>(`/groups/${groupId}/members/${memberId}/remove`);
}
static syncMember(
groupId: number,
memberId: number,
): Promise<CloudflareSyncReceipt> {
return this.post<CloudflareSyncReceipt>(
`/groups/${groupId}/members/${memberId}/sync`,
);
}
}
+19
View File
@@ -15,6 +15,10 @@ export { AccessLogService } from './access-log.service';
export { OptionService } from './option.service';
export { UptimeKumaService } from './uptimekuma.service';
export { StatusService } from './status.service';
export {
CloudflareService,
cloudflareQueryKey,
} from './cloudflare.service';
export type {
ApplyLogCleanupPayload,
@@ -143,6 +147,19 @@ export type {
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
CloudflareAvailableDomain,
CloudflareConnection,
CloudflareConnectionPayload,
CloudflareConnectionSource,
CloudflareGroup,
CloudflareGroupDetail,
CloudflareGroupPayload,
CloudflareMember,
CloudflareMemberCreatePayload,
CloudflareNodeOption,
CloudflareOverview,
CloudflareSyncReceipt,
CloudflareSyncStatus,
} from './types';
import { AccessLogService } from './access-log.service';
@@ -160,6 +177,7 @@ import { ProxyRouteService } from './proxy-route.service';
import { TlsCertificateService } from './tls-certificate.service';
import { WafService } from './waf.service';
import { ZoneDomainService, ZoneService } from './zone.service';
import { CloudflareService } from './cloudflare.service';
export const openflareServices = {
node: NodeService,
@@ -178,4 +196,5 @@ export const openflareServices = {
option: OptionService,
uptimeKuma: UptimeKumaService,
status: StatusService,
cloudflare: CloudflareService,
} as const;
+87
View File
@@ -1347,6 +1347,93 @@ export interface ZoneStats {
series: ZoneStatsPoint[];
}
export type CloudflareConnectionSource = 'dns_account' | 'standalone';
export type CloudflareSyncStatus = 'pending' | 'syncing' | 'ok' | 'error';
export interface CloudflareConnection {
configured: boolean;
ready: boolean;
source: CloudflareConnectionSource | '';
dns_account_id: number | null;
status: string;
verified_at: string | null;
}
export interface CloudflareConnectionPayload {
source: CloudflareConnectionSource;
dns_account_id: number;
api_token: string;
}
export interface CloudflareNodeOption {
id: number;
name: string;
ip: string;
}
export interface CloudflareGroup {
id: number;
name: string;
primary_node: CloudflareNodeOption;
backup_node: CloudflareNodeOption | null;
active_node: CloudflareNodeOption;
default_proxied: boolean;
enabled: boolean;
member_count: number;
created_at: string;
updated_at: string;
}
export interface CloudflareGroupPayload {
name: string;
primary_node_id: number;
backup_node_id: number | null;
default_proxied: boolean;
enabled: boolean;
}
export interface CloudflareMember {
id: number;
group_id: number;
zone_domain_id: number;
domain: string;
zone_id: number;
proxied: boolean;
desired_ip: string;
sync_status: CloudflareSyncStatus;
last_error: string;
synced_at: string | null;
}
export interface CloudflareMemberCreatePayload {
zone_domain_id: number;
proxied?: boolean;
}
export interface CloudflareGroupDetail {
group: CloudflareGroup;
members: CloudflareMember[];
}
export interface CloudflareAvailableDomain {
id: number;
zone_id: number;
domain: string;
}
export interface CloudflareOverview {
connection: CloudflareConnection;
group_count: number;
member_count: number;
ok_count: number;
pending_count: number;
error_count: number;
}
export interface CloudflareSyncReceipt {
task_id: string;
}
export interface TlsCertificateItem {
id: number;
name: string;
@@ -0,0 +1,52 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { createElement } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import CloudflarePage from '@/app/(main)/cloudflare/page';
import { CloudflareService } from '@/lib/services/openflare';
vi.mock('@/lib/services/openflare', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/lib/services/openflare')>();
return {
...actual,
CloudflareService: { ...actual.CloudflareService, getOverview: vi.fn() },
};
});
describe('Cloudflare overview', () => {
beforeEach(() => {
vi.mocked(CloudflareService.getOverview).mockResolvedValue({
connection: {
configured: false,
ready: false,
source: '',
dns_account_id: null,
status: '',
verified_at: null,
},
group_count: 0,
member_count: 0,
ok_count: 0,
pending_count: 0,
error_count: 0,
});
});
it('shows the token readiness gate and phase-one limitation', async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
render(
createElement(
QueryClientProvider,
{ client },
createElement(CloudflarePage),
),
);
expect(await screen.findByText('Cloudflare 连接尚未就绪')).toBeVisible();
expect(screen.getByText(/一期不提供自动故障切换/)).toBeVisible();
});
});
@@ -0,0 +1,58 @@
import type { AxiosResponse } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import apiClient from '@/lib/services/core/api-client';
import { CloudflareService } from '@/lib/services/openflare/cloudflare.service';
vi.mock('@/lib/services/core/api-client', () => ({
default: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
}));
function response<T>(data: T) {
return {
data: { error_msg: '', data },
status: 200,
statusText: 'OK',
headers: {},
config: { headers: {} },
} as AxiosResponse;
}
describe('CloudflareService', () => {
beforeEach(() => {
vi.mocked(apiClient.get).mockReset();
vi.mocked(apiClient.post).mockReset();
vi.mocked(apiClient.put).mockReset();
});
it('uses the Cloudflare management API paths', async () => {
vi.mocked(apiClient.get).mockResolvedValue(response([]));
vi.mocked(apiClient.post).mockResolvedValue(
response({ task_id: 'task-1' }),
);
vi.mocked(apiClient.put).mockResolvedValue(response({ configured: true }));
await CloudflareService.saveConnection({
source: 'standalone',
dns_account_id: 0,
api_token: 'secret',
});
await CloudflareService.listGroups();
await CloudflareService.syncMember(7, 9);
expect(apiClient.put).toHaveBeenCalledWith(
'/api/v1/d/cloudflare/connection',
expect.objectContaining({ source: 'standalone' }),
undefined,
);
expect(apiClient.get).toHaveBeenCalledWith(
'/api/v1/d/cloudflare/groups',
expect.objectContaining({ params: undefined }),
);
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/cloudflare/groups/7/members/9/sync',
undefined,
undefined,
);
});
});