Separate upgrade preflight status
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -40,6 +40,15 @@ type UpgradeJobState = {
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
type RuntimeStatus = {
|
||||
projectRoot: string;
|
||||
stateDir: string;
|
||||
nodeVersion: string;
|
||||
pm2Enabled: boolean;
|
||||
pm2SystemdEnabled: string | null;
|
||||
processes: Array<{ name: string; status: string; uptime?: number; restarts?: number }>;
|
||||
};
|
||||
|
||||
const MAX_PACKAGE_BYTES = 300 * 1024 * 1024;
|
||||
const RUNNING_STATUSES = new Set<UpgradeStatus>(['queued', 'running', 'rolling_back']);
|
||||
|
||||
@@ -49,11 +58,16 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const states = await readStates();
|
||||
const latestUpgrade = states.find(job => !job.dryRun) || null;
|
||||
const latestPreflight = states.find(job => job.dryRun) || null;
|
||||
return NextResponse.json({
|
||||
latest: states[0] || null,
|
||||
latest: latestUpgrade || latestPreflight,
|
||||
latestUpgrade,
|
||||
latestPreflight,
|
||||
history: states,
|
||||
stateDir: getUpgradeStateRoot(),
|
||||
running: states.some(job => RUNNING_STATUSES.has(job.status)),
|
||||
runtime: getRuntimeStatus(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[admin/upgrade] failed to read state:', error);
|
||||
@@ -61,6 +75,47 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
function getRuntimeStatus(): RuntimeStatus {
|
||||
return {
|
||||
projectRoot: process.cwd(),
|
||||
stateDir: getUpgradeStateRoot(),
|
||||
nodeVersion: process.version,
|
||||
pm2Enabled: commandExists('pm2'),
|
||||
pm2SystemdEnabled: getCommandOutput('systemctl', ['is-enabled', 'pm2-root']),
|
||||
processes: getPm2Processes(),
|
||||
};
|
||||
}
|
||||
|
||||
function commandExists(command: string): boolean {
|
||||
return spawnSync('bash', ['-lc', `command -v ${command} >/dev/null 2>&1`], { encoding: 'utf8' }).status === 0;
|
||||
}
|
||||
|
||||
function getCommandOutput(command: string, commandArgs: string[]): string | null {
|
||||
const result = spawnSync(command, commandArgs, { encoding: 'utf8', timeout: 3000 });
|
||||
const output = `${result.stdout || result.stderr || ''}`.trim();
|
||||
return output || null;
|
||||
}
|
||||
|
||||
function getPm2Processes(): RuntimeStatus['processes'] {
|
||||
if (!commandExists('pm2')) return [];
|
||||
const result = spawnSync('pm2', ['jlist'], { encoding: 'utf8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 });
|
||||
if (result.status !== 0 || !result.stdout) return [];
|
||||
try {
|
||||
const processes = JSON.parse(result.stdout) as Array<{
|
||||
name?: string;
|
||||
pm2_env?: { status?: string; pm_uptime?: number; restart_time?: number };
|
||||
}>;
|
||||
return processes.map(process => ({
|
||||
name: process.name || 'unknown',
|
||||
status: process.pm2_env?.status || 'unknown',
|
||||
uptime: process.pm2_env?.pm_uptime,
|
||||
restarts: process.pm2_env?.restart_time,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
@@ -63,9 +63,21 @@ type UpgradeJob = {
|
||||
|
||||
type UpgradeResponse = {
|
||||
latest: UpgradeJob | null;
|
||||
latestUpgrade: UpgradeJob | null;
|
||||
latestPreflight: UpgradeJob | null;
|
||||
history: UpgradeJob[];
|
||||
running: boolean;
|
||||
stateDir: string;
|
||||
runtime?: RuntimeStatus;
|
||||
};
|
||||
|
||||
type RuntimeStatus = {
|
||||
projectRoot: string;
|
||||
stateDir: string;
|
||||
nodeVersion: string;
|
||||
pm2Enabled: boolean;
|
||||
pm2SystemdEnabled: string | null;
|
||||
processes: Array<{ name: string; status: string; uptime?: number; restarts?: number }>;
|
||||
};
|
||||
|
||||
const RUNNING_STATUSES = new Set<UpgradeStatus>(['queued', 'running', 'rolling_back']);
|
||||
@@ -89,12 +101,20 @@ export default function SystemUpgradeTab() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [prechecking, setPrechecking] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [upgradeData, setUpgradeData] = useState<UpgradeResponse>({ latest: null, history: [], running: false, stateDir: '' });
|
||||
const [upgradeData, setUpgradeData] = useState<UpgradeResponse>({
|
||||
latest: null,
|
||||
latestUpgrade: null,
|
||||
latestPreflight: 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 latest = upgradeData.latest;
|
||||
const latest = upgradeData.latestUpgrade;
|
||||
const latestPreflight = upgradeData.latestPreflight;
|
||||
const latestIsRunning = latest ? RUNNING_STATUSES.has(latest.status) : false;
|
||||
|
||||
const loadStatus = useCallback(async ({ silent = false }: { silent?: boolean } = {}) => {
|
||||
@@ -112,9 +132,12 @@ export default function SystemUpgradeTab() {
|
||||
}
|
||||
setUpgradeData({
|
||||
latest: latestJob,
|
||||
latestUpgrade: data.latestUpgrade || null,
|
||||
latestPreflight: data.latestPreflight || null,
|
||||
history: Array.isArray(data.history) ? data.history : [],
|
||||
running: data.running === true,
|
||||
stateDir: data.stateDir || '',
|
||||
runtime: data.runtime,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!silent) toast.error(error instanceof Error ? error.message : '读取升级状态失败');
|
||||
@@ -128,10 +151,10 @@ export default function SystemUpgradeTab() {
|
||||
}, [loadStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestIsRunning) return;
|
||||
if (!upgradeData.running) return;
|
||||
const timer = window.setInterval(() => loadStatus({ silent: true }), 2500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [latestIsRunning, loadStatus]);
|
||||
}, [upgradeData.running, loadStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latest || !FINAL_STATUSES.has(latest.status)) return;
|
||||
@@ -317,6 +340,52 @@ export default function SystemUpgradeTab() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(21rem,0.9fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<SearchCheck className="h-5 w-5 text-primary" />
|
||||
最近预检
|
||||
</CardTitle>
|
||||
<CardDescription>预检只校验升级包,不创建备份、不覆盖文件,也不会触发重启。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
正在加载预检状态
|
||||
</div>
|
||||
) : latestPreflight ? (
|
||||
<UpgradeStatusPanel
|
||||
job={latestPreflight}
|
||||
showLogs={RUNNING_STATUSES.has(latestPreflight.status) || currentLogJobIds.has(latestPreflight.id)}
|
||||
logTitle={RUNNING_STATUSES.has(latestPreflight.status) ? '实时预检日志' : '预检日志'}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-32 flex-col items-center justify-center rounded-md border border-dashed border-border text-center">
|
||||
<SearchCheck className="mb-2 h-7 w-7 text-muted-foreground" />
|
||||
<div className="text-sm font-medium">暂无预检记录</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">选择升级包后可先预检再正式执行</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<ServerCog className="h-5 w-5 text-primary" />
|
||||
运行环境
|
||||
</CardTitle>
|
||||
<CardDescription>展示升级任务实际读取到的运行目录、状态目录和进程状态。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RuntimeStatusPanel runtime={upgradeData.runtime} fallbackStateDir={upgradeData.stateDir} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
@@ -335,7 +404,8 @@ export default function SystemUpgradeTab() {
|
||||
<div className="grid gap-3 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>
|
||||
<Badge variant="secondary">{job.dryRun ? '预检' : job.mode === 'hot' ? '热更新' : '冷更新'}</Badge>
|
||||
{job.dryRun && <Badge className="bg-sky-500/15 text-sky-600 hover:bg-sky-500/15">未覆盖</Badge>}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{job.packageName}</div>
|
||||
@@ -405,10 +475,12 @@ function UpgradeStatusPanel({
|
||||
job,
|
||||
showLogs,
|
||||
logTitle = '执行日志',
|
||||
compact = false,
|
||||
}: {
|
||||
job: UpgradeJob;
|
||||
showLogs: boolean;
|
||||
logTitle?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const changedFiles = job.changedFiles || [];
|
||||
return (
|
||||
@@ -441,7 +513,7 @@ function UpgradeStatusPanel({
|
||||
{job.sourceBackupFile && <InfoRow label="源码快照" value={job.sourceBackupFile} />}
|
||||
</div>
|
||||
|
||||
{changedFiles.length > 0 && (
|
||||
{!compact && 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">
|
||||
@@ -478,6 +550,45 @@ function UpgradeStatusPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeStatusPanel({ runtime, fallbackStateDir }: { runtime?: RuntimeStatus; fallbackStateDir: string }) {
|
||||
const processes = runtime?.processes || [];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2 text-sm">
|
||||
<InfoRow label="项目目录" value={runtime?.projectRoot || '未知'} />
|
||||
<InfoRow label="状态目录" value={runtime?.stateDir || fallbackStateDir || '未知'} />
|
||||
<InfoRow label="Node" value={runtime?.nodeVersion || '未知'} />
|
||||
<InfoRow label="PM2" value={runtime?.pm2Enabled ? '可用' : '不可用'} />
|
||||
<InfoRow label="开机自启" value={runtime?.pm2SystemdEnabled || '未知'} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3">
|
||||
<div className="mb-2 text-sm font-medium">进程状态</div>
|
||||
{processes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{processes.map(process => (
|
||||
<div key={process.name} className="grid grid-cols-[1fr_auto] gap-3 rounded bg-background/70 px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-xs">{process.name}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{process.restarts == null ? '重启次数未知' : `重启 ${process.restarts} 次`}
|
||||
{process.uptime ? ` · ${formatProcessUptime(process.uptime)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={process.status === 'online' ? 'bg-emerald-500/15 text-emerald-600 hover:bg-emerald-500/15' : 'bg-destructive/15 text-destructive hover:bg-destructive/15'}>
|
||||
{process.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded bg-background/70 px-3 py-4 text-center text-sm text-muted-foreground">未读取到 PM2 进程</div>
|
||||
)}
|
||||
</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">
|
||||
@@ -531,3 +642,14 @@ function formatDate(value: string): string {
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function formatProcessUptime(value: number): string {
|
||||
const durationMs = Date.now() - value;
|
||||
if (!Number.isFinite(durationMs) || durationMs < 0) return '运行时间未知';
|
||||
const minutes = Math.floor(durationMs / 60000);
|
||||
if (minutes < 1) return '刚刚启动';
|
||||
if (minutes < 60) return `运行 ${minutes} 分钟`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `运行 ${hours} 小时`;
|
||||
return `运行 ${Math.floor(hours / 24)} 天`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user