feat: add admin upgrade workflow
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
"backup:create": "bash ./scripts/backup-create.sh",
|
||||
"backup:list": "bash ./scripts/backup-list.sh",
|
||||
"backup:restore": "bash ./scripts/backup-restore.sh",
|
||||
"upgrade:run": "node ./scripts/admin-upgrade-runner.mjs",
|
||||
"db:patch": "bash ./scripts/apply-database-patch.sh",
|
||||
"dev": "bash ./scripts/dev.sh",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
|
||||
490
scripts/admin-upgrade-runner.mjs
Executable file
490
scripts/admin-upgrade-runner.mjs
Executable file
@@ -0,0 +1,490 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const projectRoot = path.resolve(args.project || process.cwd());
|
||||
loadEnvFile(path.join(projectRoot, '.env.local'));
|
||||
|
||||
const stateRoot = path.resolve(
|
||||
process.env.UPGRADE_STATE_DIR ||
|
||||
(process.env.LOCAL_STORAGE_DIR ? path.join(path.dirname(process.env.LOCAL_STORAGE_DIR), 'upgrade') : path.join(projectRoot, 'upgrade-state')),
|
||||
);
|
||||
|
||||
const jobId = requireArg(args, 'job-id');
|
||||
const mode = requireArg(args, 'mode');
|
||||
const packagePath = path.resolve(requireArg(args, 'package'));
|
||||
const packageName = args['package-name'] || path.basename(packagePath);
|
||||
const jobDir = path.join(stateRoot, 'jobs', jobId);
|
||||
const stateFile = path.join(jobDir, 'state.json');
|
||||
const extractDir = path.join(jobDir, 'extract');
|
||||
const sourceBackupFile = path.join(jobDir, `source-before-${jobId}.tar.gz`);
|
||||
|
||||
const HOT_ALLOWED_PREFIXES = ['public/'];
|
||||
const HOT_ALLOWED_FILES = new Set([
|
||||
'manifest.json',
|
||||
'robots.txt',
|
||||
'sitemap.xml',
|
||||
'favicon.ico',
|
||||
'icon.png',
|
||||
'apple-icon.png',
|
||||
]);
|
||||
|
||||
const COLD_ALLOWED_PREFIXES = ['src/', 'public/', 'scripts/', 'database/', 'docs/'];
|
||||
const COLD_ALLOWED_FILES = new Set([
|
||||
'manifest.json',
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'next.config.js',
|
||||
'next.config.mjs',
|
||||
'next.config.ts',
|
||||
'tsconfig.json',
|
||||
'postcss.config.mjs',
|
||||
'components.json',
|
||||
'ecosystem.config.cjs',
|
||||
]);
|
||||
|
||||
const BLOCKED_NAMES = new Set(['.git', 'node_modules', '.next', 'dist', 'backups', 'local-storage', 'upgrade-state']);
|
||||
|
||||
let state = readState() || {
|
||||
id: jobId,
|
||||
mode,
|
||||
status: 'queued',
|
||||
step: 'queued',
|
||||
message: '升级任务已创建',
|
||||
progress: 0,
|
||||
packageName,
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
logs: [],
|
||||
};
|
||||
|
||||
main().catch(error => {
|
||||
log(`fatal: ${error instanceof Error ? error.stack || error.message : String(error)}`);
|
||||
rollbackAfterFailure(error instanceof Error ? error.message : '升级任务异常退出').catch(rollbackError => {
|
||||
updateState({
|
||||
status: 'rollback_failed',
|
||||
step: 'rollback_failed',
|
||||
progress: 100,
|
||||
message: '升级失败,自动回滚也失败,请立即人工检查',
|
||||
error: `${error instanceof Error ? error.message : String(error)}; rollback: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function main() {
|
||||
ensureDir(jobDir);
|
||||
updateState({
|
||||
status: 'running',
|
||||
step: 'preflight',
|
||||
progress: 5,
|
||||
message: '正在检查升级包与运行环境',
|
||||
startedAt: state.startedAt || new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (mode !== 'hot' && mode !== 'cold') {
|
||||
throw new Error('升级方式无效');
|
||||
}
|
||||
if (!fs.existsSync(packagePath)) {
|
||||
throw new Error(`升级包不存在: ${packagePath}`);
|
||||
}
|
||||
if (!isAllowedArchive(packageName) && !isAllowedArchive(packagePath)) {
|
||||
throw new Error('仅支持 .tar、.tar.gz、.tgz 升级包');
|
||||
}
|
||||
|
||||
run('tar', tarReadArgs('list', packagePath), { cwd: projectRoot, label: '检查升级包结构' });
|
||||
|
||||
resetDir(extractDir);
|
||||
run('tar', [...tarReadArgs('extract', packagePath), '-C', extractDir], { cwd: projectRoot, label: '解压升级包' });
|
||||
|
||||
const payloadRoot = resolvePayloadRoot(extractDir);
|
||||
const files = listFiles(payloadRoot);
|
||||
if (files.length === 0) {
|
||||
throw new Error('升级包为空');
|
||||
}
|
||||
|
||||
const validation = validateFiles(files, mode);
|
||||
updateState({
|
||||
step: 'validated',
|
||||
progress: 14,
|
||||
message: `升级包校验通过,共 ${files.length} 个文件`,
|
||||
restartRequired: mode === 'cold' || validation.requiresRestart,
|
||||
packageHash: sha256(packagePath),
|
||||
changedFiles: files,
|
||||
});
|
||||
|
||||
updateState({ step: 'backup_data', progress: 22, message: '正在创建数据库、存储与环境配置备份' });
|
||||
const backupFile = runCapture('bash', ['./scripts/backup-create.sh'], {
|
||||
cwd: projectRoot,
|
||||
label: '创建数据备份',
|
||||
env: { BACKUP_DIR: path.join(stateRoot, 'data-backups'), COZE_WORKSPACE_PATH: projectRoot },
|
||||
}).trim().split('\n').pop();
|
||||
if (!backupFile || !fs.existsSync(backupFile)) {
|
||||
throw new Error('数据备份创建失败');
|
||||
}
|
||||
updateState({ backupFile });
|
||||
|
||||
updateState({ step: 'backup_source', progress: 30, message: '正在创建源码快照' });
|
||||
createSourceBackup(sourceBackupFile);
|
||||
updateState({ sourceBackupFile });
|
||||
|
||||
updateState({ step: 'apply', progress: 42, message: '正在应用升级包文件' });
|
||||
updateState({ preExistingFiles: files.filter(file => fs.existsSync(path.join(projectRoot, file))) });
|
||||
applyFiles(payloadRoot, files);
|
||||
|
||||
if (mode === 'hot') {
|
||||
updateState({ step: 'verify_hot', progress: 70, message: '正在验证热更新文件' });
|
||||
run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' });
|
||||
updateState({
|
||||
status: 'succeeded',
|
||||
step: 'completed',
|
||||
progress: 100,
|
||||
message: '热更新成功,平台未重启',
|
||||
finishedAt: new Date().toISOString(),
|
||||
restartRequired: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dependencyChanged = files.some(file => file === 'package.json' || file === 'pnpm-lock.yaml');
|
||||
if (dependencyChanged) {
|
||||
updateState({ step: 'install', progress: 54, message: '依赖文件发生变化,正在安装依赖' });
|
||||
run('pnpm', ['install', '--frozen-lockfile', '--prod=false'], { cwd: projectRoot, label: '安装依赖' });
|
||||
}
|
||||
|
||||
updateState({ step: 'ts_check', progress: 64, message: '正在执行 TypeScript 校验' });
|
||||
run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' });
|
||||
|
||||
updateState({ step: 'build', progress: 75, message: '正在构建平台' });
|
||||
run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '构建平台' });
|
||||
|
||||
updateState({ step: 'restart', progress: 86, message: '正在重启平台进程' });
|
||||
restartPlatform();
|
||||
|
||||
updateState({ step: 'health_check', progress: 94, message: '正在检查平台健康状态' });
|
||||
waitForHealth();
|
||||
|
||||
updateState({
|
||||
status: 'succeeded',
|
||||
step: 'completed',
|
||||
progress: 100,
|
||||
message: '冷更新成功,平台已重启并通过健康检查',
|
||||
finishedAt: new Date().toISOString(),
|
||||
restartRequired: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function rollbackAfterFailure(message) {
|
||||
const originalError = message;
|
||||
updateState({
|
||||
status: 'rolling_back',
|
||||
step: 'rolling_back',
|
||||
progress: 96,
|
||||
message: '升级失败,正在自动回滚到升级前状态',
|
||||
error: originalError,
|
||||
});
|
||||
|
||||
if (fs.existsSync(sourceBackupFile)) {
|
||||
restoreSourceBackup(sourceBackupFile);
|
||||
}
|
||||
|
||||
if (state.backupFile && fs.existsSync(state.backupFile)) {
|
||||
run('bash', ['./scripts/backup-restore.sh', state.backupFile], {
|
||||
cwd: projectRoot,
|
||||
label: '恢复数据备份',
|
||||
env: { COZE_WORKSPACE_PATH: projectRoot },
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === 'cold') {
|
||||
try {
|
||||
run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '回滚后重新构建' });
|
||||
restartPlatform();
|
||||
waitForHealth();
|
||||
} catch (error) {
|
||||
throw new Error(`回滚后平台恢复检查失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
updateState({
|
||||
status: 'rolled_back',
|
||||
step: 'rolled_back',
|
||||
progress: 100,
|
||||
message: '升级失败,已自动回滚到升级开始前状态',
|
||||
error: originalError,
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const item = argv[index];
|
||||
if (!item.startsWith('--')) continue;
|
||||
const key = item.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith('--')) {
|
||||
parsed[key] = 'true';
|
||||
} else {
|
||||
parsed[key] = next;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requireArg(parsed, key) {
|
||||
const value = parsed[key];
|
||||
if (!value) throw new Error(`missing --${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function loadEnvFile(file) {
|
||||
if (!fs.existsSync(file)) return;
|
||||
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue;
|
||||
const index = trimmed.indexOf('=');
|
||||
const key = trimmed.slice(0, index).trim();
|
||||
let value = trimmed.slice(index + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function readState() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateState(patch) {
|
||||
state = {
|
||||
...state,
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
logs: state.logs || [],
|
||||
};
|
||||
ensureDir(path.dirname(stateFile));
|
||||
const tempFile = `${stateFile}.tmp`;
|
||||
fs.writeFileSync(tempFile, `${JSON.stringify(state, null, 2)}\n`);
|
||||
fs.renameSync(tempFile, stateFile);
|
||||
}
|
||||
|
||||
function log(line) {
|
||||
const timestamped = `[${new Date().toISOString()}] ${line}`;
|
||||
const logs = [...(state.logs || []), timestamped].slice(-300);
|
||||
updateState({ logs });
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
function resetDir(dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
ensureDir(dir);
|
||||
}
|
||||
|
||||
function run(command, commandArgs, options = {}) {
|
||||
runCapture(command, commandArgs, options);
|
||||
}
|
||||
|
||||
function runCapture(command, commandArgs, options = {}) {
|
||||
const label = options.label || command;
|
||||
log(`${label}: ${command} ${commandArgs.join(' ')}`);
|
||||
const result = spawnSync(command, commandArgs, {
|
||||
cwd: options.cwd || projectRoot,
|
||||
env: { ...process.env, COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack', ...(options.env || {}) },
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
||||
if (output) {
|
||||
for (const line of output.split(/\r?\n/).slice(-80)) log(`${label}: ${line}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${label}失败,退出码 ${result.status ?? 'unknown'}`);
|
||||
}
|
||||
return result.stdout || '';
|
||||
}
|
||||
|
||||
function isAllowedArchive(file) {
|
||||
return file.endsWith('.tar') || file.endsWith('.tar.gz') || file.endsWith('.tgz');
|
||||
}
|
||||
|
||||
function resolvePayloadRoot(root) {
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true }).filter(entry => entry.name !== '__MACOSX');
|
||||
if (entries.length === 1 && entries[0].isDirectory()) {
|
||||
return path.join(root, entries[0].name);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function listFiles(root) {
|
||||
const files = [];
|
||||
walk(root, '');
|
||||
return files.sort();
|
||||
|
||||
function walk(currentRoot, relativeRoot) {
|
||||
for (const entry of fs.readdirSync(currentRoot, { withFileTypes: true })) {
|
||||
if (entry.name === '.DS_Store') continue;
|
||||
const relative = toPosix(path.join(relativeRoot, entry.name));
|
||||
const absolute = path.join(currentRoot, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(absolute, relative);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(relative);
|
||||
} else {
|
||||
throw new Error(`升级包包含不支持的文件类型: ${relative}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateFiles(files, updateMode) {
|
||||
for (const file of files) {
|
||||
assertSafeRelativePath(file);
|
||||
const parts = file.split('/');
|
||||
if (parts.some(part => BLOCKED_NAMES.has(part) || part.startsWith('.env'))) {
|
||||
throw new Error(`升级包包含禁止覆盖的路径: ${file}`);
|
||||
}
|
||||
if (updateMode === 'hot' && !isHotAllowed(file)) {
|
||||
throw new Error(`热更新只能包含 public 等无需重启的静态资源;${file} 需要使用冷更新`);
|
||||
}
|
||||
if (updateMode === 'cold' && !isColdAllowed(file)) {
|
||||
throw new Error(`冷更新包包含未授权路径: ${file}`);
|
||||
}
|
||||
}
|
||||
return { requiresRestart: files.some(file => !isHotAllowed(file)) };
|
||||
}
|
||||
|
||||
function assertSafeRelativePath(file) {
|
||||
if (!file || file.startsWith('/') || file.startsWith('\\') || file.includes('\\')) {
|
||||
throw new Error(`升级包包含非法路径: ${file}`);
|
||||
}
|
||||
const normalized = path.posix.normalize(file);
|
||||
if (normalized !== file || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
|
||||
throw new Error(`升级包包含目录穿越路径: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isHotAllowed(file) {
|
||||
return HOT_ALLOWED_FILES.has(file) || HOT_ALLOWED_PREFIXES.some(prefix => file.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isColdAllowed(file) {
|
||||
return COLD_ALLOWED_FILES.has(file) || COLD_ALLOWED_PREFIXES.some(prefix => file.startsWith(prefix));
|
||||
}
|
||||
|
||||
function applyFiles(root, files) {
|
||||
for (const file of files) {
|
||||
if (file === 'manifest.json') continue;
|
||||
const source = path.join(root, file);
|
||||
const target = path.join(projectRoot, file);
|
||||
ensureDir(path.dirname(target));
|
||||
fs.copyFileSync(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
function createSourceBackup(target) {
|
||||
ensureDir(path.dirname(target));
|
||||
run('tar', [
|
||||
'-czf',
|
||||
target,
|
||||
'--exclude=.git',
|
||||
'--exclude=node_modules',
|
||||
'--exclude=.next',
|
||||
'--exclude=dist',
|
||||
'--exclude=backups',
|
||||
'--exclude=local-storage',
|
||||
'--exclude=upgrade-state',
|
||||
'--exclude=tsconfig.tsbuildinfo',
|
||||
'-C',
|
||||
projectRoot,
|
||||
'.',
|
||||
], { cwd: projectRoot, label: '创建源码快照' });
|
||||
}
|
||||
|
||||
function restoreSourceBackup(source) {
|
||||
log(`恢复源码快照: ${source}`);
|
||||
const preExistingFiles = new Set(Array.isArray(state.preExistingFiles) ? state.preExistingFiles : []);
|
||||
const changedFiles = Array.isArray(state.changedFiles) ? state.changedFiles : [];
|
||||
for (const file of changedFiles) {
|
||||
if (file === 'manifest.json' || preExistingFiles.has(file)) continue;
|
||||
const target = path.join(projectRoot, file);
|
||||
if (target.startsWith(projectRoot)) {
|
||||
fs.rmSync(target, { force: true });
|
||||
}
|
||||
}
|
||||
run('tar', [
|
||||
'-xzf',
|
||||
source,
|
||||
'--exclude=.git',
|
||||
'--exclude=node_modules',
|
||||
'--exclude=.next',
|
||||
'--exclude=dist',
|
||||
'-C',
|
||||
projectRoot,
|
||||
], { cwd: projectRoot, label: '恢复源码快照' });
|
||||
}
|
||||
|
||||
function restartPlatform() {
|
||||
const restartCommand = process.env.UPGRADE_RESTART_COMMAND || detectRestartCommand();
|
||||
run('bash', ['-lc', restartCommand], { cwd: projectRoot, label: '重启平台' });
|
||||
}
|
||||
|
||||
function detectRestartCommand() {
|
||||
const pm2Names = runCapture('bash', ['-lc', 'command -v pm2 >/dev/null 2>&1 && pm2 jlist || true'], {
|
||||
cwd: projectRoot,
|
||||
label: '检测 PM2 进程',
|
||||
});
|
||||
if (pm2Names.includes('"name":"miaojing-dev"')) return 'pm2 restart miaojing-dev --update-env';
|
||||
if (fs.existsSync(path.join(projectRoot, 'ecosystem.config.cjs'))) return 'pm2 startOrReload ecosystem.config.cjs --update-env';
|
||||
return 'pm2 restart miaojing-dev --update-env';
|
||||
}
|
||||
|
||||
function tarReadArgs(action, archivePath) {
|
||||
const flag = action === 'list' ? '-tf' : '-xf';
|
||||
const gzipFlag = action === 'list' ? '-tzf' : '-xzf';
|
||||
return archivePath.endsWith('.tar') ? [flag, archivePath] : [gzipFlag, archivePath];
|
||||
}
|
||||
|
||||
function waitForHealth() {
|
||||
const healthUrl = process.env.UPGRADE_HEALTH_URL || process.env.APP_HEALTH_URL || 'http://127.0.0.1:5100/api/health';
|
||||
const timeoutMs = Number(process.env.UPGRADE_HEALTH_TIMEOUT_MS || 90000);
|
||||
const startedAt = Date.now();
|
||||
let lastError = '';
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = spawnSync('curl', ['-fsS', healthUrl], { encoding: 'utf8', timeout: 8000 });
|
||||
if (result.status === 0) {
|
||||
log(`健康检查通过: ${healthUrl}`);
|
||||
return;
|
||||
}
|
||||
lastError = `${result.stderr || result.stdout || `exit ${result.status}`}`.trim();
|
||||
sleep(3000);
|
||||
}
|
||||
throw new Error(`健康检查超时: ${healthUrl}; ${lastError}`);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function sha256(file) {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(fs.readFileSync(file));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function toPosix(file) {
|
||||
return file.split(path.sep).join('/');
|
||||
}
|
||||
198
src/app/api/admin/upgrade/route.ts
Normal file
198
src/app/api/admin/upgrade/route.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type UpgradeMode = 'hot' | 'cold';
|
||||
type UpgradeStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'rolling_back'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'rolled_back'
|
||||
| 'rollback_failed';
|
||||
|
||||
type UpgradeJobState = {
|
||||
id: string;
|
||||
mode: UpgradeMode;
|
||||
status: UpgradeStatus;
|
||||
step: string;
|
||||
message: string;
|
||||
progress: number;
|
||||
packageName: string;
|
||||
packageHash?: string;
|
||||
backupFile?: string;
|
||||
sourceBackupFile?: string;
|
||||
restartRequired?: boolean;
|
||||
changedFiles?: string[];
|
||||
preExistingFiles?: string[];
|
||||
error?: string;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
finishedAt?: string;
|
||||
logs: string[];
|
||||
};
|
||||
|
||||
const MAX_PACKAGE_BYTES = 300 * 1024 * 1024;
|
||||
const RUNNING_STATUSES = new Set<UpgradeStatus>(['queued', 'running', 'rolling_back']);
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const states = await readStates();
|
||||
return NextResponse.json({
|
||||
latest: states[0] || null,
|
||||
history: states.slice(0, 12),
|
||||
stateDir: getUpgradeStateRoot(),
|
||||
running: states.some(job => RUNNING_STATUSES.has(job.status)),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[admin/upgrade] failed to read state:', error);
|
||||
return NextResponse.json({ error: '读取升级状态失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const states = await readStates();
|
||||
const runningJob = states.find(job => RUNNING_STATUSES.has(job.status));
|
||||
if (runningJob) {
|
||||
return NextResponse.json({ error: `已有升级任务正在执行:${runningJob.id}` }, { status: 409 });
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const modeValue = String(form.get('mode') || '');
|
||||
const mode = modeValue === 'hot' || modeValue === 'cold' ? modeValue : null;
|
||||
if (!mode) {
|
||||
return NextResponse.json({ error: '请选择热更新或冷更新' }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = form.get('package');
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: '请上传升级包' }, { status: 400 });
|
||||
}
|
||||
if (file.size <= 0) {
|
||||
return NextResponse.json({ error: '升级包为空' }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_PACKAGE_BYTES) {
|
||||
return NextResponse.json({ error: '升级包不能超过 300MB' }, { status: 400 });
|
||||
}
|
||||
if (!isAllowedArchiveName(file.name)) {
|
||||
return NextResponse.json({ error: '仅支持 .tar、.tar.gz、.tgz 升级包' }, { status: 400 });
|
||||
}
|
||||
|
||||
const stateRoot = getUpgradeStateRoot();
|
||||
const jobId = `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${randomUUID().slice(0, 8)}`;
|
||||
const jobDir = path.join(stateRoot, 'jobs', jobId);
|
||||
const uploadDir = path.join(jobDir, 'upload');
|
||||
await fs.mkdir(uploadDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const safeName = sanitizeFileName(file.name);
|
||||
const packagePath = path.join(uploadDir, safeName);
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
await fs.writeFile(packagePath, bytes, { mode: 0o600 });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const initialState: UpgradeJobState = {
|
||||
id: jobId,
|
||||
mode,
|
||||
status: 'queued',
|
||||
step: 'queued',
|
||||
message: '升级包已上传,等待执行',
|
||||
progress: 0,
|
||||
packageName: file.name,
|
||||
packageHash: createHash('sha256').update(bytes).digest('hex'),
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
logs: [`[${now}] 上传升级包 ${file.name} (${file.size} bytes)`],
|
||||
};
|
||||
await writeState(jobDir, initialState);
|
||||
|
||||
const child = spawn(process.execPath, [
|
||||
path.join(process.cwd(), 'scripts/admin-upgrade-runner.mjs'),
|
||||
'--job-id',
|
||||
jobId,
|
||||
'--mode',
|
||||
mode,
|
||||
'--package',
|
||||
packagePath,
|
||||
'--package-name',
|
||||
file.name,
|
||||
'--project',
|
||||
process.cwd(),
|
||||
], {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
UPGRADE_STATE_DIR: stateRoot,
|
||||
COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack',
|
||||
},
|
||||
});
|
||||
child.unref();
|
||||
|
||||
return NextResponse.json({ success: true, job: initialState });
|
||||
} catch (error) {
|
||||
console.error('[admin/upgrade] failed to start upgrade:', error);
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : '创建升级任务失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function getUpgradeStateRoot(): string {
|
||||
const configured = process.env.UPGRADE_STATE_DIR;
|
||||
if (configured) return path.resolve(configured);
|
||||
if (process.env.LOCAL_STORAGE_DIR) return path.join(path.dirname(process.env.LOCAL_STORAGE_DIR), 'upgrade');
|
||||
return path.join(process.cwd(), 'upgrade-state');
|
||||
}
|
||||
|
||||
async function readStates(): Promise<UpgradeJobState[]> {
|
||||
const jobsRoot = path.join(getUpgradeStateRoot(), 'jobs');
|
||||
let jobNames: string[] = [];
|
||||
try {
|
||||
jobNames = await fs.readdir(jobsRoot);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const states = await Promise.all(
|
||||
jobNames.map(async jobName => {
|
||||
try {
|
||||
const statePath = path.join(jobsRoot, jobName, 'state.json');
|
||||
const raw = await fs.readFile(statePath, 'utf8');
|
||||
return JSON.parse(raw) as UpgradeJobState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return states
|
||||
.filter((job): job is UpgradeJobState => Boolean(job))
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
}
|
||||
|
||||
async function writeState(jobDir: string, state: UpgradeJobState): Promise<void> {
|
||||
await fs.mkdir(jobDir, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(path.join(jobDir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
function isAllowedArchiveName(name: string): boolean {
|
||||
return name.endsWith('.tar') || name.endsWith('.tar.gz') || name.endsWith('.tgz');
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
const baseName = path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
return baseName || 'upgrade-package.tar.gz';
|
||||
}
|
||||
452
src/components/admin/system-upgrade-tab.tsx
Normal file
452
src/components/admin/system-upgrade-tab.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
FileArchive,
|
||||
Flame,
|
||||
History,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
UploadCloud,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type UpgradeMode = 'hot' | 'cold';
|
||||
type UpgradeStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'rolling_back'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'rolled_back'
|
||||
| 'rollback_failed';
|
||||
|
||||
type UpgradeJob = {
|
||||
id: string;
|
||||
mode: UpgradeMode;
|
||||
status: UpgradeStatus;
|
||||
step: string;
|
||||
message: string;
|
||||
progress: number;
|
||||
packageName: string;
|
||||
packageHash?: string;
|
||||
backupFile?: string;
|
||||
sourceBackupFile?: string;
|
||||
restartRequired?: boolean;
|
||||
changedFiles?: string[];
|
||||
preExistingFiles?: string[];
|
||||
error?: string;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
finishedAt?: string;
|
||||
logs: string[];
|
||||
};
|
||||
|
||||
type UpgradeResponse = {
|
||||
latest: UpgradeJob | null;
|
||||
history: UpgradeJob[];
|
||||
running: boolean;
|
||||
stateDir: string;
|
||||
};
|
||||
|
||||
const RUNNING_STATUSES = new Set<UpgradeStatus>(['queued', 'running', 'rolling_back']);
|
||||
const FINAL_STATUSES = new Set<UpgradeStatus>(['succeeded', 'failed', 'rolled_back', 'rollback_failed']);
|
||||
|
||||
function getAdminAuthHeaders(): HeadersInit {
|
||||
try {
|
||||
const raw = window.localStorage.getItem('miaojing_auth');
|
||||
if (!raw) return {};
|
||||
const auth = JSON.parse(raw) as { accessToken?: string; session?: { access_token?: string } };
|
||||
const token = auth.accessToken || auth.session?.access_token;
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export default function SystemUpgradeTab() {
|
||||
const [mode, setMode] = useState<UpgradeMode>('hot');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [upgradeData, setUpgradeData] = useState<UpgradeResponse>({ latest: null, history: [], running: false, stateDir: '' });
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const latest = upgradeData.latest;
|
||||
const latestIsRunning = latest ? RUNNING_STATUSES.has(latest.status) : false;
|
||||
|
||||
const loadStatus = useCallback(async ({ silent = false }: { silent?: boolean } = {}) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/upgrade', {
|
||||
headers: getAdminAuthHeaders(),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || '读取升级状态失败');
|
||||
setUpgradeData({
|
||||
latest: data.latest || null,
|
||||
history: Array.isArray(data.history) ? data.history : [],
|
||||
running: data.running === true,
|
||||
stateDir: data.stateDir || '',
|
||||
});
|
||||
} catch (error) {
|
||||
if (!silent) toast.error(error instanceof Error ? error.message : '读取升级状态失败');
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestIsRunning) return;
|
||||
const timer = window.setInterval(() => loadStatus({ silent: true }), 2500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [latestIsRunning, loadStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latest || !FINAL_STATUSES.has(latest.status)) return;
|
||||
const timer = window.setTimeout(() => loadStatus({ silent: true }), 1200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [latest?.id, latest?.status, latest, loadStatus]);
|
||||
|
||||
const canSubmit = useMemo(() => Boolean(selectedFile) && !submitting && !upgradeData.running, [selectedFile, submitting, upgradeData.running]);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedFile) {
|
||||
toast.error('请选择升级包');
|
||||
return;
|
||||
}
|
||||
if (!/\.(tar|tgz|tar\.gz)$/i.test(selectedFile.name)) {
|
||||
toast.error('仅支持 .tar、.tar.gz、.tgz 升级包');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set('mode', mode);
|
||||
form.set('package', selectedFile);
|
||||
|
||||
const res = await fetch('/api/admin/upgrade', {
|
||||
method: 'POST',
|
||||
headers: getAdminAuthHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || '创建升级任务失败');
|
||||
|
||||
toast.success(mode === 'hot' ? '热更新任务已启动' : '冷更新任务已启动');
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
await loadStatus({ silent: true });
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '创建升级任务失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert className="border-amber-500/30 bg-amber-500/5">
|
||||
<ShieldCheck className="h-4 w-4 text-amber-600" />
|
||||
<AlertTitle>升级保护策略</AlertTitle>
|
||||
<AlertDescription>
|
||||
每次升级都会先创建数据库、存储、环境配置备份和源码快照;任务失败会自动回滚到升级开始前的源码与数据状态。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(21rem,0.9fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<UploadCloud className="h-5 w-5 text-primary" />
|
||||
上传升级包
|
||||
</CardTitle>
|
||||
<CardDescription>支持 tar、tar.gz、tgz 格式;热更新不重启平台,冷更新会构建并重启平台进程。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ModeCard
|
||||
active={mode === 'hot'}
|
||||
icon={<Flame className="h-5 w-5" />}
|
||||
title="热更新"
|
||||
description="只允许 public 静态资源等不影响运行时代码的补丁,应用后不重启。"
|
||||
onClick={() => setMode('hot')}
|
||||
/>
|
||||
<ModeCard
|
||||
active={mode === 'cold'}
|
||||
icon={<ServerCog className="h-5 w-5" />}
|
||||
title="冷更新"
|
||||
description="适合代码、依赖、脚本等较大变更,会校验、构建、重启并健康检查。"
|
||||
onClick={() => setMode('cold')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="upgrade-package">升级包</Label>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
id="upgrade-package"
|
||||
type="file"
|
||||
accept=".tar,.tgz,.tar.gz,application/gzip,application/x-tar"
|
||||
disabled={submitting || upgradeData.running}
|
||||
onChange={event => setSelectedFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
热更新包如包含 src、package.json、脚本或锁文件会被拒绝,请改用冷更新。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/35 px-3 py-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileArchive className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="truncate">{selectedFile.name}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{formatBytes(selectedFile.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-border bg-background/50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
升级前确认
|
||||
</div>
|
||||
<ul className="ml-5 list-disc space-y-1 text-sm text-muted-foreground">
|
||||
<li>升级包不能包含 .env、node_modules、.git、backups、local-storage 等敏感目录。</li>
|
||||
<li>失败回滚会恢复源码快照、数据库备份、存储目录和环境配置。</li>
|
||||
<li>冷更新完成后会自动重启平台进程,并以 /api/health 作为成功判定。</li>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-3 text-lg">
|
||||
<span className="flex items-center gap-2">
|
||||
<ServerCog className="h-5 w-5 text-primary" />
|
||||
当前状态
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => loadStatus()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
刷新
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<CardDescription>冷更新重启后也会从磁盘续上这里的任务状态。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex h-44 items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
正在加载升级状态
|
||||
</div>
|
||||
) : latest ? (
|
||||
<UpgradeStatusPanel job={latest} />
|
||||
) : (
|
||||
<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" />
|
||||
<div className="text-sm font-medium">暂无升级记录</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">上传升级包后会在这里显示进度和回滚结果</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<History className="h-5 w-5 text-primary" />
|
||||
升级历史
|
||||
</CardTitle>
|
||||
<CardDescription>显示最近 12 次升级任务,便于核对成功、失败与回滚记录。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{upgradeData.history.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">暂无历史记录</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{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 className="flex items-center gap-2">
|
||||
<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="text-xs text-muted-foreground md:text-right">{formatDate(job.updatedAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
active,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'min-h-32 rounded-md border p-4 text-left transition-colors',
|
||||
active ? 'border-primary bg-primary/10 text-foreground' : 'border-border bg-background hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2 font-semibold">
|
||||
<span className={cn('flex h-9 w-9 items-center justify-center rounded-md', active ? 'bg-primary text-zinc-950' : 'bg-muted text-muted-foreground')}>
|
||||
{icon}
|
||||
</span>
|
||||
{title}
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function UpgradeStatusPanel({ job }: { job: UpgradeJob }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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 className={statusBadgeClass(job.status)}>{statusLabel(job.status)}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{formatDate(job.updatedAt)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium">{job.message}</span>
|
||||
<span className="text-muted-foreground">{Math.max(0, Math.min(100, job.progress || 0))}%</span>
|
||||
</div>
|
||||
<Progress value={Math.max(0, Math.min(100, job.progress || 0))} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-sm">
|
||||
<InfoRow label="任务 ID" value={job.id} />
|
||||
<InfoRow label="升级包" value={job.packageName} />
|
||||
<InfoRow label="当前步骤" value={job.step} />
|
||||
{job.backupFile && <InfoRow label="数据备份" value={job.backupFile} />}
|
||||
{job.sourceBackupFile && <InfoRow label="源码快照" value={job.sourceBackupFile} />}
|
||||
</div>
|
||||
|
||||
{job.error && (
|
||||
<Alert variant={job.status === 'rollback_failed' ? 'destructive' : 'default'} className="border-amber-500/30 bg-amber-500/5">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<AlertTitle>{job.status === 'rolled_back' ? '已自动回滚' : '升级错误'}</AlertTitle>
|
||||
<AlertDescription>{job.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{job.logs?.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium">执行日志</div>
|
||||
<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')}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[5rem_1fr] gap-3 rounded-md bg-muted/35 px-3 py-2">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="truncate font-mono text-xs" title={value}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: UpgradeStatus }) {
|
||||
if (status === 'succeeded') return <CheckCircle2 className="h-4 w-4 text-emerald-500" />;
|
||||
if (status === 'rolled_back') return <RotateCcw className="h-4 w-4 text-amber-500" />;
|
||||
if (status === 'failed' || status === 'rollback_failed') return <XCircle className="h-4 w-4 text-destructive" />;
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-primary" />;
|
||||
}
|
||||
|
||||
function statusLabel(status: UpgradeStatus): string {
|
||||
const labels: Record<UpgradeStatus, string> = {
|
||||
queued: '排队中',
|
||||
running: '执行中',
|
||||
rolling_back: '回滚中',
|
||||
succeeded: '成功',
|
||||
failed: '失败',
|
||||
rolled_back: '已回滚',
|
||||
rollback_failed: '回滚失败',
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: UpgradeStatus): string {
|
||||
if (status === 'succeeded') return 'bg-emerald-500/15 text-emerald-600 hover:bg-emerald-500/15';
|
||||
if (status === 'rolled_back') return 'bg-amber-500/15 text-amber-600 hover:bg-amber-500/15';
|
||||
if (status === 'failed' || status === 'rollback_failed') return 'bg-destructive/15 text-destructive hover:bg-destructive/15';
|
||||
return 'bg-primary/15 text-primary hover:bg-primary/15';
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ['KB', 'MB', 'GB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Logs,
|
||||
Loader2,
|
||||
Menu,
|
||||
Package,
|
||||
PlugZap,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
@@ -45,6 +46,7 @@ const OrderManagementTab = dynamic(() => import('@/components/admin/order-manage
|
||||
const PaymentTab = dynamic(() => import('@/components/admin/payment-tab'), { ssr: false });
|
||||
const AnnouncementTab = dynamic(() => import('@/components/admin/announcement-tab'), { ssr: false });
|
||||
const DataManagementTab = dynamic(() => import('@/components/admin/data-management-tab'), { ssr: false });
|
||||
const SystemUpgradeTab = dynamic(() => import('@/components/admin/system-upgrade-tab'), { ssr: false });
|
||||
const TaskManagementTab = dynamic(() => import('@/components/admin/task-management-tab'), { ssr: false });
|
||||
const LogManagementTab = dynamic(() => import('@/components/admin/log-management-tab'), { ssr: false });
|
||||
const SettingsTab = dynamic(() => import('@/components/admin/settings-tab'), { ssr: false });
|
||||
@@ -58,6 +60,7 @@ type ConsoleView =
|
||||
| 'payment'
|
||||
| 'announcements'
|
||||
| 'data'
|
||||
| 'upgrade'
|
||||
| 'tasks'
|
||||
| 'logs'
|
||||
| 'settings';
|
||||
@@ -221,6 +224,7 @@ const VIEW_TITLES: Record<ConsoleView, { title: string; description: string }> =
|
||||
payment: { title: '支付配置', description: '配置可用支付方式' },
|
||||
announcements: { title: '公告管理', description: '创建和维护站点弹窗公告' },
|
||||
data: { title: '数据管理', description: '导出、导入与恢复业务数据' },
|
||||
upgrade: { title: '系统升级', description: '上传升级包,执行热更新、冷更新与失败自动回滚' },
|
||||
tasks: { title: '任务管理', description: '查看生成任务状态并清理任务' },
|
||||
logs: { title: '系统日志', description: '查看平台运行、登录、安全和管理操作日志' },
|
||||
settings: { title: '系统设置', description: '维护站点信息、邮箱与通知设置' },
|
||||
@@ -344,6 +348,7 @@ export default function ConsoleDashboardPage() {
|
||||
label: '系统',
|
||||
items: [
|
||||
{ value: 'data', label: '数据管理', icon: Database },
|
||||
{ value: 'upgrade', label: '系统升级', icon: Package },
|
||||
{ value: 'logs', label: '系统日志', icon: Logs },
|
||||
{ value: 'settings', label: '系统设置', icon: Settings },
|
||||
],
|
||||
@@ -572,6 +577,8 @@ function ConsoleContent({
|
||||
return <AnnouncementTab />;
|
||||
case 'data':
|
||||
return <DataManagementTab />;
|
||||
case 'upgrade':
|
||||
return <SystemUpgradeTab />;
|
||||
case 'tasks':
|
||||
return <TaskManagementTab />;
|
||||
case 'logs':
|
||||
|
||||
Reference in New Issue
Block a user