mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-18 18:33:06 +08:00
feat: stage online upgrades before verified restarts
Download updates asynchronously with persistent progress and validate matching source and build artifacts before an explicit restart. Keep upgrade workers alive across service restarts, verify the new runtime, and recover the browser with a one-time success notice. Publish immutable archive sizes and checksums for accurate download progress. Align the update dialog, hide unavailable actions during transfers, and show the sidebar notice only for active, ready or failed jobs. Constrain long account names with ellipsis while preserving avatar and collapse controls.
This commit is contained in:
@@ -94,6 +94,12 @@ jobs:
|
||||
run: |
|
||||
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||
|
||||
- name: publish verified upgrade download sizes
|
||||
env:
|
||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||
run: node scripts/publish-upgrade-info.cjs "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
|
||||
|
||||
static_gitlab:
|
||||
needs: build-static
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -205,6 +205,17 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/update-status',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.send(await Container.get(SystemService).getUpgradeStatus());
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/reload',
|
||||
celebrate({
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Service } from 'typedi';
|
||||
import Logger from '../loaders/logger';
|
||||
import cronClient from '../schedule/client';
|
||||
import { HttpServerService } from './http';
|
||||
import { runtimeBuild } from '../shared/runtimeBuild';
|
||||
|
||||
interface HealthStatus {
|
||||
build: typeof runtimeBuild;
|
||||
status: 'ok' | 'error';
|
||||
services: {
|
||||
http: boolean;
|
||||
@@ -22,12 +24,11 @@ interface HealthStatus {
|
||||
export class HealthService {
|
||||
private startTime = Date.now();
|
||||
|
||||
constructor(
|
||||
private httpServerService: HttpServerService,
|
||||
) {}
|
||||
constructor(private httpServerService: HttpServerService) {}
|
||||
|
||||
async check(): Promise<HealthStatus> {
|
||||
const status: HealthStatus = {
|
||||
build: runtimeBuild,
|
||||
status: 'ok',
|
||||
services: {
|
||||
http: true,
|
||||
|
||||
+36
-30
@@ -344,45 +344,51 @@ export default class SystemService {
|
||||
}
|
||||
|
||||
public async updateSystem() {
|
||||
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' });
|
||||
return this.startUpgradeJob('download');
|
||||
}
|
||||
|
||||
cp.stdout.on('data', (data) => {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateSystemVersion',
|
||||
message: data.toString(),
|
||||
});
|
||||
});
|
||||
private upgradeJobs() {
|
||||
return require(path.join(
|
||||
config.rootPath,
|
||||
'cli/src/internal/maintenance/panelUpgrade.cjs',
|
||||
));
|
||||
}
|
||||
|
||||
cp.stderr.on('data', (data) => {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateSystemVersion',
|
||||
message: data.toString(),
|
||||
});
|
||||
});
|
||||
public async getUpgradeStatus() {
|
||||
return {
|
||||
code: 200,
|
||||
data: await this.upgradeJobs().getStatus(config.tmpPath),
|
||||
};
|
||||
}
|
||||
|
||||
cp.on('error', (err) => {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateSystemVersion',
|
||||
message: JSON.stringify(err),
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
cp.on('exit', (code) => {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateSystemVersion',
|
||||
message: '',
|
||||
status: code === 0 ? 'success' : 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
return { code: 200 };
|
||||
private async startUpgradeJob(action: 'download' | 'apply') {
|
||||
try {
|
||||
const data = await this.upgradeJobs().startJob(
|
||||
{
|
||||
root: config.rootPath,
|
||||
tmp: config.tmpPath,
|
||||
env: {
|
||||
...process.env,
|
||||
dir_data: config.dataPath,
|
||||
dir_config: config.configPath,
|
||||
QlPort: String(config.port),
|
||||
QlBaseUrl: config.baseUrl,
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
return { code: 200, data };
|
||||
} catch (error: any) {
|
||||
this.logger.error('[upgrade] %s', error.message);
|
||||
return { code: 400, message: t('升级任务启动失败,请检查日志后重试') };
|
||||
}
|
||||
}
|
||||
|
||||
public async reloadSystem(target?: 'system' | 'data') {
|
||||
if (target && !['system', 'data'].includes(target)) {
|
||||
return { code: 400, message: t('参数错误') };
|
||||
}
|
||||
if (target === 'system') return this.startUpgradeJob('apply');
|
||||
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
|
||||
const cp = spawn(cmd, {
|
||||
shell: '/bin/bash',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const messages: Record<string, Record<string, string>> = {
|
||||
zh: {},
|
||||
en: {
|
||||
'升级任务启动失败,请检查日志后重试':
|
||||
'Failed to start the update. Check the logs and try again.',
|
||||
'[schedule][停止已运行任务] 任务ID: %s, PID: %s':
|
||||
'[schedule][Stopping running task] Task ID: %s, PID: %s',
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
|
||||
function read(filename: string): Buffer | undefined {
|
||||
try {
|
||||
return readFileSync(filename);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function manifest(): { sourceCommit?: string } {
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
read(path.resolve(__dirname, '../../build-info.json'))?.toString() ||
|
||||
'{}',
|
||||
);
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Capture before serving requests. Reading the installed files on each request
|
||||
// would let an old process claim the identity of a newly copied build.
|
||||
const backend = read(path.resolve(__dirname, '../app.js'));
|
||||
const root = process.env.QL_DIR || path.resolve(__dirname, '../../..');
|
||||
const version = read(path.join(root, 'version.yaml'))
|
||||
?.toString()
|
||||
.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
|
||||
|
||||
export const runtimeBuild = Object.freeze({
|
||||
instanceId: randomUUID(),
|
||||
parentPid: process.ppid,
|
||||
version,
|
||||
sourceCommit: manifest().sourceCommit,
|
||||
backendSha256: backend
|
||||
? createHash('sha256').update(backend).digest('hex')
|
||||
: undefined,
|
||||
});
|
||||
@@ -0,0 +1,471 @@
|
||||
// Persistent panel jobs run independently of HTTP requests and browser sessions.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { spawn } = require('node:child_process');
|
||||
const http = require('node:http');
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
const artifacts = require('./upgradeArtifacts.cjs');
|
||||
|
||||
const active = new Set(['starting', 'running', 'restarting']);
|
||||
const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json');
|
||||
const logFile = (tmp) => path.join(tmp, 'panel-upgrade.log');
|
||||
const launches = new Map();
|
||||
|
||||
async function withPanelLifecycle(root, tmp, env, operation, signal) {
|
||||
if (!env.QL_CLI_ROOT)
|
||||
return artifacts.withLifecycleHooks(root, tmp, env, operation);
|
||||
if (!path.isAbsolute(env.QL_CLI_ROOT))
|
||||
throw new Error('QL_CLI_ROOT must be absolute');
|
||||
const internal = path.join(env.QL_CLI_ROOT, 'dist/internal');
|
||||
// Keep the selected installation's operators loaded across file replacement.
|
||||
const { createContext, sourceEnvironment } = require(path.join(
|
||||
internal,
|
||||
'runtime/context.js',
|
||||
));
|
||||
const { cancellableOperation } = require(path.join(
|
||||
internal,
|
||||
'runtime/cancellation.js',
|
||||
));
|
||||
const operator = require(path.join(internal, 'maintenance/operator.js'));
|
||||
const { prepareUpgradeSelection } = require(path.join(
|
||||
internal,
|
||||
'maintenance/upgradeSelection.js',
|
||||
));
|
||||
const context = createContext(
|
||||
{ root, 'data-dir': env.dir_data || env.QL_DATA_DIR },
|
||||
env,
|
||||
);
|
||||
context.env = await sourceEnvironment(
|
||||
context.env,
|
||||
[
|
||||
context.paths.file_config_user,
|
||||
path.join(context.paths.dir_preload, 'lang_env.sh'),
|
||||
],
|
||||
[],
|
||||
{ signal },
|
||||
);
|
||||
const hook = (name, args = [], signal) =>
|
||||
cancellableOperation(signal, () => {
|
||||
if (name === 'install')
|
||||
return operator.installPanelDependencies(context, args[0]);
|
||||
if (name === 'stop') return operator.stopPanel(context);
|
||||
if (name === 'start') return operator.startPanel(context);
|
||||
throw new Error('Invalid lifecycle operation');
|
||||
});
|
||||
return operation(hook, (selected) =>
|
||||
prepareUpgradeSelection(context, selected.source, selected.static),
|
||||
);
|
||||
}
|
||||
|
||||
async function targetVersion(source) {
|
||||
const text = await fs.readFile(path.join(source, 'version.yaml'), 'utf8');
|
||||
const version = text.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
|
||||
if (!version) throw new Error('Invalid update version');
|
||||
return version;
|
||||
}
|
||||
|
||||
async function readRunningBuild(env) {
|
||||
let base = env.QlBaseUrl || '';
|
||||
if (base && !base.startsWith('/')) base = `/${base}`;
|
||||
base = base.replace(/\/$/, '');
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => request.destroy(new Error('Version check timed out')),
|
||||
5000,
|
||||
);
|
||||
const request = http.get(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: Number(env.QlPort || 5700),
|
||||
path: `${base}/api/health`,
|
||||
},
|
||||
(response) => {
|
||||
let body = '';
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
if (body.length > 65536)
|
||||
request.destroy(new Error('Invalid version response'));
|
||||
});
|
||||
response.on('error', reject);
|
||||
response.on('end', () => {
|
||||
try {
|
||||
const result = JSON.parse(body);
|
||||
if (
|
||||
response.statusCode !== 200 ||
|
||||
result.code !== 200 ||
|
||||
result.data?.status !== 'ok'
|
||||
)
|
||||
throw new Error('后端启动检查失败。');
|
||||
resolve(result.data.build);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.on('close', () => clearTimeout(timer));
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyRunningVersion(env, expected, previousBuild) {
|
||||
const build = await readRunningBuild(env);
|
||||
if (
|
||||
!build ||
|
||||
!build.instanceId ||
|
||||
build.instanceId === previousBuild?.instanceId ||
|
||||
(previousBuild?.parentPid !== undefined &&
|
||||
build.parentPid === previousBuild.parentPid) ||
|
||||
build.version !== expected.version ||
|
||||
build.sourceCommit !== expected.sourceCommit ||
|
||||
build.backendSha256 !== expected.backendSha256
|
||||
)
|
||||
throw new Error('启动版本与更新包不一致');
|
||||
}
|
||||
|
||||
async function stagedMetadata(selected) {
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(selected.static, 'build-info.json'), 'utf8'),
|
||||
);
|
||||
return {
|
||||
version: await targetVersion(selected.source),
|
||||
sourceCommit: manifest.sourceCommit,
|
||||
backendSha256: manifest.files['build/app.js'],
|
||||
stageDirectory: path.basename(path.dirname(selected.source)),
|
||||
};
|
||||
}
|
||||
|
||||
function assertSelected(expected, actual) {
|
||||
for (const key of [
|
||||
'sourceCommit',
|
||||
'backendSha256',
|
||||
'stageDirectory',
|
||||
'version',
|
||||
]) {
|
||||
if (expected[key] !== actual[key])
|
||||
throw new Error('暂存更新已变化,请重新下载更新。');
|
||||
}
|
||||
}
|
||||
|
||||
async function save(tmp, state) {
|
||||
const temporary = `${stateFile(tmp)}.${randomUUID()}`;
|
||||
await fs.writeFile(temporary, JSON.stringify(state), { mode: 0o600 });
|
||||
await fs.rename(temporary, stateFile(tmp));
|
||||
}
|
||||
|
||||
async function readState(tmp) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(stateFile(tmp), 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return { status: 'idle' };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getStatus(tmp) {
|
||||
const state = await readState(tmp);
|
||||
if (active.has(state.status)) {
|
||||
let alive = false;
|
||||
if (Number.isSafeInteger(state.pid) && state.pid > 0) {
|
||||
try {
|
||||
process.kill(state.pid, 0);
|
||||
alive = true;
|
||||
} catch (error) {
|
||||
alive = error.code === 'EPERM';
|
||||
}
|
||||
} else alive = Date.now() - state.startedAt < 10000;
|
||||
if (!alive) {
|
||||
state.status = 'failed';
|
||||
state.error = '升级任务意外中断,请重试';
|
||||
}
|
||||
}
|
||||
// Bound reads even when an installer produces a very large log.
|
||||
const handle = await fs.open(logFile(tmp), 'r').catch(() => undefined);
|
||||
if (handle) {
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
const buffer = Buffer.alloc(Math.min(size, 32768));
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
buffer.length,
|
||||
Math.max(0, size - buffer.length),
|
||||
);
|
||||
state.log = buffer.subarray(0, bytesRead).toString();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function startJob({ root, tmp, env = process.env }, action) {
|
||||
if (!['download', 'apply'].includes(action))
|
||||
throw new Error('Invalid upgrade action');
|
||||
await fs.mkdir(tmp, { recursive: true });
|
||||
const preceding = launches.get(tmp) || Promise.resolve();
|
||||
const launching = preceding
|
||||
.catch(() => {})
|
||||
.then(() =>
|
||||
artifacts.withUpgradeLock(
|
||||
path.join(tmp, 'panel-job-launch'),
|
||||
async () => {
|
||||
const previous = await getStatus(tmp);
|
||||
if (active.has(previous.status)) {
|
||||
if (previous.action !== action)
|
||||
throw new Error('已有更新或重载正在执行。');
|
||||
return previous;
|
||||
}
|
||||
let selected;
|
||||
let metadata;
|
||||
if (action === 'apply') {
|
||||
// Fail before launching a process or stopping the working panel.
|
||||
try {
|
||||
selected = await artifacts.selectedUpgrade(
|
||||
tmp,
|
||||
artifacts.releaseBranch(env),
|
||||
env,
|
||||
);
|
||||
metadata = await stagedMetadata(selected);
|
||||
if (previous.status === 'ready')
|
||||
assertSelected(previous, metadata);
|
||||
} catch (error) {
|
||||
const { log, ...record } = previous;
|
||||
await save(tmp, {
|
||||
...record,
|
||||
status: 'failed',
|
||||
phase: 'verifying',
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} else if (previous.status === 'ready') return previous;
|
||||
const state = {
|
||||
id: randomUUID(),
|
||||
action,
|
||||
status: 'starting',
|
||||
phase: action === 'apply' ? 'verifying' : 'downloading',
|
||||
startedAt: Date.now(),
|
||||
...metadata,
|
||||
};
|
||||
await save(tmp, state);
|
||||
const log = await fs.open(logFile(tmp), 'w', 0o600);
|
||||
try {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[__filename, 'launch', state.id],
|
||||
{
|
||||
env: { ...env, QL_DIR: root, dir_tmp: tmp },
|
||||
detached: true,
|
||||
stdio: ['ignore', log.fd, log.fd],
|
||||
},
|
||||
);
|
||||
await new Promise((resolve, reject) => {
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(`Upgrade launcher exited (${signal || code})`),
|
||||
);
|
||||
});
|
||||
child.once('error', reject);
|
||||
});
|
||||
child.unref();
|
||||
} catch (error) {
|
||||
await save(tmp, {
|
||||
...state,
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await log.close();
|
||||
}
|
||||
return state;
|
||||
},
|
||||
env,
|
||||
),
|
||||
);
|
||||
launches.set(tmp, launching);
|
||||
try {
|
||||
return await launching;
|
||||
} finally {
|
||||
if (launches.get(tmp) === launching) launches.delete(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
async function runJob({ root, tmp, env = process.env }, id) {
|
||||
let state = await readState(tmp);
|
||||
if (state.id !== id || state.status !== 'starting')
|
||||
throw new Error('Stale upgrade job');
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(new Error('升级任务意外中断,请重试'));
|
||||
process.once('SIGTERM', abort);
|
||||
process.once('SIGINT', abort);
|
||||
// Serialize progress writes so an older tick cannot overwrite ready/completed.
|
||||
let writes = Promise.resolve();
|
||||
const report = (patch) => {
|
||||
state = { ...state, ...patch, pid: process.pid, updatedAt: Date.now() };
|
||||
const snapshot = { ...state };
|
||||
writes = writes.then(() => save(tmp, snapshot));
|
||||
return writes;
|
||||
};
|
||||
try {
|
||||
await report({ status: 'running' });
|
||||
let terminal;
|
||||
await artifacts.withUpgradeLock(
|
||||
tmp,
|
||||
() =>
|
||||
withPanelLifecycle(
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
async (hook, prepareSelection) => {
|
||||
const run = (program, args, capture) =>
|
||||
artifacts.runProcess(
|
||||
env,
|
||||
program,
|
||||
args,
|
||||
capture,
|
||||
controller.signal,
|
||||
);
|
||||
if (state.action === 'download') {
|
||||
const selected = await artifacts.stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
mirror: 'github',
|
||||
fallbackMirror: 'gitee',
|
||||
run,
|
||||
signal: controller.signal,
|
||||
onProgress: report,
|
||||
install: (source) =>
|
||||
hook('install', [source], controller.signal),
|
||||
});
|
||||
terminal = {
|
||||
status: 'ready',
|
||||
phase: 'ready',
|
||||
...(await stagedMetadata(selected)),
|
||||
};
|
||||
} else {
|
||||
const selected = await artifacts.selectedUpgrade(
|
||||
tmp,
|
||||
artifacts.releaseBranch(env),
|
||||
env,
|
||||
);
|
||||
assertSelected(state, await stagedMetadata(selected));
|
||||
const previousBuild = await readRunningBuild(env).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const selection = await prepareSelection?.(selected);
|
||||
try {
|
||||
await artifacts.reloadSystem({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
data:
|
||||
env.dir_data || env.QL_DATA_DIR || path.join(root, 'data'),
|
||||
staticRoot: env.dir_static || path.join(root, 'static'),
|
||||
config:
|
||||
env.dir_config ||
|
||||
path.join(
|
||||
env.QL_DATA_DIR || path.join(root, 'data'),
|
||||
'config',
|
||||
),
|
||||
lifecycle: {
|
||||
signal: controller.signal,
|
||||
stop: async () => {
|
||||
await report({
|
||||
status: 'restarting',
|
||||
phase: 'restarting',
|
||||
});
|
||||
await hook('stop');
|
||||
},
|
||||
start: async (recover) => {
|
||||
await hook(
|
||||
'start',
|
||||
[],
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
await artifacts.waitForHealth(
|
||||
env,
|
||||
60000,
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
if (!recover)
|
||||
await verifyRunningVersion(env, state, previousBuild);
|
||||
},
|
||||
},
|
||||
additionalReplacements: selection
|
||||
? [{ source: selection.source, target: selection.target }]
|
||||
: [],
|
||||
});
|
||||
} finally {
|
||||
if (selection)
|
||||
await fs.rm(selection.directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
terminal = { status: 'completed', phase: 'completed' };
|
||||
}
|
||||
},
|
||||
controller.signal,
|
||||
),
|
||||
env,
|
||||
);
|
||||
await report(terminal);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// A failed write must not prevent recording the final failure.
|
||||
await writes.catch(() => {});
|
||||
writes = Promise.resolve();
|
||||
await report({ status: 'failed', error: error.message });
|
||||
} finally {
|
||||
process.removeListener('SIGTERM', abort);
|
||||
process.removeListener('SIGINT', abort);
|
||||
await writes;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStatus, startJob, runJob };
|
||||
async function launchWorker(id) {
|
||||
// detached creates a session but keeps the original parent. PM2 kills the
|
||||
// backend's entire descendant tree, so an intermediate launcher must exit
|
||||
// before the worker is allowed to stop that backend.
|
||||
const child = spawn(process.execPath, [__filename, 'worker', id], {
|
||||
env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) },
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
child.once('spawn', resolve);
|
||||
child.once('error', reject);
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
async function runOrphanedWorker(id) {
|
||||
const launcher = Number(process.env.QL_UPGRADE_LAUNCHER_PID);
|
||||
const deadline = Date.now() + 10000;
|
||||
while (process.platform !== 'win32' && process.ppid === launcher) {
|
||||
if (Date.now() >= deadline)
|
||||
throw new Error('Upgrade launcher did not exit');
|
||||
await delay(10);
|
||||
}
|
||||
const root = process.env.QL_DIR;
|
||||
await runJob(
|
||||
{ root, tmp: process.env.dir_tmp || path.join(root, '.tmp') },
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module && ['launch', 'worker'].includes(process.argv[2])) {
|
||||
const operation =
|
||||
process.argv[2] === 'launch' ? launchWorker : runOrphanedWorker;
|
||||
operation(process.argv[3]).catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,8 @@ const upgradeEnglish = {
|
||||
'暂存升级指针无效。': 'Invalid staged upgrade pointer',
|
||||
'暂存目录无效。': 'Invalid staging directory',
|
||||
'暂存升级就绪记录不匹配。': 'Invalid upgrade readiness',
|
||||
'运行依赖已变化,请重新下载更新。':
|
||||
'Installed dependencies changed; download the update again',
|
||||
'保留备份: %s': 'Retained backup: %s',
|
||||
'后端启动检查失败。': 'Backend failed health check',
|
||||
'保留生命周期快照: %s': 'Retained lifecycle snapshot: %s',
|
||||
@@ -171,6 +173,82 @@ async function withUpgradeLock(tmp, operation, env = process.env) {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWithProgress(
|
||||
run,
|
||||
archive,
|
||||
url,
|
||||
artifact,
|
||||
onProgress,
|
||||
quiet = true,
|
||||
expected,
|
||||
) {
|
||||
const headers = `${archive}.headers`;
|
||||
await fs.rm(archive, { force: true });
|
||||
await fs.rm(headers, { force: true });
|
||||
let sampling = Promise.resolve();
|
||||
const sample = async (finished = false) => {
|
||||
const bytes = (await fs.stat(archive).catch(() => undefined))?.size || 0;
|
||||
const response = (await fs.readFile(headers, 'utf8').catch(() => ''))
|
||||
.split(/(?=HTTP\/\S+ \d{3})/)
|
||||
.at(-1);
|
||||
const length = response?.match(/^content-length:\s*(\d+)\s*$/im);
|
||||
const total =
|
||||
expected?.size ||
|
||||
(length && Number(length[1]) > 0 ? Number(length[1]) : null);
|
||||
await onProgress({
|
||||
phase: 'downloading',
|
||||
artifact,
|
||||
downloadedBytes: bytes,
|
||||
totalBytes: total,
|
||||
percent: total
|
||||
? finished
|
||||
? 100
|
||||
: Math.min(99, Math.floor((bytes * 100) / total))
|
||||
: null,
|
||||
});
|
||||
};
|
||||
const timer = setInterval(() => {
|
||||
sampling = sampling.then(() => sample());
|
||||
// Observe failures immediately; propagate after the transfer has closed.
|
||||
sampling.catch(() => {});
|
||||
}, 1000);
|
||||
try {
|
||||
await run('curl', [
|
||||
'--fail',
|
||||
'--location',
|
||||
...(quiet ? ['--silent', '--show-error'] : []),
|
||||
'--proto',
|
||||
'=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'600',
|
||||
'--speed-limit',
|
||||
'1024',
|
||||
'--speed-time',
|
||||
'30',
|
||||
'--dump-header',
|
||||
headers,
|
||||
'--output',
|
||||
archive,
|
||||
url,
|
||||
]);
|
||||
clearInterval(timer);
|
||||
await sampling;
|
||||
if (expected) {
|
||||
const bytes = await fs.readFile(archive);
|
||||
if (bytes.length !== expected.size || hash(bytes) !== expected.sha256)
|
||||
throw new Error('Upgrade archive size or checksum mismatch');
|
||||
}
|
||||
await sample(true);
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
await sampling;
|
||||
}
|
||||
}
|
||||
|
||||
async function stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
@@ -180,38 +258,99 @@ async function stageUpgrade({
|
||||
run,
|
||||
install,
|
||||
signal,
|
||||
onProgress,
|
||||
}) {
|
||||
const report = onProgress || (async () => {});
|
||||
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
|
||||
const branch = releaseBranch(env);
|
||||
try {
|
||||
// Metadata is optional for older releases and unavailable mirrors. Never
|
||||
// invent a percentage when neither metadata nor HTTP supplies a total.
|
||||
let info;
|
||||
if (onProgress && mirror === 'github') {
|
||||
try {
|
||||
const readJson = async (url) => {
|
||||
const filename = path.join(workspace, 'metadata.json');
|
||||
await run('curl', [
|
||||
'--fail',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--location',
|
||||
'--proto',
|
||||
'=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'5',
|
||||
'--max-time',
|
||||
'10',
|
||||
'--max-filesize',
|
||||
'1048576',
|
||||
'--dump-header',
|
||||
`${filename}.headers`,
|
||||
'--output',
|
||||
filename,
|
||||
url,
|
||||
]);
|
||||
return JSON.parse(await fs.readFile(filename, 'utf8'));
|
||||
};
|
||||
const base = 'https://raw.githubusercontent.com/whyour/qinglong-static';
|
||||
const current = await readJson(
|
||||
`${base}/${branch}/build-info.json?t=${Date.now()}`,
|
||||
);
|
||||
if (!/^[a-f0-9]{40}$/.test(current.sourceCommit))
|
||||
throw new Error('Invalid commit');
|
||||
const candidate = await readJson(
|
||||
`${base}/upgrade-info/${current.sourceCommit}.json`,
|
||||
);
|
||||
if (
|
||||
candidate.version !== 1 ||
|
||||
candidate.sourceCommit !== current.sourceCommit ||
|
||||
!/^[a-f0-9]{40}$/.test(candidate.staticCommit) ||
|
||||
!['qinglong-static', 'qinglong'].every((repo) => {
|
||||
const entry = candidate.archives?.[repo];
|
||||
return (
|
||||
Number.isSafeInteger(entry?.size) &&
|
||||
entry.size > 0 &&
|
||||
/^[a-f0-9]{64}$/.test(entry.sha256)
|
||||
);
|
||||
})
|
||||
)
|
||||
throw new Error('Invalid archive metadata');
|
||||
info = candidate;
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
}
|
||||
async function extract(repository, ref) {
|
||||
const archive = path.join(workspace, `${repository}.zip`);
|
||||
const url =
|
||||
mirror === 'github'
|
||||
? `https://codeload.github.com/whyour/${repository}/zip/${ref}`
|
||||
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
|
||||
const download = (target) =>
|
||||
run('curl', [
|
||||
'--fail',
|
||||
'--location',
|
||||
'--proto',
|
||||
'=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'600',
|
||||
'--speed-limit',
|
||||
'1024',
|
||||
'--speed-time',
|
||||
'30',
|
||||
'--output',
|
||||
const download = async (target, expected) => {
|
||||
await report({
|
||||
phase: 'downloading',
|
||||
artifact: repository,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: expected?.size || null,
|
||||
percent: expected ? 0 : null,
|
||||
});
|
||||
await downloadWithProgress(
|
||||
run,
|
||||
archive,
|
||||
target,
|
||||
]);
|
||||
repository,
|
||||
report,
|
||||
Boolean(onProgress),
|
||||
expected,
|
||||
);
|
||||
};
|
||||
try {
|
||||
await download(url);
|
||||
await download(
|
||||
url,
|
||||
mirror === 'github' ? info?.archives[repository] : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (mirror !== 'github' || fallbackMirror !== 'gitee') throw error;
|
||||
@@ -220,6 +359,11 @@ async function stageUpgrade({
|
||||
`https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`,
|
||||
);
|
||||
}
|
||||
await report({
|
||||
phase: 'extracting',
|
||||
artifact: repository,
|
||||
percent: null,
|
||||
});
|
||||
const listing = await run('unzip', ['-Z1', archive], true);
|
||||
const expected = `${repository}-${ref}`;
|
||||
const names = listing.stdout.split('\n').filter(Boolean);
|
||||
@@ -243,11 +387,20 @@ async function stageUpgrade({
|
||||
}
|
||||
// Static branches are mutable snapshots. Their manifest pins the exact
|
||||
// source revision, avoiding a race between two independent branch reads.
|
||||
const staticRoot = await extract('qinglong-static', branch);
|
||||
const downloadedStatic = await extract(
|
||||
'qinglong-static',
|
||||
info?.staticCommit || branch,
|
||||
);
|
||||
const staticRoot = path.join(workspace, `qinglong-static-${branch}`);
|
||||
if (downloadedStatic !== staticRoot)
|
||||
await fs.rename(downloadedStatic, staticRoot);
|
||||
const manifest = await manifestAt(staticRoot, env);
|
||||
if (info && manifest.sourceCommit !== info.sourceCommit)
|
||||
throw new Error(upgradeMessage('构建清单无效。', env));
|
||||
const downloadedSource = await extract('qinglong', manifest.sourceCommit);
|
||||
const source = path.join(workspace, `qinglong-${branch}`);
|
||||
await fs.rename(downloadedSource, source);
|
||||
await report({ phase: 'verifying', percent: null });
|
||||
await verifyUpgrade(source, staticRoot, env);
|
||||
let dependenciesChanged = false;
|
||||
for (const name of ['package.json', 'pnpm-lock.yaml']) {
|
||||
@@ -265,6 +418,7 @@ async function stageUpgrade({
|
||||
)
|
||||
dependenciesChanged = true;
|
||||
if (dependenciesChanged) {
|
||||
await report({ phase: 'dependencies', percent: null });
|
||||
await install(source);
|
||||
await fs.access(path.join(source, 'node_modules'));
|
||||
await verifyUpgrade(source, staticRoot, env);
|
||||
@@ -275,6 +429,7 @@ async function stageUpgrade({
|
||||
path.join(workspace, 'ready.json'),
|
||||
JSON.stringify({
|
||||
...selected,
|
||||
root: path.resolve(root),
|
||||
sourceCommit: manifest.sourceCommit,
|
||||
dependenciesChanged,
|
||||
}),
|
||||
@@ -343,6 +498,28 @@ async function selectedUpgrade(tmp, branch, env = process.env) {
|
||||
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
|
||||
if (ready.dependenciesChanged)
|
||||
await fs.access(path.join(selected.source, 'node_modules'));
|
||||
else {
|
||||
const root = ready.root || env.QL_DIR;
|
||||
if (
|
||||
!root ||
|
||||
!(
|
||||
await fs.stat(path.join(root, 'node_modules')).catch(() => undefined)
|
||||
)?.isDirectory()
|
||||
)
|
||||
throw new Error(upgradeMessage('运行依赖已变化,请重新下载更新。', env));
|
||||
for (const name of ['package.json', 'pnpm-lock.yaml']) {
|
||||
const current = await fs
|
||||
.readFile(path.join(root, name))
|
||||
.catch(() => undefined);
|
||||
if (
|
||||
!current ||
|
||||
!(await fs.readFile(path.join(selected.source, name))).equals(current)
|
||||
)
|
||||
throw new Error(
|
||||
upgradeMessage('运行依赖已变化,请重新下载更新。', env),
|
||||
);
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
@@ -430,6 +607,7 @@ async function reloadSystem({
|
||||
config,
|
||||
env,
|
||||
lifecycle,
|
||||
additionalReplacements = [],
|
||||
}) {
|
||||
const selected = await selectedUpgrade(tmp, releaseBranch(env), env);
|
||||
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
||||
@@ -449,6 +627,7 @@ async function reloadSystem({
|
||||
target: path.join(config, 'config.sample.sh'),
|
||||
},
|
||||
);
|
||||
replacements.push(...additionalReplacements);
|
||||
await replaceAndReload(replacements, lifecycle, env);
|
||||
// Cleanup after a committed installation must not report a failed update.
|
||||
await fs
|
||||
@@ -687,6 +866,7 @@ async function main() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
downloadWithProgress,
|
||||
upgradeMessage,
|
||||
releaseBranch,
|
||||
verifyUpgrade,
|
||||
|
||||
@@ -416,6 +416,7 @@ export const openOperations: readonly OpenOperation[] = [
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'system update-check', method: 'PUT', path: 'system/update-check' },
|
||||
{ name: 'system update-status', method: 'GET', path: 'system/update-status' },
|
||||
{ name: 'system update', method: 'PUT', path: 'system/update' },
|
||||
{ name: 'system reload', method: 'PUT', path: 'system/reload', body: 'json' },
|
||||
{ name: 'system notify', method: 'PUT', path: 'system/notify', body: 'json' },
|
||||
|
||||
@@ -276,7 +276,7 @@ test('upgrade staging extracts both archives before publishing readiness and rej
|
||||
'utf8',
|
||||
),
|
||||
),
|
||||
{ ...staged, sourceCommit, dependenciesChanged: false },
|
||||
{ ...staged, root: ctx.root, sourceCommit, dependenciesChanged: false },
|
||||
);
|
||||
await fs.symlink(
|
||||
'../../outside',
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Publish archive sizes separately: adding them to the archive itself would
|
||||
// change its bytes. Immutable commit URLs make both size and checksum stable.
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
function publishUpgradeInfo(
|
||||
destination,
|
||||
ref,
|
||||
sourceCommit,
|
||||
execute = execFileSync,
|
||||
) {
|
||||
const run = (program, args) =>
|
||||
execute(program, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (!/^[a-f0-9]{40}$/.test(sourceCommit))
|
||||
throw new Error('Invalid source commit');
|
||||
run('git', ['check-ref-format', `refs/heads/${ref}`]);
|
||||
const staticCommit = run('git', [
|
||||
'ls-remote',
|
||||
destination,
|
||||
`refs/heads/${ref}`,
|
||||
])
|
||||
.trim()
|
||||
.split(/\s/)[0];
|
||||
if (!/^[a-f0-9]{40}$/.test(staticCommit))
|
||||
throw new Error('Missing static commit');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-upgrade-info-'));
|
||||
try {
|
||||
const archives = {};
|
||||
for (const [repository, commit] of [
|
||||
['qinglong-static', staticCommit],
|
||||
['qinglong', sourceCommit],
|
||||
]) {
|
||||
const filename = path.join(tmp, `${repository}.zip`);
|
||||
run('curl', [
|
||||
'--fail',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--location',
|
||||
'--proto',
|
||||
'=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'600',
|
||||
'--output',
|
||||
filename,
|
||||
`https://codeload.github.com/whyour/${repository}/zip/${commit}`,
|
||||
]);
|
||||
run('unzip', ['-tq', filename]);
|
||||
if (repository === 'qinglong-static') {
|
||||
const manifest = JSON.parse(
|
||||
run('unzip', [
|
||||
'-p',
|
||||
filename,
|
||||
`${repository}-${commit}/build-info.json`,
|
||||
]),
|
||||
);
|
||||
if (manifest.sourceCommit !== sourceCommit || manifest.dirty !== false)
|
||||
throw new Error('Static source commit does not match this build');
|
||||
}
|
||||
const bytes = fs.readFileSync(filename);
|
||||
archives[repository] = {
|
||||
size: bytes.length,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
};
|
||||
}
|
||||
const info = { version: 1, sourceCommit, staticCommit, archives };
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const checkout = path.join(tmp, `metadata-${attempt}`);
|
||||
const exists = run('git', [
|
||||
'ls-remote',
|
||||
destination,
|
||||
'refs/heads/upgrade-info',
|
||||
]).trim();
|
||||
if (exists)
|
||||
run('git', [
|
||||
'clone',
|
||||
'--quiet',
|
||||
'--depth',
|
||||
'1',
|
||||
'--branch',
|
||||
'upgrade-info',
|
||||
'--single-branch',
|
||||
destination,
|
||||
checkout,
|
||||
]);
|
||||
else run('git', ['init', '--quiet', '-b', 'upgrade-info', checkout]);
|
||||
const git = (...args) => run('git', ['-C', checkout, ...args]);
|
||||
git('config', 'user.name', 'github-actions[bot]');
|
||||
git(
|
||||
'config',
|
||||
'user.email',
|
||||
'github-actions[bot]@users.noreply.github.com',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(checkout, `${sourceCommit}.json`),
|
||||
JSON.stringify(info) + '\n',
|
||||
);
|
||||
git('add', `${sourceCommit}.json`);
|
||||
git(
|
||||
'commit',
|
||||
'--quiet',
|
||||
'--allow-empty',
|
||||
'-m',
|
||||
`Archive metadata for ${sourceCommit}`,
|
||||
);
|
||||
try {
|
||||
git('push', '--quiet', destination, 'HEAD:refs/heads/upgrade-info');
|
||||
return info;
|
||||
} catch (error) {
|
||||
if (attempt === 2) throw error;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { publishUpgradeInfo };
|
||||
if (require.main === module) {
|
||||
try {
|
||||
publishUpgradeInfo(...process.argv.slice(2));
|
||||
} catch {
|
||||
// Git errors may include a credential-bearing remote URL.
|
||||
console.error('Failed to publish verified upgrade archive metadata');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { CloudDownloadOutlined } from '@ant-design/icons';
|
||||
import { Modal, Tooltip } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import useUpgrade, { activeStatuses, phaseLabels } from '@/hooks/useUpgrade';
|
||||
import CheckUpdate from '@/pages/setting/checkUpdate';
|
||||
|
||||
export default function UpgradeNotice({
|
||||
collapsed,
|
||||
systemInfo,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
systemInfo: any;
|
||||
}) {
|
||||
const { job, connected } = useUpgrade();
|
||||
const [open, setOpen] = useState(false);
|
||||
const showNotice =
|
||||
activeStatuses.includes(job.status) ||
|
||||
job.status === 'ready' ||
|
||||
job.status === 'failed';
|
||||
if (!showNotice && !open) return null;
|
||||
const text = !connected
|
||||
? intl.get('连接已断开,正在重新连接')
|
||||
: job.status === 'failed'
|
||||
? intl.get('更新失败')
|
||||
: intl.get(phaseLabels[job.phase || ''] || '检查更新');
|
||||
const label =
|
||||
job.phase === 'downloading' && job.percent != null
|
||||
? `${text} ${job.percent}%`
|
||||
: text;
|
||||
return (
|
||||
<>
|
||||
{showNotice && (
|
||||
<Tooltip title={label} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
className={`upgrade-notice${
|
||||
collapsed ? ' upgrade-notice-collapsed' : ''
|
||||
}`}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={label}
|
||||
>
|
||||
<CloudDownloadOutlined />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Modal
|
||||
open={open}
|
||||
title={intl.get('更新')}
|
||||
footer={null}
|
||||
onCancel={() => setOpen(false)}
|
||||
width={650}
|
||||
destroyOnClose
|
||||
>
|
||||
<CheckUpdate systemInfo={systemInfo} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import config from '@/utils/config';
|
||||
import { notification } from 'antd';
|
||||
import intl from 'react-intl-universal';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export interface UpgradeJob {
|
||||
id?: string;
|
||||
action?: 'download' | 'apply';
|
||||
status: string;
|
||||
phase?: string;
|
||||
artifact?: string;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number | null;
|
||||
percent?: number | null;
|
||||
version?: string;
|
||||
error?: string;
|
||||
log?: string;
|
||||
}
|
||||
|
||||
export const activeStatuses = ['starting', 'running', 'restarting'];
|
||||
export const reloadKey = 'qinglong-upgrade-reload';
|
||||
export const completionKey = 'qinglong-upgrade-completed';
|
||||
export const phaseLabels: Record<string, string> = {
|
||||
downloading: '下载更新中...',
|
||||
extracting: '正在解压更新包',
|
||||
verifying: '正在校验更新包',
|
||||
dependencies: '正在准备依赖',
|
||||
ready: '更新已就绪',
|
||||
restarting: '正在重启并更新',
|
||||
completed: '更新完成',
|
||||
};
|
||||
|
||||
// The sidebar and settings share one poller and one mutation state.
|
||||
export function createUpgradeStore() {
|
||||
let snapshot = {
|
||||
job: { status: 'loading' } as UpgradeJob,
|
||||
busy: false,
|
||||
connected: true,
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
let revision = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let controller: AbortController | undefined;
|
||||
const publish = (patch: Partial<typeof snapshot>) => {
|
||||
snapshot = { ...snapshot, ...patch };
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
const poll = async () => {
|
||||
const current = revision;
|
||||
const attempt = new AbortController();
|
||||
controller = attempt;
|
||||
const timeout = setTimeout(() => attempt.abort(), 5000);
|
||||
try {
|
||||
const token = localStorage.getItem(config.authKey);
|
||||
const response = await fetch(`${config.apiPrefix}system/update-status`, {
|
||||
cache: 'no-store',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
signal: attempt.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error('Upgrade status unavailable');
|
||||
const result = await response.json();
|
||||
if (result.code !== 200 || !result.data)
|
||||
throw new Error('Invalid upgrade status');
|
||||
if (!listeners.size || current !== revision || attempt.signal.aborted)
|
||||
return;
|
||||
publish({ job: result.data, connected: true });
|
||||
if (
|
||||
result.data.status === 'completed' &&
|
||||
result.data.id === sessionStorage.getItem(reloadKey)
|
||||
) {
|
||||
sessionStorage.setItem(completionKey, result.data.id);
|
||||
sessionStorage.removeItem(reloadKey);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
result.data.status === 'completed' &&
|
||||
result.data.id === sessionStorage.getItem(completionKey)
|
||||
) {
|
||||
sessionStorage.removeItem(completionKey);
|
||||
notification.success({
|
||||
message: intl.get('升级成功'),
|
||||
description: intl.get('已重新启动并运行新版本,无需再次重启。'),
|
||||
duration: 8,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (listeners.size && current === revision && controller === attempt)
|
||||
publish({ connected: false });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (listeners.size && controller === attempt)
|
||||
timer = setTimeout(
|
||||
poll,
|
||||
!snapshot.connected || activeStatuses.includes(snapshot.job.status)
|
||||
? 1000
|
||||
: 15000,
|
||||
);
|
||||
}
|
||||
};
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (listeners.size === 1) poll();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) {
|
||||
clearTimeout(timer);
|
||||
controller?.abort();
|
||||
revision++;
|
||||
}
|
||||
};
|
||||
},
|
||||
beginMutation() {
|
||||
if (snapshot.busy) return false;
|
||||
revision++;
|
||||
publish({ busy: true });
|
||||
return true;
|
||||
},
|
||||
finishMutation() {
|
||||
revision++;
|
||||
controller?.abort();
|
||||
controller = undefined;
|
||||
clearTimeout(timer);
|
||||
publish({ busy: false });
|
||||
if (listeners.size) timer = setTimeout(poll, 1000);
|
||||
},
|
||||
setJob(job: UpgradeJob) {
|
||||
publish({ job });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const upgradeStore = createUpgradeStore();
|
||||
export default function useUpgrade() {
|
||||
return useSyncExternalStore(upgradeStore.subscribe, upgradeStore.getSnapshot);
|
||||
}
|
||||
+128
-4
@@ -354,20 +354,50 @@ select:-webkit-autofill:focus {
|
||||
|
||||
.side-menu-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: @text-color;
|
||||
|
||||
.side-menu-collapse-button:hover {
|
||||
color: #1890ff;
|
||||
.side-menu-collapse-button {
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.side-menu-user-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.side-menu-user-wrapper {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
.ant-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.side-menu-username {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
body[data-mode='phone'] .side-menu-user-wrapper {
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
.side-menu-user-drop-menu {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
@@ -475,11 +505,105 @@ body[data-mode='phone'] header {
|
||||
}
|
||||
|
||||
body[data-dark='true'] {
|
||||
.upgrade-notice {
|
||||
background: #3a3214;
|
||||
border-color: #6b5415;
|
||||
color: #ffd666;
|
||||
}
|
||||
.ant-popover-arrow-content {
|
||||
--antd-arrow-background-color: rgb(24, 26, 27);
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: calc(100% - 24px);
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
margin: 8px 12px 12px;
|
||||
border: 1px solid #ffe58f;
|
||||
border-radius: 6px;
|
||||
background: #fffbe6;
|
||||
color: #ad6800;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
> .anticon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 14px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
> span:not(.anticon) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #d48806;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.upgrade-notice-collapsed {
|
||||
justify-content: center;
|
||||
padding: 7px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 600px;
|
||||
|
||||
.upgrade-status-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.upgrade-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.ant-progress {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade-progress-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 4px 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.upgrade-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upgrade-log-link {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
+29
-2
@@ -19,6 +19,8 @@ import './index.less';
|
||||
import { init } from '../utils/init';
|
||||
import WebSocketManager from '../utils/websocket';
|
||||
import { observePageLoadTiming } from '../utils/navigationTiming';
|
||||
import UpgradeNotice from '@/components/UpgradeNotice';
|
||||
import { completionKey, reloadKey, upgradeStore } from '@/hooks/useUpgrade';
|
||||
|
||||
export interface SharedContext {
|
||||
headerStyle: React.CSSProperties;
|
||||
@@ -54,6 +56,9 @@ export default function () {
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [initLoading, setInitLoading] = useState<boolean>(true);
|
||||
const observeUpgrade =
|
||||
Boolean(user.username) &&
|
||||
!['/login', '/initialization'].includes(location.pathname);
|
||||
const {
|
||||
enable: enableDarkMode,
|
||||
disable: disableDarkMode,
|
||||
@@ -162,6 +167,18 @@ export default function () {
|
||||
getHealthStatus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// A reload during restart may initially land on the health error page.
|
||||
// Keep recovering the accepted job even before the sidebar can mount.
|
||||
if (
|
||||
observeUpgrade ||
|
||||
sessionStorage.getItem(reloadKey) ||
|
||||
sessionStorage.getItem(completionKey)
|
||||
) {
|
||||
return upgradeStore.subscribe(() => {});
|
||||
}
|
||||
}, [observeUpgrade]);
|
||||
|
||||
useEffect(() => {
|
||||
reloadSystemConfig();
|
||||
}, []);
|
||||
@@ -321,6 +338,12 @@ export default function () {
|
||||
</>
|
||||
}
|
||||
title={false}
|
||||
menuFooterRender={(props) => (
|
||||
<UpgradeNotice
|
||||
collapsed={Boolean(props?.collapsed)}
|
||||
systemInfo={systemInfo}
|
||||
/>
|
||||
)}
|
||||
menuItemRender={(menuItemProps: any, defaultDom: any) => {
|
||||
if (
|
||||
menuItemProps.isUrl ||
|
||||
@@ -351,7 +374,9 @@ export default function () {
|
||||
user.avatar ? `${config.apiPrefix}static/${user.avatar}` : ''
|
||||
}
|
||||
/>
|
||||
<span style={{ marginLeft: 5 }}>{user.username}</span>
|
||||
<span className="side-menu-username" title={user.username}>
|
||||
{user.username}
|
||||
</span>
|
||||
</span>
|
||||
</Dropdown>
|
||||
)
|
||||
@@ -377,7 +402,9 @@ export default function () {
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<span style={{ marginLeft: 5 }}>{user.username}</span>
|
||||
<span className="side-menu-username" title={user.username}>
|
||||
{user.username}
|
||||
</span>
|
||||
</span>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
+27
-2
@@ -338,7 +338,7 @@
|
||||
"显示": "Display",
|
||||
"智能微秘书": "Smart WeChat Assistant",
|
||||
"暂无数据": "No data",
|
||||
"暂无日志": "No logs available",
|
||||
"暂无日志": "No logs yet",
|
||||
"暂无脚本": "No scripts available",
|
||||
"暂无运行中任务": "No running tasks",
|
||||
"暗色": "Dark",
|
||||
@@ -731,5 +731,30 @@
|
||||
"白屏时间": "Time to first response: {duration} ms",
|
||||
"请求完毕至DOM加载": "Response end to DOM interactive: {duration} ms",
|
||||
"解释DOM树耗时": "DOM completion time: {duration} ms",
|
||||
"从开始至load总耗时": "Total load time: {duration} ms"
|
||||
"从开始至load总耗时": "Total load time: {duration} ms",
|
||||
"正在解压更新包": "Extracting update",
|
||||
"正在校验更新包": "Verifying update",
|
||||
"正在准备依赖": "Preparing dependencies",
|
||||
"更新已就绪": "Update ready",
|
||||
"正在重启并更新": "Restarting and updating",
|
||||
"更新完成": "Update complete",
|
||||
"更新失败": "Update failed",
|
||||
"正在准备更新": "Preparing update",
|
||||
"连接已断开,正在重新连接": "Connection lost. Reconnecting…",
|
||||
"更新已就绪,重启后应用新版本": "Update ready. Restart to apply the new version.",
|
||||
"更新在后台进行,可以继续使用面板": "The update runs in the background. You can keep using the panel.",
|
||||
"下载构建产物": "Downloading application files",
|
||||
"下载源码": "Downloading source files",
|
||||
"正在获取下载大小": "Total size unavailable",
|
||||
"查看更新日志": "View update log",
|
||||
"重启并更新": "Restart and update",
|
||||
"升级任务启动失败,请检查日志后重试": "Failed to start the update. Check the logs and try again.",
|
||||
"升级任务意外中断,请重试": "The update was interrupted. Please try again.",
|
||||
"系统将在30秒后自动刷新": "The page will refresh in 30 seconds.",
|
||||
"运行依赖已变化,请重新下载更新。": "Installed dependencies changed. Download the update again.",
|
||||
"暂存更新已变化,请重新下载更新。": "The staged update changed. Download the update again.",
|
||||
"启动版本与更新包不一致": "The running build does not match the update package.",
|
||||
"下载完成,等待重启": "Download complete, restart to update",
|
||||
"升级成功": "Update successful",
|
||||
"已重新启动并运行新版本,无需再次重启。": "Restarted and running the new version. No further restart is needed."
|
||||
}
|
||||
|
||||
+26
-1
@@ -731,5 +731,30 @@
|
||||
"白屏时间": "白屏时间: {duration} ms",
|
||||
"请求完毕至DOM加载": "请求完毕至DOM加载: {duration} ms",
|
||||
"解释DOM树耗时": "解释 DOM 树耗时: {duration} ms",
|
||||
"从开始至load总耗时": "从开始至 load 总耗时: {duration} ms"
|
||||
"从开始至load总耗时": "从开始至 load 总耗时: {duration} ms",
|
||||
"正在解压更新包": "正在解压更新包",
|
||||
"正在校验更新包": "正在校验更新包",
|
||||
"正在准备依赖": "正在准备依赖",
|
||||
"更新已就绪": "更新已就绪",
|
||||
"正在重启并更新": "正在重启并更新",
|
||||
"更新完成": "更新完成",
|
||||
"更新失败": "更新失败",
|
||||
"正在准备更新": "正在准备更新",
|
||||
"连接已断开,正在重新连接": "连接已断开,正在重新连接",
|
||||
"更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本",
|
||||
"更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板",
|
||||
"下载构建产物": "下载构建产物",
|
||||
"下载源码": "下载源码",
|
||||
"正在获取下载大小": "正在获取下载大小",
|
||||
"查看更新日志": "查看更新日志",
|
||||
"重启并更新": "重启并更新",
|
||||
"升级任务启动失败,请检查日志后重试": "升级任务启动失败,请检查日志后重试",
|
||||
"升级任务意外中断,请重试": "升级任务意外中断,请重试",
|
||||
"系统将在30秒后自动刷新": "系统将在30秒后自动刷新",
|
||||
"运行依赖已变化,请重新下载更新。": "运行依赖已变化,请重新下载更新。",
|
||||
"暂存更新已变化,请重新下载更新。": "暂存更新已变化,请重新下载更新。",
|
||||
"启动版本与更新包不一致": "启动版本与更新包不一致",
|
||||
"下载完成,等待重启": "下载完成,等待重启",
|
||||
"升级成功": "升级成功",
|
||||
"已重新启动并运行新版本,无需再次重启。": "已重新启动并运行新版本,无需再次重启。"
|
||||
}
|
||||
|
||||
+190
-220
@@ -1,249 +1,219 @@
|
||||
import { disableBody } from '@/utils';
|
||||
import config from '@/utils/config';
|
||||
import { request } from '@/utils/http';
|
||||
import WebSocketManager from '@/utils/websocket';
|
||||
import Ansi from 'ansi-to-react';
|
||||
import { Button, Modal, Statistic, message } from 'antd';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Modal, Progress, Space, Typography, message } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import useUpgrade, {
|
||||
activeStatuses,
|
||||
phaseLabels,
|
||||
reloadKey,
|
||||
upgradeStore,
|
||||
} from '@/hooks/useUpgrade';
|
||||
import intl from 'react-intl-universal';
|
||||
|
||||
const { Countdown } = Statistic;
|
||||
const sizeLabel = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
|
||||
const CheckUpdate = ({ systemInfo }: any) => {
|
||||
const [updateLoading, setUpdateLoading] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const modalRef = useRef<any>();
|
||||
const lastStatusRef = useRef<string | undefined>();
|
||||
const [updateStatus, setUpdateStatus] = useState<string>();
|
||||
|
||||
const checkUpgrade = () => {
|
||||
if (updateLoading || modalRef.current) return;
|
||||
setUpdateLoading(true);
|
||||
message.loading(intl.get('检查更新中...'), 0);
|
||||
request
|
||||
.put(`${config.apiPrefix}system/update-check`)
|
||||
.then(({ code, data }) => {
|
||||
message.destroy();
|
||||
if (code === 200) {
|
||||
if (data.hasNewVersion) {
|
||||
showConfirmUpdateModal(data);
|
||||
} else {
|
||||
showForceUpdateModal(data);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
message.destroy();
|
||||
message.error(intl.get('检查更新失败,请检查网络后重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setUpdateLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const showForceUpdateModal = (data: any) => {
|
||||
Modal.confirm({
|
||||
width: 500,
|
||||
title: intl.get('更新'),
|
||||
content: (
|
||||
<>
|
||||
<div>{intl.get('已经是最新版了!')}</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 400, marginTop: 5 }}>
|
||||
{intl.get('青龙')} {data.lastVersion}{' '}
|
||||
{intl.get('是目前检测到的最新可用版本了。')}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
okText: intl.get('重新下载'),
|
||||
onOk() {
|
||||
startUpdate();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showConfirmUpdateModal = (data: any) => {
|
||||
const { lastVersion, lastLog } = data;
|
||||
Modal.confirm({
|
||||
width: 500,
|
||||
title: (
|
||||
<>
|
||||
<div>{intl.get('更新可用')}</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 400, marginTop: 5 }}>
|
||||
{intl.get('新版本可用,当前版本', {
|
||||
version: lastVersion,
|
||||
current: systemInfo.version,
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
content: (
|
||||
<pre>
|
||||
<Ansi>{lastLog}</Ansi>
|
||||
</pre>
|
||||
),
|
||||
okText: intl.get('下载更新'),
|
||||
cancelText: intl.get('以后再说'),
|
||||
onOk() {
|
||||
startUpdate();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showUpdatingModal = () => {
|
||||
setValue('');
|
||||
lastStatusRef.current = 'running';
|
||||
setUpdateStatus('running');
|
||||
modalRef.current = Modal.info({
|
||||
width: 600,
|
||||
maskClosable: false,
|
||||
closable: false,
|
||||
keyboard: false,
|
||||
okButtonProps: { disabled: true },
|
||||
title: intl.get('下载更新中...'),
|
||||
centered: true,
|
||||
afterClose: () => {
|
||||
modalRef.current = undefined;
|
||||
},
|
||||
content: <pre />,
|
||||
});
|
||||
};
|
||||
const { job, busy, connected } = useUpgrade();
|
||||
const [details, setDetails] = useState(false);
|
||||
const active = activeStatuses.includes(job.status);
|
||||
const ready = job.status === 'ready';
|
||||
const completed = job.status === 'completed';
|
||||
|
||||
const startUpdate = async () => {
|
||||
showUpdatingModal();
|
||||
if (busy || active) return;
|
||||
if (!upgradeStore.beginMutation()) return;
|
||||
try {
|
||||
const response = await request.put(`${config.apiPrefix}system/update`);
|
||||
if (response.code !== 200) throw new Error(response.message);
|
||||
const result = await request.put(`${config.apiPrefix}system/update`);
|
||||
if (result.code !== 200) throw new Error(result.message);
|
||||
upgradeStore.setJob(result.data);
|
||||
} catch {
|
||||
lastStatusRef.current = 'failed';
|
||||
setUpdateStatus('failed');
|
||||
message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
|
||||
} finally {
|
||||
upgradeStore.finishMutation();
|
||||
}
|
||||
};
|
||||
|
||||
const reloadSystem = (type?: string) => {
|
||||
request
|
||||
.put(`${config.apiPrefix}update/${type}`)
|
||||
.then(({ code }: any) => {
|
||||
if (code !== 200) return;
|
||||
message.success({
|
||||
content: (
|
||||
<span>
|
||||
{intl.get('系统将在')}
|
||||
<Countdown
|
||||
className="inline-countdown"
|
||||
format="ss"
|
||||
value={Date.now() + 1000 * 30}
|
||||
/>
|
||||
{intl.get('秒后自动刷新')}
|
||||
</span>
|
||||
),
|
||||
duration: 30,
|
||||
});
|
||||
disableBody();
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 30000);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.log(error);
|
||||
const checkUpgrade = async () => {
|
||||
if (!upgradeStore.beginMutation()) return;
|
||||
try {
|
||||
const { code, data } = await request.put(
|
||||
`${config.apiPrefix}system/update-check`,
|
||||
);
|
||||
if (code !== 200) throw new Error('Update check failed');
|
||||
Modal.confirm({
|
||||
width: 500,
|
||||
title: intl.get(data.hasNewVersion ? '更新可用' : '已经是最新版了!'),
|
||||
content: data.hasNewVersion ? (
|
||||
<>
|
||||
<p>
|
||||
{intl.get('新版本可用,当前版本', {
|
||||
version: data.lastVersion,
|
||||
current: systemInfo.version,
|
||||
})}
|
||||
</p>
|
||||
<pre>
|
||||
<Ansi>{data.lastLog}</Ansi>
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
`${intl.get('青龙')} ${data.lastVersion}`
|
||||
),
|
||||
okText: intl.get(data.hasNewVersion ? '下载更新' : '重新下载'),
|
||||
cancelText: intl.get('以后再说'),
|
||||
onOk: startUpdate,
|
||||
});
|
||||
} catch {
|
||||
message.error(intl.get('检查更新失败,请检查网络后重试'));
|
||||
} finally {
|
||||
upgradeStore.finishMutation();
|
||||
}
|
||||
};
|
||||
|
||||
const showReloadModal = () => {
|
||||
const reloadSystem = async (apply: boolean) => {
|
||||
if (!upgradeStore.beginMutation()) return;
|
||||
try {
|
||||
const result = await request.put(
|
||||
`${config.apiPrefix}update/${apply ? 'system' : 'reload'}`,
|
||||
);
|
||||
if (result.code !== 200) throw new Error(result.message);
|
||||
if (apply) {
|
||||
upgradeStore.setJob(result.data);
|
||||
sessionStorage.setItem(reloadKey, result.data.id);
|
||||
} else {
|
||||
message.success(intl.get('系统将在30秒后自动刷新'));
|
||||
setTimeout(() => window.location.reload(), 30000);
|
||||
}
|
||||
} catch {
|
||||
message.error(intl.get('升级任务启动失败,请检查日志后重试'));
|
||||
} finally {
|
||||
upgradeStore.finishMutation();
|
||||
}
|
||||
};
|
||||
|
||||
const showReloadModal = () =>
|
||||
Modal.confirm({
|
||||
width: 600,
|
||||
maskClosable: false,
|
||||
title: intl.get('确认重启'),
|
||||
centered: true,
|
||||
content: intl.get('系统安装包下载成功,确认重启'),
|
||||
okText: intl.get('重启'),
|
||||
onOk() {
|
||||
reloadSystem('system');
|
||||
},
|
||||
onCancel() {
|
||||
modalRef.current?.update({
|
||||
maskClosable: true,
|
||||
closable: true,
|
||||
okButtonProps: { disabled: false },
|
||||
});
|
||||
},
|
||||
content: intl.get('更新已就绪,重启后应用新版本'),
|
||||
okText: intl.get('重启并更新'),
|
||||
cancelText: intl.get('以后再说'),
|
||||
onOk: () => reloadSystem(true),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalRef.current) return;
|
||||
const updateFailed = updateStatus === 'failed';
|
||||
|
||||
modalRef.current.update({
|
||||
maskClosable: updateFailed,
|
||||
closable: updateFailed,
|
||||
okButtonProps: { disabled: !updateFailed },
|
||||
content: (
|
||||
<>
|
||||
<pre>
|
||||
<Ansi>{value}</Ansi>
|
||||
</pre>
|
||||
<div id="log-identifier" style={{ paddingBottom: 5 }}></div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}, [value, updateStatus]);
|
||||
|
||||
const handleMessage = useCallback((payload: any) => {
|
||||
if (!modalRef.current || lastStatusRef.current !== 'running') return;
|
||||
const { message: _message, status } = payload;
|
||||
|
||||
if (status === 'failed') {
|
||||
lastStatusRef.current = 'failed';
|
||||
setUpdateStatus('failed');
|
||||
message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
lastStatusRef.current = 'success';
|
||||
setUpdateStatus('success');
|
||||
setTimeout(() => {
|
||||
if (modalRef.current && lastStatusRef.current === 'success') {
|
||||
showReloadModal();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
document
|
||||
.querySelector('#log-identifier')
|
||||
?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 600);
|
||||
|
||||
if (_message) {
|
||||
setValue((p) => `${p}${_message}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = WebSocketManager.getInstance();
|
||||
ws.subscribe('updateSystemVersion', handleMessage);
|
||||
|
||||
return () => {
|
||||
ws.unsubscribe('updateSystemVersion', handleMessage);
|
||||
modalRef.current?.destroy();
|
||||
modalRef.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="primary" onClick={checkUpgrade}>
|
||||
{intl.get('检查更新')}
|
||||
</Button>
|
||||
const label =
|
||||
job.status === 'failed'
|
||||
? intl.get('更新失败')
|
||||
: ready
|
||||
? intl.get('下载完成,等待重启')
|
||||
: completed
|
||||
? intl.get('升级成功')
|
||||
: intl.get(phaseLabels[job.phase || ''] || '正在准备更新');
|
||||
const hasJob = job.status !== 'idle' && job.status !== 'loading';
|
||||
const actions = !active && (
|
||||
<Space wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => reloadSystem('reload')}
|
||||
style={{ marginLeft: 8 }}
|
||||
loading={busy}
|
||||
disabled={!connected || job.status === 'loading'}
|
||||
onClick={ready ? showReloadModal : checkUpgrade}
|
||||
>
|
||||
{intl.get('重新启动')}
|
||||
{intl.get(ready ? '重启并更新' : '检查更新')}
|
||||
</Button>
|
||||
</>
|
||||
{!ready && !completed && (
|
||||
<Button
|
||||
disabled={busy || !connected || job.status === 'loading'}
|
||||
onClick={() => reloadSystem(false)}
|
||||
>
|
||||
{intl.get('重新启动')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="upgrade-panel">
|
||||
{!connected && (
|
||||
<Typography.Text type="warning" role="status">
|
||||
{intl.get('连接已断开,正在重新连接')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{!hasJob && actions}
|
||||
{hasJob && (
|
||||
<div
|
||||
className="upgrade-status-content"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Typography.Text strong>
|
||||
{label}
|
||||
{job.version ? ` · v${job.version}` : ''}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type={job.status === 'failed' ? 'danger' : 'secondary'}
|
||||
>
|
||||
{job.status === 'failed'
|
||||
? intl.get(job.error || '更新失败,请检查网络及日志或稍后再试') ||
|
||||
job.error
|
||||
: intl.get(
|
||||
completed
|
||||
? '已重新启动并运行新版本,无需再次重启。'
|
||||
: ready
|
||||
? '更新已就绪,重启后应用新版本'
|
||||
: '更新在后台进行,可以继续使用面板',
|
||||
)}
|
||||
</Typography.Text>
|
||||
{job.phase === 'downloading' && active && (
|
||||
<div className="upgrade-progress">
|
||||
<div className="upgrade-progress-meta">
|
||||
<Typography.Text type="secondary">
|
||||
{intl.get(
|
||||
job.artifact === 'qinglong-static'
|
||||
? '下载构建产物'
|
||||
: '下载源码',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{sizeLabel(job.downloadedBytes || 0)}
|
||||
{job.totalBytes ? ` / ${sizeLabel(job.totalBytes)}` : ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={job.percent ?? 0}
|
||||
showInfo={job.percent != null}
|
||||
status="active"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="upgrade-actions">
|
||||
{actions}
|
||||
<Button
|
||||
type="link"
|
||||
className="upgrade-log-link"
|
||||
onClick={() => setDetails(true)}
|
||||
>
|
||||
{intl.get('查看更新日志')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
open={details}
|
||||
title={intl.get('更新日志')}
|
||||
footer={null}
|
||||
onCancel={() => setDetails(false)}
|
||||
width={700}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
maxHeight: '60vh',
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
<Ansi>{job.log || intl.get('暂无日志')}</Ansi>
|
||||
</pre>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const syncFs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
const {
|
||||
startJob,
|
||||
getStatus,
|
||||
runJob,
|
||||
} = require('../../cli/src/internal/maintenance/panelUpgrade.cjs');
|
||||
const { downloadWithProgress } = require('../../shell/upgrade.cjs');
|
||||
const {
|
||||
writeManifest,
|
||||
sourceCommit,
|
||||
} = require('../helpers/upgrade-fixture.cjs');
|
||||
|
||||
async function fixture(t) {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-panel-job-'));
|
||||
const root = path.join(base, 'panel'),
|
||||
tmp = path.join(root, '.tmp');
|
||||
const source = path.join(base, `qinglong-${sourceCommit}`);
|
||||
const staticRoot = path.join(base, 'qinglong-static-master');
|
||||
const write = async (name, contents) => {
|
||||
await fs.mkdir(path.dirname(name), { recursive: true });
|
||||
await fs.writeFile(name, contents);
|
||||
};
|
||||
for (const dir of [root, source]) {
|
||||
await write(path.join(dir, 'package.json'), '{"version":"2.24.0"}');
|
||||
await write(path.join(dir, 'pnpm-lock.yaml'), 'lock');
|
||||
await write(path.join(dir, 'sample/config.sample.sh'), '# config');
|
||||
}
|
||||
await write(
|
||||
path.join(root, 'node_modules/dependency'),
|
||||
'installed dependency',
|
||||
);
|
||||
await write(path.join(root, 'version.yaml'), 'version: 2.23.0');
|
||||
await write(path.join(source, 'version.yaml'), 'version: 2.24.0');
|
||||
await write(path.join(staticRoot, 'build/app.js'), 'new backend');
|
||||
writeManifest(source, staticRoot);
|
||||
for (const repo of ['qinglong', 'qinglong-static'])
|
||||
execFileSync(
|
||||
'zip',
|
||||
[
|
||||
'-qr',
|
||||
path.join(base, `${repo}.zip`),
|
||||
repo === 'qinglong' ? path.basename(source) : path.basename(staticRoot),
|
||||
],
|
||||
{ cwd: base },
|
||||
);
|
||||
await write(
|
||||
path.join(root, 'shell/share.sh'),
|
||||
`
|
||||
import_config() { :; }
|
||||
delete_pm2() { printf stopped >> "$QL_DIR/stops"; }
|
||||
reload_pm2() { printf started >> "$QL_DIR/starts"; }
|
||||
npm_install_2() { mkdir -p "$1/node_modules"; printf installed > "$1/node_modules/dependency"; exit_status=0; }
|
||||
`,
|
||||
);
|
||||
await write(path.join(root, 'shell/env.sh'), '');
|
||||
await write(
|
||||
path.join(base, 'bin/curl'),
|
||||
`#!${process.execPath}
|
||||
const fs = require('node:fs'), path = require('node:path');
|
||||
const args = process.argv.slice(2);
|
||||
if (process.env.CHECK_WORKER_PARENT === 'true') {
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const parent = Number(execFileSync('ps', ['-o', 'ppid=', '-p', String(process.ppid)], { encoding: 'utf8' }).trim());
|
||||
fs.writeFileSync(path.join(process.env.ARCHIVES, 'worker-parent.json'), JSON.stringify({ worker: process.ppid, parent }));
|
||||
}
|
||||
const repo = args.at(-1).includes('/qinglong-static/') ? 'qinglong-static' : 'qinglong';
|
||||
if (process.env.FAIL_DOWNLOAD === 'true') process.exit(22);
|
||||
const buffer = fs.readFileSync(path.join(process.env.ARCHIVES, repo + '.zip'));
|
||||
fs.writeFileSync(args[args.indexOf('--dump-header') + 1], 'HTTP/1.1 200 OK\\r\\nContent-Length: ' + buffer.length + '\\r\\n\\r\\n');
|
||||
fs.writeFileSync(args[args.indexOf('--output') + 1], buffer);
|
||||
`,
|
||||
);
|
||||
await fs.chmod(path.join(base, 'bin/curl'), 0o700);
|
||||
const runtime = {
|
||||
version: '2.24.0',
|
||||
sourceCommit,
|
||||
instanceId: 'after-restart',
|
||||
parentPid: 100002,
|
||||
backendSha256: JSON.parse(
|
||||
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
|
||||
).files['build/app.js'],
|
||||
};
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: req.url.endsWith('/api/system')
|
||||
? { version: runtime.version }
|
||||
: {
|
||||
status: 'ok',
|
||||
build: {
|
||||
...runtime,
|
||||
instanceId: syncFs.existsSync(path.join(root, 'starts'))
|
||||
? runtime.instanceId
|
||||
: 'before-restart',
|
||||
parentPid: syncFs.existsSync(path.join(root, 'starts'))
|
||||
? runtime.parentPid
|
||||
: 100001,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${base}/bin:${process.env.PATH}`,
|
||||
ARCHIVES: base,
|
||||
QL_BRANCH: 'master',
|
||||
QlPort: String(server.address().port),
|
||||
};
|
||||
t.after(async () => {
|
||||
server.close();
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
});
|
||||
return { root, tmp, env, base, runtime };
|
||||
}
|
||||
|
||||
async function finished(tmp) {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const status = await getStatus(tmp);
|
||||
if (['ready', 'completed', 'failed'].includes(status.status)) return status;
|
||||
await delay(25);
|
||||
}
|
||||
assert.fail('Worker did not finish');
|
||||
}
|
||||
|
||||
test('panel jobs are reparented before running outside the initiating backend process tree', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.env.CHECK_WORKER_PARENT = 'true';
|
||||
await startJob(f, 'download');
|
||||
const ready = await finished(f.tmp);
|
||||
assert.equal(ready.status, 'ready', ready.log);
|
||||
const relationship = JSON.parse(
|
||||
await fs.readFile(path.join(f.base, 'worker-parent.json'), 'utf8'),
|
||||
);
|
||||
assert.equal(relationship.worker, ready.pid);
|
||||
assert.notEqual(
|
||||
relationship.parent,
|
||||
process.pid,
|
||||
'PM2 must not find the upgrade worker in the backend process tree',
|
||||
);
|
||||
});
|
||||
|
||||
test('detached download survives its initiating request and is idempotent; applying is explicit', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const [first, duplicate] = await Promise.all([
|
||||
startJob(f, 'download'),
|
||||
startJob(f, 'download'),
|
||||
]);
|
||||
assert.equal(first.id, duplicate.id);
|
||||
const ready = await finished(f.tmp);
|
||||
assert.equal(ready.status, 'ready', ready.log);
|
||||
assert.equal(ready.version, '2.24.0');
|
||||
assert.equal(ready.sourceCommit, sourceCommit);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
assert.equal((await startJob(f, 'download')).id, ready.id);
|
||||
const applying = await startJob(f, 'apply');
|
||||
assert.notEqual(applying.id, ready.id);
|
||||
const complete = await finished(f.tmp);
|
||||
assert.equal(complete.status, 'completed', complete.log);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'stops'), 'utf8'),
|
||||
'stopped',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
|
||||
'version: 2.24.0',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/dependency'), 'utf8'),
|
||||
'installed dependency',
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid staged payload is rejected before spawning a restart', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
const pointer = JSON.parse(
|
||||
await fs.readFile(path.join(f.tmp, 'upgrade-ready-master.json'), 'utf8'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.tmp, pointer.directory, 'qinglong-static-master/build/app.js'),
|
||||
'corrupted',
|
||||
);
|
||||
await assert.rejects(startJob(f, 'apply'), /checksum|损坏/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
assert.equal((await getStatus(f.tmp)).status, 'failed');
|
||||
});
|
||||
|
||||
test('failed download persists its failure without stopping the panel and can be retried', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(
|
||||
{ ...f, env: { ...f.env, FAIL_DOWNLOAD: 'true' } },
|
||||
'download',
|
||||
);
|
||||
const failed = await finished(f.tmp);
|
||||
assert.equal(failed.status, 'failed');
|
||||
assert.match(failed.log, /curl failed/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
});
|
||||
|
||||
test('orphaned jobs become retryable and status log reads are bounded', async (t) => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-orphan-job-'));
|
||||
t.after(() => fs.rm(tmp, { recursive: true, force: true }));
|
||||
await fs.writeFile(
|
||||
path.join(tmp, 'panel-upgrade.json'),
|
||||
JSON.stringify({ status: 'starting', startedAt: 0 }),
|
||||
);
|
||||
await fs.writeFile(path.join(tmp, 'panel-upgrade.log'), 'x'.repeat(100000));
|
||||
const result = await getStatus(tmp);
|
||||
assert.equal(result.status, 'failed');
|
||||
assert.equal(Buffer.byteLength(result.log), 32768);
|
||||
});
|
||||
|
||||
test('a healthy backend with the wrong version rolls the update back', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
f.runtime.version = '2.23.0';
|
||||
await startJob(f, 'apply');
|
||||
const failed = await finished(f.tmp);
|
||||
assert.equal(failed.status, 'failed', failed.log);
|
||||
assert.match(failed.error, /版本/);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
|
||||
'version: 2.23.0',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'starts'), 'utf8'),
|
||||
'startedstarted',
|
||||
);
|
||||
});
|
||||
|
||||
for (const [name, patch] of [
|
||||
['same version but old commit', { sourceCommit: 'b'.repeat(40) }],
|
||||
['same commit but wrong backend', { backendSha256: '0'.repeat(64) }],
|
||||
['process did not restart', { instanceId: 'before-restart' }],
|
||||
['only the HTTP worker restarted', { parentPid: 100001 }],
|
||||
])
|
||||
test(`a healthy backend cannot claim update success: ${name}`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
Object.assign(f.runtime, patch);
|
||||
await startJob(f, 'apply');
|
||||
const status = await finished(f.tmp);
|
||||
assert.equal(status.status, 'failed', status.log);
|
||||
assert.match(status.error, /版本/);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
|
||||
'version: 2.23.0',
|
||||
);
|
||||
});
|
||||
|
||||
for (const name of ['package.json', 'pnpm-lock.yaml', 'node_modules'])
|
||||
test(`reused dependencies are checked again before stopping: ${name}`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
if (name === 'node_modules')
|
||||
await fs.rm(path.join(f.root, name), { recursive: true });
|
||||
else await fs.writeFile(path.join(f.root, name), 'changed after download');
|
||||
await assert.rejects(startJob(f, 'apply'), /依赖已变化/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
});
|
||||
|
||||
test('changed dependencies are fully staged and installed instead of reusing the current tree', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.writeFile(path.join(f.root, 'pnpm-lock.yaml'), 'old lock');
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
await startJob(f, 'apply');
|
||||
const status = await finished(f.tmp);
|
||||
assert.equal(status.status, 'completed', status.log);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/dependency'), 'utf8'),
|
||||
'installed',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'pnpm-lock.yaml'), 'utf8'),
|
||||
'lock',
|
||||
);
|
||||
});
|
||||
|
||||
async function replaceStage(f) {
|
||||
const pointer = path.join(f.tmp, 'upgrade-ready-master.json');
|
||||
const selected = JSON.parse(await fs.readFile(pointer, 'utf8'));
|
||||
const directory = `upgrade-${randomUUID()}`;
|
||||
const workspace = path.join(f.tmp, directory);
|
||||
await fs.cp(path.join(f.tmp, selected.directory), workspace, {
|
||||
recursive: true,
|
||||
});
|
||||
const readyFile = path.join(workspace, 'ready.json');
|
||||
const ready = JSON.parse(await fs.readFile(readyFile, 'utf8'));
|
||||
ready.source = path.join(workspace, 'qinglong-master');
|
||||
ready.static = path.join(workspace, 'qinglong-static-master');
|
||||
await fs.writeFile(readyFile, JSON.stringify(ready));
|
||||
await fs.writeFile(pointer, JSON.stringify({ directory }));
|
||||
}
|
||||
|
||||
test('replacing even a same-commit stage requires explicit preparation again', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
await replaceStage(f);
|
||||
await assert.rejects(startJob(f, 'apply'), /暂存更新已变化/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
});
|
||||
|
||||
test('the worker revalidates its accepted target under the upgrade lock', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
const ready = await finished(f.tmp);
|
||||
assert.equal(ready.status, 'ready');
|
||||
const accepted = {
|
||||
...ready,
|
||||
id: randomUUID(),
|
||||
status: 'starting',
|
||||
action: 'apply',
|
||||
};
|
||||
delete accepted.log;
|
||||
await fs.writeFile(
|
||||
path.join(f.tmp, 'panel-upgrade.json'),
|
||||
JSON.stringify(accepted),
|
||||
);
|
||||
await replaceStage(f);
|
||||
await runJob(f, accepted.id);
|
||||
const failed = await getStatus(f.tmp);
|
||||
assert.equal(failed.status, 'failed');
|
||||
assert.match(failed.error, /暂存更新已变化/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
});
|
||||
|
||||
test('an in-progress download cannot be reported as an accepted restart', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.mkdir(f.tmp, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(f.tmp, 'panel-upgrade.json'),
|
||||
JSON.stringify({
|
||||
id: randomUUID(),
|
||||
status: 'running',
|
||||
action: 'download',
|
||||
pid: process.pid,
|
||||
}),
|
||||
);
|
||||
await assert.rejects(startJob(f, 'apply'), /已有更新/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
});
|
||||
|
||||
async function selectCli(f) {
|
||||
const cliRoot = path.join(f.base, 'selected-cli');
|
||||
const write = async (relative, contents) => {
|
||||
const file = path.join(cliRoot, 'dist/internal', relative);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, contents);
|
||||
};
|
||||
await write(
|
||||
'runtime/context.js',
|
||||
`exports.createContext = (values, env) => ({ root: values.root, env, paths: { file_config_user: values.root + '/data/config/config.sh', dir_preload: values.root + '/shell/preload' } }); exports.sourceEnvironment = async env => env;`,
|
||||
);
|
||||
await write(
|
||||
'runtime/cancellation.js',
|
||||
`exports.cancellableOperation = async (signal, operation) => { signal?.throwIfAborted(); return operation(); };`,
|
||||
);
|
||||
await write(
|
||||
'maintenance/operator.js',
|
||||
`
|
||||
const fs = require('node:fs/promises'), path = require('node:path');
|
||||
exports.stopPanel = ctx => fs.appendFile(path.join(ctx.root, 'cli-stops'), 'stopped');
|
||||
exports.startPanel = ctx => fs.appendFile(path.join(ctx.root, 'starts'), 'started');
|
||||
exports.installPanelDependencies = async (ctx, source) => {
|
||||
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
|
||||
await fs.writeFile(path.join(source, 'node_modules/dependency'), 'selected-cli');
|
||||
};`,
|
||||
);
|
||||
await write(
|
||||
'maintenance/upgradeSelection.js',
|
||||
`
|
||||
const fs = require('node:fs/promises'), path = require('node:path');
|
||||
exports.prepareUpgradeSelection = async (ctx) => {
|
||||
const directory = await fs.mkdtemp(path.join(ctx.root, '.tmp/selected-loader-'));
|
||||
const target = path.join(ctx.root, 'static/build/loaders/deps.js');
|
||||
const source = path.join(directory, 'deps.js');
|
||||
await fs.copyFile(target, source);
|
||||
return { directory, source, target };
|
||||
};`,
|
||||
);
|
||||
const loader = path.join(f.root, 'static/build/loaders/deps.js');
|
||||
await fs.mkdir(path.dirname(loader), { recursive: true });
|
||||
await fs.writeFile(loader, '// selected CLI loader');
|
||||
f.env.QL_CLI_ROOT = cliRoot;
|
||||
return loader;
|
||||
}
|
||||
|
||||
for (const fail of [false, true])
|
||||
test(`selected CLI lifecycle and loader survive panel updating; rollback=${fail}`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
const loader = await selectCli(f);
|
||||
await fs.writeFile(path.join(f.root, 'pnpm-lock.yaml'), 'old lock');
|
||||
await startJob(f, 'download');
|
||||
const ready = await finished(f.tmp);
|
||||
assert.equal(ready.status, 'ready', ready.log);
|
||||
if (fail) f.runtime.sourceCommit = 'b'.repeat(40);
|
||||
await startJob(f, 'apply');
|
||||
const status = await finished(f.tmp);
|
||||
assert.equal(status.status, fail ? 'failed' : 'completed', status.log);
|
||||
assert.equal(await fs.readFile(loader, 'utf8'), '// selected CLI loader');
|
||||
assert.equal(
|
||||
syncFs.existsSync(path.join(f.root, 'stops')),
|
||||
false,
|
||||
'legacy broad shell stop was not used',
|
||||
);
|
||||
assert.match(
|
||||
await fs.readFile(path.join(f.root, 'cli-stops'), 'utf8'),
|
||||
/stopped/,
|
||||
);
|
||||
if (!fail)
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/dependency'), 'utf8'),
|
||||
'selected-cli',
|
||||
);
|
||||
});
|
||||
|
||||
for (const knownSize of [true, false])
|
||||
test(`download progress measures real bytes and handles unknown totals; known=${knownSize}`, async (t) => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-progress-'));
|
||||
t.after(() => fs.rm(tmp, { recursive: true, force: true }));
|
||||
const reports = [];
|
||||
await downloadWithProgress(
|
||||
async (program, args) => {
|
||||
assert.equal(program, 'curl');
|
||||
await fs.writeFile(
|
||||
args[args.indexOf('--dump-header') + 1],
|
||||
`HTTP/1.1 302 Found\r\nContent-Length: 1\r\n\r\nHTTP/2 200 OK\r\n${
|
||||
knownSize ? 'Content-Length: 100\r\n' : ''
|
||||
}\r\n`,
|
||||
);
|
||||
const archive = args[args.indexOf('--output') + 1];
|
||||
await fs.writeFile(archive, Buffer.alloc(50));
|
||||
await delay(1150);
|
||||
await fs.writeFile(archive, Buffer.alloc(100));
|
||||
},
|
||||
path.join(tmp, 'file.zip'),
|
||||
'https://example.test/file.zip',
|
||||
'qinglong',
|
||||
async (report) => reports.push(report),
|
||||
);
|
||||
assert.ok(reports.some((r) => r.downloadedBytes === 50));
|
||||
assert.equal(reports[0].percent, knownSize ? 50 : null);
|
||||
assert.equal(reports.at(-1).downloadedBytes, 100);
|
||||
assert.equal(reports.at(-1).percent, knownSize ? 100 : null);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const ts = require('typescript');
|
||||
|
||||
function load(root) {
|
||||
const module = { exports: {} };
|
||||
const output = ts.transpileModule(
|
||||
fs.readFileSync('back/shared/runtimeBuild.ts', 'utf8'),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
new Function('require', 'module', 'exports', '__dirname', 'process', output)(
|
||||
require,
|
||||
module,
|
||||
module.exports,
|
||||
path.join(root, 'static/build/shared'),
|
||||
{ env: { QL_DIR: root } },
|
||||
);
|
||||
return module.exports.runtimeBuild;
|
||||
}
|
||||
|
||||
test('a running process retains its own build identity when installed files change', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-build-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(root, 'static/build'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0');
|
||||
fs.writeFileSync(path.join(root, 'static/build/app.js'), 'old backend');
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'static/build-info.json'),
|
||||
JSON.stringify({ sourceCommit: 'a'.repeat(40) }),
|
||||
);
|
||||
const running = load(root);
|
||||
fs.writeFileSync(path.join(root, 'static/build/app.js'), 'new backend');
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'static/build-info.json'),
|
||||
JSON.stringify({ sourceCommit: 'b'.repeat(40) }),
|
||||
);
|
||||
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.25.0');
|
||||
assert.equal(running.version, '2.24.0');
|
||||
assert.equal(running.sourceCommit, 'a'.repeat(40));
|
||||
assert.equal(
|
||||
running.backendSha256,
|
||||
createHash('sha256').update('old backend').digest('hex'),
|
||||
);
|
||||
const restarted = load(root);
|
||||
assert.equal(restarted.version, '2.25.0');
|
||||
assert.equal(restarted.sourceCommit, 'b'.repeat(40));
|
||||
assert.notEqual(restarted.backendSha256, running.backendSha256);
|
||||
assert.notEqual(restarted.instanceId, running.instanceId);
|
||||
assert.equal(Object.isFrozen(running), true);
|
||||
});
|
||||
|
||||
test('unbuilt development environments keep health available without claiming a verified build', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-unbuilt-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
assert.equal(load(root).sourceCommit, undefined);
|
||||
fs.mkdirSync(path.join(root, 'static'), { recursive: true });
|
||||
for (const content of ['broken', 'null', '42']) {
|
||||
fs.writeFileSync(path.join(root, 'static/build-info.json'), content);
|
||||
assert.equal(load(root).backendSha256, undefined);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { createHash } = require('node:crypto');
|
||||
const {
|
||||
publishUpgradeInfo,
|
||||
} = require('../../scripts/publish-upgrade-info.cjs');
|
||||
const {
|
||||
stageUpgrade,
|
||||
downloadWithProgress,
|
||||
} = require('../../shell/upgrade.cjs');
|
||||
const { writeManifest } = require('../helpers/upgrade-fixture.cjs');
|
||||
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
function fixture(t) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-download-info-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||
const source = path.join(tmp, 'source'),
|
||||
built = path.join(tmp, 'built'),
|
||||
remote = path.join(tmp, 'remote.git');
|
||||
for (const dir of [source]) {
|
||||
git('init', '-b', 'develop', dir);
|
||||
git('-C', dir, 'config', 'user.name', 'Fixture');
|
||||
git('-C', dir, 'config', 'user.email', 'fixture@example.invalid');
|
||||
}
|
||||
fs.mkdirSync(path.join(source, 'sample'));
|
||||
fs.writeFileSync(path.join(source, 'package.json'), '{"version":"2.24.0"}');
|
||||
fs.writeFileSync(path.join(source, 'pnpm-lock.yaml'), 'lock');
|
||||
fs.writeFileSync(path.join(source, 'sample/config.sample.sh'), '# config');
|
||||
git('-C', source, 'add', '.');
|
||||
git('-C', source, 'commit', '-m', 'source');
|
||||
const sourceCommit = git('-C', source, 'rev-parse', 'HEAD');
|
||||
fs.mkdirSync(path.join(built, 'build'), { recursive: true });
|
||||
fs.writeFileSync(path.join(built, 'build/app.js'), 'backend');
|
||||
writeManifest(source, built);
|
||||
// Fixture helper defaults to its fixed source revision.
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(built, 'build-info.json')),
|
||||
);
|
||||
manifest.sourceCommit = sourceCommit;
|
||||
fs.writeFileSync(
|
||||
path.join(built, 'build-info.json'),
|
||||
JSON.stringify(manifest),
|
||||
);
|
||||
git('init', '-b', 'develop', built);
|
||||
git('-C', built, 'config', 'user.name', 'Fixture');
|
||||
git('-C', built, 'config', 'user.email', 'fixture@example.invalid');
|
||||
git('-C', built, 'add', '.');
|
||||
git('-C', built, 'commit', '-m', 'built');
|
||||
const staticCommit = git('-C', built, 'rev-parse', 'HEAD');
|
||||
git('init', '--bare', remote);
|
||||
git('-C', built, 'push', remote, 'develop');
|
||||
const archives = {};
|
||||
for (const [repo, dir, commit] of [
|
||||
['qinglong', source, sourceCommit],
|
||||
['qinglong-static', built, staticCommit],
|
||||
])
|
||||
archives[repo] = execFileSync('git', [
|
||||
'-C',
|
||||
dir,
|
||||
'archive',
|
||||
'--format=zip',
|
||||
`--prefix=${repo}-${commit}/`,
|
||||
commit,
|
||||
]);
|
||||
const execute = (program, args, options) => {
|
||||
if (program !== 'curl') return execFileSync(program, args, options);
|
||||
const url = args.at(-1),
|
||||
repo = url.includes('/qinglong-static/') ? 'qinglong-static' : 'qinglong';
|
||||
assert.ok(url.endsWith(repo === 'qinglong' ? sourceCommit : staticCommit));
|
||||
fs.writeFileSync(args[args.indexOf('--output') + 1], archives[repo]);
|
||||
return '';
|
||||
};
|
||||
return {
|
||||
tmp,
|
||||
source,
|
||||
built,
|
||||
remote,
|
||||
git,
|
||||
sourceCommit,
|
||||
staticCommit,
|
||||
archives,
|
||||
execute,
|
||||
};
|
||||
}
|
||||
|
||||
test('published archive metadata retains history and supplies percentages without Content-Length', async (t) => {
|
||||
const f = fixture(t);
|
||||
const info = publishUpgradeInfo(
|
||||
f.remote,
|
||||
'develop',
|
||||
f.sourceCommit,
|
||||
f.execute,
|
||||
);
|
||||
for (const [repo, bytes] of Object.entries(f.archives)) {
|
||||
assert.equal(info.archives[repo].size, bytes.length);
|
||||
assert.equal(info.archives[repo].sha256, digest(bytes));
|
||||
}
|
||||
const stored = JSON.parse(
|
||||
f.git('-C', f.remote, 'show', `upgrade-info:${f.sourceCommit}.json`),
|
||||
);
|
||||
assert.deepEqual(stored, info);
|
||||
const first = f.git('-C', f.remote, 'rev-parse', 'upgrade-info');
|
||||
publishUpgradeInfo(f.remote, 'develop', f.sourceCommit, f.execute);
|
||||
assert.equal(f.git('-C', f.remote, 'rev-parse', 'upgrade-info^'), first);
|
||||
const root = path.join(f.tmp, 'panel'),
|
||||
tmp = path.join(root, '.tmp');
|
||||
fs.mkdirSync(tmp, { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'node_modules'));
|
||||
for (const name of ['package.json', 'pnpm-lock.yaml'])
|
||||
fs.copyFileSync(path.join(f.source, name), path.join(root, name));
|
||||
const reports = [],
|
||||
urls = [];
|
||||
const run = async (program, args) => {
|
||||
if (program !== 'curl')
|
||||
return { stdout: execFileSync(program, args, { encoding: 'utf8' }) };
|
||||
const url = args.at(-1);
|
||||
urls.push(url);
|
||||
const out = args[args.indexOf('--output') + 1];
|
||||
if (new URL(url).pathname.endsWith('/build-info.json'))
|
||||
fs.writeFileSync(out, JSON.stringify({ sourceCommit: f.sourceCommit }));
|
||||
else if (url.endsWith('.json')) fs.writeFileSync(out, JSON.stringify(info));
|
||||
else {
|
||||
const repo = url.includes('/qinglong-static/')
|
||||
? 'qinglong-static'
|
||||
: 'qinglong';
|
||||
fs.writeFileSync(out, f.archives[repo]);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
args[args.indexOf('--dump-header') + 1],
|
||||
'HTTP/2 200 OK\r\n\r\n',
|
||||
);
|
||||
};
|
||||
await stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env: { QL_BRANCH: 'develop' },
|
||||
mirror: 'github',
|
||||
run,
|
||||
install: () => assert.fail('no changed dependencies'),
|
||||
onProgress: async (r) => reports.push(r),
|
||||
});
|
||||
assert.ok(urls.some((url) => url.endsWith(`/zip/${f.staticCommit}`)));
|
||||
for (const artifact of ['qinglong', 'qinglong-static']) {
|
||||
assert.ok(reports.some((r) => r.artifact === artifact && r.percent === 0));
|
||||
assert.ok(
|
||||
reports.some((r) => r.artifact === artifact && r.percent === 100),
|
||||
);
|
||||
}
|
||||
const pointer = JSON.parse(
|
||||
fs.readFileSync(path.join(tmp, 'upgrade-ready-develop.json')),
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
tmp,
|
||||
pointer.directory,
|
||||
'qinglong-static-develop/build-info.json',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('publisher rejects a static artifact from a different source before publishing metadata', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.throws(
|
||||
() =>
|
||||
publishUpgradeInfo(
|
||||
f.remote,
|
||||
'develop',
|
||||
'a'.repeat(40),
|
||||
(program, args, options) => {
|
||||
if (program === 'curl') {
|
||||
fs.writeFileSync(
|
||||
args[args.indexOf('--output') + 1],
|
||||
f.archives['qinglong-static'],
|
||||
);
|
||||
return '';
|
||||
}
|
||||
return execFileSync(program, args, options);
|
||||
},
|
||||
),
|
||||
/does not match/,
|
||||
);
|
||||
assert.equal(f.git('-C', f.remote, 'branch', '--list', 'upgrade-info'), '');
|
||||
});
|
||||
|
||||
for (const mismatch of ['size', 'sha256'])
|
||||
test(`archive metadata ${mismatch} mismatch never reports completion`, async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-wrong-download-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const bytes = Buffer.alloc(100),
|
||||
reports = [];
|
||||
await assert.rejects(
|
||||
downloadWithProgress(
|
||||
async (program, args) => {
|
||||
fs.writeFileSync(args[args.indexOf('--output') + 1], bytes);
|
||||
},
|
||||
path.join(tmp, 'file.zip'),
|
||||
'https://example.invalid/file.zip',
|
||||
'qinglong',
|
||||
async (r) => reports.push(r),
|
||||
true,
|
||||
{
|
||||
size: mismatch === 'size' ? 101 : 100,
|
||||
sha256: mismatch === 'sha256' ? '0'.repeat(64) : digest(bytes),
|
||||
},
|
||||
),
|
||||
/checksum mismatch/,
|
||||
);
|
||||
assert.ok(!reports.some((r) => r.percent === 100));
|
||||
});
|
||||
|
||||
test('published size gives intermediate real percentages when the response omits its length', async (t) => {
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-sized-download-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const bytes = Buffer.alloc(100),
|
||||
reports = [];
|
||||
await downloadWithProgress(
|
||||
async (program, args) => {
|
||||
const out = args[args.indexOf('--output') + 1];
|
||||
fs.writeFileSync(
|
||||
args[args.indexOf('--dump-header') + 1],
|
||||
'HTTP/2 200 OK\r\n\r\n',
|
||||
);
|
||||
fs.writeFileSync(out, bytes.subarray(0, 50));
|
||||
await delay(1150);
|
||||
fs.writeFileSync(out, bytes);
|
||||
},
|
||||
path.join(tmp, 'file.zip'),
|
||||
'https://example.invalid/file.zip',
|
||||
'qinglong',
|
||||
async (r) => reports.push(r),
|
||||
true,
|
||||
{ size: 100, sha256: digest(bytes) },
|
||||
);
|
||||
assert.ok(reports.some((r) => r.downloadedBytes === 50 && r.percent === 50));
|
||||
assert.equal(reports.at(-1).percent, 100);
|
||||
});
|
||||
+406
-188
@@ -3,215 +3,433 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const ts = require('typescript');
|
||||
// Drive the real component's handlers and effects without a browser or network.
|
||||
function panel() {
|
||||
let cursor = 0,
|
||||
tree,
|
||||
subscriber;
|
||||
const hooks = [],
|
||||
effects = [],
|
||||
cleanups = [],
|
||||
|
||||
function fixture() {
|
||||
const timers = new Map(),
|
||||
responses = [],
|
||||
requests = [],
|
||||
confirms = [],
|
||||
infos = [],
|
||||
timers = [],
|
||||
responses = [];
|
||||
const react = {
|
||||
useState(initial) {
|
||||
const i = cursor++;
|
||||
if (!(i in hooks)) hooks[i] = initial;
|
||||
return [
|
||||
hooks[i],
|
||||
(v) => {
|
||||
hooks[i] = typeof v === 'function' ? v(hooks[i]) : v;
|
||||
},
|
||||
];
|
||||
},
|
||||
useRef(initial) {
|
||||
const i = cursor++;
|
||||
return (hooks[i] ||= { current: initial });
|
||||
},
|
||||
useCallback(fn) {
|
||||
cursor++;
|
||||
return fn;
|
||||
},
|
||||
useEffect(fn, deps) {
|
||||
const i = cursor++;
|
||||
if (!hooks[i] || deps.some((v, j) => !Object.is(v, hooks[i][j]))) {
|
||||
hooks[i] = deps;
|
||||
effects.push(fn);
|
||||
}
|
||||
},
|
||||
errors = [],
|
||||
notifications = [];
|
||||
const persistent = new Map();
|
||||
let timerId = 0,
|
||||
reloads = 0,
|
||||
snapshot,
|
||||
fetchCount = 0;
|
||||
const storage = {
|
||||
getItem: (key) => persistent.get(key) || null,
|
||||
setItem: (key, value) => persistent.set(key, value),
|
||||
removeItem: (key) => persistent.delete(key),
|
||||
};
|
||||
function modal(options, list) {
|
||||
const result = {
|
||||
options,
|
||||
update(patch) {
|
||||
Object.assign(options, patch);
|
||||
},
|
||||
destroy() {
|
||||
options.afterClose?.();
|
||||
},
|
||||
};
|
||||
list.push(result);
|
||||
return result;
|
||||
}
|
||||
const mocks = {
|
||||
'@/utils': { disableBody() {} },
|
||||
'@/utils/config': { apiPrefix: '/api/' },
|
||||
'@/utils/http': {
|
||||
request: {
|
||||
put: async (url) =>
|
||||
responses.length
|
||||
? responses.shift()(url)
|
||||
: {
|
||||
code: 200,
|
||||
data: { hasNewVersion: false, lastVersion: '2.23.0' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'@/utils/websocket': {
|
||||
getInstance: () => ({
|
||||
subscribe(topic, fn) {
|
||||
subscriber = fn;
|
||||
},
|
||||
unsubscribe() {
|
||||
subscriber = undefined;
|
||||
},
|
||||
}),
|
||||
},
|
||||
'ansi-to-react': () => null,
|
||||
'@/utils/config': { apiPrefix: '/panel/api/', authKey: 'token' },
|
||||
antd: {
|
||||
Button: 'button',
|
||||
Statistic: { Countdown: 'span' },
|
||||
message: { loading() {}, destroy() {}, error() {}, success() {} },
|
||||
Modal: {
|
||||
confirm: (o) => modal(o, confirms),
|
||||
info: (o) => modal(o, infos),
|
||||
},
|
||||
notification: { success: (options) => notifications.push(options) },
|
||||
},
|
||||
react,
|
||||
'react-intl-universal': { get: (key) => key },
|
||||
react: { useSyncExternalStore: (subscribe, getSnapshot) => getSnapshot() },
|
||||
};
|
||||
const module = { exports: {} };
|
||||
const compiled = ts.transpileModule(
|
||||
fs.readFileSync(
|
||||
path.join(__dirname, '../../src/pages/setting/checkUpdate.tsx'),
|
||||
'utf8',
|
||||
),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
esModuleInterop: true,
|
||||
function load(file, extra = {}) {
|
||||
const module = { exports: {} };
|
||||
const output = ts.transpileModule(
|
||||
fs.readFileSync(path.join(__dirname, '../..', file), 'utf8'),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
new Function(
|
||||
'require',
|
||||
'module',
|
||||
'exports',
|
||||
'setTimeout',
|
||||
'document',
|
||||
compiled,
|
||||
)(
|
||||
(name) => mocks[name] || require(name),
|
||||
module,
|
||||
module.exports,
|
||||
(fn) => {
|
||||
timers.push(fn);
|
||||
},
|
||||
{ querySelector: () => null },
|
||||
);
|
||||
function render() {
|
||||
cursor = 0;
|
||||
tree = module.exports.default({ systemInfo: { version: '2.23.0' } });
|
||||
while (effects.length) {
|
||||
const cleanup = effects.shift()();
|
||||
if (cleanup) cleanups.push(cleanup);
|
||||
).outputText;
|
||||
new Function(
|
||||
'require',
|
||||
'module',
|
||||
'exports',
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
'fetch',
|
||||
'localStorage',
|
||||
'sessionStorage',
|
||||
'window',
|
||||
output,
|
||||
)(
|
||||
(name) => extra[name] || mocks[name] || require(name),
|
||||
module,
|
||||
module.exports,
|
||||
(fn, ms) => {
|
||||
const id = ++timerId;
|
||||
timers.set(id, { fn, ms });
|
||||
return id;
|
||||
},
|
||||
(id) => timers.delete(id),
|
||||
async (url, options) => {
|
||||
fetchCount++;
|
||||
requests.push({ url, options });
|
||||
const next = responses.shift();
|
||||
if (typeof next === 'function') return next(options);
|
||||
if (next instanceof Error) throw next;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ code: 200, data: next || { status: 'idle' } }),
|
||||
};
|
||||
},
|
||||
storage,
|
||||
storage,
|
||||
{ location: { reload: () => reloads++ } },
|
||||
);
|
||||
return module.exports;
|
||||
}
|
||||
const hooks = load('src/hooks/useUpgrade.ts');
|
||||
const store = hooks.upgradeStore;
|
||||
function component(file) {
|
||||
let cursor = 0;
|
||||
const values = [];
|
||||
const Modal = () => null;
|
||||
Modal.confirm = (options) => {
|
||||
confirms.push(options);
|
||||
return { destroy() {} };
|
||||
};
|
||||
const Component = load(file, {
|
||||
'@/hooks/useUpgrade': {
|
||||
__esModule: true,
|
||||
...hooks,
|
||||
default: () => store.getSnapshot(),
|
||||
},
|
||||
'@/utils/http': {
|
||||
request: {
|
||||
put: async (url) => {
|
||||
requests.push({ url });
|
||||
if (url.endsWith('update-check'))
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
hasNewVersion: true,
|
||||
lastVersion: '2.24.0',
|
||||
lastLog: 'Notes',
|
||||
},
|
||||
};
|
||||
const result = responses.shift();
|
||||
if (result instanceof Error) throw result;
|
||||
return (
|
||||
result || {
|
||||
code: 200,
|
||||
data: {
|
||||
id: 'apply',
|
||||
status: 'starting',
|
||||
action: 'apply',
|
||||
phase: 'verifying',
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
'@/pages/setting/checkUpdate': () => null,
|
||||
'@ant-design/icons': { CloudDownloadOutlined: 'DownloadIcon' },
|
||||
'ansi-to-react': 'Ansi',
|
||||
'react-intl-universal': { get: (key) => key },
|
||||
antd: {
|
||||
Alert: 'Alert',
|
||||
Button: 'Button',
|
||||
Modal,
|
||||
Progress: 'Progress',
|
||||
Space: 'Space',
|
||||
Tooltip: 'Tooltip',
|
||||
Typography: { Text: 'Text' },
|
||||
message: { error: (e) => errors.push(e), success() {} },
|
||||
},
|
||||
react: {
|
||||
useState(initial) {
|
||||
const index = cursor++;
|
||||
if (!(index in values)) values[index] = initial;
|
||||
return [values[index], (v) => (values[index] = v)];
|
||||
},
|
||||
},
|
||||
}).default;
|
||||
let tree;
|
||||
function render(props = {}) {
|
||||
cursor = 0;
|
||||
tree = Component({ systemInfo: { version: '2.23.0' }, ...props });
|
||||
return tree;
|
||||
}
|
||||
function nodes(type) {
|
||||
const found = [];
|
||||
const visit = (item) => {
|
||||
if (!item || typeof item !== 'object') return;
|
||||
if (Array.isArray(item)) return item.forEach(visit);
|
||||
if (item.type === type) found.push(item);
|
||||
visit(item.props?.children);
|
||||
visit(item.props?.description);
|
||||
};
|
||||
visit(tree);
|
||||
return found;
|
||||
}
|
||||
return { render, nodes, Modal };
|
||||
}
|
||||
async function settle() {
|
||||
await new Promise(setImmediate);
|
||||
render();
|
||||
}
|
||||
render();
|
||||
return {
|
||||
confirms,
|
||||
infos,
|
||||
store,
|
||||
hooks,
|
||||
responses,
|
||||
requests,
|
||||
confirms,
|
||||
errors,
|
||||
notifications,
|
||||
component,
|
||||
storage,
|
||||
timers,
|
||||
settle,
|
||||
async start() {
|
||||
tree.props.children[0].props.onClick();
|
||||
await settle();
|
||||
confirms.at(-1).options.onOk();
|
||||
await settle();
|
||||
return infos.at(-1);
|
||||
get fetchCount() {
|
||||
return fetchCount;
|
||||
},
|
||||
async send(payload) {
|
||||
subscriber?.(payload);
|
||||
await settle();
|
||||
get reloads() {
|
||||
return reloads;
|
||||
},
|
||||
close() {
|
||||
infos.at(-1).destroy();
|
||||
async settle() {
|
||||
await new Promise(setImmediate);
|
||||
snapshot = store.getSnapshot();
|
||||
return snapshot;
|
||||
},
|
||||
unmount() {
|
||||
cleanups.forEach((fn) => fn());
|
||||
async tick(ms = 1000) {
|
||||
const entry = [...timers].find(([, v]) => v.ms === ms);
|
||||
assert.ok(entry, `timer ${ms} exists`);
|
||||
timers.delete(entry[0]);
|
||||
entry[1].fn();
|
||||
return this.settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
for (const logged of [false, true])
|
||||
test(`failed final update event unlocks the modal without an additional log; logged=${logged}`, async () => {
|
||||
const p = panel(),
|
||||
m = await p.start();
|
||||
if (logged) await p.send({ message: 'download failed\n' });
|
||||
assert.equal(m.options.okButtonProps.disabled, true);
|
||||
await p.send({ message: '', status: 'failed' });
|
||||
assert.equal(m.options.okButtonProps.disabled, false);
|
||||
assert.equal(m.options.closable, true);
|
||||
|
||||
test('sidebar and settings share one poller; mounting restores progress and the last unsubscribe stops it', async () => {
|
||||
const f = fixture();
|
||||
f.storage.setItem('token', 'secret');
|
||||
f.responses.push({ status: 'running', phase: 'downloading', percent: 40 });
|
||||
const off1 = f.store.subscribe(() => {}),
|
||||
off2 = f.store.subscribe(() => {});
|
||||
assert.equal((await f.settle()).job.percent, 40);
|
||||
assert.equal(f.fetchCount, 1);
|
||||
assert.equal(f.requests[0].url, '/panel/api/system/update-status');
|
||||
assert.equal(f.requests[0].options.headers.Authorization, 'Bearer secret');
|
||||
off1();
|
||||
f.responses.push({ status: 'ready' });
|
||||
assert.equal((await f.tick()).job.status, 'ready');
|
||||
off2();
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test('polling reconnects after downtime and recovers ready without forcing a restart', async () => {
|
||||
const f = fixture();
|
||||
f.responses.push(new Error('offline'));
|
||||
const off = f.store.subscribe(() => {});
|
||||
assert.equal((await f.settle()).connected, false);
|
||||
f.responses.push({ status: 'ready', version: '2.24.0' });
|
||||
const recovered = await f.tick();
|
||||
assert.equal(recovered.connected, true);
|
||||
assert.equal(recovered.job.status, 'ready');
|
||||
assert.equal(f.reloads, 0);
|
||||
off();
|
||||
});
|
||||
|
||||
test('a timed out status request is aborted and polling continues', async () => {
|
||||
const f = fixture();
|
||||
f.responses.push(
|
||||
({ signal }) =>
|
||||
new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted'))),
|
||||
),
|
||||
);
|
||||
const off = f.store.subscribe(() => {});
|
||||
assert.equal((await f.tick(5000)).connected, false);
|
||||
assert.equal((await f.tick()).connected, true);
|
||||
off();
|
||||
});
|
||||
|
||||
test('stale poll responses cannot overwrite a newly accepted update job', async () => {
|
||||
const f = fixture();
|
||||
let release;
|
||||
f.responses.push(() => new Promise((resolve) => (release = resolve)));
|
||||
const off = f.store.subscribe(() => {});
|
||||
f.store.beginMutation();
|
||||
f.store.setJob({ status: 'starting', id: 'new' });
|
||||
f.store.finishMutation();
|
||||
release({
|
||||
ok: true,
|
||||
json: async () => ({ code: 200, data: { status: 'idle' } }),
|
||||
});
|
||||
for (const rejected of [false, true])
|
||||
test(`update start request failure unlocks modal; rejected=${rejected}`, async () => {
|
||||
const p = panel();
|
||||
p.responses.push(
|
||||
async () => ({ code: 200, data: { hasNewVersion: false } }),
|
||||
async () => {
|
||||
if (rejected) throw new Error('offline');
|
||||
return { code: 400, message: 'busy' };
|
||||
},
|
||||
);
|
||||
const m = await p.start();
|
||||
assert.equal(m.options.okButtonProps.disabled, false);
|
||||
assert.equal(m.options.closable, true);
|
||||
assert.equal((await f.settle()).job.id, 'new');
|
||||
off();
|
||||
});
|
||||
|
||||
test('completed application reloads the initiating tab once, including after a page refresh', async () => {
|
||||
const f = fixture();
|
||||
f.storage.setItem(f.hooks.reloadKey, 'update-1');
|
||||
f.responses.push({ id: 'update-1', status: 'completed' });
|
||||
const off = f.store.subscribe(() => {});
|
||||
await f.settle();
|
||||
assert.equal(f.reloads, 1);
|
||||
f.responses.push({ id: 'update-1', status: 'completed' });
|
||||
await f.tick(15000);
|
||||
assert.equal(f.reloads, 1);
|
||||
off();
|
||||
});
|
||||
|
||||
test('download is nonblocking, progress survives closing the log, and ready exposes explicit apply', async () => {
|
||||
const f = fixture(),
|
||||
p = f.component('src/pages/setting/checkUpdate.tsx');
|
||||
f.store.setJob({ status: 'idle' });
|
||||
p.render();
|
||||
await p.nodes('Button')[0].props.onClick();
|
||||
await f.confirms.at(-1).onOk();
|
||||
f.store.setJob({
|
||||
status: 'running',
|
||||
phase: 'downloading',
|
||||
percent: 35,
|
||||
downloadedBytes: 35,
|
||||
totalBytes: 100,
|
||||
});
|
||||
test('retry clears the previous failure and ignores notifications without an active modal', async () => {
|
||||
const p = panel();
|
||||
await p.send({ status: 'success' });
|
||||
assert.equal(p.timers.length, 0);
|
||||
await p.start();
|
||||
await p.send({ status: 'failed', message: '' });
|
||||
p.close();
|
||||
const retry = await p.start();
|
||||
assert.equal(retry.options.okButtonProps.disabled, true);
|
||||
assert.equal(retry.options.closable, false);
|
||||
p.render();
|
||||
assert.equal(p.nodes('Progress')[0].props.percent, 35);
|
||||
assert.deepEqual(
|
||||
p.nodes('Button').map((node) => node.props.children),
|
||||
['查看更新日志'],
|
||||
);
|
||||
p.nodes('Button').at(-1).props.onClick();
|
||||
p.render();
|
||||
p.nodes(p.Modal)[0].props.onCancel();
|
||||
p.render();
|
||||
assert.equal(p.nodes(p.Modal)[0].props.open, false);
|
||||
f.store.setJob({ status: 'restarting', phase: 'restarting' });
|
||||
p.render();
|
||||
assert.deepEqual(
|
||||
p.nodes('Button').map((node) => node.props.children),
|
||||
['查看更新日志'],
|
||||
);
|
||||
f.store.setJob({ status: 'ready' });
|
||||
p.render();
|
||||
p.nodes('Button')[0].props.onClick();
|
||||
assert.equal(f.confirms.at(-1).okText, '重启并更新');
|
||||
await f.confirms.at(-1).onOk();
|
||||
assert.ok(f.requests.some((r) => r.url.endsWith('update/system')));
|
||||
assert.equal(f.storage.getItem(f.hooks.reloadKey), 'apply');
|
||||
});
|
||||
test('successful completion prompts restart once and cancel leaves the log dismissible', async () => {
|
||||
const p = panel(),
|
||||
m = await p.start();
|
||||
await p.send({ status: 'success', message: '' });
|
||||
await p.send({ status: 'success', message: '' });
|
||||
p.timers.splice(0).forEach((fn) => fn());
|
||||
assert.equal(p.confirms.length, 2);
|
||||
p.confirms.at(-1).options.onCancel();
|
||||
assert.equal(m.options.okButtonProps.disabled, false);
|
||||
assert.equal(m.options.closable, true);
|
||||
|
||||
test('start failure leaves retry enabled and unknown file size has no fake percentage', async () => {
|
||||
const f = fixture(),
|
||||
p = f.component('src/pages/setting/checkUpdate.tsx');
|
||||
f.store.setJob({ status: 'idle' });
|
||||
p.render();
|
||||
await p.nodes('Button')[0].props.onClick();
|
||||
f.responses.push(new Error('offline'));
|
||||
await f.confirms.at(-1).onOk();
|
||||
assert.equal(f.store.getSnapshot().busy, false);
|
||||
assert.equal(f.errors.length, 1);
|
||||
f.store.setJob({ status: 'running', phase: 'downloading', percent: null });
|
||||
p.render();
|
||||
assert.equal(p.nodes('Progress')[0].props.showInfo, false);
|
||||
});
|
||||
test('unmount removes the modal and suppresses a pending restart prompt', async () => {
|
||||
const p = panel();
|
||||
await p.start();
|
||||
await p.send({ status: 'success' });
|
||||
p.unmount();
|
||||
p.timers.splice(0).forEach((fn) => fn());
|
||||
assert.equal(p.confirms.length, 1);
|
||||
|
||||
test('the persistent yellow sidebar entry shows live progress and remains accessible when collapsed', () => {
|
||||
const f = fixture(),
|
||||
p = f.component('src/components/UpgradeNotice.tsx');
|
||||
f.store.setJob({ status: 'running', phase: 'downloading', percent: 62 });
|
||||
p.render({ collapsed: false });
|
||||
assert.match(p.nodes('button')[0].props['aria-label'], /62%/);
|
||||
p.nodes('button')[0].props.onClick();
|
||||
p.render({ collapsed: true });
|
||||
assert.match(p.nodes('button')[0].props.className, /collapsed/);
|
||||
assert.equal(p.nodes(p.Modal)[0].props.open, true);
|
||||
f.store.setJob({ status: 'ready', phase: 'ready' });
|
||||
p.render();
|
||||
assert.equal(p.nodes('button')[0].props['aria-label'], '更新已就绪');
|
||||
});
|
||||
|
||||
test('completed update refreshes once and reminds once after reconnecting the refreshed page', async () => {
|
||||
const f = fixture();
|
||||
f.storage.setItem(f.hooks.reloadKey, 'job');
|
||||
f.responses.push({ id: 'job', status: 'completed', phase: 'completed' });
|
||||
const off = f.store.subscribe(() => {});
|
||||
await f.settle();
|
||||
assert.equal(f.reloads, 1);
|
||||
assert.equal(f.notifications.length, 0);
|
||||
assert.equal(f.storage.getItem(f.hooks.completionKey), 'job');
|
||||
off();
|
||||
f.responses.push({ id: 'job', status: 'completed', phase: 'completed' });
|
||||
const off2 = f.store.subscribe(() => {});
|
||||
await f.settle();
|
||||
assert.equal(f.notifications.length, 1);
|
||||
assert.equal(f.storage.getItem(f.hooks.completionKey), null);
|
||||
f.responses.push({ id: 'job', status: 'completed', phase: 'completed' });
|
||||
await f.tick(15000);
|
||||
assert.equal(f.notifications.length, 1);
|
||||
off2();
|
||||
});
|
||||
|
||||
test('completed sidebar disappears while an open result dialog and completed history remain available', () => {
|
||||
const f = fixture(),
|
||||
p = f.component('src/components/UpgradeNotice.tsx');
|
||||
f.store.setJob({ status: 'running', phase: 'downloading', percent: 12 });
|
||||
p.render();
|
||||
p.nodes('button')[0].props.onClick();
|
||||
f.store.setJob({
|
||||
id: 'done',
|
||||
status: 'completed',
|
||||
phase: 'completed',
|
||||
log: 'verified',
|
||||
});
|
||||
p.render({ collapsed: false });
|
||||
assert.equal(p.nodes('button').length, 0);
|
||||
assert.equal(p.nodes(p.Modal)[0].props.open, true);
|
||||
assert.equal(f.store.getSnapshot().job.status, 'completed');
|
||||
assert.equal(f.store.getSnapshot().job.log, 'verified');
|
||||
p.nodes(p.Modal)[0].props.onCancel();
|
||||
assert.equal(p.render({ collapsed: true }), null);
|
||||
});
|
||||
|
||||
test('sidebar only shows updates that are in progress, ready or failed', () => {
|
||||
const f = fixture(),
|
||||
p = f.component('src/components/UpgradeNotice.tsx');
|
||||
for (const status of ['loading', 'idle', 'completed']) {
|
||||
f.store.setJob({ status, phase: status });
|
||||
assert.equal(p.render(), null);
|
||||
}
|
||||
for (const status of [
|
||||
'starting',
|
||||
'running',
|
||||
'restarting',
|
||||
'ready',
|
||||
'failed',
|
||||
]) {
|
||||
f.store.setJob({
|
||||
status,
|
||||
phase: status === 'running' ? 'downloading' : status,
|
||||
percent: 12,
|
||||
});
|
||||
p.render({ collapsed: true });
|
||||
assert.equal(p.nodes('button').length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('update modal uses plain aligned content and explains restart state', () => {
|
||||
const f = fixture(),
|
||||
p = f.component('src/pages/setting/checkUpdate.tsx');
|
||||
f.store.setJob({ status: 'ready', phase: 'ready' });
|
||||
p.render();
|
||||
assert.equal(p.nodes('Alert').length, 0);
|
||||
assert.ok(
|
||||
p
|
||||
.nodes('Text')
|
||||
.some((n) => n.props.children.includes?.('下载完成,等待重启')),
|
||||
);
|
||||
f.store.setJob({ status: 'completed', phase: 'completed' });
|
||||
p.render();
|
||||
assert.deepEqual(
|
||||
p.nodes('Button').map((n) => n.props.children),
|
||||
['检查更新', '查看更新日志'],
|
||||
);
|
||||
assert.ok(
|
||||
p
|
||||
.nodes('Text')
|
||||
.some(
|
||||
(n) => n.props.children === '已重新启动并运行新版本,无需再次重启。',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user