mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
feat(cloudflare): add DNS pointing integration
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
或页面中回显。
|
||||
|
||||
@@ -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'] },
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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} "任务执行记录列表"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user