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: [