From 4eced2b7217bdfa3d9670434bcc54d863c27a4f7 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 4 Aug 2026 13:30:21 +0800 Subject: [PATCH] feat(cloudflare): add DNS pointing integration --- docs/changelog/index.md | 5 + .../components/member-add-dialog.tsx | 353 +++++++++++-- .../components/sync-tasks-panel.tsx | 492 ++++++++++++++++++ .../(main)/cloudflare/groups/[id]/page.tsx | 130 +++-- .../app/(main)/cloudflare/groups/page.tsx | 200 +------ frontend/app/(main)/cloudflare/page.tsx | 243 +++++++-- .../app/(main)/cloudflare/settings/page.tsx | 17 +- frontend/lib/navigation/openflare-nav.ts | 2 +- frontend/lib/services/admin/types.ts | 4 + frontend/lib/services/openflare/types.ts | 2 + frontend/lib/theme/themes.json | 2 +- .../cloudflare-group-detail.test.tsx | 106 ++++ .../cloudflare/cloudflare-overview.test.tsx | 103 +++- .../cloudflare/member-add-dialog.test.tsx | 127 +++++ .../cloudflare/sync-tasks-panel.test.tsx | 145 ++++++ internal/apps/admin/task/routers.go | 2 + internal/apps/openflare/cloudflare/logics.go | 15 +- internal/apps/openflare/cloudflare/tasks.go | 196 ++++++- internal/apps/openflare/cloudflare/types.go | 7 +- internal/model/task_execution.go | 12 +- internal/repository/task_execution.go | 19 + internal/repository/task_execution_test.go | 28 + 22 files changed, 1834 insertions(+), 376 deletions(-) create mode 100644 frontend/app/(main)/cloudflare/components/sync-tasks-panel.tsx create mode 100644 frontend/tests/cloudflare/cloudflare-group-detail.test.tsx create mode 100644 frontend/tests/cloudflare/member-add-dialog.test.tsx create mode 100644 frontend/tests/cloudflare/sync-tasks-panel.test.tsx diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 832fead3..6fb24ba0 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -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 的错误文案定义边界。 diff --git a/frontend/app/(main)/cloudflare/components/member-add-dialog.tsx b/frontend/app/(main)/cloudflare/components/member-add-dialog.tsx index 2f7aadf5..718c8921 100644 --- a/frontend/app/(main)/cloudflare/components/member-add-dialog.tsx +++ b/frontend/app/(main)/cloudflare/components/member-add-dialog.tsx @@ -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(); + 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([]); const [proxied, setProxied] = useState(defaultProxied); + const [collapsedZones, setCollapsedZones] = useState>( + () => 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 ( - + 添加域名成员 - 加入后会创建或接管唯一同名 A 记录;多条同名 A 会拒绝同步。 + 支持搜索筛选与批量勾选;加入后会创建或接管唯一同名 A 记录,多条同名 + A 会拒绝同步。 - Zone 域名 - + Zone 域名 +
+
+ + setKeyword(event.target.value)} + placeholder='搜索域名或顶级域…' + className='pl-8' + disabled={pending} + /> +
+ +
+
+ + 已选 {selectedIDs.length} + + 可见 {visibleIDs.length} +
+
+ + +
+
+ + {domains.length === 0 ? ( +
+ 暂无可用域名(均已加入分组,或尚未在网站管理中添加)。 +
+ ) : filteredGroups.length === 0 ? ( +
+ 没有匹配的域名 +
+ ) : ( +
+ {/* Header row: select all visible */} + + + {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 ( + toggleCollapsed(group.zoneId)} + > +
+
+ toggleGroup(group)} + aria-label={`选择顶级域 ${group.zoneDomain}`} + className='ml-1' + /> + + + +
+ + +
+ {group.domains.map((domain) => { + const checked = selected.has(domain.id); + const isApex = + domain.domain === group.zoneDomain; + return ( + + ); + })} +
+
+
+
+ ); + })} +
+ )} +
开启橙云代理
-
diff --git a/frontend/app/(main)/cloudflare/components/sync-tasks-panel.tsx b/frontend/app/(main)/cloudflare/components/sync-tasks-panel.tsx new file mode 100644 index 00000000..527732d7 --- /dev/null +++ b/frontend/app/(main)/cloudflare/components/sync-tasks-panel.tsx @@ -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(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 { + 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 = { + pending: '等待中', + running: '执行中', + succeeded: '成功', + failed: '失败', +}; + +const TRIGGER_LABELS: Record = { + 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('all'); + const [selectedId, setSelectedId] = useState(null); + const [preview, setPreview] = useState(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 ( + + +
+ + + 同步任务 + + + 仅展示 Cloudflare + 域名同步(sync_member)与分组同步(sync_group)的执行记录。 + +
+
+ + +
+
+ + {executionsQuery.isError ? ( + void executionsQuery.refetch()} + /> + ) : loading && executions.length === 0 ? ( + + ) : executions.length === 0 ? ( + + ) : ( +
+ + + + 任务 + 状态 + 触发 + 耗时 + 结果/错误 + 创建时间 + + + + {executions.map((execution) => ( + openDetail(execution)} + > + +
+ + {execution.task_name || execution.task_type} + + + {execution.task_type} + +
+
+ + + {STATUS_LABELS[execution.status] || execution.status} + + + + + {TRIGGER_LABELS[execution.triggered_by] || + execution.triggered_by} + + + + {formatDuration(execution.duration)} + + + {execution.error_message || execution.result || '-'} + + + {formatDateTime(execution.created_at)} + +
+ ))} +
+
+
+ )} + +
+
+ 共 {total} 条,第 {page}/{totalPages} 页 +
+
+ + +
+
+
+ + + + + 同步任务详情 + + {selected?.task_name || selected?.task_type || 'Cloudflare 同步'} + + +
+ {detailQuery.isFetching && !selected ? ( + + ) : selected ? ( + <> +
+
+
状态
+
+ + {STATUS_LABELS[selected.status] || selected.status} + +
+
+
+
触发
+
+ {TRIGGER_LABELS[selected.triggered_by] || + selected.triggered_by} +
+
+
+
重试
+
+ {selected.retry_count}/{selected.max_retry} +
+
+
+
耗时
+
+ {formatDuration(selected.duration)} +
+
+
+ +
+
任务 ID
+
+ {selected.task_id} +
+
+ +
+
+
+ 创建时间 +
+
+ {formatDateTime(selected.created_at)} +
+
+
+
+ 结束时间 +
+
+ {formatDateTime(selected.finished_at)} +
+
+
+ +
+
执行结果
+
+ {selected.result || '-'} +
+
+ + {selected.error_message ? ( +
+
+ 错误信息 +
+
+ {selected.error_message} +
+
+ ) : null} + +
+
Payload
+
+                    {selected.payload || '{}'}
+                  
+
+ + {selected.log ? ( +
+
+ 执行日志 +
+
+                      {selected.log}
+                    
+
+ ) : null} + + ) : null} +
+ + {selected?.retryable && selected.status === 'failed' ? ( + + ) : null} + + +
+
+
+ ); +} diff --git a/frontend/app/(main)/cloudflare/groups/[id]/page.tsx b/frontend/app/(main)/cloudflare/groups/[id]/page.tsx index 19a0efe5..7884310f 100644 --- a/frontend/app/(main)/cloudflare/groups/[id]/page.tsx +++ b/frontend/app/(main)/cloudflare/groups/[id]/page.tsx @@ -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 (
-
-
- -

- {group.name} -

-
-
- - - +
+ +
+
+ +

+ {group.name} +

+
+
+ + + +
- + - 当前指向 + 当前指向 生效节点 {group.active_node.name} · {group.active_node.ip} @@ -178,18 +227,9 @@ export default function CloudflareGroupDetailPage() { - - - 远端记录所有权 - - 移出成员会立即删除本模块缓存或唯一同名 A 记录;存在多条同名 A - 时会拒绝操作并要求先手动清理。 - - - - + - 域名成员 + 域名成员 成员级橙云是同步时的唯一依据。 @@ -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 }) } />
diff --git a/frontend/app/(main)/cloudflare/groups/page.tsx b/frontend/app/(main)/cloudflare/groups/page.tsx index f007740b..07ab34eb 100644 --- a/frontend/app/(main)/cloudflare/groups/page.tsx +++ b/frontend/app/(main)/cloudflare/groups/page.tsx @@ -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( - 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 ( -
-
-
- -

- Cloudflare 指向分组 -

-
-
- - -
-
- - {groupsQuery.isLoading ? ( - - ) : groupsQuery.isError ? ( - void groupsQuery.refetch()} - /> - ) : (groupsQuery.data ?? []).length === 0 ? ( - - - 暂无指向分组。 - - - ) : ( -
- {(groupsQuery.data ?? []).map((group) => ( - - -
-
- {group.name} - - {group.active_node.name} · {group.active_node.ip} - -
- - {group.enabled ? '已启用' : '已停用'} - -
-
- -

- 成员 {group.member_count} 个 · 新成员默认 - {group.default_proxied ? '开启' : '关闭'}橙云 -

-
- - - -
-
-
- ))} -
- )} - - createMutation.mutate(payload)} - /> - - !open && setDeleteTarget(null)} - > - - - 删除指向分组 - - 将删除 {deleteTarget?.name} 的全部成员及本模块管理的远端 A - 记录。此操作不可撤销。 - - - - 取消 - - deleteTarget && deleteMutation.mutate(deleteTarget.id) - } - > - 确认删除 - - - - -
- ); + redirect('/cloudflare'); } diff --git a/frontend/app/(main)/cloudflare/page.tsx b/frontend/app/(main)/cloudflare/page.tsx index 0ccd62b7..5e340ef9 100644 --- a/frontend/app/(main)/cloudflare/page.tsx +++ b/frontend/app/(main)/cloudflare/page.tsx @@ -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( + 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() { 连接设置 -
@@ -59,14 +127,10 @@ export default function CloudflarePage() { description='加载 Cloudflare 总览中...' /> ) : overviewQuery.isError ? ( - - - void overviewQuery.refetch()} - /> - - + void overviewQuery.refetch()} + /> ) : !overview?.connection.ready ? ( @@ -81,46 +145,121 @@ export default function CloudflarePage() { - ) : ( -
- {[ - ['指向分组', overview.group_count], - ['域名成员', overview.member_count], - ['同步正常', overview.ok_count], - ['等待同步', overview.pending_count], - ['同步错误', overview.error_count], - ].map(([label, value]) => ( - - - {label} - {value} - - - ))} -
- )} + ) : null} - - - 同步边界 - - OpenFlare 数据库是本模块的期望状态来源。 - - - -

