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
+5
View File
@@ -28,6 +28,11 @@ sidebar: false
### 改进
- Cloudflare 分组添加域名成员时支持按顶级域分层展示、搜索筛选与批量勾选,可一次加入多个域名并排队同步。
- Cloudflare 首页展示域名同步(sync_member)与分组同步(sync_group)任务执行记录,可筛选状态、查看详情与失败重试。
- Cloudflare 域名/分组同步任务日志补充域名、分组、生效节点 IP、橙云状态及逐域名进度等关键信息,便于排查同步结果。
- Cloudflare 域名同步与分组同步任务改为可在任务管理中调度的标准任务类型,并提供成员 ID / 分组 ID 参数表单。
- Cloudflare 首页直接提供指向分组管理,并为分组详情增加自动刷新与手动刷新,减少页面跳转并及时展示同步状态。
- 统一数据访问分层:业务持久化经 `internal/repository`,`internal/model` 仅保留实体与无 IO 领域规则,避免双轨 CRUD 与职责混淆。
- 构建检查增加 `internal/model` 禁止直接访问数据库/Redis 的架构守卫,并收敛 model 与 repository 的错误文案定义边界。
@@ -1,8 +1,16 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Check, ChevronDown, Search } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Dialog,
DialogContent,
@@ -12,17 +20,43 @@ import {
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 { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import type { CloudflareAvailableDomain } from '@/lib/services/openflare';
type DomainZoneGroup = {
zoneId: number;
zoneDomain: string;
domains: CloudflareAvailableDomain[];
};
function groupDomainsByZone(
domains: CloudflareAvailableDomain[],
): DomainZoneGroup[] {
const map = new Map<number, DomainZoneGroup>();
for (const domain of domains) {
const existing = map.get(domain.zone_id);
if (existing) {
existing.domains.push(domain);
continue;
}
map.set(domain.zone_id, {
zoneId: domain.zone_id,
zoneDomain: domain.zone_domain || `Zone #${domain.zone_id}`,
domains: [domain],
});
}
return [...map.values()]
.map((group) => ({
...group,
domains: [...group.domains].sort((a, b) =>
a.domain.localeCompare(b.domain),
),
}))
.sort((a, b) => a.zoneDomain.localeCompare(b.zoneDomain));
}
export function MemberAddDialog({
open,
onOpenChange,
@@ -36,62 +70,315 @@ export function MemberAddDialog({
domains: CloudflareAvailableDomain[];
defaultProxied: boolean;
pending: boolean;
onSubmit: (zoneDomainID: number, proxied: boolean) => void;
onSubmit: (zoneDomainIDs: number[], proxied: boolean) => void;
}) {
const [domainID, setDomainID] = useState('');
const [keyword, setKeyword] = useState('');
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
const [proxied, setProxied] = useState(defaultProxied);
const [collapsedZones, setCollapsedZones] = useState<Set<number>>(
() => new Set(),
);
useEffect(() => {
if (!open) return;
setDomainID('');
setKeyword('');
setSelectedIDs([]);
setProxied(defaultProxied);
setCollapsedZones(new Set());
}, [defaultProxied, open]);
const selected = useMemo(() => new Set(selectedIDs), [selectedIDs]);
const filteredGroups = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
const list = normalized
? domains.filter((domain) => {
const zoneRoot = domain.zone_domain ?? '';
return (
domain.domain.toLowerCase().includes(normalized) ||
zoneRoot.toLowerCase().includes(normalized) ||
String(domain.id).includes(normalized)
);
})
: domains;
return groupDomainsByZone(list);
}, [domains, keyword]);
const visibleIDs = useMemo(
() => filteredGroups.flatMap((group) => group.domains.map((d) => d.id)),
[filteredGroups],
);
const allVisibleSelected =
visibleIDs.length > 0 && visibleIDs.every((id) => selected.has(id));
const someVisibleSelected =
visibleIDs.some((id) => selected.has(id)) && !allVisibleSelected;
const toggleOne = (id: number) => {
setSelectedIDs((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
);
};
const toggleGroup = (group: DomainZoneGroup) => {
const ids = group.domains.map((d) => d.id);
const allSelected = ids.every((id) => selected.has(id));
setSelectedIDs((prev) => {
if (allSelected) {
return prev.filter((id) => !ids.includes(id));
}
const next = new Set(prev);
for (const id of ids) next.add(id);
return [...next];
});
};
const toggleAllVisible = () => {
setSelectedIDs((prev) => {
if (allVisibleSelected) {
return prev.filter((id) => !visibleIDs.includes(id));
}
const next = new Set(prev);
for (const id of visibleIDs) next.add(id);
return [...next];
});
};
const toggleCollapsed = (zoneId: number) => {
setCollapsedZones((prev) => {
const next = new Set(prev);
if (next.has(zoneId)) next.delete(zoneId);
else next.add(zoneId);
return next;
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>添加域名成员</DialogTitle>
<DialogDescription>
加入后会创建或接管唯一同名 A 记录;多条同名 A 会拒绝同步。
支持搜索筛选与批量勾选;加入后会创建或接管唯一同名 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>
<FieldLabel htmlFor='cf-domain-search'>Zone 域名</FieldLabel>
<div className='space-y-2'>
<div className='relative'>
<Search className='pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground' />
<Input
id='cf-domain-search'
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder='搜索域名或顶级域…'
className='pl-8'
disabled={pending}
/>
</div>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Badge variant='secondary' className='font-normal'>
已选 {selectedIDs.length}
</Badge>
<span>可见 {visibleIDs.length}</span>
</div>
<div className='flex items-center gap-1'>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
disabled={pending || visibleIDs.length === 0}
onClick={toggleAllVisible}
>
{allVisibleSelected ? '取消全选' : '全选可见'}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
disabled={pending || selectedIDs.length === 0}
onClick={() => setSelectedIDs([])}
>
清空
</Button>
</div>
</div>
{domains.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
暂无可用域名(均已加入分组,或尚未在网站管理中添加)。
</div>
) : filteredGroups.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
没有匹配的域名
</div>
) : (
<div className='max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2'>
{/* Header row: select all visible */}
<label className='flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-muted/50'>
<Checkbox
checked={
allVisibleSelected
? true
: someVisibleSelected
? 'indeterminate'
: false
}
disabled={pending}
onCheckedChange={toggleAllVisible}
/>
<span>按顶级域分组</span>
</label>
{filteredGroups.map((group) => {
const groupIDs = group.domains.map((d) => d.id);
const groupAllSelected = groupIDs.every((id) =>
selected.has(id),
);
const groupSomeSelected =
groupIDs.some((id) => selected.has(id)) &&
!groupAllSelected;
const open = !collapsedZones.has(group.zoneId);
return (
<Collapsible
key={group.zoneId}
open={open}
onOpenChange={() => toggleCollapsed(group.zoneId)}
>
<div
className={cn(
'rounded-md',
(groupAllSelected || groupSomeSelected) &&
'bg-muted/30',
)}
>
<div className='flex items-center gap-1 px-1 py-0.5'>
<Checkbox
checked={
groupAllSelected
? true
: groupSomeSelected
? 'indeterminate'
: false
}
disabled={pending}
onCheckedChange={() => toggleGroup(group)}
aria-label={`选择顶级域 ${group.zoneDomain}`}
className='ml-1'
/>
<CollapsibleTrigger asChild>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-sm font-medium hover:bg-muted/60'
>
<ChevronDown
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform',
!open && '-rotate-90',
)}
/>
<span className='truncate'>
{group.zoneDomain}
</span>
<Badge
variant='outline'
className='ml-auto shrink-0 font-normal text-[10px]'
>
{
groupIDs.filter((id) => selected.has(id))
.length
}
/{group.domains.length}
</Badge>
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className='ml-4 space-y-0.5 border-l border-border/70 py-0.5 pl-2'>
{group.domains.map((domain) => {
const checked = selected.has(domain.id);
const isApex =
domain.domain === group.zoneDomain;
return (
<label
key={domain.id}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/60',
checked && 'bg-muted/40',
)}
>
<Checkbox
checked={checked}
disabled={pending}
onCheckedChange={() =>
toggleOne(domain.id)
}
/>
<span className='min-w-0 flex-1 truncate text-sm'>
{domain.domain}
</span>
{isApex ? (
<Badge
variant='secondary'
className='shrink-0 text-[10px] font-normal'
>
顶级域
</Badge>
) : null}
{checked ? (
<Check className='size-3.5 shrink-0 text-primary' />
) : null}
</label>
);
})}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
})}
</div>
)}
</div>
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-member-proxied'>开启橙云代理</FieldLabel>
<Switch
id='cf-member-proxied'
checked={proxied}
disabled={pending}
onCheckedChange={setProxied}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
<Button
variant='outline'
disabled={pending}
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button
disabled={pending || !domainID}
onClick={() => onSubmit(Number(domainID), proxied)}
disabled={pending || selectedIDs.length === 0}
onClick={() =>
onSubmit(
[...selectedIDs].sort((a, b) => a - b),
proxied,
)
}
>
添加并同步
{selectedIDs.length > 1
? `添加并同步(${selectedIDs.length})`
: '添加并同步'}
</Button>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,492 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import {
Activity,
ChevronLeft,
ChevronRight,
RefreshCw,
RotateCcw,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Spinner } from '@/components/ui/spinner';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { AdminTaskService } from '@/lib/services/admin';
import type {
ListTaskExecutionsResponse,
TaskExecution,
TaskExecutionStatus,
} from '@/lib/services/admin';
import { cloudflareQueryKey } from '@/lib/services/openflare';
/**
* Only domain-member and group sync jobs.
* Uses exact asynq task_type filters (compatible with existing admin API).
* Excludes cloudflare:sync_by_node and any unrelated scheduled system tasks.
*/
export const CLOUDFLARE_SYNC_TASK_TYPES = [
'cloudflare:sync_member',
'cloudflare:sync_group',
] as const;
const ALLOWED_TASK_TYPES = new Set<string>(CLOUDFLARE_SYNC_TASK_TYPES);
/** Per-type fetch window; merged and paginated on the client. */
const FETCH_PAGE_SIZE = 50;
const PAGE_SIZE = 10;
async function listCloudflareSyncExecutions(options: {
page: number;
status?: TaskExecutionStatus;
}): Promise<ListTaskExecutionsResponse> {
const status = options.status;
const responses = await Promise.all(
CLOUDFLARE_SYNC_TASK_TYPES.map((taskType) =>
AdminTaskService.listTaskExecutions({
task_type: taskType,
page: 1,
page_size: FETCH_PAGE_SIZE,
status,
}),
),
);
const merged = responses
.flatMap((response) => response.items)
.filter((item) => ALLOWED_TASK_TYPES.has(item.task_type))
.sort((a, b) => {
const timeA = Date.parse(a.created_at) || 0;
const timeB = Date.parse(b.created_at) || 0;
if (timeA !== timeB) return timeB - timeA;
return String(b.id).localeCompare(String(a.id), undefined, {
numeric: true,
});
});
const start = (options.page - 1) * PAGE_SIZE;
return {
items: merged.slice(start, start + PAGE_SIZE),
total: merged.length,
page: options.page,
page_size: PAGE_SIZE,
};
}
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
pending: '等待中',
running: '执行中',
succeeded: '成功',
failed: '失败',
};
const TRIGGER_LABELS: Record<string, string> = {
system: '系统',
manual: '手动',
retry: '重试',
schedule: '定时',
};
function formatDateTime(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return format(date, 'yyyy-MM-dd HH:mm:ss');
}
function formatDuration(duration: number) {
if (!duration) return '-';
if (duration < 1000) return `${duration}ms`;
return `${(duration / 1000).toFixed(2)}s`;
}
function statusVariant(status: TaskExecutionStatus) {
if (status === 'failed') return 'destructive' as const;
if (status === 'succeeded') return 'secondary' as const;
return 'outline' as const;
}
export function SyncTasksPanel() {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [status, setStatus] = useState<TaskExecutionStatus | 'all'>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [preview, setPreview] = useState<TaskExecution | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const executionsQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'sync-executions', page, status],
queryFn: () =>
listCloudflareSyncExecutions({
page,
status: status === 'all' ? undefined : status,
}),
refetchInterval: (query) => {
const items = query.state.data?.items ?? [];
const active = items.some(
(item) => item.status === 'pending' || item.status === 'running',
);
return active ? 3000 : 15000;
},
});
const detailQuery = useQuery({
queryKey: ['admin', 'task-execution', selectedId],
queryFn: () => AdminTaskService.getTaskExecution(selectedId!),
enabled: detailOpen && !!selectedId,
});
const retryMutation = useMutation({
mutationFn: (id: string) => AdminTaskService.retryTaskExecution(id),
onSuccess: (taskID) => {
toast.success('同步任务已重新下发', {
description: `新任务 ID:${taskID}`,
});
void queryClient.invalidateQueries({
queryKey: [...cloudflareQueryKey, 'sync-executions'],
});
},
onError: (err: Error) => {
toast.error('任务重试失败', {
description: err.message || '未知错误',
});
},
});
const executions = executionsQuery.data?.items ?? [];
const total = executionsQuery.data?.total ?? 0;
const loading = executionsQuery.isPending || executionsQuery.isFetching;
const selected = detailQuery.data ?? preview;
const totalPages = useMemo(
() => Math.max(1, Math.ceil(total / PAGE_SIZE)),
[total],
);
const openDetail = (execution: TaskExecution) => {
setPreview(execution);
setSelectedId(execution.id);
setDetailOpen(true);
};
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-base'>
<Activity className='size-4 text-primary' />
同步任务
</CardTitle>
<CardDescription>
仅展示 Cloudflare
域名同步(sync_member)与分组同步(sync_group)的执行记录。
</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-2'>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as TaskExecutionStatus | 'all');
setPage(1);
}}
>
<SelectTrigger size='sm' className='w-[120px]'>
<SelectValue placeholder='状态' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>全部状态</SelectItem>
<SelectItem value='pending'>等待中</SelectItem>
<SelectItem value='running'>执行中</SelectItem>
<SelectItem value='succeeded'>成功</SelectItem>
<SelectItem value='failed'>失败</SelectItem>
</SelectContent>
</Select>
<Button
variant='outline'
size='sm'
onClick={() => void executionsQuery.refetch()}
disabled={loading}
>
{loading ? (
<Spinner className='size-4' />
) : (
<RefreshCw data-icon='inline-start' />
)}
刷新
</Button>
</div>
</CardHeader>
<CardContent className='space-y-4'>
{executionsQuery.isError ? (
<ErrorInline
message={
executionsQuery.error instanceof Error
? executionsQuery.error.message
: '加载同步任务失败'
}
onRetry={() => void executionsQuery.refetch()}
/>
) : loading && executions.length === 0 ? (
<LoadingStateWithBorder
icon={Activity}
description='加载同步任务中...'
/>
) : executions.length === 0 ? (
<EmptyStateWithBorder
icon={Activity}
title='暂无同步任务'
description='添加域名或同步分组后,这里会显示 Cloudflare 域名 / 分组同步执行记录。'
/>
) : (
<div className='rounded-lg border'>
<Table className='min-w-[720px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[200px]'>任务</TableHead>
<TableHead className='w-[100px]'>状态</TableHead>
<TableHead className='w-[100px]'>触发</TableHead>
<TableHead className='w-[100px]'>耗时</TableHead>
<TableHead className='min-w-[180px]'>结果/错误</TableHead>
<TableHead className='w-[160px]'>创建时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{executions.map((execution) => (
<TableRow
key={execution.id}
className='cursor-pointer'
onClick={() => openDetail(execution)}
>
<TableCell>
<div className='flex flex-col gap-1'>
<span className='text-sm font-medium'>
{execution.task_name || execution.task_type}
</span>
<span className='font-mono text-[11px] text-muted-foreground'>
{execution.task_type}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariant(execution.status)}>
{STATUS_LABELS[execution.status] || execution.status}
</Badge>
</TableCell>
<TableCell>
<Badge variant='outline'>
{TRIGGER_LABELS[execution.triggered_by] ||
execution.triggered_by}
</Badge>
</TableCell>
<TableCell className='font-mono text-xs text-muted-foreground'>
{formatDuration(execution.duration)}
</TableCell>
<TableCell className='max-w-[280px] truncate text-xs text-muted-foreground'>
{execution.error_message || execution.result || '-'}
</TableCell>
<TableCell className='font-mono text-[11px] text-muted-foreground'>
{formatDateTime(execution.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className='flex items-center justify-between gap-2'>
<div className='text-xs text-muted-foreground'>
共 {total} 条,第 {page}/{totalPages} 页
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft className='size-4' />
上一页
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
下一页
<ChevronRight className='size-4' />
</Button>
</div>
</div>
</CardContent>
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[560px]'>
<SheetHeader className='border-b'>
<SheetTitle>同步任务详情</SheetTitle>
<SheetDescription>
{selected?.task_name || selected?.task_type || 'Cloudflare 同步'}
</SheetDescription>
</SheetHeader>
<div className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
{detailQuery.isFetching && !selected ? (
<LoadingStateWithBorder
icon={Activity}
description='加载任务详情中...'
/>
) : selected ? (
<>
<div className='grid grid-cols-2 gap-3'>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>状态</div>
<div className='mt-2'>
<Badge variant={statusVariant(selected.status)}>
{STATUS_LABELS[selected.status] || selected.status}
</Badge>
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>触发</div>
<div className='mt-2 text-sm font-medium'>
{TRIGGER_LABELS[selected.triggered_by] ||
selected.triggered_by}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>重试</div>
<div className='mt-2 font-mono text-sm'>
{selected.retry_count}/{selected.max_retry}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>耗时</div>
<div className='mt-2 font-mono text-sm'>
{formatDuration(selected.duration)}
</div>
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>任务 ID</div>
<div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
{selected.task_id}
</div>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
创建时间
</div>
<div className='font-mono text-xs'>
{formatDateTime(selected.created_at)}
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
结束时间
</div>
<div className='font-mono text-xs'>
{formatDateTime(selected.finished_at)}
</div>
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>执行结果</div>
<div className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
{selected.result || '-'}
</div>
</div>
{selected.error_message ? (
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
错误信息
</div>
<div className='rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all'>
{selected.error_message}
</div>
</div>
) : null}
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>Payload</div>
<pre className='max-h-36 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selected.payload || '{}'}
</pre>
</div>
{selected.log ? (
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
执行日志
</div>
<pre className='max-h-48 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selected.log}
</pre>
</div>
) : null}
</>
) : null}
</div>
<SheetFooter className='border-t'>
{selected?.retryable && selected.status === 'failed' ? (
<Button
variant='outline'
disabled={retryMutation.isPending}
onClick={() => retryMutation.mutate(selected.id)}
>
{retryMutation.isPending ? (
<Spinner className='size-4' />
) : (
<RotateCcw data-icon='inline-start' />
)}
重试
</Button>
) : null}
<Button variant='outline' onClick={() => setDetailOpen(false)}>
关闭
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</Card>
);
}
@@ -1,7 +1,15 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Cloud, Plus, RefreshCw, Settings, Trash2 } from 'lucide-react';
import {
ArrowLeft,
Cloud,
Loader2,
Plus,
RefreshCw,
Settings,
Trash2,
} from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useState } from 'react';
@@ -48,6 +56,7 @@ export default function CloudflareGroupDetailPage() {
queryKey: [...cloudflareQueryKey, 'groups', groupID],
queryFn: () => CloudflareService.getGroup(groupID),
enabled: Number.isInteger(groupID) && groupID > 0,
refetchInterval: 5000,
});
const domainsQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'domains', 'available'],
@@ -71,19 +80,37 @@ export default function CloudflareGroupDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)),
});
const addMutation = useMutation({
mutationFn: ({
domainID,
mutationFn: async ({
domainIDs,
proxied,
}: {
domainID: number;
domainIDs: number[];
proxied: boolean;
}) =>
CloudflareService.createMember(groupID, {
zone_domain_id: domainID,
proxied,
}),
onSuccess: async () => {
toast.success('域名已加入并排队同步');
}) => {
const results = await Promise.allSettled(
domainIDs.map((domainID) =>
CloudflareService.createMember(groupID, {
zone_domain_id: domainID,
proxied,
}),
),
);
const failed = results.filter((r) => r.status === 'rejected');
const succeeded = results.length - failed.length;
return { succeeded, failed: failed.length, total: results.length };
},
onSuccess: async ({ succeeded, failed, total }) => {
if (failed === 0) {
toast.success(
total === 1
? '域名已加入并排队同步'
: `已添加 ${succeeded} 个域名并排队同步`,
);
} else if (succeeded === 0) {
toast.error(`添加失败:${failed} 个域名未能加入`);
} else {
toast.warning(`部分成功:${succeeded} 个已加入,${failed} 个失败`);
}
setAddOpen(false);
await invalidate();
},
@@ -138,31 +165,53 @@ export default function CloudflareGroupDetailPage() {
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 className='flex flex-col gap-4'>
<Button variant='ghost' size='sm' className='self-start' asChild>
<Link href='/cloudflare'>
<ArrowLeft data-icon='inline-start' />
返回列表
</Link>
</Button>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{group.name}
</h1>
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => void detailQuery.refetch()}
disabled={detailQuery.isFetching}
>
{detailQuery.isFetching ? (
<Loader2 data-icon='inline-start' className='animate-spin' />
) : (
<RefreshCw data-icon='inline-start' />
)}
刷新
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setEditOpen(true)}
>
<Settings data-icon='inline-start' />
编辑
</Button>
<Button size='sm' onClick={() => setAddOpen(true)}>
<Plus data-icon='inline-start' />
添加域名
</Button>
</div>
</div>
</div>
<Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle>当前指向</CardTitle>
<CardTitle className='text-base'>当前指向</CardTitle>
<CardDescription>
生效节点 {group.active_node.name} · {group.active_node.ip}
</CardDescription>
@@ -178,18 +227,9 @@ export default function CloudflareGroupDetailPage() {
</CardContent>
</Card>
<Alert>
<Cloud />
<AlertTitle>远端记录所有权</AlertTitle>
<AlertDescription>
移出成员会立即删除本模块缓存或唯一同名 A 记录;存在多条同名 A
时会拒绝操作并要求先手动清理。
</AlertDescription>
</Alert>
<Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle>域名成员</CardTitle>
<CardTitle className='text-base'>域名成员</CardTitle>
<CardDescription>成员级橙云是同步时的唯一依据。</CardDescription>
</CardHeader>
<CardContent>
@@ -286,8 +326,8 @@ export default function CloudflareGroupDetailPage() {
domains={domainsQuery.data ?? []}
defaultProxied={group.default_proxied}
pending={addMutation.isPending}
onSubmit={(domainID, proxied) =>
addMutation.mutate({ domainID, proxied })
onSubmit={(domainIDs, proxied) =>
addMutation.mutate({ domainIDs, proxied })
}
/>
</div>
+2 -198
View File
@@ -1,201 +1,5 @@
'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';
import { redirect } from 'next/navigation';
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>
);
redirect('/cloudflare');
}
+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>
);
}
@@ -1,7 +1,7 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Cloud, Save, ShieldCheck, Trash2 } from 'lucide-react';
import { ArrowLeft, Cloud, Save, ShieldCheck, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
@@ -114,16 +114,19 @@ export default function CloudflareSettingsPage() {
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 flex-col gap-4'>
<Button variant='ghost' size='sm' className='self-start' asChild>
<Link href='/cloudflare'>
<ArrowLeft data-icon='inline-start' />
返回列表
</Link>
</Button>
<div className='flex items-center 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 ? (
@@ -133,9 +136,9 @@ export default function CloudflareSettingsPage() {
/>
) : null}
<Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle>凭据来源</CardTitle>
<CardTitle className='text-base'>凭据来源</CardTitle>
<CardDescription>
Token 建议授予 Zone:Read 与 DNS:Edit 权限;Token 不会在 API
或页面中回显。
+1 -1
View File
@@ -57,7 +57,7 @@ export const openflareWebsiteNavGroup: OpenFlareNavGroup = {
{
title: 'Cloudflare',
url: '/cloudflare',
childUrls: ['/cloudflare/settings', '/cloudflare/groups'],
childUrls: ['/cloudflare/settings'],
},
{ title: '源站地址', url: '/origins', childUrls: ['/origins/detail'] },
],
+4
View File
@@ -212,6 +212,10 @@ export interface TaskExecution {
export interface ListTaskExecutionsRequest {
status?: TaskExecutionStatus;
task_type?: string;
/** Prefix match on stored asynq task type (ignored when task_type / task_types is set). */
task_type_prefix?: string;
/** Comma-separated exact asynq task types for IN filter (ignored when task_type is set). */
task_types?: string;
page?: number;
page_size?: number;
}
+2
View File
@@ -1419,6 +1419,8 @@ export interface CloudflareAvailableDomain {
id: number;
zone_id: number;
domain: string;
/** Zone apex / root domain used for hierarchical grouping. */
zone_domain: string;
}
export interface CloudflareOverview {
+1 -1
View File
@@ -4559,4 +4559,4 @@
}
}
}
]
]
@@ -0,0 +1,106 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import CloudflareGroupDetailPage from '@/app/(main)/cloudflare/groups/[id]/page';
import { CloudflareService, NodeService } from '@/lib/services/openflare';
vi.mock('next/navigation', () => ({
useParams: () => ({ id: '7' }),
}));
vi.mock('@/lib/services/openflare', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/lib/services/openflare')>();
return {
...actual,
CloudflareService: {
...actual.CloudflareService,
getGroup: vi.fn(),
listAvailableDomains: vi.fn(),
updateGroup: vi.fn(),
createMember: vi.fn(),
updateMember: vi.fn(),
syncMember: vi.fn(),
removeMember: vi.fn(),
},
NodeService: { ...actual.NodeService, listNodes: vi.fn() },
};
});
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
render(
<QueryClientProvider client={client}>
<CloudflareGroupDetailPage />
</QueryClientProvider>,
);
}
describe('Cloudflare group detail refresh', () => {
beforeEach(() => {
vi.mocked(CloudflareService.getGroup).mockReset();
vi.mocked(CloudflareService.listAvailableDomains).mockReset();
vi.mocked(NodeService.listNodes).mockReset();
vi.mocked(CloudflareService.getGroup).mockResolvedValue({
group: {
id: 7,
name: '生产节点',
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
backup_node: null,
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
default_proxied: true,
enabled: true,
member_count: 0,
created_at: '',
updated_at: '',
},
members: [],
});
vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]);
vi.mocked(NodeService.listNodes).mockResolvedValue([]);
});
afterEach(() => {
vi.useRealTimers();
});
it('refreshes detail data when the refresh button is clicked', async () => {
renderPage();
expect(
await screen.findByRole('heading', { name: '生产节点' }),
).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await waitFor(() => {
expect(CloudflareService.getGroup).toHaveBeenCalledTimes(2);
});
});
it('automatically refreshes detail data every five seconds', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
renderPage();
expect(
await screen.findByRole('heading', { name: '生产节点' }),
).toBeVisible();
expect(CloudflareService.getGroup).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
await waitFor(() => {
expect(CloudflareService.getGroup).toHaveBeenCalledTimes(2);
});
});
});
@@ -4,19 +4,62 @@ 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';
import { AdminTaskService } from '@/lib/services/admin';
import { CloudflareService, NodeService } 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() },
CloudflareService: {
...actual.CloudflareService,
getOverview: vi.fn(),
listGroups: vi.fn(),
createGroup: vi.fn(),
syncGroup: vi.fn(),
deleteGroup: vi.fn(),
},
NodeService: { ...actual.NodeService, listNodes: vi.fn() },
};
});
vi.mock('@/lib/services/admin', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/services/admin')>();
return {
...actual,
AdminTaskService: {
...actual.AdminTaskService,
listTaskExecutions: vi.fn(),
getTaskExecution: vi.fn(),
retryTaskExecution: vi.fn(),
},
};
});
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
render(
createElement(
QueryClientProvider,
{ client },
createElement(CloudflarePage),
),
);
}
describe('Cloudflare overview', () => {
beforeEach(() => {
vi.mocked(CloudflareService.listGroups).mockResolvedValue([]);
vi.mocked(NodeService.listNodes).mockResolvedValue([]);
vi.mocked(AdminTaskService.listTaskExecutions).mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 10,
});
vi.mocked(CloudflareService.getOverview).mockResolvedValue({
connection: {
configured: false,
@@ -34,19 +77,51 @@ describe('Cloudflare overview', () => {
});
});
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),
),
);
it('shows the token readiness gate without the synchronization boundary card', async () => {
renderPage();
expect(await screen.findByText('Cloudflare 连接尚未就绪')).toBeVisible();
expect(screen.getByText(/一期不提供自动故障切换/)).toBeVisible();
expect(screen.queryByText('同步边界')).not.toBeInTheDocument();
});
it('renders pointing groups directly on the overview page', async () => {
vi.mocked(CloudflareService.getOverview).mockResolvedValue({
connection: {
configured: true,
ready: true,
source: 'standalone',
dns_account_id: null,
status: 'ready',
verified_at: null,
},
group_count: 1,
member_count: 2,
ok_count: 1,
pending_count: 1,
error_count: 0,
});
vi.mocked(CloudflareService.listGroups).mockResolvedValue([
{
id: 7,
name: '生产节点',
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
backup_node: null,
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
default_proxied: true,
enabled: true,
member_count: 2,
created_at: '',
updated_at: '',
},
]);
renderPage();
expect(await screen.findByText('生产节点')).toBeVisible();
expect(screen.getByRole('link', { name: '管理' })).toHaveAttribute(
'href',
'/cloudflare/groups/7',
);
expect(await screen.findByText('同步任务')).toBeVisible();
});
});
@@ -0,0 +1,127 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { MemberAddDialog } from '@/app/(main)/cloudflare/components/member-add-dialog';
import type { CloudflareAvailableDomain } from '@/lib/services/openflare';
const domains: CloudflareAvailableDomain[] = [
{
id: 1,
zone_id: 10,
domain: 'example.com',
zone_domain: 'example.com',
},
{
id: 2,
zone_id: 10,
domain: 'www.example.com',
zone_domain: 'example.com',
},
{
id: 3,
zone_id: 10,
domain: 'api.example.com',
zone_domain: 'example.com',
},
{
id: 4,
zone_id: 20,
domain: 'app.other.io',
zone_domain: 'other.io',
},
];
describe('MemberAddDialog advanced domain picker', () => {
it('groups domains under top-level zone roots and supports batch select', () => {
const onSubmit = vi.fn();
render(
<MemberAddDialog
open
onOpenChange={vi.fn()}
domains={domains}
defaultProxied
pending={false}
onSubmit={onSubmit}
/>,
);
expect(screen.getAllByText('example.com').length).toBeGreaterThan(0);
expect(screen.getAllByText('other.io').length).toBeGreaterThan(0);
expect(screen.getByText('www.example.com')).toBeVisible();
expect(screen.getByText('api.example.com')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '全选可见' }));
fireEvent.click(screen.getByRole('button', { name: '添加并同步(4)' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const [ids, proxied] = onSubmit.mock.calls[0] as [number[], boolean];
expect([...ids].sort((a, b) => a - b)).toEqual([1, 2, 3, 4]);
expect(proxied).toBe(true);
});
it('filters by keyword across domain and zone root', () => {
render(
<MemberAddDialog
open
onOpenChange={vi.fn()}
domains={domains}
defaultProxied={false}
pending={false}
onSubmit={vi.fn()}
/>,
);
fireEvent.change(screen.getByPlaceholderText('搜索域名或顶级域…'), {
target: { value: 'other' },
});
expect(screen.queryByText('www.example.com')).not.toBeInTheDocument();
expect(screen.getByText('app.other.io')).toBeVisible();
expect(screen.getByText('other.io')).toBeVisible();
});
it('selects an entire top-level domain group', () => {
const onSubmit = vi.fn();
render(
<MemberAddDialog
open
onOpenChange={vi.fn()}
domains={domains}
defaultProxied
pending={false}
onSubmit={onSubmit}
/>,
);
fireEvent.click(
screen.getByRole('checkbox', { name: '选择顶级域 example.com' }),
);
fireEvent.click(screen.getByRole('button', { name: '添加并同步(3)' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const [ids, proxied] = onSubmit.mock.calls[0] as [number[], boolean];
expect([...ids].sort((a, b) => a - b)).toEqual([1, 2, 3]);
expect(proxied).toBe(true);
});
it('toggles individual domains within a group', () => {
const onSubmit = vi.fn();
render(
<MemberAddDialog
open
onOpenChange={vi.fn()}
domains={domains}
defaultProxied={false}
pending={false}
onSubmit={onSubmit}
/>,
);
const wwwLabel = screen.getByText('www.example.com').closest('label');
expect(wwwLabel).toBeTruthy();
fireEvent.click(within(wwwLabel as HTMLElement).getByRole('checkbox'));
fireEvent.click(screen.getByRole('button', { name: '添加并同步' }));
expect(onSubmit).toHaveBeenCalledWith([2], false);
});
});
@@ -0,0 +1,145 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SyncTasksPanel } from '@/app/(main)/cloudflare/components/sync-tasks-panel';
import { AdminTaskService } from '@/lib/services/admin';
import type { TaskExecution } from '@/lib/services/admin';
vi.mock('@/lib/services/admin', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/services/admin')>();
return {
...actual,
AdminTaskService: {
...actual.AdminTaskService,
listTaskExecutions: vi.fn(),
getTaskExecution: vi.fn(),
retryTaskExecution: vi.fn(),
},
};
});
function renderPanel() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
render(
<QueryClientProvider client={client}>
<SyncTasksPanel />
</QueryClientProvider>,
);
}
function execution(
partial: Partial<TaskExecution> &
Pick<TaskExecution, 'id' | 'task_type' | 'task_name'>,
): TaskExecution {
return {
task_id: `task_${partial.id}`,
status: 'succeeded',
retryable: true,
max_retry: 2,
retry_count: 0,
log: '',
error_message: '',
result: 'ok',
started_at: null,
finished_at: null,
duration: 100,
payload: '{}',
triggered_by: 'manual',
created_at: '2026-08-04T10:00:00Z',
updated_at: '2026-08-04T10:00:01Z',
...partial,
};
}
describe('Cloudflare sync tasks panel', () => {
beforeEach(() => {
vi.mocked(AdminTaskService.listTaskExecutions).mockReset();
});
it('queries only sync_member and sync_group and hides other tasks', async () => {
vi.mocked(AdminTaskService.listTaskExecutions).mockImplementation(
async (request) => {
if (request.task_type === 'cloudflare:sync_member') {
return {
items: [
execution({
id: '1',
task_type: 'cloudflare:sync_member',
task_name: 'Cloudflare 域名同步',
created_at: '2026-08-04T12:00:00Z',
}),
],
total: 1,
page: 1,
page_size: 50,
};
}
if (request.task_type === 'cloudflare:sync_group') {
return {
items: [
execution({
id: '2',
task_type: 'cloudflare:sync_group',
task_name: 'Cloudflare 分组同步',
created_at: '2026-08-04T11:00:00Z',
}),
// Defensive: even if API returns unrelated rows, panel must drop them.
execution({
id: '99',
task_type: 'system:cleanup',
task_name: '系统垃圾清理',
triggered_by: 'schedule',
created_at: '2026-08-04T13:00:00Z',
}),
],
total: 2,
page: 1,
page_size: 50,
};
}
return { items: [], total: 0, page: 1, page_size: 50 };
},
);
renderPanel();
expect(await screen.findByText('Cloudflare 域名同步')).toBeVisible();
expect(screen.getByText('Cloudflare 分组同步')).toBeVisible();
expect(screen.queryByText('系统垃圾清理')).not.toBeInTheDocument();
expect(screen.queryByText('system:cleanup')).not.toBeInTheDocument();
await waitFor(() => {
expect(AdminTaskService.listTaskExecutions).toHaveBeenCalledWith(
expect.objectContaining({
task_type: 'cloudflare:sync_member',
page: 1,
page_size: 50,
}),
);
expect(AdminTaskService.listTaskExecutions).toHaveBeenCalledWith(
expect.objectContaining({
task_type: 'cloudflare:sync_group',
page: 1,
page_size: 50,
}),
);
});
expect(AdminTaskService.listTaskExecutions).toHaveBeenCalledTimes(2);
});
it('shows empty state when no cloudflare tasks exist', async () => {
vi.mocked(AdminTaskService.listTaskExecutions).mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 50,
});
renderPanel();
expect(await screen.findByText('暂无同步任务')).toBeVisible();
});
});
+2
View File
@@ -102,6 +102,8 @@ func DispatchTask(c *gin.Context) {
// @Security SessionCookie
// @Param status query string false "状态筛选 (pending/running/succeeded/failed)"
// @Param task_type query string false "任务类型筛选"
// @Param task_type_prefix query string false "任务类型前缀筛选(与 task_type / task_types 互斥,精确类型优先)"
// @Param task_types query string false "逗号分隔的精确任务类型列表(IN 筛选,优先于前缀)"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页条数" default(20)
// @Success 200 {object} response.Any{data=object} "任务执行记录列表"
+14 -1
View File
@@ -394,9 +394,22 @@ func ListAvailableDomains(ctx context.Context) ([]AvailableDomain, error) {
if err != nil {
return nil, err
}
zones, err := repository.ListZones(ctx)
if err != nil {
return nil, err
}
zoneRoots := make(map[uint]string, len(zones))
for i := range zones {
zoneRoots[zones[i].ID] = zones[i].Domain
}
items := make([]AvailableDomain, 0, len(domains))
for _, domain := range domains {
items = append(items, AvailableDomain{ID: domain.ID, ZoneID: domain.ZoneID, Domain: domain.Domain})
items = append(items, AvailableDomain{
ID: domain.ID,
ZoneID: domain.ZoneID,
Domain: domain.Domain,
ZoneDomain: zoneRoots[domain.ZoneID],
})
}
return items, nil
}
+179 -17
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"strings"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/model"
@@ -32,14 +33,62 @@ const (
TaskTypeSyncByNode = "of_cloudflare_sync_by_node"
)
// SyncMemberMeta describes one-member reconciliation.
var SyncMemberMeta = task.TaskMeta{Type: TaskTypeSyncMember, AsynqTask: SyncMemberTask, Name: "Cloudflare 域名同步", Description: "同步单个域名的 Cloudflare A 记录", MaxRetry: 3, Queue: task.QueueDefault, Retryable: true, InternalOnly: true}
// SyncMemberMeta describes one-member reconciliation (admin-dispatchable).
var SyncMemberMeta = task.TaskMeta{
Type: TaskTypeSyncMember,
AsynqTask: SyncMemberTask,
Name: "Cloudflare 域名同步",
Description: "同步单个域名的 Cloudflare A 记录",
SupportsTime: false,
MaxRetry: 3,
Queue: task.QueueDefault,
Retryable: true,
Params: []task.TaskParam{
{
Name: "member_id",
Label: "成员 ID",
Type: "number",
Required: true,
Placeholder: "请输入 Cloudflare 指向成员 ID",
Description: "of_cf_pointing_members 表中的成员主键 ID",
},
},
}
// SyncGroupMeta describes group reconciliation.
var SyncGroupMeta = task.TaskMeta{Type: TaskTypeSyncGroup, AsynqTask: SyncGroupTask, Name: "Cloudflare 分组同步", Description: "同步指向分组内全部域名", MaxRetry: 2, Queue: task.QueueDefault, Retryable: true, InternalOnly: true}
// SyncGroupMeta describes group reconciliation (admin-dispatchable).
var SyncGroupMeta = task.TaskMeta{
Type: TaskTypeSyncGroup,
AsynqTask: SyncGroupTask,
Name: "Cloudflare 分组同步",
Description: "同步指向分组内全部域名",
SupportsTime: false,
MaxRetry: 2,
Queue: task.QueueDefault,
Retryable: true,
Params: []task.TaskParam{
{
Name: "group_id",
Label: "分组 ID",
Type: "number",
Required: true,
Placeholder: "请输入 Cloudflare 指向分组 ID",
Description: "of_cf_pointing_groups 表中的分组主键 ID",
},
},
}
// SyncByNodeMeta describes node-triggered reconciliation.
var SyncByNodeMeta = task.TaskMeta{Type: TaskTypeSyncByNode, AsynqTask: SyncByNodeTask, Name: "Cloudflare 节点同步", Description: "同步当前指向指定节点的全部域名", MaxRetry: 2, Queue: task.QueueDefault, Retryable: true, InternalOnly: true}
// SyncByNodeMeta describes node-triggered reconciliation (internal only).
var SyncByNodeMeta = task.TaskMeta{
Type: TaskTypeSyncByNode,
AsynqTask: SyncByNodeTask,
Name: "Cloudflare 节点同步",
Description: "同步当前指向指定节点的全部域名",
SupportsTime: false,
MaxRetry: 2,
Queue: task.QueueDefault,
Retryable: true,
InternalOnly: true,
}
// SyncMemberPayload identifies one member.
type SyncMemberPayload struct {
@@ -93,9 +142,15 @@ type SyncMemberTaskHandler struct{}
// ValidatePayload validates a one-member task payload.
func (handler *SyncMemberTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, errors.New("任务参数不能为空")
}
var input SyncMemberPayload
if err := decodePayload(payload, &input); err != nil || input.MemberID == 0 {
return nil, errors.New("无效的 Cloudflare 成员同步参数")
if err := decodePayload(payload, &input); err != nil {
return nil, fmt.Errorf("无效的 Cloudflare 成员同步参数: %w", err)
}
if input.MemberID == 0 {
return nil, errors.New("成员 ID 不能为空或零")
}
return json.Marshal(input)
}
@@ -108,11 +163,47 @@ func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byt
}
var input SyncMemberPayload
_ = json.Unmarshal(normalized, &input)
task.AppendLog(ctx, "正在同步 Cloudflare 成员 ID=%d", input.MemberID)
state, loadErr := repository.GetCFPointingMemberContext(ctx, input.MemberID)
if loadErr != nil {
task.AppendLog(ctx, "加载成员上下文失败: member_id=%d error=%v", input.MemberID, loadErr)
} else {
task.AppendLog(ctx,
"开始域名同步: domain=%s zone=%s group=%s(#%d) node=%s(%s) proxied=%v member_id=%d",
state.Domain.Domain,
state.Zone.Domain,
state.Group.Name,
state.Group.ID,
state.Node.Name,
strings.TrimSpace(state.Node.IP),
state.Member.Proxied,
input.MemberID,
)
}
if err = ReconcileMember(ctx, input.MemberID); err != nil {
if state != nil {
task.AppendLog(ctx, "域名同步失败: domain=%s member_id=%d error=%v",
state.Domain.Domain, input.MemberID, err)
} else {
task.AppendLog(ctx, "域名同步失败: member_id=%d error=%v", input.MemberID, err)
}
return nil, fmt.Errorf("%s: %w", errSyncFailed, err)
}
return &task.TaskResult{Message: "Cloudflare 域名同步成功"}, nil
message := "Cloudflare 域名同步成功"
if state != nil {
ip := strings.TrimSpace(state.Node.IP)
message = fmt.Sprintf("Cloudflare 域名同步成功: %s → %s (proxied=%v)",
state.Domain.Domain, ip, state.Member.Proxied)
task.AppendLog(ctx,
"域名同步成功: domain=%s desired_ip=%s proxied=%v group=%s node=%s",
state.Domain.Domain, ip, state.Member.Proxied, state.Group.Name, state.Node.Name,
)
} else {
task.AppendLog(ctx, "域名同步成功: member_id=%d", input.MemberID)
}
return &task.TaskResult{Message: message}, nil
}
// SyncGroupTaskHandler reconciles every member in a group.
@@ -120,9 +211,15 @@ type SyncGroupTaskHandler struct{}
// ValidatePayload validates a group task payload.
func (handler *SyncGroupTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, errors.New("任务参数不能为空")
}
var input SyncGroupPayload
if err := decodePayload(payload, &input); err != nil || input.GroupID == 0 {
return nil, errors.New("无效的 Cloudflare 分组同步参数")
if err := decodePayload(payload, &input); err != nil {
return nil, fmt.Errorf("无效的 Cloudflare 分组同步参数: %w", err)
}
if input.GroupID == 0 {
return nil, errors.New("分组 ID 不能为空或零")
}
return json.Marshal(input)
}
@@ -137,8 +234,27 @@ func (handler *SyncGroupTaskHandler) Execute(ctx context.Context, payload []byte
if err = json.Unmarshal(normalized, &input); err != nil {
return nil, task.PermanentError(err.Error())
}
scopeName := fmt.Sprintf("#%d", input.GroupID)
activeNode := ""
if group, groupErr := repository.GetCFPointingGroup(ctx, input.GroupID); groupErr != nil {
task.AppendLog(ctx, "加载分组失败: group_id=%d error=%v", input.GroupID, groupErr)
} else {
scopeName = group.Name
if node, nodeErr := repository.GetOpenFlareNodeByID(ctx, group.ActiveNodeID); nodeErr != nil {
task.AppendLog(ctx, "加载生效节点失败: group=%s active_node_id=%d error=%v",
group.Name, group.ActiveNodeID, nodeErr)
} else {
activeNode = fmt.Sprintf("%s(%s)", node.Name, strings.TrimSpace(node.IP))
}
task.AppendLog(ctx,
"准备分组同步: group=%s id=%d enabled=%v active_node=%s default_proxied=%v",
group.Name, group.ID, group.Enabled, activeNode, group.DefaultProxied,
)
}
members, err := repository.ListCFPointingMembersByGroupID(ctx, input.GroupID)
return executeBatchSync(ctx, members, err, "分组")
return executeBatchSync(ctx, members, err, "分组", scopeName, input.GroupID, activeNode)
}
// SyncByNodeTaskHandler reconciles every member targeting a node.
@@ -163,20 +279,66 @@ func (handler *SyncByNodeTaskHandler) Execute(ctx context.Context, payload []byt
if err = json.Unmarshal(normalized, &input); err != nil {
return nil, task.PermanentError(err.Error())
}
scopeName := fmt.Sprintf("#%d", input.NodeID)
activeNode := ""
if node, nodeErr := repository.GetOpenFlareNodeByID(ctx, input.NodeID); nodeErr != nil {
task.AppendLog(ctx, "加载节点失败: node_id=%d error=%v", input.NodeID, nodeErr)
} else {
scopeName = node.Name
activeNode = fmt.Sprintf("%s(%s)", node.Name, strings.TrimSpace(node.IP))
task.AppendLog(ctx, "准备节点同步: node=%s id=%d ip=%s",
node.Name, node.ID, strings.TrimSpace(node.IP))
}
members, err := repository.ListCFPointingMembersByActiveNodeID(ctx, input.NodeID)
return executeBatchSync(ctx, members, err, "节点")
return executeBatchSync(ctx, members, err, "节点", scopeName, input.NodeID, activeNode)
}
func executeBatchSync(ctx context.Context, members []model.CFPointingMember, listErr error, scope string) (*task.TaskResult, error) {
func executeBatchSync(
ctx context.Context,
members []model.CFPointingMember,
listErr error,
scope, scopeName string,
scopeID uint,
activeNode string,
) (*task.TaskResult, error) {
if listErr != nil {
task.AppendLog(ctx, "列出%s成员失败: name=%s id=%d error=%v",
scope, scopeName, scopeID, listErr)
return nil, listErr
}
for _, member := range members {
task.AppendLog(ctx, "开始%s同步: name=%s id=%d active_node=%s 域名数=%d",
scope, scopeName, scopeID, activeNode, len(members))
if len(members) == 0 {
message := fmt.Sprintf("Cloudflare %s同步完成: %s 无域名成员", scope, scopeName)
task.AppendLog(ctx, "%s", message)
return &task.TaskResult{Message: message}, nil
}
for index, member := range members {
domainName := fmt.Sprintf("zone_domain_id=%d", member.ZoneDomainID)
if domain, domainErr := repository.GetZoneDomainByID(ctx, member.ZoneDomainID); domainErr == nil {
domainName = domain.Domain
}
task.AppendLog(ctx, "[%d/%d] 同步域名 %s (member_id=%d proxied=%v)",
index+1, len(members), domainName, member.ID, member.Proxied)
if err := ReconcileMember(ctx, member.ID); err != nil {
task.AppendLog(ctx, "[%d/%d] 失败: domain=%s member_id=%d error=%v",
index+1, len(members), domainName, member.ID, err)
return nil, err
}
task.AppendLog(ctx, "[%d/%d] 成功: domain=%s", index+1, len(members), domainName)
}
return &task.TaskResult{Message: fmt.Sprintf("Cloudflare %s同步完成,共 %d 个域名", scope, len(members))}, nil
message := fmt.Sprintf("Cloudflare %s同步完成: %s 共 %d 个域名", scope, scopeName, len(members))
if activeNode != "" {
message = fmt.Sprintf("Cloudflare %s同步完成: %s → %s,共 %d 个域名",
scope, scopeName, activeNode, len(members))
}
task.AppendLog(ctx, "%s", message)
return &task.TaskResult{Message: message}, nil
}
func decodePayload(payload []byte, target any) error {
+4 -3
View File
@@ -85,9 +85,10 @@ type GroupDetail struct {
// AvailableDomain is a ZoneDomain eligible for pointing.
type AvailableDomain struct {
ID uint `json:"id"`
ZoneID uint `json:"zone_id"`
Domain string `json:"domain"`
ID uint `json:"id"`
ZoneID uint `json:"zone_id"`
Domain string `json:"domain"`
ZoneDomain string `json:"zone_domain"`
}
// Overview summarizes Cloudflare pointing readiness and sync health.
+8 -4
View File
@@ -54,8 +54,12 @@ func (TaskExecution) TableName() string {
// ListTaskExecutionsRequest 查询任务执行记录列表请求
type ListTaskExecutionsRequest struct {
Status string `form:"status"`
TaskType string `form:"task_type"`
Page int `form:"page"`
PageSize int `form:"page_size"`
Status string `form:"status"`
TaskType string `form:"task_type"`
TaskTypePrefix string `form:"task_type_prefix"`
// TaskTypes is a comma-separated list of exact asynq task types (IN filter).
// Used when TaskType is empty; takes precedence over TaskTypePrefix.
TaskTypes string `form:"task_types"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
+19
View File
@@ -146,6 +146,10 @@ func ListTaskExecutions(ctx context.Context, req model.ListTaskExecutionsRequest
}
if req.TaskType != "" {
query = query.Where("task_type = ?", req.TaskType)
} else if types := parseTaskTypesFilter(req.TaskTypes); len(types) > 0 {
query = query.Where("task_type IN ?", types)
} else if req.TaskTypePrefix != "" {
query = query.Where("task_type LIKE ?", req.TaskTypePrefix+"%")
}
var total int64
@@ -165,6 +169,21 @@ func ListTaskExecutions(ctx context.Context, req model.ListTaskExecutionsRequest
return executions, total, nil
}
func parseTaskTypesFilter(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
// MarkFailedTaskExecutionsSucceededTx marks failed executions of a task type as succeeded within a transaction.
func MarkFailedTaskExecutionsSucceededTx(
tx *gorm.DB,
@@ -415,6 +415,34 @@ func TestListTaskExecutions(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Equal(t, "list_001", items[0].TaskID)
// 按类型前缀筛选
items, total, err = ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{TaskTypePrefix: "system:", Page: 1, PageSize: 10})
require.NoError(t, err)
assert.Equal(t, int64(3), total)
assert.Len(t, items, 3)
// 按多类型 IN 筛选
items, total, err = ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{
TaskTypes: "system:cleanup,other:task",
Page: 1,
PageSize: 10,
})
require.NoError(t, err)
assert.Equal(t, int64(5), total)
assert.Len(t, items, 5)
// 精确类型优先于 task_types / 前缀
items, total, err = ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{
TaskType: "other:task",
TaskTypes: "system:cleanup",
TaskTypePrefix: "system:",
Page: 1,
PageSize: 10,
})
require.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.Len(t, items, 2)
}
func TestListTaskExecutionsDefaultPaging(t *testing.T) {