mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 23:06:36 +08:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user