- 同步会覆盖本模块接管的同名 A 记录;如果 Cloudflare 中存在多条同名 - A,需先手动清理。 +

+
+

指向分组

+

+ 管理 Cloudflare A 记录对应的节点与域名成员。

-

成员移出或分组删除时,默认同时删除本模块管理的远端 A 记录。

-
- 一期限制 - - 一期不提供自动故障切换,备用节点仅保存配置。 - +
+ + {groupsQuery.isLoading ? ( + + ) : groupsQuery.isError ? ( + void groupsQuery.refetch()} + /> + ) : (groupsQuery.data ?? []).length === 0 ? ( + setCreateOpen(true)} + /> + ) : ( +
+ {(groupsQuery.data ?? []).map((group) => ( + + +
+
+ {group.name} + + {group.active_node.name} · {group.active_node.ip} + +
+ + {group.enabled ? '已启用' : '已停用'} + +
+
+ +

+ 成员 {group.member_count} 个 · 新成员默认 + {group.default_proxied ? '开启' : '关闭'}橙云 +

+
+ + + +
+
+
+ ))}
- - + )} +
+ + + + createMutation.mutate(payload)} + /> + + !open && setDeleteTarget(null)} + > + + + 删除指向分组 + + 将删除 {deleteTarget?.name} 的全部成员及本模块管理的远端 A + 记录。此操作不可撤销。 + + + + 取消 + + deleteTarget && deleteMutation.mutate(deleteTarget.id) + } + > + 确认删除 + + + +
); } diff --git a/frontend/app/(main)/cloudflare/settings/page.tsx b/frontend/app/(main)/cloudflare/settings/page.tsx index 1015b173..3e657e0d 100644 --- a/frontend/app/(main)/cloudflare/settings/page.tsx +++ b/frontend/app/(main)/cloudflare/settings/page.tsx @@ -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 (
-
+
+

Cloudflare 连接设置

-
{connectionQuery.isError ? ( @@ -133,9 +136,9 @@ export default function CloudflareSettingsPage() { /> ) : null} - + - 凭据来源 + 凭据来源 Token 建议授予 Zone:Read 与 DNS:Edit 权限;Token 不会在 API 或页面中回显。 diff --git a/frontend/lib/navigation/openflare-nav.ts b/frontend/lib/navigation/openflare-nav.ts index 1c141bbd..e7fbc7a0 100644 --- a/frontend/lib/navigation/openflare-nav.ts +++ b/frontend/lib/navigation/openflare-nav.ts @@ -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'] }, ], diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts index ca9a46d0..10a361a8 100644 --- a/frontend/lib/services/admin/types.ts +++ b/frontend/lib/services/admin/types.ts @@ -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; } diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index ab6d5df7..196ecec1 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -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 { diff --git a/frontend/lib/theme/themes.json b/frontend/lib/theme/themes.json index 40c4d4ba..9818de0f 100644 --- a/frontend/lib/theme/themes.json +++ b/frontend/lib/theme/themes.json @@ -4559,4 +4559,4 @@ } } } -] +] \ No newline at end of file diff --git a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx new file mode 100644 index 00000000..c47a64a6 --- /dev/null +++ b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx @@ -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(); + 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( + + + , + ); +} + +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); + }); + }); +}); diff --git a/frontend/tests/cloudflare/cloudflare-overview.test.tsx b/frontend/tests/cloudflare/cloudflare-overview.test.tsx index 0330b3ab..e4d75e45 100644 --- a/frontend/tests/cloudflare/cloudflare-overview.test.tsx +++ b/frontend/tests/cloudflare/cloudflare-overview.test.tsx @@ -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(); 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(); + 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(); }); }); diff --git a/frontend/tests/cloudflare/member-add-dialog.test.tsx b/frontend/tests/cloudflare/member-add-dialog.test.tsx new file mode 100644 index 00000000..757e58bc --- /dev/null +++ b/frontend/tests/cloudflare/member-add-dialog.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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); + }); +}); diff --git a/frontend/tests/cloudflare/sync-tasks-panel.test.tsx b/frontend/tests/cloudflare/sync-tasks-panel.test.tsx new file mode 100644 index 00000000..40fdaf0d --- /dev/null +++ b/frontend/tests/cloudflare/sync-tasks-panel.test.tsx @@ -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(); + 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( + + + , + ); +} + +function execution( + partial: Partial & + Pick, +): 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(); + }); +}); diff --git a/internal/apps/admin/task/routers.go b/internal/apps/admin/task/routers.go index 5459f7e1..8f6bf135 100644 --- a/internal/apps/admin/task/routers.go +++ b/internal/apps/admin/task/routers.go @@ -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} "任务执行记录列表" diff --git a/internal/apps/openflare/cloudflare/logics.go b/internal/apps/openflare/cloudflare/logics.go index cbc6c4ff..2078f4ac 100644 --- a/internal/apps/openflare/cloudflare/logics.go +++ b/internal/apps/openflare/cloudflare/logics.go @@ -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 } diff --git a/internal/apps/openflare/cloudflare/tasks.go b/internal/apps/openflare/cloudflare/tasks.go index 78560f6c..3e9d79fd 100644 --- a/internal/apps/openflare/cloudflare/tasks.go +++ b/internal/apps/openflare/cloudflare/tasks.go @@ -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 { diff --git a/internal/apps/openflare/cloudflare/types.go b/internal/apps/openflare/cloudflare/types.go index c5c8df05..2c184484 100644 --- a/internal/apps/openflare/cloudflare/types.go +++ b/internal/apps/openflare/cloudflare/types.go @@ -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. diff --git a/internal/model/task_execution.go b/internal/model/task_execution.go index 2db07cdd..2ecb4a80 100644 --- a/internal/model/task_execution.go +++ b/internal/model/task_execution.go @@ -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"` } diff --git a/internal/repository/task_execution.go b/internal/repository/task_execution.go index aa07d9db..8d60d398 100644 --- a/internal/repository/task_execution.go +++ b/internal/repository/task_execution.go @@ -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, diff --git a/internal/repository/task_execution_test.go b/internal/repository/task_execution_test.go index b80b94cc..9d169be2 100644 --- a/internal/repository/task_execution_test.go +++ b/internal/repository/task_execution_test.go @@ -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) {