From 8ae28e030da184302b1bf2ddb6f8e2112004d44b Mon Sep 17 00:00:00 2001 From: FengLee Date: Sun, 10 May 2026 00:18:03 +0800 Subject: [PATCH] Add admin upgrade package preflight --- scripts/admin-upgrade-runner.mjs | 29 ++++++++++- src/app/api/admin/upgrade/route.ts | 15 ++++-- src/components/admin/system-upgrade-tab.tsx | 53 +++++++++++++++------ 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/scripts/admin-upgrade-runner.mjs b/scripts/admin-upgrade-runner.mjs index 75d226e..39d6a8c 100755 --- a/scripts/admin-upgrade-runner.mjs +++ b/scripts/admin-upgrade-runner.mjs @@ -15,6 +15,7 @@ const stateRoot = path.resolve( const jobId = requireArg(args, 'job-id'); const mode = requireArg(args, 'mode'); +const dryRun = args['dry-run'] === 'true'; const packagePath = path.resolve(requireArg(args, 'package')); const packageName = args['package-name'] || path.basename(packagePath); const jobDir = path.join(stateRoot, 'jobs', jobId); @@ -64,6 +65,17 @@ let state = readState() || { main().catch(error => { log(`fatal: ${error instanceof Error ? error.stack || error.message : String(error)}`); + if (dryRun) { + updateState({ + status: 'failed', + step: 'preflight_failed', + progress: 100, + message: '升级包预检失败,请按错误信息调整升级包', + error: error instanceof Error ? error.message : '升级包预检异常退出', + finishedAt: new Date().toISOString(), + }); + return; + } rollbackAfterFailure(error instanceof Error ? error.message : '升级任务异常退出').catch(rollbackError => { updateState({ status: 'rollback_failed', @@ -85,7 +97,7 @@ async function main() { message: '正在检查升级包与运行环境', startedAt: state.startedAt || new Date().toISOString(), }); - logStep('开始升级任务', `任务 ${jobId} 使用${mode === 'hot' ? '热更新' : '冷更新'}模式,升级包 ${packageName}`); + logStep('开始升级任务', `任务 ${jobId} 使用${mode === 'hot' ? '热更新' : '冷更新'}模式,升级包 ${packageName}${dryRun ? ',仅执行预检' : ''}`); if (mode !== 'hot' && mode !== 'cold') { throw new Error('升级方式无效'); @@ -118,8 +130,23 @@ async function main() { restartRequired: mode === 'cold' || validation.requiresRestart, packageHash: sha256(packagePath), changedFiles: files, + dryRun, }); + if (dryRun) { + logStep('预检完成', `升级包可用于${mode === 'hot' ? '热更新' : '冷更新'},${mode === 'cold' || validation.requiresRestart ? '需要重启平台' : '无需重启平台'}`); + updateState({ + status: 'succeeded', + step: 'preflight_completed', + progress: 100, + message: `预检通过:共 ${files.length} 个文件,${mode === 'cold' || validation.requiresRestart ? '执行时需要重启平台' : '执行时无需重启平台'}`, + finishedAt: new Date().toISOString(), + restartRequired: mode === 'cold' || validation.requiresRestart, + dryRun: true, + }); + return; + } + updateState({ step: 'backup_data', progress: 22, message: '正在创建数据库、存储与环境配置备份' }); logStep('创建数据备份', '开始备份数据库、存储目录和环境配置'); const backupFile = runCapture('bash', ['./scripts/backup-create.sh'], { diff --git a/src/app/api/admin/upgrade/route.ts b/src/app/api/admin/upgrade/route.ts index f75b936..463a36a 100644 --- a/src/app/api/admin/upgrade/route.ts +++ b/src/app/api/admin/upgrade/route.ts @@ -37,6 +37,7 @@ type UpgradeJobState = { updatedAt: string; finishedAt?: string; logs: string[]; + dryRun?: boolean; }; const MAX_PACKAGE_BYTES = 300 * 1024 * 1024; @@ -74,6 +75,7 @@ export async function POST(request: NextRequest) { const form = await request.formData(); const modeValue = String(form.get('mode') || ''); const mode = modeValue === 'hot' || modeValue === 'cold' ? modeValue : null; + const dryRun = String(form.get('dryRun') || '') === 'true'; if (!mode) { return NextResponse.json({ error: '请选择热更新或冷更新' }, { status: 400 }); } @@ -116,10 +118,14 @@ export async function POST(request: NextRequest) { startedAt: now, updatedAt: now, logs: [`[${now}] 上传升级包 ${file.name} (${file.size} bytes)`], + dryRun, }; + if (dryRun) { + initialState.message = '升级包已上传,正在执行预检'; + } await writeState(jobDir, initialState); - const child = spawn(process.execPath, [ + const runnerArgs = [ path.join(process.cwd(), 'scripts/admin-upgrade-runner.mjs'), '--job-id', jobId, @@ -131,7 +137,10 @@ export async function POST(request: NextRequest) { file.name, '--project', process.cwd(), - ], { + ]; + if (dryRun) runnerArgs.push('--dry-run', 'true'); + + const child = spawn(process.execPath, runnerArgs, { cwd: process.cwd(), detached: true, stdio: 'ignore', @@ -143,7 +152,7 @@ export async function POST(request: NextRequest) { }); child.unref(); - return NextResponse.json({ success: true, job: initialState }); + return NextResponse.json({ success: true, dryRun, job: initialState }); } catch (error) { console.error('[admin/upgrade] failed to start upgrade:', error); return NextResponse.json({ error: error instanceof Error ? error.message : '创建升级任务失败' }, { status: 500 }); diff --git a/src/components/admin/system-upgrade-tab.tsx b/src/components/admin/system-upgrade-tab.tsx index 04be83a..905a4b6 100644 --- a/src/components/admin/system-upgrade-tab.tsx +++ b/src/components/admin/system-upgrade-tab.tsx @@ -16,6 +16,7 @@ import { ShieldCheck, UploadCloud, XCircle, + SearchCheck, } from 'lucide-react'; import { toast } from 'sonner'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -57,6 +58,7 @@ type UpgradeJob = { updatedAt: string; finishedAt?: string; logs: string[]; + dryRun?: boolean; }; type UpgradeResponse = { @@ -85,6 +87,7 @@ export default function SystemUpgradeTab() { const [mode, setMode] = useState('hot'); const [selectedFile, setSelectedFile] = useState(null); const [submitting, setSubmitting] = useState(false); + const [prechecking, setPrechecking] = useState(false); const [loading, setLoading] = useState(true); const [upgradeData, setUpgradeData] = useState({ latest: null, history: [], running: false, stateDir: '' }); const [currentLogJobIds, setCurrentLogJobIds] = useState>(new Set()); @@ -136,9 +139,12 @@ export default function SystemUpgradeTab() { return () => window.clearTimeout(timer); }, [latest?.id, latest?.status, latest, loadStatus]); - const canSubmit = useMemo(() => Boolean(selectedFile) && !submitting && !upgradeData.running, [selectedFile, submitting, upgradeData.running]); + const canSubmit = useMemo( + () => Boolean(selectedFile) && !submitting && !prechecking && !upgradeData.running, + [selectedFile, submitting, prechecking, upgradeData.running], + ); - async function handleSubmit() { + async function handleSubmit(dryRun = false) { if (!selectedFile) { toast.error('请选择升级包'); return; @@ -148,11 +154,16 @@ export default function SystemUpgradeTab() { return; } - setSubmitting(true); + if (dryRun) { + setPrechecking(true); + } else { + setSubmitting(true); + } try { const form = new FormData(); form.set('mode', mode); form.set('package', selectedFile); + if (dryRun) form.set('dryRun', 'true'); const res = await fetch('/api/admin/upgrade', { method: 'POST', @@ -165,14 +176,20 @@ export default function SystemUpgradeTab() { if (data.job?.id) { setCurrentLogJobIds(previous => new Set(previous).add(data.job.id)); } - toast.success(mode === 'hot' ? '热更新任务已启动' : '冷更新任务已启动'); - setSelectedFile(null); - if (fileInputRef.current) fileInputRef.current.value = ''; + toast.success(dryRun ? '升级包预检已启动' : mode === 'hot' ? '热更新任务已启动' : '冷更新任务已启动'); + if (!dryRun) { + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + } await loadStatus({ silent: true }); } catch (error) { - toast.error(error instanceof Error ? error.message : '创建升级任务失败'); + toast.error(error instanceof Error ? error.message : dryRun ? '启动预检失败' : '创建升级任务失败'); } finally { - setSubmitting(false); + if (dryRun) { + setPrechecking(false); + } else { + setSubmitting(false); + } } } @@ -220,7 +237,7 @@ export default function SystemUpgradeTab() { id="upgrade-package" type="file" accept=".tar,.tgz,.tar.gz,application/gzip,application/x-tar" - disabled={submitting || upgradeData.running} + disabled={submitting || prechecking || upgradeData.running} onChange={event => setSelectedFile(event.target.files?.[0] || null)} />

@@ -250,10 +267,16 @@ export default function SystemUpgradeTab() { - +

+ + +
@@ -393,7 +416,8 @@ function UpgradeStatusPanel({
- {job.mode === 'hot' ? '热更新' : '冷更新'} + {job.dryRun ? '预检' : job.mode === 'hot' ? '热更新' : '冷更新'} + {job.dryRun && 未覆盖文件} {statusLabel(job.status)}
{formatDate(job.updatedAt)}
@@ -412,6 +436,7 @@ function UpgradeStatusPanel({ + {job.backupFile && } {job.sourceBackupFile && }