diff --git a/Makefile b/Makefile
index bc7ba817..cb3c5149 100644
--- a/Makefile
+++ b/Makefile
@@ -143,30 +143,40 @@ dev:
fi
-# Merge local feat into canary, push canary, then restore the previous branch.
-# Does not leave the working tree on canary when started from another branch.
+# Merge the current local branch into canary, push canary, then restore it.
+# Dirty worktrees are auto-stashed and restored after the operation.
canary:
@set -e; \
if ! git rev-parse --git-dir >/dev/null 2>&1; then \
echo "Error: not a git repository"; exit 1; \
fi; \
- if [ -n "$$(git status --porcelain)" ]; then \
- echo "Error: working tree is dirty; commit or stash first"; exit 1; \
- fi; \
orig=$$(git rev-parse --abbrev-ref HEAD); \
if [ "$$orig" = "HEAD" ]; then \
echo "Error: detached HEAD; checkout a branch first"; exit 1; \
fi; \
+ if [ "$$orig" = "canary" ]; then \
+ echo "Error: already on canary; checkout a source branch first"; exit 1; \
+ fi; \
+ stashed=0; \
+ if [ -n "$$(git status --porcelain)" ]; then \
+ echo "Working tree is dirty; stashing local changes..."; \
+ git stash push -u -m "make canary auto-stash from $$orig"; \
+ stashed=1; \
+ fi; \
cleanup() { \
cur=$$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true); \
if [ "$$cur" != "$$orig" ]; then \
echo "Restoring branch $$orig..."; \
git checkout -q "$$orig"; \
fi; \
+ if [ "$$stashed" = "1" ]; then \
+ echo "Restoring stashed local changes..."; \
+ git stash pop; \
+ fi; \
}; \
trap cleanup EXIT; \
- echo "Merging feat -> canary (started from $$orig)..."; \
+ echo "Merging $$orig -> canary..."; \
git checkout canary; \
- git merge --no-edit feat; \
+ git merge --no-edit "$$orig"; \
git push -u origin canary; \
- echo "Done: feat merged into canary and pushed."
+ echo "Done: $$orig merged into canary and pushed."
diff --git a/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx b/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx
new file mode 100644
index 00000000..344e1f5e
--- /dev/null
+++ b/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx
@@ -0,0 +1,334 @@
+'use client';
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+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';
+import { toast } from 'sonner';
+
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Switch } from '@/components/ui/switch';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { ErrorInline } from '@/components/layout/error';
+import { LoadingStateWithBorder } from '@/components/layout/loading';
+import {
+ CloudflareService,
+ cloudflareQueryKey,
+ NodeService,
+ type CloudflareGroupPayload,
+} from '@/lib/services/openflare';
+import { getErrorMessage } from '../../../websites/components/website-utils';
+import { GroupDialog } from '../../components/group-dialog';
+import { MemberAddDialog } from '../../components/member-add-dialog';
+
+export function CloudflareGroupDetailPageClient() {
+ const params = useParams<{ id: string }>();
+ const groupID = Number(params.id);
+ const queryClient = useQueryClient();
+ const [editOpen, setEditOpen] = useState(false);
+ const [addOpen, setAddOpen] = useState(false);
+ const detailQuery = useQuery({
+ queryKey: [...cloudflareQueryKey, 'groups', groupID],
+ queryFn: () => CloudflareService.getGroup(groupID),
+ enabled: Number.isInteger(groupID) && groupID > 0,
+ refetchInterval: 5000,
+ });
+ const domainsQuery = useQuery({
+ queryKey: [...cloudflareQueryKey, 'domains', 'available'],
+ queryFn: () => CloudflareService.listAvailableDomains(),
+ });
+ const nodesQuery = useQuery({
+ queryKey: ['openflare', 'nodes'],
+ queryFn: () => NodeService.listNodes(),
+ });
+ const invalidate = async () =>
+ queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
+
+ const updateGroupMutation = useMutation({
+ mutationFn: (payload: CloudflareGroupPayload) =>
+ CloudflareService.updateGroup(groupID, payload),
+ onSuccess: async () => {
+ toast.success('分组配置已更新');
+ setEditOpen(false);
+ await invalidate();
+ },
+ onError: (error) => toast.error(getErrorMessage(error)),
+ });
+ const addMutation = useMutation({
+ mutationFn: async ({
+ domainIDs,
+ proxied,
+ }: {
+ domainIDs: number[];
+ proxied: boolean;
+ }) => {
+ 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();
+ },
+ onError: (error) => toast.error(getErrorMessage(error)),
+ });
+ const proxiedMutation = useMutation({
+ mutationFn: ({
+ memberID,
+ proxied,
+ }: {
+ memberID: number;
+ proxied: boolean;
+ }) => CloudflareService.updateMember(groupID, memberID, proxied),
+ onSuccess: async () => {
+ toast.success('橙云设置已更新并排队同步');
+ await invalidate();
+ },
+ onError: (error) => toast.error(getErrorMessage(error)),
+ });
+ const syncMutation = useMutation({
+ mutationFn: (memberID: number) =>
+ CloudflareService.syncMember(groupID, memberID),
+ onSuccess: () => toast.success('成员同步任务已入队'),
+ onError: (error) => toast.error(getErrorMessage(error)),
+ });
+ const removeMutation = useMutation({
+ mutationFn: (memberID: number) =>
+ CloudflareService.removeMember(groupID, memberID),
+ onSuccess: async () => {
+ toast.success('成员及远端 A 记录已删除');
+ await invalidate();
+ },
+ onError: (error) => toast.error(getErrorMessage(error)),
+ });
+
+ if (detailQuery.isLoading)
+ return (
+
+
+
+ );
+ if (detailQuery.isError || !detailQuery.data)
+ return (
+
+ void detailQuery.refetch()}
+ />
+
+ );
+ const { group, members } = detailQuery.data;
+
+ return (
+
+
+
+
+
+
+
+ {group.name}
+
+
+
+
+
+
+
+
+
+
+
+
+ 当前指向
+
+ 生效节点 {group.active_node.name} · {group.active_node.ip}
+
+
+
+
+ {group.enabled ? '同步已启用' : '同步已停用'}
+
+ 主节点 {group.primary_node.name}
+
+ 备用节点 {group.backup_node?.name ?? '未设置'}
+
+
+
+
+
+
+ 域名成员
+ 成员级橙云是同步时的唯一依据。
+
+
+ {members.length === 0 ? (
+
+ 暂无域名成员。
+
+ ) : (
+
+
+
+ 域名
+ 期望 IP
+ 状态
+ 橙云
+ 操作
+
+
+
+ {members.map((member) => (
+
+
+ {member.domain}
+ {member.last_error ? (
+
+ {member.last_error}
+
+ ) : null}
+
+ {member.desired_ip || '待同步'}
+
+
+ {member.sync_status}
+
+
+
+
+ proxiedMutation.mutate({
+ memberID: member.id,
+ proxied,
+ })
+ }
+ />
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
updateGroupMutation.mutate(payload)}
+ />
+
+ addMutation.mutate({ domainIDs, proxied })
+ }
+ />
+
+ );
+}
diff --git a/frontend/app/(main)/cloudflare/groups/[id]/page.tsx b/frontend/app/(main)/cloudflare/groups/[id]/page.tsx
index 7884310f..38901c91 100644
--- a/frontend/app/(main)/cloudflare/groups/[id]/page.tsx
+++ b/frontend/app/(main)/cloudflare/groups/[id]/page.tsx
@@ -1,335 +1,28 @@
-'use client';
+import { Suspense } from 'react';
+import { Cloud } from 'lucide-react';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-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';
-import { toast } from 'sonner';
-
-import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
-import { Switch } from '@/components/ui/switch';
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table';
-import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
-import {
- CloudflareService,
- cloudflareQueryKey,
- NodeService,
- type CloudflareGroupPayload,
-} from '@/lib/services/openflare';
-import { getErrorMessage } from '../../../websites/components/website-utils';
-import { GroupDialog } from '../../components/group-dialog';
-import { MemberAddDialog } from '../../components/member-add-dialog';
-export default function CloudflareGroupDetailPage() {
- const params = useParams<{ id: string }>();
- const groupID = Number(params.id);
- const queryClient = useQueryClient();
- const [editOpen, setEditOpen] = useState(false);
- const [addOpen, setAddOpen] = useState(false);
- const detailQuery = useQuery({
- queryKey: [...cloudflareQueryKey, 'groups', groupID],
- queryFn: () => CloudflareService.getGroup(groupID),
- enabled: Number.isInteger(groupID) && groupID > 0,
- refetchInterval: 5000,
- });
- const domainsQuery = useQuery({
- queryKey: [...cloudflareQueryKey, 'domains', 'available'],
- queryFn: () => CloudflareService.listAvailableDomains(),
- });
- const nodesQuery = useQuery({
- queryKey: ['openflare', 'nodes'],
- queryFn: () => NodeService.listNodes(),
- });
- const invalidate = async () =>
- queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
+import { CloudflareGroupDetailPageClient } from './page-client';
- const updateGroupMutation = useMutation({
- mutationFn: (payload: CloudflareGroupPayload) =>
- CloudflareService.updateGroup(groupID, payload),
- onSuccess: async () => {
- toast.success('分组配置已更新');
- setEditOpen(false);
- await invalidate();
- },
- onError: (error) => toast.error(getErrorMessage(error)),
- });
- const addMutation = useMutation({
- mutationFn: async ({
- domainIDs,
- proxied,
- }: {
- domainIDs: number[];
- proxied: boolean;
- }) => {
- 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();
- },
- onError: (error) => toast.error(getErrorMessage(error)),
- });
- const proxiedMutation = useMutation({
- mutationFn: ({
- memberID,
- proxied,
- }: {
- memberID: number;
- proxied: boolean;
- }) => CloudflareService.updateMember(groupID, memberID, proxied),
- onSuccess: async () => {
- toast.success('橙云设置已更新并排队同步');
- await invalidate();
- },
- onError: (error) => toast.error(getErrorMessage(error)),
- });
- const syncMutation = useMutation({
- mutationFn: (memberID: number) =>
- CloudflareService.syncMember(groupID, memberID),
- onSuccess: () => toast.success('成员同步任务已入队'),
- onError: (error) => toast.error(getErrorMessage(error)),
- });
- const removeMutation = useMutation({
- mutationFn: (memberID: number) =>
- CloudflareService.removeMember(groupID, memberID),
- onSuccess: async () => {
- toast.success('成员及远端 A 记录已删除');
- await invalidate();
- },
- onError: (error) => toast.error(getErrorMessage(error)),
- });
-
- if (detailQuery.isLoading)
- return (
-
-
-
- );
- if (detailQuery.isError || !detailQuery.data)
- return (
-
- void detailQuery.refetch()}
- />
-
- );
- const { group, members } = detailQuery.data;
+/** Placeholder for static export; real ids resolve client-side via useParams. */
+export async function generateStaticParams() {
+ return [{ id: '1' }];
+}
+export default async function CloudflareGroupDetailPage() {
return (
-
-
-
-
-
-
-
- {group.name}
-
-
-
-
-
-
-
+
+
-
-
-
-
- 当前指向
-
- 生效节点 {group.active_node.name} · {group.active_node.ip}
-
-
-
-
- {group.enabled ? '同步已启用' : '同步已停用'}
-
- 主节点 {group.primary_node.name}
-
- 备用节点 {group.backup_node?.name ?? '未设置'}
-
-
-
-
-
-
- 域名成员
- 成员级橙云是同步时的唯一依据。
-
-
- {members.length === 0 ? (
-
- 暂无域名成员。
-
- ) : (
-
-
-
- 域名
- 期望 IP
- 状态
- 橙云
- 操作
-
-
-
- {members.map((member) => (
-
-
- {member.domain}
- {member.last_error ? (
-
- {member.last_error}
-
- ) : null}
-
- {member.desired_ip || '待同步'}
-
-
- {member.sync_status}
-
-
-
-
- proxiedMutation.mutate({
- memberID: member.id,
- proxied,
- })
- }
- />
-
-
-
-
-
-
-
-
- ))}
-
-
- )}
-
-
-
-
updateGroupMutation.mutate(payload)}
- />
-
- addMutation.mutate({ domainIDs, proxied })
- }
- />
-
+ }
+ >
+
+
);
}
diff --git a/frontend/package.json b/frontend/package.json
index ab959333..d10e44cf 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -90,8 +90,8 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
- "@types/node": "^20",
- "@types/react": "^19",
+ "@types/node": "^20.19.24",
+ "@types/react": "^19.2.2",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.6",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 9f412905..b01d358d 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -232,10 +232,10 @@ importers:
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/node':
- specifier: ^20
+ specifier: ^20.19.24
version: 20.19.24
'@types/react':
- specifier: ^19
+ specifier: ^19.2.2
version: 19.2.2
'@types/react-dom':
specifier: ^19
diff --git a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx
index c47a64a6..f6b644b4 100644
--- a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx
+++ b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx
@@ -8,7 +8,7 @@ import {
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import CloudflareGroupDetailPage from '@/app/(main)/cloudflare/groups/[id]/page';
+import { CloudflareGroupDetailPageClient } from '@/app/(main)/cloudflare/groups/[id]/page-client';
import { CloudflareService, NodeService } from '@/lib/services/openflare';
vi.mock('next/navigation', () => ({
@@ -40,7 +40,7 @@ function renderPage() {
});
render(
-
+
,
);
}
diff --git a/frontend/tests/cloudflare/sync-tasks-panel.test.tsx b/frontend/tests/cloudflare/sync-tasks-panel.test.tsx
index 40fdaf0d..a071f03d 100644
--- a/frontend/tests/cloudflare/sync-tasks-panel.test.tsx
+++ b/frontend/tests/cloudflare/sync-tasks-panel.test.tsx
@@ -61,7 +61,7 @@ describe('Cloudflare sync tasks panel', () => {
it('queries only sync_member and sync_group and hides other tasks', async () => {
vi.mocked(AdminTaskService.listTaskExecutions).mockImplementation(
- async (request) => {
+ async (request = {}) => {
if (request.task_type === 'cloudflare:sync_member') {
return {
items: [