From 4481677ef36e3b1437fe4f4992b7704b4c557380 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 19 Jul 2026 20:41:09 +0800 Subject: [PATCH] =?UTF-8?q?refactor(pages):=20=E5=85=AC=E5=BC=80=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=BA=90=E4=BB=BB=E5=8A=A1=E5=B9=B6=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E6=89=AB=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 Pages 部署源任务的 InternalOnly 限制,任务管理可查看与调度; 将 scanner cron 与 GitHub latest 默认检查间隔调整为每天一次, 并优化部署历史列表展示。 --- .../detail/components/deployment-history.tsx | 381 +++++++++++------- .../detail/components/pages-source-dialog.tsx | 2 +- .../tests/openflare/pages-service.test.ts | 4 +- .../tests/openflare/pages-source-ui.test.tsx | 11 +- internal/apps/openflare/pages/source.go | 2 +- .../apps/openflare/pages/source_scanner.go | 4 +- .../openflare/pages/source_scanner_test.go | 6 +- internal/apps/openflare/pages/source_tasks.go | 3 +- .../apps/openflare/pages/source_tasks_test.go | 6 +- .../202607190003_seed_pages_source_scan.sql | 4 +- .../202607190003_seed_pages_source_scan.sql | 4 +- .../pages_source_scan_migration_test.go | 2 +- 12 files changed, 269 insertions(+), 160 deletions(-) diff --git a/frontend/app/(main)/pages/detail/components/deployment-history.tsx b/frontend/app/(main)/pages/detail/components/deployment-history.tsx index 98e7d8d5..23910911 100644 --- a/frontend/app/(main)/pages/detail/components/deployment-history.tsx +++ b/frontend/app/(main)/pages/detail/components/deployment-history.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { ChevronDown, ChevronRight, Upload } from 'lucide-react'; +import { ChevronDown, ChevronRight, Rocket, Upload } from 'lucide-react'; import { toast } from 'sonner'; import { EmptyStateWithBorder } from '@/components/layout/empty'; @@ -30,7 +30,7 @@ import { } from '@/components/ui/card'; import { Spinner } from '@/components/ui/spinner'; import { type PagesDeployment, PagesService } from '@/lib/services/openflare'; -import { formatDateTime } from '@/lib/utils'; +import { cn, formatDateTime } from '@/lib/utils'; import { DeploymentUploadDialog } from '../../components/deployment-upload-dialog'; import { @@ -69,6 +69,15 @@ type PendingAction = { deployment: PagesDeployment; }; +function isActiveDeployment( + deployment: PagesDeployment, + activeDeploymentId?: number | null, +) { + return ( + deployment.id === activeDeploymentId || deployment.status === 'active' + ); +} + function deploymentSnapshot(deployment: PagesDeployment) { return [ SOURCE_LABELS[deployment.source_type], @@ -79,6 +88,107 @@ function deploymentSnapshot(deployment: PagesDeployment) { .join(' · '); } +function DeploymentMeta({ deployment }: { deployment: PagesDeployment }) { + return ( + <> +

+ {deployment.checksum.slice(0, 16)} · {deployment.file_count} 个文件 ·{' '} + {formatBytes(deployment.total_size)} +

+

+ 创建于 {formatDateTime(deployment.created_at)} + {deployment.activated_at + ? ` · 激活于 ${formatDateTime(deployment.activated_at)}` + : ''} +

+ + ); +} + +interface DeploymentRowProps { + deployment: PagesDeployment; + active: boolean; + expanded: boolean; + actionPending: boolean; + showActions: boolean; + onToggleExpand: () => void; + onActivate: () => void; + onDelete: () => void; + projectId: number; +} + +function DeploymentRow({ + deployment, + active, + expanded, + actionPending, + showActions, + onToggleExpand, + onActivate, + onDelete, + projectId, +}: DeploymentRowProps) { + return ( +
+
+
+ +
+
+ + 部署 #{deployment.deployment_number} + + {deploymentSnapshot(deployment)} +
+ +
+
+ {showActions ? ( +
+ + +
+ ) : null} +
+ {expanded ? ( + + ) : null} +
+ ); +} + export function DeploymentHistory({ projectId, activeDeploymentId, @@ -99,16 +209,20 @@ export function DeploymentHistory({ queryFn: () => PagesService.listDeployments(projectId), }); - const deployments = useMemo(() => { + const { productionDeployment, allDeployments } = useMemo(() => { const records = [...(deploymentsQuery.data ?? [])]; - return records.sort((left, right) => { - const leftActive = - left.id === activeDeploymentId || left.status === 'active'; - const rightActive = - right.id === activeDeploymentId || right.status === 'active'; - if (leftActive !== rightActive) return leftActive ? -1 : 1; - return right.deployment_number - left.deployment_number; - }); + records.sort( + (left, right) => right.deployment_number - left.deployment_number, + ); + + const production = + records.find((item) => isActiveDeployment(item, activeDeploymentId)) ?? + null; + + return { + productionDeployment: production, + allDeployments: records, + }; }, [activeDeploymentId, deploymentsQuery.data]); const invalidateDeploymentState = async () => { @@ -153,132 +267,14 @@ export function DeploymentHistory({ const actionPending = activateMutation.isPending || deleteMutation.isPending; - return ( + const toggleExpand = (deploymentId: number) => { + setExpandedDeploymentId((current) => + current === deploymentId ? null : deploymentId, + ); + }; + + const dialogs = ( <> - - - 部署历史 - - 部署记录不可变,来源信息是创建部署时的安全快照。 - - - - - - - {deploymentsQuery.isLoading ? ( - - ) : deploymentsQuery.isError ? ( -
- void deploymentsQuery.refetch()} - /> -
- ) : deployments.length === 0 ? ( - - ) : ( - deployments.map((deployment) => { - const active = - deployment.id === activeDeploymentId || - deployment.status === 'active'; - const expanded = expandedDeploymentId === deployment.id; - - return ( -
-
-
- -
-
- - 部署 #{deployment.deployment_number} - - - {active ? 'Production' : '历史部署'} - - - {deploymentSnapshot(deployment)} - -
-

- {deployment.checksum.slice(0, 16)} ·{' '} - {deployment.file_count} 个文件 ·{' '} - {formatBytes(deployment.total_size)} -

-

- 创建于 {formatDateTime(deployment.created_at)} -

-
-
-
- - -
-
- {expanded ? ( - - ) : null} -
- ); - }) - )} -
-
- { @@ -326,4 +322,117 @@ export function DeploymentHistory({ /> ); + + if (deploymentsQuery.isLoading) { + return ( + <> + + {dialogs} + + ); + } + + if (deploymentsQuery.isError) { + return ( + <> +
+ void deploymentsQuery.refetch()} + /> +
+ {dialogs} + + ); + } + + return ( +
+ + + Production + + 当前对外生效的生产部署;切换激活状态后会立即生效。 + + + + + + + {productionDeployment ? ( + toggleExpand(productionDeployment.id)} + onActivate={() => undefined} + onDelete={() => undefined} + projectId={projectId} + /> + ) : ( + + )} + + + + + + All deployments + + 部署记录不可变,来源信息是创建部署时的安全快照。可从历史部署激活或删除。 + + + + {allDeployments.length === 0 ? ( + + ) : ( + allDeployments.map((deployment) => { + const active = isActiveDeployment(deployment, activeDeploymentId); + return ( + toggleExpand(deployment.id)} + onActivate={() => + setPendingAction({ type: 'activate', deployment }) + } + onDelete={() => + setPendingAction({ type: 'delete', deployment }) + } + projectId={projectId} + /> + ); + }) + )} + + + + {dialogs} +
+ ); } diff --git a/frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx b/frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx index 3f6635f2..d33c94ea 100644 --- a/frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx +++ b/frontend/app/(main)/pages/detail/components/pages-source-dialog.tsx @@ -72,7 +72,7 @@ interface PagesSourceDialogProps { } const DEFAULT_GITHUB_ASSET = 'dist.zip'; -const DEFAULT_GITHUB_CHECK_INTERVAL = 60; +const DEFAULT_GITHUB_CHECK_INTERVAL = 1440; const EMPTY_GITHUB_ERRORS: PagesGitHubSourceFormErrors = { repository: '', releaseTag: '', diff --git a/frontend/tests/openflare/pages-service.test.ts b/frontend/tests/openflare/pages-service.test.ts index f2634525..6476318a 100644 --- a/frontend/tests/openflare/pages-service.test.ts +++ b/frontend/tests/openflare/pages-service.test.ts @@ -105,7 +105,7 @@ describe('PagesService', () => { release_tag: '', asset_name: 'dist.zip', auto_update_enabled: false, - check_interval_minutes: 60, + check_interval_minutes: 1440, }); expect(apiClient.post).toHaveBeenCalledWith( @@ -117,7 +117,7 @@ describe('PagesService', () => { release_tag: '', asset_name: 'dist.zip', auto_update_enabled: false, - check_interval_minutes: 60, + check_interval_minutes: 1440, }, undefined, ); diff --git a/frontend/tests/openflare/pages-source-ui.test.tsx b/frontend/tests/openflare/pages-source-ui.test.tsx index 97803397..b93be55b 100644 --- a/frontend/tests/openflare/pages-source-ui.test.tsx +++ b/frontend/tests/openflare/pages-source-ui.test.tsx @@ -79,7 +79,7 @@ const githubLatestSource: PagesGitHubReleaseSource = { release_selector: 'latest', asset_name: 'dist.zip', auto_update_enabled: false, - check_interval_minutes: 60, + check_interval_minutes: 1440, sync_status: 'update_available', update_available: true, last_seen: { @@ -172,7 +172,7 @@ describe('Pages source UI', () => { await user.click(screen.getByRole('radio', { name: 'GitHub Release' })); expect(screen.getByRole('switch', { name: '自动更新' })).not.toBeChecked(); - expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(60); + expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(1440); }); it('submits the GitHub latest automatic update settings', async () => { @@ -391,7 +391,7 @@ describe('Pages source UI', () => { expect(screen.getByText('自动更新')).toBeVisible(); expect(screen.getByText('已关闭')).toBeVisible(); expect(screen.getByText('检查间隔')).toBeVisible(); - expect(screen.getByText('60 分钟')).toBeVisible(); + expect(screen.getByText('1440 分钟')).toBeVisible(); }); it('dispatches a GitHub check and starts TaskExecution polling', async () => { @@ -632,7 +632,10 @@ describe('Pages source UI', () => { , ); - expect(await screen.findByText('GitHub · v1.2.3 · 手动同步')).toBeVisible(); + expect( + await screen.findAllByText('GitHub · v1.2.3 · 手动同步'), + ).toHaveLength(2); expect(screen.getByText('Production')).toBeVisible(); + expect(screen.getByText('All deployments')).toBeVisible(); }); }); diff --git a/internal/apps/openflare/pages/source.go b/internal/apps/openflare/pages/source.go index 6f849698..e5f3df23 100644 --- a/internal/apps/openflare/pages/source.go +++ b/internal/apps/openflare/pages/source.go @@ -38,7 +38,7 @@ const ( defaultRemoteAssetLabel = "pages-package" defaultGitHubAssetName = "dist.zip" - defaultCheckInterval = 60 + defaultCheckInterval = 1440 minimumCheckInterval = 5 maximumCheckInterval = 1440 ) diff --git a/internal/apps/openflare/pages/source_scanner.go b/internal/apps/openflare/pages/source_scanner.go index 29fc0dc8..1c753d74 100644 --- a/internal/apps/openflare/pages/source_scanner.go +++ b/internal/apps/openflare/pages/source_scanner.go @@ -27,8 +27,7 @@ const ( pagesSourceScanBatchSize = 20 ) -// PagesSourceScanMeta is available to the scheduler registry but hidden from -// generic Admin task dispatch and schedule mutation APIs. +// PagesSourceScanMeta describes the periodic Pages source scanner schedule. var PagesSourceScanMeta = task.TaskMeta{ Type: TaskTypePagesSourceScan, AsynqTask: PagesSourceScanTask, @@ -38,7 +37,6 @@ var PagesSourceScanMeta = task.TaskMeta{ MaxRetry: 0, Queue: task.QueueDefault, Retryable: false, - InternalOnly: true, } type pagesSourceScanPayload struct{} diff --git a/internal/apps/openflare/pages/source_scanner_test.go b/internal/apps/openflare/pages/source_scanner_test.go index 2d7c2394..8fdd9646 100644 --- a/internal/apps/openflare/pages/source_scanner_test.go +++ b/internal/apps/openflare/pages/source_scanner_test.go @@ -477,7 +477,7 @@ func TestPagesSourceScannerIncludesOrphanCleanupSummary(t *testing.T) { } } -func TestPagesSourceScanPayloadAndMetaAreInternalOnly(t *testing.T) { +func TestPagesSourceScanPayloadAndMeta(t *testing.T) { handler := &SourceScanHandler{} if normalized, err := handler.ValidatePayload(nil); err != nil || string(normalized) != "{}" { t.Errorf("ValidatePayload(nil) = %s, %v; want {}, nil", normalized, err) @@ -485,9 +485,9 @@ func TestPagesSourceScanPayloadAndMetaAreInternalOnly(t *testing.T) { if _, err := handler.ValidatePayload([]byte(`{"unexpected":true}`)); err == nil { t.Error("ValidatePayload(unknown field) error = nil, want non-nil") } - if !PagesSourceScanMeta.InternalOnly || PagesSourceScanMeta.Type != TaskTypePagesSourceScan || + if PagesSourceScanMeta.InternalOnly || PagesSourceScanMeta.Type != TaskTypePagesSourceScan || PagesSourceScanMeta.AsynqTask != PagesSourceScanTask || PagesSourceScanMeta.MaxRetry != 0 { - t.Errorf("PagesSourceScanMeta = %+v, want internal bounded scheduled scanner", PagesSourceScanMeta) + t.Errorf("PagesSourceScanMeta = %+v, want public bounded scheduled scanner", PagesSourceScanMeta) } if PagesSourceScanMeta.SupportsTime { t.Error("PagesSourceScanMeta.SupportsTime = true, want empty scanner payload") diff --git a/internal/apps/openflare/pages/source_tasks.go b/internal/apps/openflare/pages/source_tasks.go index 4dcae87f..048e5bb5 100644 --- a/internal/apps/openflare/pages/source_tasks.go +++ b/internal/apps/openflare/pages/source_tasks.go @@ -32,7 +32,7 @@ const ( var errUnexpectedJSONTrailingValue = errors.New("unexpected trailing JSON value") -// PagesSourceActionMeta is intentionally hidden from generic Admin task APIs. +// PagesSourceActionMeta describes check/sync work for Pages deployment sources. var PagesSourceActionMeta = task.TaskMeta{ Type: TaskTypePagesSourceAction, AsynqTask: PagesSourceActionTask, @@ -42,7 +42,6 @@ var PagesSourceActionMeta = task.TaskMeta{ MaxRetry: 2, Queue: task.QueueDefault, Retryable: false, - InternalOnly: true, } // SourceActionPayload is the credential-free internal queue contract. diff --git a/internal/apps/openflare/pages/source_tasks_test.go b/internal/apps/openflare/pages/source_tasks_test.go index bc34a459..e2adfd31 100644 --- a/internal/apps/openflare/pages/source_tasks_test.go +++ b/internal/apps/openflare/pages/source_tasks_test.go @@ -165,9 +165,9 @@ func TestRemoteCheckActionIsPermanentWithoutExposingURL(t *testing.T) { } } -func TestPagesSourceActionMetaIsInternalOnly(t *testing.T) { - if !PagesSourceActionMeta.InternalOnly { - t.Error("PagesSourceActionMeta.InternalOnly = false, want true") +func TestPagesSourceActionMeta(t *testing.T) { + if PagesSourceActionMeta.InternalOnly { + t.Error("PagesSourceActionMeta.InternalOnly = true, want false") } if PagesSourceActionMeta.Type != TaskTypePagesSourceAction { t.Errorf("PagesSourceActionMeta.Type = %q, want %q", PagesSourceActionMeta.Type, TaskTypePagesSourceAction) diff --git a/internal/db/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql b/internal/db/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql index d50e45b3..2c64b37d 100644 --- a/internal/db/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql +++ b/internal/db/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql @@ -20,7 +20,7 @@ INSERT INTO w_schedules (name, task_type, cron, payload, is_active, created_at, SELECT 'OpenFlare Pages 部署源扫描', 'of_pages_source_scan', - '*/5 * * * *', + '0 0 * * *', '{}', TRUE, CURRENT_TIMESTAMP, @@ -33,6 +33,6 @@ WHERE NOT EXISTS ( DELETE FROM w_schedules WHERE task_type = 'of_pages_source_scan' AND name = 'OpenFlare Pages 部署源扫描' - AND cron = '*/5 * * * *' + AND cron = '0 0 * * *' AND payload = '{}' AND is_active = TRUE; diff --git a/internal/db/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql b/internal/db/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql index dd0235bf..d9364c20 100644 --- a/internal/db/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql +++ b/internal/db/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql @@ -3,7 +3,7 @@ INSERT INTO w_schedules (name, task_type, cron, payload, is_active, created_at, SELECT 'OpenFlare Pages 部署源扫描', 'of_pages_source_scan', - '*/5 * * * *', + '0 0 * * *', '{}', 1, CURRENT_TIMESTAMP, @@ -16,6 +16,6 @@ WHERE NOT EXISTS ( DELETE FROM w_schedules WHERE task_type = 'of_pages_source_scan' AND name = 'OpenFlare Pages 部署源扫描' - AND cron = '*/5 * * * *' + AND cron = '0 0 * * *' AND payload = '{}' AND is_active = 1; diff --git a/internal/db/migrator/pages_source_scan_migration_test.go b/internal/db/migrator/pages_source_scan_migration_test.go index fa1d24c3..1436c33d 100644 --- a/internal/db/migrator/pages_source_scan_migration_test.go +++ b/internal/db/migrator/pages_source_scan_migration_test.go @@ -144,7 +144,7 @@ func assertPagesSourceScanSchedule(t *testing.T, gormDB *gorm.DB) model.Schedule require.Len(t, schedules, 1) schedule := schedules[0] assert.Equal(t, "OpenFlare Pages 部署源扫描", schedule.Name) - assert.Equal(t, "*/5 * * * *", schedule.Cron) + assert.Equal(t, "0 0 * * *", schedule.Cron) assert.Equal(t, "{}", schedule.Payload) assert.True(t, schedule.IsActive) return schedule