Separate upgrade preflight status

This commit is contained in:
FengLee
2026-05-10 09:11:29 +08:00
parent 8ae28e030d
commit 61e9fbd6d4
2 changed files with 185 additions and 8 deletions

View File

@@ -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;

View File

@@ -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)}`;
}