Add admin upgrade package preflight
This commit is contained in:
@@ -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'], {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<UpgradeMode>('hot');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [prechecking, setPrechecking] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [upgradeData, setUpgradeData] = useState<UpgradeResponse>({ latest: null, history: [], running: false, stateDir: '' });
|
||||
const [currentLogJobIds, setCurrentLogJobIds] = useState<Set<string>>(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)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -250,10 +267,16 @@ export default function SystemUpgradeTab() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit} className="gap-2">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <UploadCloud className="h-4 w-4" />}
|
||||
{submitting ? '正在上传...' : mode === 'hot' ? '启动热更新' : '启动冷更新'}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button variant="outline" onClick={() => handleSubmit(true)} disabled={!canSubmit} className="gap-2">
|
||||
{prechecking ? <Loader2 className="h-4 w-4 animate-spin" /> : <SearchCheck className="h-4 w-4" />}
|
||||
{prechecking ? '正在预检...' : '先预检升级包'}
|
||||
</Button>
|
||||
<Button onClick={() => handleSubmit(false)} disabled={!canSubmit} className="gap-2">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <UploadCloud className="h-4 w-4" />}
|
||||
{submitting ? '正在上传...' : mode === 'hot' ? '启动热更新' : '启动冷更新'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -393,7 +416,8 @@ function UpgradeStatusPanel({
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={job.status} />
|
||||
<Badge variant="secondary">{job.mode === 'hot' ? '热更新' : '冷更新'}</Badge>
|
||||
<Badge variant="secondary">{job.dryRun ? '预检' : job.mode === 'hot' ? '热更新' : '冷更新'}</Badge>
|
||||
{job.dryRun && <Badge className="bg-sky-500/15 text-sky-600 hover:bg-sky-500/15">未覆盖文件</Badge>}
|
||||
<Badge className={statusBadgeClass(job.status)}>{statusLabel(job.status)}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{formatDate(job.updatedAt)}</div>
|
||||
@@ -412,6 +436,7 @@ function UpgradeStatusPanel({
|
||||
<InfoRow label="升级包" value={job.packageName} />
|
||||
<InfoRow label="当前步骤" value={job.step} />
|
||||
<InfoRow label="文件数量" value={`${changedFiles.length} 个文件`} />
|
||||
<InfoRow label="需要重启" value={job.restartRequired ? '是' : '否'} />
|
||||
{job.backupFile && <InfoRow label="数据备份" value={job.backupFile} />}
|
||||
{job.sourceBackupFile && <InfoRow label="源码快照" value={job.sourceBackupFile} />}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user