feat: add admin upgrade workflow

This commit is contained in:
Codex
2026-05-09 07:52:57 +00:00
parent 24eab34305
commit 8ae0f57488
5 changed files with 1148 additions and 0 deletions

View File

@@ -8,6 +8,7 @@
"backup:create": "bash ./scripts/backup-create.sh", "backup:create": "bash ./scripts/backup-create.sh",
"backup:list": "bash ./scripts/backup-list.sh", "backup:list": "bash ./scripts/backup-list.sh",
"backup:restore": "bash ./scripts/backup-restore.sh", "backup:restore": "bash ./scripts/backup-restore.sh",
"upgrade:run": "node ./scripts/admin-upgrade-runner.mjs",
"db:patch": "bash ./scripts/apply-database-patch.sh", "db:patch": "bash ./scripts/apply-database-patch.sh",
"dev": "bash ./scripts/dev.sh", "dev": "bash ./scripts/dev.sh",
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",

490
scripts/admin-upgrade-runner.mjs Executable file
View 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('/');
}

View 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';
}

View 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> tartar.gztgz </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">
srcpackage.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> .envnode_modules.gitbackupslocal-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 });
}

View File

@@ -20,6 +20,7 @@ import {
Logs, Logs,
Loader2, Loader2,
Menu, Menu,
Package,
PlugZap, PlugZap,
Receipt, Receipt,
RefreshCw, RefreshCw,
@@ -45,6 +46,7 @@ const OrderManagementTab = dynamic(() => import('@/components/admin/order-manage
const PaymentTab = dynamic(() => import('@/components/admin/payment-tab'), { ssr: false }); const PaymentTab = dynamic(() => import('@/components/admin/payment-tab'), { ssr: false });
const AnnouncementTab = dynamic(() => import('@/components/admin/announcement-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 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 TaskManagementTab = dynamic(() => import('@/components/admin/task-management-tab'), { ssr: false });
const LogManagementTab = dynamic(() => import('@/components/admin/log-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 }); const SettingsTab = dynamic(() => import('@/components/admin/settings-tab'), { ssr: false });
@@ -58,6 +60,7 @@ type ConsoleView =
| 'payment' | 'payment'
| 'announcements' | 'announcements'
| 'data' | 'data'
| 'upgrade'
| 'tasks' | 'tasks'
| 'logs' | 'logs'
| 'settings'; | 'settings';
@@ -221,6 +224,7 @@ const VIEW_TITLES: Record<ConsoleView, { title: string; description: string }> =
payment: { title: '支付配置', description: '配置可用支付方式' }, payment: { title: '支付配置', description: '配置可用支付方式' },
announcements: { title: '公告管理', description: '创建和维护站点弹窗公告' }, announcements: { title: '公告管理', description: '创建和维护站点弹窗公告' },
data: { title: '数据管理', description: '导出、导入与恢复业务数据' }, data: { title: '数据管理', description: '导出、导入与恢复业务数据' },
upgrade: { title: '系统升级', description: '上传升级包,执行热更新、冷更新与失败自动回滚' },
tasks: { title: '任务管理', description: '查看生成任务状态并清理任务' }, tasks: { title: '任务管理', description: '查看生成任务状态并清理任务' },
logs: { title: '系统日志', description: '查看平台运行、登录、安全和管理操作日志' }, logs: { title: '系统日志', description: '查看平台运行、登录、安全和管理操作日志' },
settings: { title: '系统设置', description: '维护站点信息、邮箱与通知设置' }, settings: { title: '系统设置', description: '维护站点信息、邮箱与通知设置' },
@@ -344,6 +348,7 @@ export default function ConsoleDashboardPage() {
label: '系统', label: '系统',
items: [ items: [
{ value: 'data', label: '数据管理', icon: Database }, { value: 'data', label: '数据管理', icon: Database },
{ value: 'upgrade', label: '系统升级', icon: Package },
{ value: 'logs', label: '系统日志', icon: Logs }, { value: 'logs', label: '系统日志', icon: Logs },
{ value: 'settings', label: '系统设置', icon: Settings }, { value: 'settings', label: '系统设置', icon: Settings },
], ],
@@ -572,6 +577,8 @@ function ConsoleContent({
return <AnnouncementTab />; return <AnnouncementTab />;
case 'data': case 'data':
return <DataManagementTab />; return <DataManagementTab />;
case 'upgrade':
return <SystemUpgradeTab />;
case 'tasks': case 'tasks':
return <TaskManagementTab />; return <TaskManagementTab />;
case 'logs': case 'logs':