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:
whyour
2026-10-17 17:14:15 +08:00
parent a1104ef47c
commit 286088a32c
22 changed files with 2698 additions and 471 deletions
+6
View File
@@ -94,6 +94,12 @@ jobs:
run: | run: |
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE" 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: static_gitlab:
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
+11
View File
@@ -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( route.put(
'/reload', '/reload',
celebrate({ celebrate({
+4 -3
View File
@@ -2,8 +2,10 @@ import { Service } from 'typedi';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import cronClient from '../schedule/client'; import cronClient from '../schedule/client';
import { HttpServerService } from './http'; import { HttpServerService } from './http';
import { runtimeBuild } from '../shared/runtimeBuild';
interface HealthStatus { interface HealthStatus {
build: typeof runtimeBuild;
status: 'ok' | 'error'; status: 'ok' | 'error';
services: { services: {
http: boolean; http: boolean;
@@ -22,12 +24,11 @@ interface HealthStatus {
export class HealthService { export class HealthService {
private startTime = Date.now(); private startTime = Date.now();
constructor( constructor(private httpServerService: HttpServerService) {}
private httpServerService: HttpServerService,
) {}
async check(): Promise<HealthStatus> { async check(): Promise<HealthStatus> {
const status: HealthStatus = { const status: HealthStatus = {
build: runtimeBuild,
status: 'ok', status: 'ok',
services: { services: {
http: true, http: true,
+36 -30
View File
@@ -344,45 +344,51 @@ export default class SystemService {
} }
public async updateSystem() { public async updateSystem() {
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' }); return this.startUpgradeJob('download');
}
cp.stdout.on('data', (data) => { private upgradeJobs() {
this.sockService.sendMessage({ return require(path.join(
type: 'updateSystemVersion', config.rootPath,
message: data.toString(), 'cli/src/internal/maintenance/panelUpgrade.cjs',
}); ));
}); }
cp.stderr.on('data', (data) => { public async getUpgradeStatus() {
this.sockService.sendMessage({ return {
type: 'updateSystemVersion', code: 200,
message: data.toString(), data: await this.upgradeJobs().getStatus(config.tmpPath),
}); };
}); }
cp.on('error', (err) => { private async startUpgradeJob(action: 'download' | 'apply') {
this.sockService.sendMessage({ try {
type: 'updateSystemVersion', const data = await this.upgradeJobs().startJob(
message: JSON.stringify(err), {
status: 'failed', root: config.rootPath,
}); tmp: config.tmpPath,
}); env: {
...process.env,
cp.on('exit', (code) => { dir_data: config.dataPath,
this.sockService.sendMessage({ dir_config: config.configPath,
type: 'updateSystemVersion', QlPort: String(config.port),
message: '', QlBaseUrl: config.baseUrl,
status: code === 0 ? 'success' : 'failed', },
}); },
}); action,
);
return { code: 200 }; 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') { public async reloadSystem(target?: 'system' | 'data') {
if (target && !['system', 'data'].includes(target)) { if (target && !['system', 'data'].includes(target)) {
return { code: 400, message: t('参数错误') }; return { code: 400, message: t('参数错误') };
} }
if (target === 'system') return this.startUpgradeJob('apply');
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`; const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
const cp = spawn(cmd, { const cp = spawn(cmd, {
shell: '/bin/bash', shell: '/bin/bash',
+2
View File
@@ -1,6 +1,8 @@
const messages: Record<string, Record<string, string>> = { const messages: Record<string, Record<string, string>> = {
zh: {}, zh: {},
en: { en: {
'升级任务启动失败,请检查日志后重试':
'Failed to start the update. Check the logs and try again.',
'[schedule][停止已运行任务] 任务ID: %s, PID: %s': '[schedule][停止已运行任务] 任务ID: %s, PID: %s':
'[schedule][Stopping running task] Task ID: %s, PID: %s', '[schedule][Stopping running task] Task ID: %s, PID: %s',
+41
View File
@@ -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;
});
}
+200 -20
View File
@@ -24,6 +24,8 @@ const upgradeEnglish = {
'暂存升级指针无效。': 'Invalid staged upgrade pointer', '暂存升级指针无效。': 'Invalid staged upgrade pointer',
'暂存目录无效。': 'Invalid staging directory', '暂存目录无效。': 'Invalid staging directory',
'暂存升级就绪记录不匹配。': 'Invalid upgrade readiness', '暂存升级就绪记录不匹配。': 'Invalid upgrade readiness',
'运行依赖已变化,请重新下载更新。':
'Installed dependencies changed; download the update again',
'保留备份: %s': 'Retained backup: %s', '保留备份: %s': 'Retained backup: %s',
'后端启动检查失败。': 'Backend failed health check', '后端启动检查失败。': 'Backend failed health check',
'保留生命周期快照: %s': 'Retained lifecycle snapshot: %s', '保留生命周期快照: %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({ async function stageUpgrade({
root, root,
tmp, tmp,
@@ -180,38 +258,99 @@ async function stageUpgrade({
run, run,
install, install,
signal, signal,
onProgress,
}) { }) {
const report = onProgress || (async () => {});
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-')); const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const branch = releaseBranch(env); const branch = releaseBranch(env);
try { 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) { async function extract(repository, ref) {
const archive = path.join(workspace, `${repository}.zip`); const archive = path.join(workspace, `${repository}.zip`);
const url = const url =
mirror === 'github' mirror === 'github'
? `https://codeload.github.com/whyour/${repository}/zip/${ref}` ? `https://codeload.github.com/whyour/${repository}/zip/${ref}`
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`; : `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
const download = (target) => const download = async (target, expected) => {
run('curl', [ await report({
'--fail', phase: 'downloading',
'--location', artifact: repository,
'--proto', downloadedBytes: 0,
'=https', totalBytes: expected?.size || null,
'--proto-redir', percent: expected ? 0 : null,
'=https', });
'--connect-timeout', await downloadWithProgress(
'30', run,
'--max-time',
'600',
'--speed-limit',
'1024',
'--speed-time',
'30',
'--output',
archive, archive,
target, target,
]); repository,
report,
Boolean(onProgress),
expected,
);
};
try { try {
await download(url); await download(
url,
mirror === 'github' ? info?.archives[repository] : undefined,
);
} catch (error) { } catch (error) {
signal?.throwIfAborted(); signal?.throwIfAborted();
if (mirror !== 'github' || fallbackMirror !== 'gitee') throw error; if (mirror !== 'github' || fallbackMirror !== 'gitee') throw error;
@@ -220,6 +359,11 @@ async function stageUpgrade({
`https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`, `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 listing = await run('unzip', ['-Z1', archive], true);
const expected = `${repository}-${ref}`; const expected = `${repository}-${ref}`;
const names = listing.stdout.split('\n').filter(Boolean); 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 // Static branches are mutable snapshots. Their manifest pins the exact
// source revision, avoiding a race between two independent branch reads. // 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); 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 downloadedSource = await extract('qinglong', manifest.sourceCommit);
const source = path.join(workspace, `qinglong-${branch}`); const source = path.join(workspace, `qinglong-${branch}`);
await fs.rename(downloadedSource, source); await fs.rename(downloadedSource, source);
await report({ phase: 'verifying', percent: null });
await verifyUpgrade(source, staticRoot, env); await verifyUpgrade(source, staticRoot, env);
let dependenciesChanged = false; let dependenciesChanged = false;
for (const name of ['package.json', 'pnpm-lock.yaml']) { for (const name of ['package.json', 'pnpm-lock.yaml']) {
@@ -265,6 +418,7 @@ async function stageUpgrade({
) )
dependenciesChanged = true; dependenciesChanged = true;
if (dependenciesChanged) { if (dependenciesChanged) {
await report({ phase: 'dependencies', percent: null });
await install(source); await install(source);
await fs.access(path.join(source, 'node_modules')); await fs.access(path.join(source, 'node_modules'));
await verifyUpgrade(source, staticRoot, env); await verifyUpgrade(source, staticRoot, env);
@@ -275,6 +429,7 @@ async function stageUpgrade({
path.join(workspace, 'ready.json'), path.join(workspace, 'ready.json'),
JSON.stringify({ JSON.stringify({
...selected, ...selected,
root: path.resolve(root),
sourceCommit: manifest.sourceCommit, sourceCommit: manifest.sourceCommit,
dependenciesChanged, dependenciesChanged,
}), }),
@@ -343,6 +498,28 @@ async function selectedUpgrade(tmp, branch, env = process.env) {
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env)); throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
if (ready.dependenciesChanged) if (ready.dependenciesChanged)
await fs.access(path.join(selected.source, 'node_modules')); 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; return selected;
} }
@@ -430,6 +607,7 @@ async function reloadSystem({
config, config,
env, env,
lifecycle, lifecycle,
additionalReplacements = [],
}) { }) {
const selected = await selectedUpgrade(tmp, releaseBranch(env), env); const selected = await selectedUpgrade(tmp, releaseBranch(env), env);
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']); const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
@@ -449,6 +627,7 @@ async function reloadSystem({
target: path.join(config, 'config.sample.sh'), target: path.join(config, 'config.sample.sh'),
}, },
); );
replacements.push(...additionalReplacements);
await replaceAndReload(replacements, lifecycle, env); await replaceAndReload(replacements, lifecycle, env);
// Cleanup after a committed installation must not report a failed update. // Cleanup after a committed installation must not report a failed update.
await fs await fs
@@ -687,6 +866,7 @@ async function main() {
} }
module.exports = { module.exports = {
downloadWithProgress,
upgradeMessage, upgradeMessage,
releaseBranch, releaseBranch,
verifyUpgrade, verifyUpgrade,
+1
View File
@@ -416,6 +416,7 @@ export const openOperations: readonly OpenOperation[] = [
body: 'json', body: 'json',
}, },
{ name: 'system update-check', method: 'PUT', path: 'system/update-check' }, { 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 update', method: 'PUT', path: 'system/update' },
{ name: 'system reload', method: 'PUT', path: 'system/reload', body: 'json' }, { name: 'system reload', method: 'PUT', path: 'system/reload', body: 'json' },
{ name: 'system notify', method: 'PUT', path: 'system/notify', body: 'json' }, { name: 'system notify', method: 'PUT', path: 'system/notify', body: 'json' },
+1 -1
View File
@@ -276,7 +276,7 @@ test('upgrade staging extracts both archives before publishing readiness and rej
'utf8', 'utf8',
), ),
), ),
{ ...staged, sourceCommit, dependenciesChanged: false }, { ...staged, root: ctx.root, sourceCommit, dependenciesChanged: false },
); );
await fs.symlink( await fs.symlink(
'../../outside', '../../outside',
+136
View File
@@ -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;
}
}
+60
View File
@@ -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>
</>
);
}
+138
View File
@@ -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
View File
@@ -354,20 +354,50 @@ select:-webkit-autofill:focus {
.side-menu-container { .side-menu-container {
display: flex; display: flex;
width: 100%;
min-width: 0;
gap: 8px;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
color: @text-color; color: @text-color;
.side-menu-collapse-button:hover { .side-menu-collapse-button {
color: #1890ff; flex-shrink: 0;
&:hover {
color: #1890ff;
}
} }
.side-menu-user-wrapper { .side-menu-user-wrapper {
display: flex; flex: 1;
align-items: center; 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 { .side-menu-user-drop-menu {
position: relative; position: relative;
text-align: left; text-align: left;
@@ -475,11 +505,105 @@ body[data-mode='phone'] header {
} }
body[data-dark='true'] { body[data-dark='true'] {
.upgrade-notice {
background: #3a3214;
border-color: #6b5415;
color: #ffd666;
}
.ant-popover-arrow-content { .ant-popover-arrow-content {
--antd-arrow-background-color: rgb(24, 26, 27); --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 { .ant-tabs-content-holder {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
+29 -2
View File
@@ -19,6 +19,8 @@ import './index.less';
import { init } from '../utils/init'; import { init } from '../utils/init';
import WebSocketManager from '../utils/websocket'; import WebSocketManager from '../utils/websocket';
import { observePageLoadTiming } from '../utils/navigationTiming'; import { observePageLoadTiming } from '../utils/navigationTiming';
import UpgradeNotice from '@/components/UpgradeNotice';
import { completionKey, reloadKey, upgradeStore } from '@/hooks/useUpgrade';
export interface SharedContext { export interface SharedContext {
headerStyle: React.CSSProperties; headerStyle: React.CSSProperties;
@@ -54,6 +56,9 @@ export default function () {
); );
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [initLoading, setInitLoading] = useState<boolean>(true); const [initLoading, setInitLoading] = useState<boolean>(true);
const observeUpgrade =
Boolean(user.username) &&
!['/login', '/initialization'].includes(location.pathname);
const { const {
enable: enableDarkMode, enable: enableDarkMode,
disable: disableDarkMode, disable: disableDarkMode,
@@ -162,6 +167,18 @@ export default function () {
getHealthStatus(); 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(() => { useEffect(() => {
reloadSystemConfig(); reloadSystemConfig();
}, []); }, []);
@@ -321,6 +338,12 @@ export default function () {
</> </>
} }
title={false} title={false}
menuFooterRender={(props) => (
<UpgradeNotice
collapsed={Boolean(props?.collapsed)}
systemInfo={systemInfo}
/>
)}
menuItemRender={(menuItemProps: any, defaultDom: any) => { menuItemRender={(menuItemProps: any, defaultDom: any) => {
if ( if (
menuItemProps.isUrl || menuItemProps.isUrl ||
@@ -351,7 +374,9 @@ export default function () {
user.avatar ? `${config.apiPrefix}static/${user.avatar}` : '' 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> </span>
</Dropdown> </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> </span>
</Dropdown> </Dropdown>
)} )}
+27 -2
View File
@@ -338,7 +338,7 @@
"显示": "Display", "显示": "Display",
"智能微秘书": "Smart WeChat Assistant", "智能微秘书": "Smart WeChat Assistant",
"暂无数据": "No data", "暂无数据": "No data",
"暂无日志": "No logs available", "暂无日志": "No logs yet",
"暂无脚本": "No scripts available", "暂无脚本": "No scripts available",
"暂无运行中任务": "No running tasks", "暂无运行中任务": "No running tasks",
"暗色": "Dark", "暗色": "Dark",
@@ -731,5 +731,30 @@
"白屏时间": "Time to first response: {duration} ms", "白屏时间": "Time to first response: {duration} ms",
"请求完毕至DOM加载": "Response end to DOM interactive: {duration} ms", "请求完毕至DOM加载": "Response end to DOM interactive: {duration} ms",
"解释DOM树耗时": "DOM completion time: {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
View File
@@ -731,5 +731,30 @@
"白屏时间": "白屏时间: {duration} ms", "白屏时间": "白屏时间: {duration} ms",
"请求完毕至DOM加载": "请求完毕至DOM加载: {duration} ms", "请求完毕至DOM加载": "请求完毕至DOM加载: {duration} ms",
"解释DOM树耗时": "解释 DOM 树耗时: {duration} ms", "解释DOM树耗时": "解释 DOM 树耗时: {duration} ms",
"从开始至load总耗时": "从开始至 load 总耗时: {duration} ms" "从开始至load总耗时": "从开始至 load 总耗时: {duration} ms",
"正在解压更新包": "正在解压更新包",
"正在校验更新包": "正在校验更新包",
"正在准备依赖": "正在准备依赖",
"更新已就绪": "更新已就绪",
"正在重启并更新": "正在重启并更新",
"更新完成": "更新完成",
"更新失败": "更新失败",
"正在准备更新": "正在准备更新",
"连接已断开,正在重新连接": "连接已断开,正在重新连接",
"更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本",
"更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板",
"下载构建产物": "下载构建产物",
"下载源码": "下载源码",
"正在获取下载大小": "正在获取下载大小",
"查看更新日志": "查看更新日志",
"重启并更新": "重启并更新",
"升级任务启动失败,请检查日志后重试": "升级任务启动失败,请检查日志后重试",
"升级任务意外中断,请重试": "升级任务意外中断,请重试",
"系统将在30秒后自动刷新": "系统将在30秒后自动刷新",
"运行依赖已变化,请重新下载更新。": "运行依赖已变化,请重新下载更新。",
"暂存更新已变化,请重新下载更新。": "暂存更新已变化,请重新下载更新。",
"启动版本与更新包不一致": "启动版本与更新包不一致",
"下载完成,等待重启": "下载完成,等待重启",
"升级成功": "升级成功",
"已重新启动并运行新版本,无需再次重启。": "已重新启动并运行新版本,无需再次重启。"
} }
+190 -220
View File
@@ -1,249 +1,219 @@
import { disableBody } from '@/utils';
import config from '@/utils/config'; import config from '@/utils/config';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import WebSocketManager from '@/utils/websocket';
import Ansi from 'ansi-to-react'; import Ansi from 'ansi-to-react';
import { Button, Modal, Statistic, message } from 'antd'; import { Button, Modal, Progress, Space, Typography, message } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useState } from 'react';
import useUpgrade, {
activeStatuses,
phaseLabels,
reloadKey,
upgradeStore,
} from '@/hooks/useUpgrade';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
const { Countdown } = Statistic; const sizeLabel = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
const CheckUpdate = ({ systemInfo }: any) => { const CheckUpdate = ({ systemInfo }: any) => {
const [updateLoading, setUpdateLoading] = useState(false); const { job, busy, connected } = useUpgrade();
const [value, setValue] = useState(''); const [details, setDetails] = useState(false);
const modalRef = useRef<any>(); const active = activeStatuses.includes(job.status);
const lastStatusRef = useRef<string | undefined>(); const ready = job.status === 'ready';
const [updateStatus, setUpdateStatus] = useState<string>(); const completed = job.status === 'completed';
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 startUpdate = async () => { const startUpdate = async () => {
showUpdatingModal(); if (busy || active) return;
if (!upgradeStore.beginMutation()) return;
try { try {
const response = await request.put(`${config.apiPrefix}system/update`); const result = await request.put(`${config.apiPrefix}system/update`);
if (response.code !== 200) throw new Error(response.message); if (result.code !== 200) throw new Error(result.message);
upgradeStore.setJob(result.data);
} catch { } catch {
lastStatusRef.current = 'failed';
setUpdateStatus('failed');
message.error(intl.get('更新失败,请检查网络及日志或稍后再试')); message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
} finally {
upgradeStore.finishMutation();
} }
}; };
const reloadSystem = (type?: string) => { const checkUpgrade = async () => {
request if (!upgradeStore.beginMutation()) return;
.put(`${config.apiPrefix}update/${type}`) try {
.then(({ code }: any) => { const { code, data } = await request.put(
if (code !== 200) return; `${config.apiPrefix}system/update-check`,
message.success({ );
content: ( if (code !== 200) throw new Error('Update check failed');
<span> Modal.confirm({
{intl.get('系统将在')} width: 500,
<Countdown title: intl.get(data.hasNewVersion ? '更新可用' : '已经是最新版了!'),
className="inline-countdown" content: data.hasNewVersion ? (
format="ss" <>
value={Date.now() + 1000 * 30} <p>
/> {intl.get('新版本可用,当前版本', {
{intl.get('秒后自动刷新')} version: data.lastVersion,
</span> current: systemInfo.version,
), })}
duration: 30, </p>
}); <pre>
disableBody(); <Ansi>{data.lastLog}</Ansi>
setTimeout(() => { </pre>
window.location.reload(); </>
}, 30000); ) : (
}) `${intl.get('青龙')} ${data.lastVersion}`
.catch((error: any) => { ),
console.log(error); 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({ Modal.confirm({
width: 600,
maskClosable: false,
title: intl.get('确认重启'), title: intl.get('确认重启'),
centered: true, content: intl.get('更新已就绪,重启后应用新版本'),
content: intl.get('系统安装包下载成功,确认重启'), okText: intl.get('重启并更新'),
okText: intl.get('重启'), cancelText: intl.get('以后再说'),
onOk() { onOk: () => reloadSystem(true),
reloadSystem('system');
},
onCancel() {
modalRef.current?.update({
maskClosable: true,
closable: true,
okButtonProps: { disabled: false },
});
},
}); });
};
useEffect(() => { const label =
if (!modalRef.current) return; job.status === 'failed'
const updateFailed = updateStatus === 'failed'; ? intl.get('更新失败')
: ready
modalRef.current.update({ ? intl.get('下载完成,等待重启')
maskClosable: updateFailed, : completed
closable: updateFailed, ? intl.get('升级成功')
okButtonProps: { disabled: !updateFailed }, : intl.get(phaseLabels[job.phase || ''] || '正在准备更新');
content: ( const hasJob = job.status !== 'idle' && job.status !== 'loading';
<> const actions = !active && (
<pre> <Space wrap>
<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>
<Button <Button
type="primary" type="primary"
onClick={() => reloadSystem('reload')} loading={busy}
style={{ marginLeft: 8 }} disabled={!connected || job.status === 'loading'}
onClick={ready ? showReloadModal : checkUpgrade}
> >
{intl.get('重新启动')} {intl.get(ready ? '重启并更新' : '检查更新')}
</Button> </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>
); );
}; };
+469
View File
@@ -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);
});
+71
View File
@@ -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);
}
});
+245
View File
@@ -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
View File
@@ -3,215 +3,433 @@ const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const ts = require('typescript'); const ts = require('typescript');
// Drive the real component's handlers and effects without a browser or network.
function panel() { function fixture() {
let cursor = 0, const timers = new Map(),
tree, responses = [],
subscriber; requests = [],
const hooks = [],
effects = [],
cleanups = [],
confirms = [], confirms = [],
infos = [], errors = [],
timers = [], notifications = [];
responses = []; const persistent = new Map();
const react = { let timerId = 0,
useState(initial) { reloads = 0,
const i = cursor++; snapshot,
if (!(i in hooks)) hooks[i] = initial; fetchCount = 0;
return [ const storage = {
hooks[i], getItem: (key) => persistent.get(key) || null,
(v) => { setItem: (key, value) => persistent.set(key, value),
hooks[i] = typeof v === 'function' ? v(hooks[i]) : v; removeItem: (key) => persistent.delete(key),
},
];
},
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);
}
},
}; };
function modal(options, list) {
const result = {
options,
update(patch) {
Object.assign(options, patch);
},
destroy() {
options.afterClose?.();
},
};
list.push(result);
return result;
}
const mocks = { const mocks = {
'@/utils': { disableBody() {} }, '@/utils/config': { apiPrefix: '/panel/api/', authKey: 'token' },
'@/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,
antd: { antd: {
Button: 'button', notification: { success: (options) => notifications.push(options) },
Statistic: { Countdown: 'span' },
message: { loading() {}, destroy() {}, error() {}, success() {} },
Modal: {
confirm: (o) => modal(o, confirms),
info: (o) => modal(o, infos),
},
}, },
react,
'react-intl-universal': { get: (key) => key }, 'react-intl-universal': { get: (key) => key },
react: { useSyncExternalStore: (subscribe, getSnapshot) => getSnapshot() },
}; };
const module = { exports: {} }; function load(file, extra = {}) {
const compiled = ts.transpileModule( const module = { exports: {} };
fs.readFileSync( const output = ts.transpileModule(
path.join(__dirname, '../../src/pages/setting/checkUpdate.tsx'), fs.readFileSync(path.join(__dirname, '../..', file), 'utf8'),
'utf8', {
), compilerOptions: {
{ module: ts.ModuleKind.CommonJS,
compilerOptions: { target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX,
target: ts.ScriptTarget.ES2020, esModuleInterop: true,
jsx: ts.JsxEmit.ReactJSX, },
esModuleInterop: true,
}, },
}, ).outputText;
).outputText; new Function(
new Function( 'require',
'require', 'module',
'module', 'exports',
'exports', 'setTimeout',
'setTimeout', 'clearTimeout',
'document', 'fetch',
compiled, 'localStorage',
)( 'sessionStorage',
(name) => mocks[name] || require(name), 'window',
module, output,
module.exports, )(
(fn) => { (name) => extra[name] || mocks[name] || require(name),
timers.push(fn); module,
}, module.exports,
{ querySelector: () => null }, (fn, ms) => {
); const id = ++timerId;
function render() { timers.set(id, { fn, ms });
cursor = 0; return id;
tree = module.exports.default({ systemInfo: { version: '2.23.0' } }); },
while (effects.length) { (id) => timers.delete(id),
const cleanup = effects.shift()(); async (url, options) => {
if (cleanup) cleanups.push(cleanup); 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 { return {
confirms, store,
infos, hooks,
responses, responses,
requests,
confirms,
errors,
notifications,
component,
storage,
timers, timers,
settle, get fetchCount() {
async start() { return fetchCount;
tree.props.children[0].props.onClick();
await settle();
confirms.at(-1).options.onOk();
await settle();
return infos.at(-1);
}, },
async send(payload) { get reloads() {
subscriber?.(payload); return reloads;
await settle();
}, },
close() { async settle() {
infos.at(-1).destroy(); await new Promise(setImmediate);
snapshot = store.getSnapshot();
return snapshot;
}, },
unmount() { async tick(ms = 1000) {
cleanups.forEach((fn) => fn()); 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 () => { test('sidebar and settings share one poller; mounting restores progress and the last unsubscribe stops it', async () => {
const p = panel(), const f = fixture();
m = await p.start(); f.storage.setItem('token', 'secret');
if (logged) await p.send({ message: 'download failed\n' }); f.responses.push({ status: 'running', phase: 'downloading', percent: 40 });
assert.equal(m.options.okButtonProps.disabled, true); const off1 = f.store.subscribe(() => {}),
await p.send({ message: '', status: 'failed' }); off2 = f.store.subscribe(() => {});
assert.equal(m.options.okButtonProps.disabled, false); assert.equal((await f.settle()).job.percent, 40);
assert.equal(m.options.closable, true); 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]) assert.equal((await f.settle()).job.id, 'new');
test(`update start request failure unlocks modal; rejected=${rejected}`, async () => { off();
const p = panel(); });
p.responses.push(
async () => ({ code: 200, data: { hasNewVersion: false } }), test('completed application reloads the initiating tab once, including after a page refresh', async () => {
async () => { const f = fixture();
if (rejected) throw new Error('offline'); f.storage.setItem(f.hooks.reloadKey, 'update-1');
return { code: 400, message: 'busy' }; f.responses.push({ id: 'update-1', status: 'completed' });
}, const off = f.store.subscribe(() => {});
); await f.settle();
const m = await p.start(); assert.equal(f.reloads, 1);
assert.equal(m.options.okButtonProps.disabled, false); f.responses.push({ id: 'update-1', status: 'completed' });
assert.equal(m.options.closable, true); 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 () => { p.render();
const p = panel(); assert.equal(p.nodes('Progress')[0].props.percent, 35);
await p.send({ status: 'success' }); assert.deepEqual(
assert.equal(p.timers.length, 0); p.nodes('Button').map((node) => node.props.children),
await p.start(); ['查看更新日志'],
await p.send({ status: 'failed', message: '' }); );
p.close(); p.nodes('Button').at(-1).props.onClick();
const retry = await p.start(); p.render();
assert.equal(retry.options.okButtonProps.disabled, true); p.nodes(p.Modal)[0].props.onCancel();
assert.equal(retry.options.closable, false); 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(), test('start failure leaves retry enabled and unknown file size has no fake percentage', async () => {
m = await p.start(); const f = fixture(),
await p.send({ status: 'success', message: '' }); p = f.component('src/pages/setting/checkUpdate.tsx');
await p.send({ status: 'success', message: '' }); f.store.setJob({ status: 'idle' });
p.timers.splice(0).forEach((fn) => fn()); p.render();
assert.equal(p.confirms.length, 2); await p.nodes('Button')[0].props.onClick();
p.confirms.at(-1).options.onCancel(); f.responses.push(new Error('offline'));
assert.equal(m.options.okButtonProps.disabled, false); await f.confirms.at(-1).onOk();
assert.equal(m.options.closable, true); 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(); test('the persistent yellow sidebar entry shows live progress and remains accessible when collapsed', () => {
await p.start(); const f = fixture(),
await p.send({ status: 'success' }); p = f.component('src/components/UpgradeNotice.tsx');
p.unmount(); f.store.setJob({ status: 'running', phase: 'downloading', percent: 62 });
p.timers.splice(0).forEach((fn) => fn()); p.render({ collapsed: false });
assert.equal(p.confirms.length, 1); 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 === '已重新启动并运行新版本,无需再次重启。',
),
);
}); });