mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 15:06:37 +08:00
feat(cloudflare): add DNS pointing integration
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user