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