feat: improve admin upgrade logs

This commit is contained in:
Codex
2026-05-09 08:07:24 +00:00
parent e072f219e4
commit f2817ab8fd
3 changed files with 110 additions and 20 deletions

View File

@@ -84,6 +84,7 @@ async function main() {
message: '正在检查升级包与运行环境', message: '正在检查升级包与运行环境',
startedAt: state.startedAt || new Date().toISOString(), startedAt: state.startedAt || new Date().toISOString(),
}); });
logStep('开始升级任务', `任务 ${jobId} 使用${mode === 'hot' ? '热更新' : '冷更新'}模式,升级包 ${packageName}`);
if (mode !== 'hot' && mode !== 'cold') { if (mode !== 'hot' && mode !== 'cold') {
throw new Error('升级方式无效'); throw new Error('升级方式无效');
@@ -95,6 +96,7 @@ async function main() {
throw new Error('仅支持 .tar、.tar.gz、.tgz 升级包'); throw new Error('仅支持 .tar、.tar.gz、.tgz 升级包');
} }
logStep('校验升级包', '正在读取压缩包目录并检查格式');
run('tar', tarReadArgs('list', packagePath), { cwd: projectRoot, label: '检查升级包结构' }); run('tar', tarReadArgs('list', packagePath), { cwd: projectRoot, label: '检查升级包结构' });
resetDir(extractDir); resetDir(extractDir);
@@ -107,6 +109,7 @@ async function main() {
} }
const validation = validateFiles(files, mode); const validation = validateFiles(files, mode);
logStep('升级包内容', `校验通过,共 ${files.length} 个文件:${files.slice(0, 20).join('、')}${files.length > 20 ? `${files.length} 个文件` : ''}`);
updateState({ updateState({
step: 'validated', step: 'validated',
progress: 14, progress: 14,
@@ -117,6 +120,7 @@ async function main() {
}); });
updateState({ step: 'backup_data', progress: 22, message: '正在创建数据库、存储与环境配置备份' }); updateState({ step: 'backup_data', progress: 22, message: '正在创建数据库、存储与环境配置备份' });
logStep('创建数据备份', '开始备份数据库、存储目录和环境配置');
const backupFile = runCapture('bash', ['./scripts/backup-create.sh'], { const backupFile = runCapture('bash', ['./scripts/backup-create.sh'], {
cwd: projectRoot, cwd: projectRoot,
label: '创建数据备份', label: '创建数据备份',
@@ -126,18 +130,25 @@ async function main() {
throw new Error('数据备份创建失败'); throw new Error('数据备份创建失败');
} }
updateState({ backupFile }); updateState({ backupFile });
logStep('数据备份完成', `备份文件:${backupFile}`);
updateState({ step: 'backup_source', progress: 30, message: '正在创建源码快照' }); updateState({ step: 'backup_source', progress: 30, message: '正在创建源码快照' });
logStep('创建源码快照', '开始保存升级前源码状态');
createSourceBackup(sourceBackupFile); createSourceBackup(sourceBackupFile);
updateState({ sourceBackupFile }); updateState({ sourceBackupFile });
logStep('源码快照完成', `快照文件:${sourceBackupFile}`);
updateState({ step: 'apply', progress: 42, message: '正在应用升级包文件' }); updateState({ step: 'apply', progress: 42, message: '正在应用升级包文件' });
logStep('应用升级文件', '开始覆盖升级包中的文件');
updateState({ preExistingFiles: files.filter(file => fs.existsSync(path.join(projectRoot, file))) }); updateState({ preExistingFiles: files.filter(file => fs.existsSync(path.join(projectRoot, file))) });
applyFiles(payloadRoot, files); applyFiles(payloadRoot, files);
logStep('升级文件应用完成', `已应用 ${files.filter(file => file !== 'manifest.json').length} 个文件`);
if (mode === 'hot') { if (mode === 'hot') {
updateState({ step: 'verify_hot', progress: 70, message: '正在验证热更新文件' }); updateState({ step: 'verify_hot', progress: 70, message: '正在验证热更新文件' });
logStep('热更新验证', '正在执行 TypeScript 校验,确认补丁不会破坏现有代码');
run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' }); run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' });
logStep('热更新完成', '升级成功,平台未重启,前端业务不中断');
updateState({ updateState({
status: 'succeeded', status: 'succeeded',
step: 'completed', step: 'completed',
@@ -152,20 +163,30 @@ async function main() {
const dependencyChanged = files.some(file => file === 'package.json' || file === 'pnpm-lock.yaml'); const dependencyChanged = files.some(file => file === 'package.json' || file === 'pnpm-lock.yaml');
if (dependencyChanged) { if (dependencyChanged) {
updateState({ step: 'install', progress: 54, message: '依赖文件发生变化,正在安装依赖' }); updateState({ step: 'install', progress: 54, message: '依赖文件发生变化,正在安装依赖' });
logStep('安装依赖', '检测到 package.json 或 pnpm-lock.yaml 变化,开始安装依赖');
run('pnpm', ['install', '--frozen-lockfile', '--prod=false'], { cwd: projectRoot, label: '安装依赖' }); run('pnpm', ['install', '--frozen-lockfile', '--prod=false'], { cwd: projectRoot, label: '安装依赖' });
logStep('依赖安装完成', '依赖安装已完成');
} }
updateState({ step: 'ts_check', progress: 64, message: '正在执行 TypeScript 校验' }); updateState({ step: 'ts_check', progress: 64, message: '正在执行 TypeScript 校验' });
logStep('代码校验', '开始执行 TypeScript 校验');
run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' }); run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' });
logStep('代码校验完成', 'TypeScript 校验已通过');
updateState({ step: 'build', progress: 75, message: '正在构建平台' }); updateState({ step: 'build', progress: 75, message: '正在构建平台' });
logStep('平台构建', '开始构建生产版本');
run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '构建平台' }); run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '构建平台' });
logStep('平台构建完成', '生产构建已完成');
updateState({ step: 'restart', progress: 86, message: '正在重启平台进程' }); updateState({ step: 'restart', progress: 86, message: '正在重启平台进程' });
logStep('重启平台', '冷更新需要重启平台进程,重启后升级状态会从磁盘继续读取');
restartPlatform(); restartPlatform();
logStep('平台重启命令完成', '平台重启命令已执行,开始等待健康检查');
updateState({ step: 'health_check', progress: 94, message: '正在检查平台健康状态' }); updateState({ step: 'health_check', progress: 94, message: '正在检查平台健康状态' });
logStep('健康检查', '正在确认平台接口恢复正常');
waitForHealth(); waitForHealth();
logStep('冷更新完成', '平台已重启并通过健康检查');
updateState({ updateState({
status: 'succeeded', status: 'succeeded',
@@ -179,6 +200,7 @@ async function main() {
async function rollbackAfterFailure(message) { async function rollbackAfterFailure(message) {
const originalError = message; const originalError = message;
logStep('升级失败', `失败原因:${originalError}`);
updateState({ updateState({
status: 'rolling_back', status: 'rolling_back',
step: 'rolling_back', step: 'rolling_back',
@@ -188,27 +210,35 @@ async function rollbackAfterFailure(message) {
}); });
if (fs.existsSync(sourceBackupFile)) { if (fs.existsSync(sourceBackupFile)) {
logStep('回滚源码', '正在恢复升级前源码快照,并移除升级中新建的文件');
restoreSourceBackup(sourceBackupFile); restoreSourceBackup(sourceBackupFile);
logStep('源码回滚完成', '源码已恢复到升级开始前状态');
} }
if (state.backupFile && fs.existsSync(state.backupFile)) { if (state.backupFile && fs.existsSync(state.backupFile)) {
logStep('回滚数据', '正在恢复数据库、存储目录和环境配置备份');
run('bash', ['./scripts/backup-restore.sh', state.backupFile], { run('bash', ['./scripts/backup-restore.sh', state.backupFile], {
cwd: projectRoot, cwd: projectRoot,
label: '恢复数据备份', label: '恢复数据备份',
env: { COZE_WORKSPACE_PATH: projectRoot }, env: { COZE_WORKSPACE_PATH: projectRoot },
}); });
logStep('数据回滚完成', '数据库、存储目录和环境配置已恢复');
} }
if (mode === 'cold') { if (mode === 'cold') {
try { try {
logStep('回滚后重建', '冷更新失败后正在重新构建回滚版本');
run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '回滚后重新构建' }); run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '回滚后重新构建' });
logStep('回滚后重启', '正在重启回滚后的平台版本');
restartPlatform(); restartPlatform();
waitForHealth(); waitForHealth();
logStep('回滚后健康检查通过', '平台已恢复到升级前版本');
} catch (error) { } catch (error) {
throw new Error(`回滚后平台恢复检查失败: ${error instanceof Error ? error.message : String(error)}`); throw new Error(`回滚后平台恢复检查失败: ${error instanceof Error ? error.message : String(error)}`);
} }
} }
logStep('自动回滚完成', '升级失败,但已自动恢复到升级开始前状态');
updateState({ updateState({
status: 'rolled_back', status: 'rolled_back',
step: 'rolled_back', step: 'rolled_back',
@@ -281,10 +311,14 @@ function updateState(patch) {
function log(line) { function log(line) {
const timestamped = `[${new Date().toISOString()}] ${line}`; const timestamped = `[${new Date().toISOString()}] ${line}`;
const logs = [...(state.logs || []), timestamped].slice(-300); const logs = [...(state.logs || []), timestamped].slice(-1000);
updateState({ logs }); updateState({ logs });
} }
function logStep(title, detail = '') {
log(detail ? `${title}${detail}` : title);
}
function ensureDir(dir) { function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
} }
@@ -300,7 +334,7 @@ function run(command, commandArgs, options = {}) {
function runCapture(command, commandArgs, options = {}) { function runCapture(command, commandArgs, options = {}) {
const label = options.label || command; const label = options.label || command;
log(`${label}: ${command} ${commandArgs.join(' ')}`); logStep(label, `执行命令 ${command} ${commandArgs.join(' ')}`);
const result = spawnSync(command, commandArgs, { const result = spawnSync(command, commandArgs, {
cwd: options.cwd || projectRoot, cwd: options.cwd || projectRoot,
env: { ...process.env, COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack', ...(options.env || {}) }, env: { ...process.env, COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack', ...(options.env || {}) },
@@ -309,7 +343,7 @@ function runCapture(command, commandArgs, options = {}) {
}); });
const output = `${result.stdout || ''}${result.stderr || ''}`.trim(); const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
if (output) { if (output) {
for (const line of output.split(/\r?\n/).slice(-80)) log(`${label}: ${line}`); for (const line of output.split(/\r?\n/).slice(-180)) log(`${label}输出:${line}`);
} }
if (result.status !== 0) { if (result.status !== 0) {
throw new Error(`${label}失败,退出码 ${result.status ?? 'unknown'}`); throw new Error(`${label}失败,退出码 ${result.status ?? 'unknown'}`);

View File

@@ -50,7 +50,7 @@ export async function GET(request: NextRequest) {
const states = await readStates(); const states = await readStates();
return NextResponse.json({ return NextResponse.json({
latest: states[0] || null, latest: states[0] || null,
history: states.slice(0, 12), history: states,
stateDir: getUpgradeStateRoot(), stateDir: getUpgradeStateRoot(),
running: states.some(job => RUNNING_STATUSES.has(job.status)), running: states.some(job => RUNNING_STATUSES.has(job.status)),
}); });

View File

@@ -3,6 +3,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AlertTriangle, AlertTriangle,
ChevronDown,
ChevronUp,
CheckCircle2, CheckCircle2,
FileArchive, FileArchive,
Flame, Flame,
@@ -85,6 +87,8 @@ export default function SystemUpgradeTab() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [upgradeData, setUpgradeData] = useState<UpgradeResponse>({ latest: null, history: [], running: false, stateDir: '' }); const [upgradeData, setUpgradeData] = useState<UpgradeResponse>({ latest: null, history: [], running: false, stateDir: '' });
const [currentLogJobIds, setCurrentLogJobIds] = useState<Set<string>>(new Set());
const [expandedHistoryId, setExpandedHistoryId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const latest = upgradeData.latest; const latest = upgradeData.latest;
@@ -99,8 +103,12 @@ export default function SystemUpgradeTab() {
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || '读取升级状态失败'); if (!res.ok) throw new Error(data.error || '读取升级状态失败');
const latestJob = data.latest || null;
if (latestJob && RUNNING_STATUSES.has(latestJob.status)) {
setCurrentLogJobIds(previous => new Set(previous).add(latestJob.id));
}
setUpgradeData({ setUpgradeData({
latest: data.latest || null, latest: latestJob,
history: Array.isArray(data.history) ? data.history : [], history: Array.isArray(data.history) ? data.history : [],
running: data.running === true, running: data.running === true,
stateDir: data.stateDir || '', stateDir: data.stateDir || '',
@@ -154,6 +162,9 @@ export default function SystemUpgradeTab() {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || '创建升级任务失败'); if (!res.ok) throw new Error(data.error || '创建升级任务失败');
if (data.job?.id) {
setCurrentLogJobIds(previous => new Set(previous).add(data.job.id));
}
toast.success(mode === 'hot' ? '热更新任务已启动' : '冷更新任务已启动'); toast.success(mode === 'hot' ? '热更新任务已启动' : '冷更新任务已启动');
setSelectedFile(null); setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
@@ -267,7 +278,11 @@ export default function SystemUpgradeTab() {
</div> </div>
) : latest ? ( ) : latest ? (
<UpgradeStatusPanel job={latest} /> <UpgradeStatusPanel
job={latest}
showLogs={latestIsRunning || currentLogJobIds.has(latest.id)}
logTitle={latestIsRunning ? '实时升级日志' : '本次升级日志'}
/>
) : ( ) : (
<div className="flex h-44 flex-col items-center justify-center rounded-md border border-dashed border-border text-center"> <div className="flex h-44 flex-col items-center justify-center rounded-md border border-dashed border-border text-center">
<History className="mb-2 h-8 w-8 text-muted-foreground" /> <History className="mb-2 h-8 w-8 text-muted-foreground" />
@@ -285,7 +300,7 @@ export default function SystemUpgradeTab() {
<History className="h-5 w-5 text-primary" /> <History className="h-5 w-5 text-primary" />
</CardTitle> </CardTitle>
<CardDescription> 12 便</CardDescription> <CardDescription>便</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{upgradeData.history.length === 0 ? ( {upgradeData.history.length === 0 ? (
@@ -293,16 +308,33 @@ export default function SystemUpgradeTab() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{upgradeData.history.map(job => ( {upgradeData.history.map(job => (
<div key={job.id} className="grid gap-3 rounded-md border border-border p-3 text-sm md:grid-cols-[9rem_1fr_auto] md:items-center"> <div key={job.id} className="rounded-md border border-border p-3 text-sm">
<div className="flex items-center gap-2"> <div className="grid gap-3 md:grid-cols-[9rem_1fr_auto] md:items-center">
<StatusIcon status={job.status} /> <div className="flex items-center gap-2">
<Badge variant="secondary">{job.mode === 'hot' ? '热更新' : '冷更新'}</Badge> <StatusIcon status={job.status} />
<Badge variant="secondary">{job.mode === 'hot' ? '热更新' : '冷更新'}</Badge>
</div>
<div className="min-w-0">
<div className="truncate font-medium">{job.packageName}</div>
<div className="truncate text-xs text-muted-foreground">{job.message}</div>
</div>
<div className="flex items-center justify-between gap-3 md:justify-end">
<div className="text-xs text-muted-foreground md:text-right">{formatDate(job.updatedAt)}</div>
<Button
variant="outline"
size="sm"
onClick={() => setExpandedHistoryId(expandedHistoryId === job.id ? null : job.id)}
>
{expandedHistoryId === job.id ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{expandedHistoryId === job.id ? '收起详情' : '查看详情'}
</Button>
</div>
</div> </div>
<div className="min-w-0"> {expandedHistoryId === job.id && (
<div className="truncate font-medium">{job.packageName}</div> <div className="mt-4">
<div className="truncate text-xs text-muted-foreground">{job.message}</div> <UpgradeStatusPanel job={job} showLogs logTitle="历史升级日志" />
</div> </div>
<div className="text-xs text-muted-foreground md:text-right">{formatDate(job.updatedAt)}</div> )}
</div> </div>
))} ))}
</div> </div>
@@ -346,7 +378,16 @@ function ModeCard({
); );
} }
function UpgradeStatusPanel({ job }: { job: UpgradeJob }) { function UpgradeStatusPanel({
job,
showLogs,
logTitle = '执行日志',
}: {
job: UpgradeJob;
showLogs: boolean;
logTitle?: string;
}) {
const changedFiles = job.changedFiles || [];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -370,10 +411,22 @@ function UpgradeStatusPanel({ job }: { job: UpgradeJob }) {
<InfoRow label="任务 ID" value={job.id} /> <InfoRow label="任务 ID" value={job.id} />
<InfoRow label="升级包" value={job.packageName} /> <InfoRow label="升级包" value={job.packageName} />
<InfoRow label="当前步骤" value={job.step} /> <InfoRow label="当前步骤" value={job.step} />
<InfoRow label="文件数量" value={`${changedFiles.length} 个文件`} />
{job.backupFile && <InfoRow label="数据备份" value={job.backupFile} />} {job.backupFile && <InfoRow label="数据备份" value={job.backupFile} />}
{job.sourceBackupFile && <InfoRow label="源码快照" value={job.sourceBackupFile} />} {job.sourceBackupFile && <InfoRow label="源码快照" value={job.sourceBackupFile} />}
</div> </div>
{changedFiles.length > 0 && (
<div className="rounded-md border border-border bg-muted/25 p-3">
<div className="mb-2 text-sm font-medium"></div>
<div className="max-h-40 overflow-auto rounded bg-background/70 p-2 font-mono text-xs leading-5 text-muted-foreground">
{changedFiles.map(file => (
<div key={file} className="truncate" title={file}>{file}</div>
))}
</div>
</div>
)}
{job.error && ( {job.error && (
<Alert variant={job.status === 'rollback_failed' ? 'destructive' : 'default'} className="border-amber-500/30 bg-amber-500/5"> <Alert variant={job.status === 'rollback_failed' ? 'destructive' : 'default'} className="border-amber-500/30 bg-amber-500/5">
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
@@ -382,13 +435,16 @@ function UpgradeStatusPanel({ job }: { job: UpgradeJob }) {
</Alert> </Alert>
)} )}
{job.logs?.length > 0 && ( {showLogs && job.logs?.length > 0 && (
<> <>
<Separator /> <Separator />
<div> <div>
<div className="mb-2 text-sm font-medium"></div> <div className="mb-2 flex items-center justify-between gap-3 text-sm">
<span className="font-medium">{logTitle}</span>
<span className="text-xs text-muted-foreground">{job.logs.length} </span>
</div>
<pre className="max-h-72 overflow-auto rounded-md bg-zinc-950 p-3 text-xs leading-5 text-zinc-100"> <pre className="max-h-72 overflow-auto rounded-md bg-zinc-950 p-3 text-xs leading-5 text-zinc-100">
{job.logs.slice(-120).join('\n')} {job.logs.join('\n')}
</pre> </pre>
</div> </div>
</> </>